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>
170 lines
8.3 KiB
Markdown
170 lines
8.3 KiB
Markdown
# 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):
|
||
|
||
```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 <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`).
|