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>
10 KiB
Minecraft server — itzg NeoForge
Summary
itzg/minecraft-server:java21, NeoForge 1.21.1, offline-mode + authlib-injector
pointing at drasl. Mods come from a local ./server-mods volume (mounted at
/mods), NOT packwiz — see 04-mods.md. itzg installs MC + NeoForge + libraries
on first boot and auto-syncs /mods → /data/mods every boot; the stack assumes
the internet is present (for the NeoForge installer), so TYPE=NEOFORGE boots
normally every time.
Server config
TYPE=NEOFORGE, VERSION=1.21.1, NEOFORGE_VERSION=21.1.x
REMOVE_OLD_MODS=TRUE # ./server-mods is the authoritative mod set
volume ./server-mods:/mods:ro
./server-mods is populated by tooling/sync-server-mods.sh (the both/server
subset of mods-sides.json, copied from $DISTRIBUTION_WEB_ROOT). First boot
installs MC + NeoForge + libraries into the mc_data volume; every boot itzg
mirrors /mods into /data/mods and prunes removed ones (REMOVE_OLD_MODS).
Auth / config
ONLINE_MODE=TRUE
ENFORCE_SECURE_PROFILE=FALSE # pairs with drasl SignPublicKeys=false
JVM_OPTS=-javaagent:/extras/authlib-injector.jar=http://auth.${BASE_DOMAIN}/authlib-injector
authlib URL uses the auth subdomain; resolves internally via the caddy alias.
(Server still needs the agent even though Fjord clients have it built in.)
ONLINE_MODE MUST be true. authlib-injector works by redirecting the
server's normal online-auth handshake from Mojang to Drasl. With
ONLINE_MODE=FALSE the server skips that handshake entirely → players get
offline-hash UUIDs, no session validation, and no skins (the server never
fetches textures from Drasl). The thing that must stay off for 1.21+ is the
secure profile (ENFORCE_SECURE_PROFILE=FALSE + Drasl SignPublicKeys=false),
not online-mode. Symptom of getting this wrong: everyone can join but everyone is
Steve, and usercache.json shows offline-hash UUIDs.
Memory: INIT_MEMORY 4G, MAX_MEMORY 10G, USE_AIKAR_FLAGS TRUE.
RCON enabled for mc-backup.
Server operators (OP)
Do NOT use the itzg OPS env var. It resolves usernames to UUIDs via a
Mojang lookup, but this server is ONLINE_MODE=TRUE validated against Drasl —
Drasl assigns its own UUIDs (not Mojang, not the offline-hash). A Mojang-resolved UUID
would not match the player's real session UUID, so the op entry would be inert
(and could clobber a correct ops.json on reboot).
Op via RCON instead. The player must have connected at least once so their
real Drasl UUID is cached in /data/usercache.json; then op <name> writes that
cached UUID to ops.json. On the host:
ssh cochi
docker exec minecraft rcon-cli list # confirm cache / who's on
docker exec minecraft sh -c 'cat /data/usercache.json' # verify name→UUID cached
docker exec minecraft rcon-cli op <name>
docker exec minecraft sh -c 'cat /data/ops.json' # verify level 4 + right UUID
ops.json lives in the mc_data volume → persists across restarts; no reboot
needed (takes effect live). Current op: oier
(7fbc9adb-bcf4-3913-9a99-46d46acd1009), level 4, set 2026-06-21.
Simple Voice Chat — voice_host (NOT repo-tracked)
Voice chat is UDP 24454/udp, published in compose. The mod auto-generates
/data/config/voicechat/voicechat-server.properties in the mc_data volume.
Gotcha — voice_host must be set or remote clients get a red/crossed icon.
Behind any NAT/Docker the server announces an address for clients' UDP
connection; if voice_host= is empty it auto-detects the internal Docker IP →
unreachable → the secret (TCP) is sent but the UDP handshake never completes
("Sent secret" with no following "successfully connected to voice chat").
Set on prod:
docker exec minecraft sed -i 's/^voice_host=.*/voice_host=ulicraft.net/' \
/data/config/voicechat/voicechat-server.properties
docker restart minecraft # mod reads props only at startup
⚠️ This value lives in the mc_data volume, NOT auto-managed from this repo.
It survives restarts and compose down/up, but a world wipe / fresh mc_data
regenerates the file with voice_host= empty → voice silently breaks again.
A known-good prod snapshot is committed at
server-configs/voicechat-server.properties (reference copy, voice_host=ulicraft.net).
After a world reset, either re-run the sed + docker restart above, or restore
the snapshot:
docker cp server-configs/voicechat-server.properties \
minecraft:/data/config/voicechat/voicechat-server.properties
docker restart minecraft
(Snapshot is not auto-mounted — to make it durable, add a bind mount of
server-configs/voicechat-server.properties into /data/config/voicechat/ in
compose.)
Verify after a client joins:
docker logs minecraft 2>&1 | grep -i "connected to voice" → expect
Player <name> ... successfully connected to voice chat.
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 - Compose: mount
./server-mods:/mods:ro+REMOVE_OLD_MODS=TRUE - Run
tooling/sync-server-mods.shbefore bring-up (populates./server-mods) - Mount
./runtimefor authlib-injector.jar at/extras depends_on: drasl (authlib must resolve at boot)- Confirm
ENFORCE_SECURE_PROFILE=FALSE↔ draslSignPublicKeys=false - Verify a client joins with their drasl skin
- After any world wipe: re-set
voice_host(see Simple Voice Chat section)