On a fresh mc_data volume the minecraft service crash-loops with "AccessDeniedException: /data/kubejs/config" (ConsoleJS.STARTUP null NPE): Docker auto-creates the bind-mount parent /data/kubejs as root:root, but the itzg container runs as uid 1000, so KubeJS can't create its writable siblings (config/cache/generated/logs). Only bites fresh worlds — cochi's aged volume predates the ro submounts and is already 1000-owned. Fix (durable): chown 1000:1000 /data/kubejs once, then restart. - plan/22-migration.md: Troubleshooting section with symptom + fix. - migrate-import.sh: manual tail now flags it with the exact commands. Hit + resolved during the cochi->oxps migration. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
8.1 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.
Troubleshooting
minecraft crash-loops on a fresh host: AccessDeniedException: /data/kubejs/config
Symptom (in docker compose logs minecraft), restarting every ~15s:
java.nio.file.AccessDeniedException: /data/kubejs/config
Caused by: NullPointerException: Cannot invoke "...ConsoleJS.error(...)"
because "dev.latvian.mods.kubejs.script.ConsoleJS.STARTUP" is null
Cause. The compose file bind-mounts the kubejs source subdirs read-only
(server_scripts, startup_scripts, data, assets). To place those mounts,
Docker auto-creates their parent /data/kubejs — and on a fresh mc_data
volume it creates it as root:root. The itzg container runs as uid 1000,
so KubeJS can't create its writable siblings (config/, cache/, generated/,
logs/) → init aborts → ConsoleJS.STARTUP stays null → NPE → exit 1.
This only bites a fresh world (migration, new host, wiped mc_data). On an
aged host like cochi /data/kubejs predates the ro submounts and is already
1000-owned, so it never surfaced there.
Fix — chown the dir to 1000 once (durable; survives restarts):
docker compose stop minecraft
docker run --rm -v <project>_mc_data:/data alpine chown 1000:1000 /data/kubejs
docker compose up -d minecraft
<project> is the checkout dir basename (e.g. ulicraft-server). Distinct from
the "don't mount the whole kubejs dir ro" gotcha in plan/05-minecraft.md — same
mod, different trigger (ownership of the auto-created parent, not a ro root).
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.