# 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/`. - `./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): ```bash 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): ```bash 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. ## Verify ```bash 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 ```bash git log --oneline -5 # find the last-good commit git checkout # or: git reset --hard 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`).