Files
ulicraft-server-v1/plan/05-minecraft.md
T
oierandClaude Opus 5 92c6f5fc8f 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>
2026-07-26 00:46:30 +02:00

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.sh before bring-up (populates ./server-mods)
  • Mount ./runtime for authlib-injector.jar at /extras
  • depends_on: drasl (authlib must resolve at boot)
  • Confirm ENFORCE_SECURE_PROFILE=FALSE ↔ drasl SignPublicKeys=false
  • Verify a client joins with their drasl skin
  • After any world wipe: re-set voice_host (see Simple Voice Chat section)