feat(compose): mount mods, config and kubejs writable

Drop :ro from ./server-mods:/mods and the four kubejs source subdirs, and
add a new ./server-config:/data/config bind — mod config used to live only
inside the mc_data volume, unreachable from the host without root.

The config bind needs seeding: a bind mount shadows what is under it, so an
empty ./server-config hides the generated config and every mod falls back to
defaults. tooling/export-server-config.sh copies /data/config out of the
volume first, halting if the target is non-empty, if the container is still
running, or if the mc_data volume name is ambiguous.

Writable mounts also need the server's uid (itzg runs as 1000) to have write
access on the host dir; oxps' host user is 1002, hence MC_UID/MC_GID.

The kubejs ROOT still must not be mounted — BaseProperties.save() NPEs and
kubejs never initialises. That is about mounting the root at all, not about
the mode, so dropping :ro does not change it. It does mean kubejs writes now
land in $DISTRIBUTION_WEB_ROOT as uncommitted changes in that host's checkout.

Also documents how to reach the mc_data volume (exec/cp, resolved host path,
stop-before-write rule) in plan/05-minecraft.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
oierandClaude Opus 5 committed 2026-07-26 00:46:30 +02:00
1 parent dfb8719074
commit 92c6f5fc8f
7 files changed
+252 -8

No files matched your search

+107
View File
@@ -114,6 +114,113 @@ Note: a red voice icon can ALSO be client-side — a VPN/zero-trust client
(Cloudflare One/WARP, FortiClient) on the player's machine silently drops the
voice UDP. Rule out VPNs before touching the server.
## Writable bind mounts (mods / config / kubejs)
These three used to be `:ro`, which meant edits could only flow host → container
and the server could never write back. They are now mounted **rw**:
| Mount | Host source | Notes |
|---|---|---|
| `/mods` | `./server-mods` | still overwritten by `tooling/sync-server-mods.sh` |
| `/data/config` | `./server-config` | NEW bind — was inside `mc_data` |
| `/data/kubejs/{server_scripts,startup_scripts,data,assets}` | `$DISTRIBUTION_WEB_ROOT/servers/ulicraft-1.21.1/files/kubejs/…` | writes land in the distribution git checkout |
Still `:ro` on purpose: `./runtime` → `/extras` (vendored jars) and
`CustomSkinLoader` (static json).
**Unchanged rule — the kubejs ROOT must stay writable.** Only the four *source*
subdirs are bind-mounted; kubejs writes `config/`, `cache/`, `generated/`,
`logs/` under `/data/kubejs` at boot, and mounting the whole dir makes
`BaseProperties.save()` NPE so kubejs never initialises. Dropping `:ro` does not
change that — the root is what matters, not the mode.
### Seed `./server-config` before first boot
A bind mount **shadows** what is under it. An empty `./server-config` hides the
existing `/data/config` and every mod regenerates its defaults — tuning silently
reverted. The old files are not destroyed (they stay in `mc_data`, under the
mount) but the server stops seeing them. So, once per host:
```
docker compose stop minecraft
tooling/export-server-config.sh # oxps: MC_UID=1002 MC_GID=1002 tooling/…
docker compose up -d --force-recreate minecraft
```
The script halts rather than guessing: refuses if `./server-config` is non-empty,
if the container is still running, or if the `mc_data` volume name is ambiguous.
### Ownership — rw mounts need the server's uid
itzg runs the server as **uid 1000**. A writable bind mount is only writable if
the host dir permits uid 1000; otherwise the server logs permission errors (or
kubejs/config writes fail) even though the mount is rw. On `cochi` the host user
is uid 1000, so this is free. On `oxps` the host user `oier` is **uid 1002** —
`./server-config` gets chowned to `MC_UID`/`MC_GID` by the export script, so pass
the right values there, and expect the dir not to be editable as your own user
without sudo or a shared group.
`$DISTRIBUTION_WEB_ROOT` (kubejs) and `./server-mods` must satisfy the same rule
now that they are rw.
### Consequence — kubejs edits mutate the distribution repo
`/data/kubejs/{server_scripts,startup_scripts,data,assets}` are the live
distribution checkout. Anything the server writes there (or an in-game
`/kubejs reload` regenerating a file) shows up as an **uncommitted change in the
distribution repo**, on that host only. `cochi` and `oxps` hold separate
checkouts that already drift — check `git status` in `$DISTRIBUTION_WEB_ROOT`
before pulling or publishing from either.
## Accessing the `mc_data` volume
`mc_data` is a **named docker volume**, not a bind mount — there is no path in
this repo that mirrors the world. Same on both hosts (`cochi`, `oxps`); on `oxps`
the stack lives at `~/mc/ulicraft-server` and the host is LAN-only, so `ssh oxps`
works from the LAN only.
Preferred — go through the container (`container_name: minecraft`, world at
`/data`):
```
ssh oxps
docker exec -it minecraft bash # interactive poke around /data
docker exec minecraft ls /data/config # one-shot read
```
Copy files in/out without root:
```
docker cp minecraft:/data/logs/latest.log ./latest.log
docker cp ./foo.json minecraft:/data/config/
```
Host path (root-owned, needs sudo). The volume name is prefixed with the compose
project name, which defaults to the **directory name** — `ulicraft-server_mc_data`
unless the checkout dir differs, so resolve it rather than hardcoding:
```
docker volume ls | grep mc_data
docker volume inspect ulicraft-server_mc_data -f '{{.Mountpoint}}'
# → /var/lib/docker/volumes/ulicraft-server_mc_data/_data
sudo ls /var/lib/docker/volumes/ulicraft-server_mc_data/_data
```
Root-free equivalent when the container is down (also the shape used by the
`chown` fix in `plan/22-migration.md`):
```
docker run --rm -v ulicraft-server_mc_data:/data alpine ls /data
```
**⚠️ Do not write world files while the server is running** — the running server
holds region files open and will overwrite or corrupt concurrent edits. Stop it
first (`docker compose stop minecraft`), edit, then start. Reads are fine.
For a consistent snapshot of the world, prefer `backups/` (mc-backup mounts
`mc_data:/data:ro` and quiesces saves over RCON — see `plan/07-mc-backup.md`)
over copying `/data/world` out from under a live server.
## Tasks
- [ ] Compose: authlib URL `http://auth.${BASE_DOMAIN}/authlib-injector`
+12
View File
@@ -13,6 +13,7 @@ Surviving scripts:
- `classify-mods.py` — read each jar's `neoforge.mods.toml`, seed `mods-sides.json` (see 04-mods.md)
- `sync-server-mods.sh` — copy `both`/`server` jars from the distribution → `./server-mods` (see 04-mods.md)
- `build-modlist.py` — generate the landing `mods.json` + extract logos (see 17-mod-shortlist.md)
- `export-server-config.sh` — seed `./server-config` from `mc_data:/data/config` before the rw bind mount takes over (once per host, see 05-minecraft.md)
- `render-nginx.sh` — render/install the host nginx vhost (TLS terminator → caddy)
- `issue-letsencrypt.sh` — issue LE certs via OVH DNS-01 (see 15-letsencrypt.md)
- `fetch-launcher.sh` — download Fjord launcher release assets (see 06-launcher.md)
@@ -68,6 +69,15 @@ Downloads FjordLauncherUnlocked release assets → `launcher/<tag>/` and maintai
a `latest` symlink + stable per-OS filename symlinks. Full script in
`06-launcher.md`.
## export-server-config.sh
One-shot, **once per host**. Copies `/data/config` out of the `mc_data` volume
into `./server-config` so the rw bind mount `./server-config:/data/config` does
not shadow the mod configs the server already generated. Halts if the target is
non-empty, if the `minecraft` container is still running, or if the `mc_data`
volume name is ambiguous. Chowns the result to `MC_UID`/`MC_GID` (default
`1000:1000`; `oxps` needs `1002`). See `05-minecraft.md`.
## Generated / gitignored
```
@@ -76,6 +86,8 @@ nmsr/config.toml # rendered
launcher/ # launcher assets
www/ # landing build output
certs/ # Let's Encrypt certs
server-mods/ # synced from the distribution
server-config/ # exported from mc_data, then bind-mounted rw
.env # secrets + host-specific
```
+6
View File
@@ -136,6 +136,12 @@ restart (a few minutes); they reconnect once `minecraft` is healthy again.
- 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.
- **Once per host, before the first deploy carrying the `./server-config` bind
mount** — run `tooling/export-server-config.sh` with the server stopped. The
mount shadows `/data/config`, so deploying without seeding first boots the
server against an empty config dir and every mod regenerates its defaults.
`oxps`: `MC_UID=1002 MC_GID=1002 tooling/export-server-config.sh`. See
`05-minecraft.md` → "Writable bind mounts".
## Verify