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