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:
1 parent
dfb8719074
commit
92c6f5fc8f
7 files changed
+252
-8
No files matched your search
@@ -14,6 +14,11 @@ filestash/config.json
|
||||
# tooling/sync-server-mods.sh (source of truth is mods-sides.json + $DIST jars)
|
||||
server-mods/
|
||||
|
||||
# mod configs, seeded out of the mc_data volume by tooling/export-server-config.sh
|
||||
# and then bind-mounted rw at /data/config. Host-specific runtime state (cochi and
|
||||
# oxps diverge), regenerated by the mods themselves — not a source of truth.
|
||||
server-config/
|
||||
|
||||
# vendored binaries
|
||||
launcher/
|
||||
|
||||
|
||||
@@ -109,6 +109,22 @@ automatically; cosmetic-client mods that declare `BOTH` stay `both` until you
|
||||
hand-edit `mods-sides.json` to `client`. No Modrinth/packwiz, no network at
|
||||
classify/sync time. See `plan/04-mods.md`.
|
||||
|
||||
### Writable bind mounts — seed `./server-config` first
|
||||
`/mods`, `/data/config` and the four kubejs source subdirs are mounted **rw**
|
||||
(2026-07-26). Two consequences:
|
||||
- `./server-config:/data/config` is a NEW bind — config used to live inside
|
||||
`mc_data`. A bind mount **shadows** what's under it, so booting with an empty
|
||||
`./server-config` makes every mod regenerate defaults. Run
|
||||
`tooling/export-server-config.sh` once per host, server stopped, before that
|
||||
deploy. The old files survive in `mc_data` under the mount, but the server
|
||||
stops seeing them.
|
||||
- itzg runs as **uid 1000**; a rw mount only works if the host dir permits it.
|
||||
`cochi` is uid 1000, **`oxps` `oier` is uid 1002** → pass `MC_UID`/`MC_GID`.
|
||||
- kubejs writes now land in `$DISTRIBUTION_WEB_ROOT` — i.e. as uncommitted
|
||||
changes in that host's distribution checkout.
|
||||
The kubejs ROOT still must not be mounted (NPE at init) — unchanged by the mode.
|
||||
Full detail: `plan/05-minecraft.md` → "Writable bind mounts".
|
||||
|
||||
### Filestash (upload.) — four traps
|
||||
Filestash serves at `upload.${BASE_DOMAIN}` (can't live under a subpath — SPA
|
||||
hardcodes `/api`, `/assets`, `/login` at root). `files.${BASE_DOMAIN}` is a
|
||||
|
||||
+19
-8
@@ -100,17 +100,28 @@ services:
|
||||
volumes:
|
||||
- mc_data:/data
|
||||
- ./runtime:/extras:ro # authlib-injector.jar lives here
|
||||
- ./server-mods:/mods:ro # itzg auto-syncs /mods → /data/mods
|
||||
# Writable (no :ro) so edits round-trip: the container can write back into
|
||||
# the host dir. All of these must be writable by the server's uid (itzg runs
|
||||
# as 1000) — see plan/05-minecraft.md "Writable bind mounts".
|
||||
- ./server-mods:/mods # itzg auto-syncs /mods → /data/mods
|
||||
# Mod configs. NOT in mc_data anymore — bind-mounted so they are editable
|
||||
# from the host without root. Seed the dir from the volume BEFORE the first
|
||||
# boot with this mount (tooling/export-server-config.sh), otherwise an empty
|
||||
# host dir shadows the existing /data/config and every mod regenerates
|
||||
# defaults.
|
||||
- ./server-config:/data/config
|
||||
# Distribution client-override files the server also needs, from the live
|
||||
# distribution (DISTRIBUTION_WEB_ROOT), same source as caddy. Only the kubejs
|
||||
# *source* subdirs are bind-mounted ro — kubejs itself writes config/, cache/,
|
||||
# *source* subdirs are bind-mounted — kubejs itself writes config/, cache/,
|
||||
# generated/, logs/ at boot, so the kubejs ROOT must stay writable (lives in
|
||||
# mc_data). Mounting the whole kubejs dir ro makes BaseProperties.save() NPE
|
||||
# and kubejs never initialises. CustomSkinLoader is ro (read-only json).
|
||||
- ${DISTRIBUTION_WEB_ROOT:?DISTRIBUTION_WEB_ROOT unset in .env}/servers/ulicraft-1.21.1/files/kubejs/server_scripts:/data/kubejs/server_scripts:ro
|
||||
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/kubejs/startup_scripts:/data/kubejs/startup_scripts:ro
|
||||
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/kubejs/data:/data/kubejs/data:ro
|
||||
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/kubejs/assets:/data/kubejs/assets:ro
|
||||
# mc_data). Mounting the whole kubejs dir makes BaseProperties.save() NPE
|
||||
# and kubejs never initialises.
|
||||
# These write back into the distribution checkout — an in-game /kubejs
|
||||
# reload + edit lands in DISTRIBUTION_WEB_ROOT, i.e. in git. Intentional.
|
||||
- ${DISTRIBUTION_WEB_ROOT:?DISTRIBUTION_WEB_ROOT unset in .env}/servers/ulicraft-1.21.1/files/kubejs/server_scripts:/data/kubejs/server_scripts
|
||||
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/kubejs/startup_scripts:/data/kubejs/startup_scripts
|
||||
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/kubejs/data:/data/kubejs/data
|
||||
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/kubejs/assets:/data/kubejs/assets
|
||||
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/CustomSkinLoader:/data/CustomSkinLoader:ro
|
||||
depends_on:
|
||||
- drasl # authlib (auth.) must resolve at boot
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Executable
+87
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env bash
|
||||
# export-server-config.sh — seed ./server-config/ from /data/config inside the
|
||||
# mc_data volume, so the compose bind mount `./server-config:/data/config` can
|
||||
# take over without losing the mod configs the server already generated.
|
||||
#
|
||||
# WHY: a bind mount shadows whatever is under it. Mounting an empty host dir over
|
||||
# /data/config hides every existing config file and each mod regenerates its
|
||||
# defaults — silently reverting tuning. Run this ONCE, before the first boot with
|
||||
# the mount in place.
|
||||
#
|
||||
# Cautious by design: refuses to run if the target already has content, if the
|
||||
# volume can't be resolved unambiguously, or if the server is still running.
|
||||
#
|
||||
# Usage:
|
||||
# tooling/export-server-config.sh # export, then chown to MC_UID:MC_GID
|
||||
# MC_UID=1002 MC_GID=1002 tooling/… # oxps (host user oier = 1002)
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.." # repo root
|
||||
|
||||
[ -f .env ] && { set -a; . ./.env; set +a; }
|
||||
|
||||
TARGET="./server-config"
|
||||
MC_UID="${MC_UID:-1000}"
|
||||
MC_GID="${MC_GID:-1000}"
|
||||
|
||||
# ── resolve the mc_data volume ──────────────────────────────────────────────
|
||||
# Compose prefixes the volume with the project name (defaults to the repo dir
|
||||
# name), so never hardcode it — match and require exactly one.
|
||||
mapfile -t VOLS < <(docker volume ls -q | grep -E '(^|_)mc_data$' || true)
|
||||
if [ "${#VOLS[@]}" -eq 0 ]; then
|
||||
echo "FATAL: no mc_data volume found (docker volume ls). Nothing to export." >&2
|
||||
echo " If this is a fresh host there is no config yet — create an empty" >&2
|
||||
echo " $TARGET and let the server generate defaults into it." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "${#VOLS[@]}" -gt 1 ]; then
|
||||
echo "FATAL: multiple mc_data volumes, refusing to guess:" >&2
|
||||
printf ' %s\n' "${VOLS[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
VOL="${VOLS[0]}"
|
||||
|
||||
# ── refuse while the server is live (it rewrites config on shutdown) ────────
|
||||
if [ -n "$(docker ps -q -f name='^minecraft$')" ]; then
|
||||
echo "FATAL: the minecraft container is running. Stop it first:" >&2
|
||||
echo " docker compose stop minecraft" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── refuse to clobber an existing export ────────────────────────────────────
|
||||
if [ -d "$TARGET" ] && [ -n "$(ls -A "$TARGET" 2>/dev/null)" ]; then
|
||||
echo "FATAL: $TARGET already has content — refusing to overwrite." >&2
|
||||
echo " Move it aside if you really want a fresh export." >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$TARGET"
|
||||
|
||||
# ── copy out of the volume ──────────────────────────────────────────────────
|
||||
echo "Exporting $VOL:/data/config -> $TARGET"
|
||||
docker run --rm \
|
||||
-v "$VOL":/data:ro \
|
||||
-v "$PWD/$TARGET":/out \
|
||||
alpine sh -euc '
|
||||
if [ ! -d /data/config ]; then
|
||||
echo "FATAL: /data/config does not exist in the volume." >&2
|
||||
exit 1
|
||||
fi
|
||||
cp -a /data/config/. /out/
|
||||
'
|
||||
|
||||
# ── ownership: the server (uid 1000 under itzg) must be able to write ───────
|
||||
docker run --rm -v "$PWD/$TARGET":/out \
|
||||
alpine chown -R "$MC_UID:$MC_GID" /out
|
||||
|
||||
COUNT=$(find "$TARGET" -type f | wc -l)
|
||||
echo "Exported $COUNT files, owned by $MC_UID:$MC_GID."
|
||||
|
||||
if [ "$(id -u)" != "$MC_UID" ]; then
|
||||
echo
|
||||
echo "NOTE: your host uid ($(id -u)) != $MC_UID, so $TARGET is not yours to edit." >&2
|
||||
echo " Either edit via sudo, or add group write and join the group." >&2
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Next: the compose bind mount ./server-config:/data/config now takes over."
|
||||
echo " docker compose up -d --force-recreate minecraft"
|
||||
Reference in new issue
Block a user