Files
ulicraft-server-v1/plan/22-migration.md
T
oierandClaude Opus 4.8 b9bcc13b73 feat(tooling): host migration scripts (export/import/check)
Move the stack to a new host keeping the same domain, carrying Drasl
users + skins (+ kuma monitors, filestash index) but not the world.

- migrate-check.sh: validate the target meets requirements. [restore]
  hard-fails (docker, compose v2, git, tar, ssh); [deploy] warns
  (node/pnpm, py>=3.11, nginx, acme.sh, RAM, disk, ports). --host runs
  it remotely by streaming itself over ssh.
- migrate-export.sh: workstation-run, pulls the source read-only over
  ssh into one 0600 bundle (.env, certs/, drasl_state/kuma_data/
  filestash_state volumes, checksummed MANIFEST). Certs in by default
  (--without-certs); world opt-in (--with-world).
- migrate-import.sh: target-run, sha256-verify, refuse existing volumes
  (--force), backup+restore .env with --path-prefix rewrite, check host
  paths resolve, restore certs+volumes, print the manual tail.
- plan/22-migration.md: human walkthrough (cochi -> oxps), rollback
  window warning, rehearsal notes.
- .gitignore: ignore ulicraft-bundle-*.tar.gz (holds secrets + LE keys).

Assumes Compose v2 on the target (no update-all.sh changes). Distribution
repo, TLS issuance, DNS and firewall stay manual. Round-trip tested
against prod: 9.2M bundle, drasl.db restored byte-exact, clobber guard
verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 00:41:39 +02:00

6.6 KiB

Migrating the stack to another host

Move the Ulicraft stack from one machine to another keeping the same domain, carrying the Drasl users + skins (and monitors + file-share index), but not the Minecraft world. Written for the cochi → oxps move, but the scripts take --from / paths so it generalises.

Three scripts do the mechanical work; the rest is deploying the distribution repo, TLS, DNS, and firewall — the same one-time setup any fresh host needs.

script runs on does
tooling/migrate-check.sh target (or --host) validate the machine meets the requirements
tooling/migrate-export.sh your workstation pull state from the source host into one bundle
tooling/migrate-import.sh target restore that bundle into this host's stack

What actually moves

In the bundle (migrate-export.sh):

  • .env — all secrets (RCON, FILES_* hashes, DRASL_ADMIN_*, OVH DNS API).
  • certs/ — the Let's Encrypt cert+key for every name (opt out --without-certs).
  • volume drasl_state — accounts, passwords, skins, capes, signing key. 144K. This is the whole point of the migration.
  • volume kuma_data — Uptime Kuma monitors + history (~38M).
  • volume filestash_state — filestash sqlite + search index (~700K).

You provide on the target (not in the bundle — too big, or you rebuild it):

  • The distribution repo checkout at DISTRIBUTION_WEB_ROOT (~3.3G). The minecraft service bind-mounts kubejs source dirs from it and will not boot until it exists. You deploy this yourself (Gitea deploy key is per-repo, per host).
  • generators-data/ (~109M, needs WAN) via tooling/sync-generators-data.sh.
  • runtime/authlib-injector.jar, launcher/ via their fetch-*.sh scripts.

Deliberately dropped: the world (mc_data) and backups/ — you said the world isn't needed. (Carry the world anyway with migrate-export.sh --with-world.) www/, server-mods/, and rendered configs are regenerated by update-all.sh.

Requirements on the target

migrate-check.sh splits them:

  • [restore] (hard) — docker daemon, Compose v2 (docker compose, not the legacy docker-compose v1 binary), git, tar, gzip, ssh. Import refuses without these.
  • [deploy] (warn) — envsubst, rsync, node+pnpm, python ≥ 3.11, nginx, acme.sh, ≥12G RAM (MAX_MEMORY: 10G), ~10G free disk, free ports. You can restore without these, but need them before ./update-all.sh --hard.

Installing Compose v2 by hand (if the host only has v1):

mkdir -p ~/.docker/cli-plugins
curl -SL https://github.com/docker/compose/releases/latest/download/docker-compose-linux-$(uname -m) \
  -o ~/.docker/cli-plugins/docker-compose
chmod +x ~/.docker/cli-plugins/docker-compose
docker compose version      # confirm v2+

Step by step (cochi → oxps)

0. Check the target

tooling/migrate-check.sh --host oxps

Fix every ✗. Warnings are fine to defer to step 5.

1. Export (on your workstation)

tooling/migrate-export.sh --from cochi --out ~/bundles
# → ~/bundles/ulicraft-bundle-cochi-YYYYMMDD-HHMMSS.tar.gz  (mode 0600)

Reads the source read-only; writes nothing there. The bundle holds private keys and .env secrets — it is created 0600, keep it that way.

2. Get the repo + bundle onto the target

# clone the stack repo on oxps (add oxps's Gitea deploy key first)
git clone <repo> ~/mc/ulicraft-server && cd ~/mc/ulicraft-server
# copy the bundle over
scp ~/bundles/ulicraft-bundle-cochi-*.tar.gz oxps:~/mc/ulicraft-server/

The checkout directory name is the Compose project name (here ulicraft-server). Volumes restore as <dirname>_drasl_state, and docker compose later looks for exactly that — so whatever you name the dir, be consistent. (Pin COMPOSE_PROJECT_NAME in .env if you want it fixed.)

3. Import (on the target)

cd ~/mc/ulicraft-server
tooling/migrate-import.sh ulicraft-bundle-cochi-*.tar.gz \
  --path-prefix /home/ubuntu/mc=/home/$USER/mc

--path-prefix OLD=NEW rewrites the source-absolute host paths in .env (DISTRIBUTION_WEB_ROOT, GENERATORS_REPO, GENERATORS_DATA_DIR, FILES_DATA_DIR) to where they live on oxps. Import then checks each one exists and warns loudly about any that don't — DISTRIBUTION_WEB_ROOT especially, since the server won't boot without it.

Guards: it refuses to overwrite volumes that already exist (pass --force to replace them — a clean wipe, not a merge), backs up any existing .env, and verifies the bundle's sha256 checksums before touching anything.

4. Deploy the distribution repo

Clone/publish the distribution repo to the DISTRIBUTION_WEB_ROOT path from step 3. Two checkouts drift — publish deliberately, don't blind-rsync from cochi. Add oxps's own Gitea deploy key for that repo.

5. Finish the host setup

tooling/fetch-authlib.sh
tooling/fetch-launcher.sh
tooling/sync-generators-data.sh          # needs WAN
tooling/render-nginx.sh --install        # host nginx vhost → caddy
tooling/issue-letsencrypt.sh             # seed acme.sh + its renewal cron
# firewall: tcp 25565, 80, 443, CADDY_HTTP_PORT ; udp 24454

Bundle certs are a bridge, not a renewal. The certs from the bundle let nginx start immediately, but nothing renews them — acme.sh's state lived on cochi. Run issue-letsencrypt.sh on oxps once so renewals happen here. It uses the OVH DNS-01 challenge, so it works before DNS points at oxps and needs no inbound ports.

6. Cut over

./update-all.sh --hard          # render → sync → build → up
docker compose ps               # all services healthy

Point DNS ${BASE_DOMAIN} + subdomains at oxps. Once a player has joined:

docker compose exec minecraft rcon-cli op <name>   # OPS env breaks on Drasl UUIDs

Rollback window — one way, mind the clock

Same domain ⇒ DNS points at one host. cochi stays as a fallback, but the moment a player registers or changes a skin on oxps, drasl.db diverges from cochi's copy. Rolling back to cochi then silently loses those new accounts/skins. Safe for hours as a fallback; not for weeks. If you must roll back after real use, re-export from oxps and re-import onto cochi first.

Re-running / testing

Everything is idempotent-ish and safe to rehearse:

  • migrate-export.sh never writes on the source; run it as often as you like.
  • migrate-import.sh refuses to clobber existing volumes without --force, so a second run on a populated host stops instead of nuking data. To rehearse a full restore on a throwaway host, import, inspect, docker compose down -v, repeat.