Fold files./filestash into the docs that carry the service list, the vhost
map and the deploy procedure — README stack table, 00-overview, 02-caddy,
12-build-order, 14-deploy, and the deploy skill.
Two real gaps the filestash deploy exposed, now guardrails in the skill:
- update-all.sh never touches host nginx, so a NEW SUBDOMAIN silently 404s
after deploy. It needs issue-letsencrypt.sh + render-nginx.sh --install.
- a new service with ${FOO:?} guards fails the WHOLE compose file, so a
missing .env var means `up` starts nothing — and --hard has already run
`down`. Check the host's .env BEFORE tearing the stack down.
Also: filestash/config.json is a single-file bind mount re-rendered every
run, so a rolling update-all leaves it on a stale inode (same trap as the
caddy conf) — force-recreate after any FILES_* change.
Correct the cochi divergence section: `git diff HEAD` on the host is now
empty (verified this deploy — the ff-pull succeeded). The documented
docker-compose.yml/package.json divergence was converged; only untracked
dnsmasq/, caddy-root-ca.crt and a stale pack/ remain. The stash-pull-pop
procedure it prescribed is no longer needed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
8.3 KiB
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--hardfor a full redeploy.Caddy conf bind mounts are inode-pinned — recreate, don't reload. Each
caddy/conf.d/*.caddyis a single-file bind mount, which Docker pins to the host file's inode at container creation.git pullrewrites a tracked file via atomic rename = a NEW inode, so the running container keeps the OLD config.docker compose restart caddyandcaddy reloadreuse the same container → same stale inode → no effect. Only re-creating the container (up -d --force-recreate caddy, or the--harddown/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 everyupdate-all.shrun, so a rolling update after anyFILES_*change (rotating the shared password, flippingFILES_AUTH) leaves filestash reading the stale inode. Apply withdocker compose up -d --force-recreate filestash.--hardis unaffected. Also: never rundocker compose upon the host without rendering first — a missingfilestash/config.jsonmakes Docker create a directory at that path. Seeplan/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/trusthttps://auth.etc. Host-local infra, deliberately not in git.pack/— stale leftover from the packwiz era (packwiz was removed; see04-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/modsdrasl_state— Drasl accounts/skinskuma_data— Uptime Kuma config/historybackups/— 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
cochialias. .envpresent and complete:BASE_DOMAIN,RCON_PASSWORD,CADDY_HTTP_PORT,CADDY_HTTP_BIND,DISTRIBUTION_WEB_ROOT,REGISTRATION_MODE(invite|open, defaultsinvite), theFILES_*block (plan/20-files.md), and the Let's Encrypt block.render-config.shhalts on an unsetBASE_DOMAIN, an invalidREGISTRATION_MODE/FILES_AUTH, or aFILES_PASSWORD_HASHthe 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 — soupstarts nothing, and a--harddeploy has already rundown. Check.envon the host before deploying a commit that adds a guarded service.- Docker + compose plugin,
envsubst,jq(used bysync-server-mods.sh; falls back topython3if 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.
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
cochiat 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 --installifBASE_DOMAIN/CADDY_HTTP_PORTchanged (see15-letsencrypt.md).