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>
234 lines
10 KiB
Markdown
234 lines
10 KiB
Markdown
# 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)
|