Files
ulicraft-server-v1/plan/05-minecraft.md
Oier Bravo Urtasun e45ce210ba docs(mc): document Simple Voice Chat voice_host gotcha + commit prod snapshot
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>
2026-06-24 23:16:02 +02:00

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.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)