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>
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). Theminecraftservice 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) viatooling/sync-generators-data.sh.runtime/authlib-injector.jar,launcher/via theirfetch-*.shscripts.
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 legacydocker-composev1 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.shon 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.shnever writes on the source; run it as often as you like.migrate-import.shrefuses 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.