voice_host lives in the mc_data volume and regenerates empty on world wipe, silently breaking remote voice (secret sent, UDP handshake never completes → red icon). Document the set/restore steps and the VPN-client false-positive (Cloudflare One/WARP, FortiClient drop voice UDP). Add a known-good prod snapshot at server-configs/voicechat-server.properties (voice_host=ulicraft.net). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
5.6 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.
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)