Files
ulicraft-server-v1/plan/14-deploy.md
T
oierandClaude Opus 5 92c6f5fc8f feat(compose): mount mods, config and kubejs writable
Drop :ro from ./server-mods:/mods and the four kubejs source subdirs, and
add a new ./server-config:/data/config bind — mod config used to live only
inside the mc_data volume, unreachable from the host without root.

The config bind needs seeding: a bind mount shadows what is under it, so an
empty ./server-config hides the generated config and every mod falls back to
defaults. tooling/export-server-config.sh copies /data/config out of the
volume first, halting if the target is non-empty, if the container is still
running, or if the mc_data volume name is ambiguous.

Writable mounts also need the server's uid (itzg runs as 1000) to have write
access on the host dir; oxps' host user is 1002, hence MC_UID/MC_GID.

The kubejs ROOT still must not be mounted — BaseProperties.save() NPEs and
kubejs never initialises. That is about mounting the root at all, not about
the mode, so dropping :ro does not change it. It does mean kubejs writes now
land in $DISTRIBUTION_WEB_ROOT as uncommitted changes in that host's checkout.

Also documents how to reach the mc_data volume (exec/cp, resolved host path,
stop-before-write rule) in plan/05-minecraft.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 00:46:30 +02:00

8.7 KiB
Raw Blame History

Deployment — redeploy to cochi

Production host for the Ulicraft stack.

Host cochi (SSH alias)
Path /home/ubuntu/mc/ulicraft-server-v1
Branch main
Compose single docker-compose.yml (no overrides)
Auth Drasl (password login)
Ingress host nginx (Let's Encrypt TLS) → caddy → services (see 15-letsencrypt.md)
Restart hard (compose down → compose up --build) — brief full downtime

Routine redeploy

update-all.sh (repo root) is the single source of truth for the post-pull build + restart steps. Run it AFTER a git pull (it never pulls or pushes). Strict halt on any error — never a partial deploy.

Two modes:

  • ./update-all.sh — rolling: docker compose up -d --build, then restart ONLY the services whose mounted inputs changed since a pre-run snapshot (drasl/nmsr on a config re-render, minecraft on a mod change). Minimal downtime. Does not detect caddy static-conf (caddy/conf.d/*.caddy) changes — apply those with a recreate, not a reload/restart (see gotcha below): docker compose up -d --force-recreate caddy (no world downtime), or --hard for a full redeploy.

    Caddy conf bind mounts are inode-pinned — recreate, don't reload. Each caddy/conf.d/*.caddy is a single-file bind mount, which Docker pins to the host file's inode at container creation. git pull rewrites a tracked file via atomic rename = a NEW inode, so the running container keeps the OLD config. docker compose restart caddy and caddy reload reuse the same container → same stale inode → no effect. Only re-creating the container (up -d --force-recreate caddy, or the --hard down/up) re-resolves the mount. Verify: docker compose exec caddy cat /etc/caddy/conf.d/<file>.

  • ./update-all.sh --hard — docker compose down --remove-orphans && up -d --build. Full restart (Minecraft world downtime); picks up everything.

One-shot full redeploy from your workstation (the deploy skill does exactly this):

ssh cochi 'set -e
  cd /home/ubuntu/mc/ulicraft-server-v1
  git pull --ff-only
  tooling/fetch-authlib.sh        # ensure authlib jar (rarely changes; NOT in update-all)
  ./update-all.sh --hard'

Everyday landing/config tweak (no world downtime):

ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && git pull --ff-only && ./update-all.sh'

Same inode trap for filestash/config.json. It is a single-file bind mount and it is re-rendered on every update-all.sh run, so a rolling update after any FILES_* change (rotating the shared password, flipping FILES_AUTH) leaves filestash reading the stale inode. Apply with docker compose up -d --force-recreate filestash. --hard is unaffected. Also: never run docker compose up on the host without rendering first — a missing filestash/config.json makes Docker create a directory at that path. See plan/20-files.md.

Steps update-all.sh runs, in order: render-config.sh (drasl/nmsr/filestash from .env; halts on a bad REGISTRATION_MODE, a bad FILES_AUTH, or a FILES_PASSWORD_HASH in a format the htpasswd plugin can't verify) → sync-server-mods.sh (mirror the both/server subset from mods-sides.json out of $DISTRIBUTION_WEB_ROOT into ./server-mods) → build-modlist.py (regen landing mods.json + logos; run under python>=3.11 — cochi's default python3 is 3.10, but python3.11 is installed and the script auto-selects it) → landing pnpm build (sources nvm + .env, never npm) → docker compose. $DISTRIBUTION_WEB_ROOT must resolve on the host (it also backs the caddy distribution. mount).

Landing site + mod list (generated, not in git)

Both the static site www/ AND the landing mods.json are gitignored — a git pull never updates them. update-all.sh regenerates both every run (mods.json via build-modlist.py, www/ via pnpm build). A flag-only REGISTRATION_MODE change just needs a rolling ./update-all.sh: it rebuilds www/ (bakes the invite-field on/off) and restarts drasl only if render-config.sh changed its config (Drasl's RequireInvite/CORS/RateLimit) — no world downtime.

Host-local divergence on cochi — RESOLVED (verified 2026-07-14)

There are no longer any tracked modifications on the host. git diff HEAD is empty; git pull --ff-only fast-forwards cleanly (confirmed on the filestash deploy). The old divergence — docker-compose.yml forced http→https, landing/package.json, landing/pnpm-workspace.yaml — has been converged into the repo. The stash–pull–pop dance previously documented here is no longer needed.

What remains on the host is untracked only, so it never blocks a pull:

  • dnsmasq/, caddy/caddy-root-ca.crt — internal DNS + private CA so containers resolve/trust https://auth. etc. Host-local infra, deliberately not in git.
  • pack/ — stale leftover from the packwiz era (packwiz was removed; see 04-mods.md). Nothing reads it. Safe to delete.

Still true, and worth keeping: never git reset --hard or discard on the host without confirming first, and re-check git status there before any deploy that touches tracked files — a new host-local edit would reintroduce the abort.

What survives a hard restart

Named volumes persist — world and auth data are safe:

  • mc_data — world + installed NeoForge/mods
  • drasl_state — Drasl accounts/skins
  • kuma_data — Uptime Kuma config/history
  • backups/ — mc-backup snapshots

down removes containers, not volumes. Players are disconnected during the restart (a few minutes); they reconnect once minecraft is healthy again.

Prerequisites (one-time, on cochi)

  • SSH access as the cochi alias.
  • .env present and complete: BASE_DOMAIN, RCON_PASSWORD, CADDY_HTTP_PORT, CADDY_HTTP_BIND, DISTRIBUTION_WEB_ROOT, REGISTRATION_MODE (invite|open, defaults invite), the FILES_* block (plan/20-files.md), and the Let's Encrypt block. render-config.sh halts on an unset BASE_DOMAIN, an invalid REGISTRATION_MODE/FILES_AUTH, or a FILES_PASSWORD_HASH the htpasswd plugin can't verify.

    A missing FILES_* var takes the WHOLE stack down, not just filestash. The compose service uses ${FILES_DATA_DIR:?} / ${FILES_LOCAL_SECRET:?} guards, and compose fails the entire file on an unset required var — so up starts nothing, and a --hard deploy has already run down. Check .env on the host before deploying a commit that adds a guarded service.

  • Docker + compose plugin, envsubst, jq (used by sync-server-mods.sh; falls back to python3 if absent), git.
  • Host nginx + Let's Encrypt certs installed once — see 15-letsencrypt.md.
  • First deploy only — online prep (build landing, fetch launcher, fetch authlib into runtime/). Not needed for routine redeploys.
  • Once per host, before the first deploy carrying the ./server-config bind mount — run tooling/export-server-config.sh with the server stopped. The mount shadows /data/config, so deploying without seeding first boots the server against an empty config dir and every mod regenerates its defaults. oxps: MC_UID=1002 MC_GID=1002 tooling/export-server-config.sh. See 05-minecraft.md → "Writable bind mounts".

Verify

docker compose ps      # caddy drasl minecraft mc-backup nmsr mc-status uptime-kuma filestash
# self-hosted live status feed (plan/15-mc-status.md):
curl -fsS "https://$(grep '^BASE_DOMAIN=' .env | cut -d= -f2)/api/mcstatus/v2/status/java/$(grep '^BASE_DOMAIN=' .env | cut -d= -f2)" | jq .online
# server ready — match the full marker, not a bare "Done" (that also hits mod-load lines)
docker compose logs -f minecraft | grep -m1 'Done ([0-9.]*s)! For help'
curl -fsS "https://auth.$(grep '^BASE_DOMAIN=' .env | cut -d= -f2)/" >/dev/null && echo "auth ok"
# files. — 200 = live; anonymous API must be REFUSED (auth is on)
curl -s -o /dev/null -w 'files: %{http_code}\n' "https://files.$(grep '^BASE_DOMAIN=' .env | cut -d= -f2)/"
curl -s -H 'X-Requested-With: XmlHttpRequest' "https://files.$(grep '^BASE_DOMAIN=' .env | cut -d= -f2)/api/session"  # want "Not authorised"

Rollback

git log --oneline -5            # find the last-good commit
git checkout <sha>              # or: git reset --hard <sha>
docker compose down --remove-orphans
docker compose up -d --build

Notes

  • Production needs internet on cochi at restart: the server installs/re-syncs NeoForge + mods over the network.
  • All ingress goes through host nginx → caddy. After a redeploy, nginx config is unchanged; only re-run tooling/render-nginx.sh --install if BASE_DOMAIN / CADDY_HTTP_PORT changed (see 15-letsencrypt.md).