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>
199 lines
8.1 KiB
Markdown
199 lines
8.1 KiB
Markdown
# 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):
|
|
|
|
```bash
|
|
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
|
|
|
|
```bash
|
|
tooling/migrate-check.sh --host oxps
|
|
```
|
|
|
|
Fix every `✗`. Warnings are fine to defer to step 5.
|
|
|
|
### 1. Export (on your workstation)
|
|
|
|
```bash
|
|
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
|
|
|
|
```bash
|
|
# 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)
|
|
|
|
```bash
|
|
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
|
|
|
|
```bash
|
|
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
|
|
|
|
```bash
|
|
./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:
|
|
|
|
```bash
|
|
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):
|
|
|
|
```bash
|
|
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.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.
|