Files
ulicraft-server-v1/docker-compose.yml
T
oier b6b02398da feat(generators): offline data pack generators at generators.
Add generators.${BASE_DOMAIN}: a pinned fork of misode.github.io served
with its own mcmeta + vanilla-mcdoc mirror, so it works with zero internet
at the LAN party. Fork lives in ulicraft-generators (branch ulicraft),
pinned at upstream 2bfe5522.

The app is 100% client-side: every data fetch comes from the player's
browser, not from cochi. So offline capability is entirely about which
origins the bundle refers to, plus a mirror to serve them from. The vhost
sets Content-Security-Policy with connect-src 'self' as ENFORCEMENT: an
external origin that sneaks back in (or returns via an upstream merge)
then breaks loudly in prod, on an ordinary day, instead of silently at the
venue with no internet to debug it.

sync-generators-data.sh mirrors ~109M: the 1.21.1 summary/data/atlas tags,
assets-tiny (assets minus hundreds of MB of .ogg — hence no sounds
generator), vanilla-mcdoc symbols.json, and summary/versions/data.min.json,
which comes from a BRANCH head rather than a version tag and runs during
Spyglass init. It verifies a sentinel file per source instead of trusting a
clean clone exit, since a renamed upstream path would otherwise sync
"successfully" and only fail in the browser at the party.

The container serves the bundle and the mirror on one origin, so there is
no CORS, no second cert and no second DNS name.

DEPLOY ORDER MATTERS. GENERATORS_REPO must be set in cochi's .env BEFORE
pulling this: compose uses ${GENERATORS_REPO:?...}, and an unset required
variable makes docker compose fail to parse the file — taking down every
service including Minecraft, not just this one. Full runbook and the rest
of the prerequisites: plan/21-generators.md.

Verified offline in real Chromium with every non-origin request aborted:
zero off-origin attempts, zero same-origin failures, zero console errors,
generators rendering schemas and live JSON.
2026-07-21 18:59:01 +02:00

300 lines
14 KiB
YAML

# ──────────────────────────────────────────────────────────────────
# Ulicraft LAN-party stack — Minecraft 1.21.1 NeoForge + Drasl auth
# ──────────────────────────────────────────────────────────────────
# Single, unified compose. One file holds the whole stack:
# drasl auth + skins (Yggdrasil), internal only
# minecraft the server (itzg/NEOFORGE), mods from ./server-mods volume
# mc-backup world backups every 6h via RCON
# caddy internal ingress — routes auth./apex/launcher/avatar.
# /distribution. by Host header (conf.d/*.caddy)
# nmsr skin/avatar renderer at avatar.${BASE_DOMAIN}
# uptime-kuma status monitoring at status.${BASE_DOMAIN}
#
# Bring up: docker compose up -d (render configs first: tooling/render-config.sh)
#
# DNS is handled OUTSIDE this repo — point every service name at the host.
# Production TLS terminates at the HOST's nginx in front of caddy
# (nginx/ulicraft-caddy.conf.tmpl + Let's Encrypt); caddy is published on a
# localhost-only port (CADDY_HTTP_BIND/CADDY_HTTP_PORT in .env). Containers reach
# the stack's own names internally via caddy's mcnet alias (auth.).
# ──────────────────────────────────────────────────────────────────
services:
drasl:
image: unmojang/drasl:latest
container_name: drasl
restart: unless-stopped
# Internal-only: no published host port. Reached via caddy at
# auth.${BASE_DOMAIN} (caddy holds the network alias on mcnet).
volumes:
- ./drasl/config:/etc/drasl:ro
- drasl_state:/var/lib/drasl
networks:
- mcnet
# No env vars — config is the rendered ./drasl/config/config.toml
# (from config.toml.tmpl via tooling/render-config.sh).
minecraft:
image: itzg/minecraft-server:java21
container_name: minecraft
restart: unless-stopped
stdin_open: true
tty: true
ports:
- "25565:25565"
- "24454:24454/udp" # Simple Voice Chat, if used
environment:
EULA: "TRUE"
TYPE: "NEOFORGE"
VERSION: "1.21.1"
# Verify NEOFORGE_VERSION against projects.neoforged.net before deploy.
# Must match the distribution's NeoForge (distribution.json → 21.1.233);
# several mods (ferritecore≥218, farmersdelight≥219, configured≥211) need it.
NEOFORGE_VERSION: "21.1.233" # pin a known-good build; update deliberately
# ── Memory / performance ────────────────────────────────────
INIT_MEMORY: "4G"
MAX_MEMORY: "10G" # 8 players + 100 mods comfortably
USE_AIKAR_FLAGS: "TRUE"
JVM_XX_OPTS: "-XX:+UseG1GC"
# ── Auth: ONLINE mode validated against Drasl via authlib-injector ─
# MUST be true: authlib-injector redirects the normal online-auth
# handshake from Mojang to Drasl. online-mode=false skips the handshake
# entirely -> offline UUIDs, no session validation, NO SKINS for anyone.
# Secure-profile is the part that must stay off on 1.21+ (see
# ENFORCE_SECURE_PROFILE + Drasl SignPublicKeys=false), NOT online-mode.
ONLINE_MODE: "TRUE"
# Resolves over mcnet to caddy (alias auth.${BASE_DOMAIN}) -> drasl.
JVM_OPTS: "-javaagent:/extras/authlib-injector.jar=https://auth.${BASE_DOMAIN}/authlib-injector"
# ── Server config ───────────────────────────────────────────
DIFFICULTY: "normal"
MODE: "survival"
MOTD: "Uli LAN party"
# Server-list icon (auto-scaled to 64x64). PNG lives in ./runtime (mounted
# /extras); OVERRIDE_ICON re-applies it on every boot. Also surfaces in the
# landing ServerCard via the SLP favicon.
ICON: "/extras/server-icon.png"
OVERRIDE_ICON: "TRUE"
MAX_PLAYERS: "10"
VIEW_DISTANCE: "10"
SIMULATION_DISTANCE: "8"
ENFORCE_SECURE_PROFILE: "FALSE" # required for authlib-injector on 1.21+
ALLOW_FLIGHT: "TRUE" # many tech mods need this
# ── Mod source: filtered subset of the distribution modset ──
# Jars are synced into ./server-mods by tooling/sync-server-mods.sh
# (both/server entries from mods-sides.json) and mounted at /mods below;
# itzg auto-syncs /mods → /data/mods. REMOVE_OLD_MODS makes that mirror
# authoritative so removed mods get pruned. See plan/04-mods.md.
REMOVE_OLD_MODS: "TRUE"
# ── RCON for remote admin ───────────────────────────────────
ENABLE_RCON: "TRUE"
RCON_PASSWORD: ${RCON_PASSWORD}
RCON_PORT: 25575
TZ: "Europe/Madrid"
volumes:
- mc_data:/data
- ./runtime:/extras:ro # authlib-injector.jar lives here
- ./server-mods:/mods:ro # itzg auto-syncs /mods → /data/mods
# Distribution client-override files the server also needs, from the live
# distribution (DISTRIBUTION_WEB_ROOT), same source as caddy. Only the kubejs
# *source* subdirs are bind-mounted ro — kubejs itself writes config/, cache/,
# generated/, logs/ at boot, so the kubejs ROOT must stay writable (lives in
# mc_data). Mounting the whole kubejs dir ro makes BaseProperties.save() NPE
# and kubejs never initialises. CustomSkinLoader is ro (read-only json).
- ${DISTRIBUTION_WEB_ROOT:?DISTRIBUTION_WEB_ROOT unset in .env}/servers/ulicraft-1.21.1/files/kubejs/server_scripts:/data/kubejs/server_scripts:ro
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/kubejs/startup_scripts:/data/kubejs/startup_scripts:ro
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/kubejs/data:/data/kubejs/data:ro
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/kubejs/assets:/data/kubejs/assets:ro
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/CustomSkinLoader:/data/CustomSkinLoader:ro
depends_on:
- drasl # authlib (auth.) must resolve at boot
# Containers don't inherit the host's /etc/hosts; the LAN resolver returns a
# dead address for the public names. Pin auth. to the host so HTTPS to it
# lands on the host's nginx (valid LE cert) -> caddy -> drasl.
extra_hosts:
- "auth.${BASE_DOMAIN}:host-gateway"
networks:
- mcnet
mc-backup:
image: itzg/mc-backup
container_name: mc-backup
restart: unless-stopped
environment:
BACKUP_INTERVAL: "6h"
RCON_HOST: "minecraft"
RCON_PASSWORD: ${RCON_PASSWORD}
PRUNE_BACKUPS_DAYS: "14"
TZ: "Europe/Madrid"
volumes:
- mc_data:/data:ro
- ./backups:/backups
depends_on:
- minecraft
networks:
- mcnet
# Internal ingress. Routes every vhost by Host header (conf.d/*.caddy):
# auth. -> drasl, apex -> landing + /launcher,
# avatar. -> nmsr, status. -> uptime-kuma, distribution. -> static site.
# Published on a localhost-only port; the host's nginx terminates TLS in
# front of it (nginx/ulicraft-caddy.conf.tmpl).
caddy:
image: caddy:alpine
container_name: caddy
restart: unless-stopped
ports:
- "${CADDY_HTTP_BIND:-127.0.0.1}:${CADDY_HTTP_PORT:-8880}:80"
environment:
BASE_DOMAIN: ${BASE_DOMAIN}
volumes:
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy/conf.d/00-core.caddy:/etc/caddy/conf.d/00-core.caddy:ro
- ./caddy/conf.d/10-static.caddy:/etc/caddy/conf.d/10-static.caddy:ro
- ./caddy/conf.d/30-distribution.caddy:/etc/caddy/conf.d/30-distribution.caddy:ro
- ./caddy/conf.d/40-avatar.caddy:/etc/caddy/conf.d/40-avatar.caddy:ro
- ./caddy/conf.d/50-status.caddy:/etc/caddy/conf.d/50-status.caddy:ro
- ./caddy/conf.d/60-files.caddy:/etc/caddy/conf.d/60-files.caddy:ro
- ./caddy/conf.d/70-generators.caddy:/etc/caddy/conf.d/70-generators.caddy:ro
- ./www:/srv/www:ro
- ./launcher:/srv/launcher:ro
# Static site from ANOTHER repo (absolute host path in .env).
- ${DISTRIBUTION_WEB_ROOT:?DISTRIBUTION_WEB_ROOT unset in .env}:/srv/distribution:ro
networks:
mcnet:
# Containers resolve the stack's own public names to caddy internally.
aliases:
- "auth.${BASE_DOMAIN}"
# Minecraft data pack generators at generators.${BASE_DOMAIN} — a pinned fork
# of misode.github.io, built from source. caddy proxies generators. -> :80.
# Full design + the offline rationale: plan/21-generators.md.
#
# Built from the FORK's own checkout (GENERATORS_REPO), not from ./docker/,
# because the fork is not published yet — unlike nmsr, its Dockerfile cannot
# git-clone a pinned upstream ref. The fork carries its own Dockerfile+Caddyfile.
#
# The container serves the app AND the data mirror on one origin. That is the
# entire offline strategy: this app is 100% client-side, so every fetch comes
# from the player's browser. At the party there is no internet — anything
# off-origin is simply dead, which is why caddy/conf.d/70-generators.caddy
# enforces `connect-src 'self'`.
generators:
build:
context: ${GENERATORS_REPO:?GENERATORS_REPO unset in .env (path to the ulicraft-generators checkout)}
args:
# Must match the mcmeta ref mirrored into GENERATORS_DATA_DIR and the
# MIRRORED_VERSION pin in the fork's src/app/services/Versions.ts.
LATEST_VERSION: "1.21.1"
image: ulicraft/generators:local
container_name: generators
restart: unless-stopped
volumes:
# ~107M mcmeta + vanilla-mcdoc mirror from tooling/sync-generators-data.sh.
# Read-only: it is a fetched artifact, never something the app writes.
- ${GENERATORS_DATA_DIR:-./generators-data}:/srv/data:ro
networks:
- mcnet
# Avatar/skin renderer — built from source (docker/nmsr/Dockerfile), pulls
# player profiles from Drasl over mcnet. caddy proxies avatar. -> nmsr:8080.
nmsr:
build:
context: ./docker/nmsr
image: ulicraft/nmsr:local
container_name: nmsr
restart: unless-stopped
volumes:
- ./nmsr/config.toml:/nmsr/config.toml:ro
# Drasl embeds absolute HTTPS skin URLs (BaseURL is https) in the profile.
# The mcnet alias resolves auth. -> caddy (http :80 only), so NMSR's https
# skin fetch to auth.:443 finds no listener and every avatar falls back to
# the default skin. Pin auth. to the host (same as the minecraft service) so
# NMSR reaches the host nginx on :443 with the real LE cert.
extra_hosts:
- "auth.${BASE_DOMAIN}:host-gateway"
networks:
- mcnet
# Self-hosted SLP→JSON pinger (built from docker/mc-status, mcutil wrapper).
# Emits mcstatus.io v2 JSON so the landing's ServerCard reads it unchanged.
# No published port — caddy proxies apex /api/mcstatus/* -> mc-status:8080.
# Pings the minecraft service internally over mcnet; result cached 30s.
mc-status:
build:
context: ./docker/mc-status
image: ulicraft/mc-status:local
container_name: mc-status
restart: unless-stopped
environment:
MC_STATUS_TARGET: "minecraft:25565"
MC_STATUS_CACHE_TTL: "30s"
# Registered-players roster (/api/mcstatus/players). mc-status logs into
# the Drasl admin API server-side and exposes a sanitized [{uuid,name}]
# list; these creds NEVER reach the browser. Use a dedicated, rotatable
# admin account. Isolated from the status pinger (own TTL/client).
DRASL_API_URL: "http://drasl:25585/drasl/api/v2"
DRASL_ADMIN_USERNAME: ${DRASL_ADMIN_USERNAME}
DRASL_ADMIN_PASSWORD: ${DRASL_ADMIN_PASSWORD}
MC_STATUS_ROSTER_TTL: "60s"
networks:
- mcnet
# Player file share (screenshots, schematics, maps) at files.${BASE_DOMAIN}.
# ONE shared login for everybody (FILES_AUTH=htpasswd), writable. See
# plan/20-files.md — it documents every non-obvious bit of this service.
#
# Config is rendered read-only from filestash/config.json.tmpl: filestash has
# no env-var config, and its admin console writes config.json back at runtime,
# so pinning it :ro is what keeps git authoritative. The console still loads —
# it just cannot save. That is intentional, not a bug.
filestash:
image: machines/filestash@sha256:8cb09d42470328a02aec6e3861f912addf8a9d54ad63d9c965bcb48df5ad36b1
container_name: filestash
restart: unless-stopped
environment:
# The `local` storage backend is admin-gated in filestash: it refuses to
# init unless the connection params carry this secret (or the admin
# password). config.json's attribute_mapping supplies it server-side, so
# players never see it. Without this, every login dies on "Not Allowed".
LOCAL_BACKEND_SECRET: ${FILES_LOCAL_SECRET:?FILES_LOCAL_SECRET unset in .env}
# Do NOT set APPLICATION_URL / ADMIN_PASSWORD here: either one makes
# filestash rewrite config.json at boot, which fails on the :ro mount.
# Both live in the rendered config instead (general.host / auth.admin).
volumes:
# /app/data/state holds config + sqlite + search index + logs, so the
# DIRECTORY must stay writable. Only config.json is pinned read-only.
- filestash_state:/app/data/state
- ./filestash/config.json:/app/data/state/config/config.json:ro
# The share itself. FILES_MOUNT_MODE is the literal mount flag (ro|rw) —
# `ro` enforces a read-only share at the KERNEL, whatever the UI thinks.
- ${FILES_DATA_DIR:?FILES_DATA_DIR unset in .env}:/data/files:${FILES_MOUNT_MODE:-ro}
networks:
- mcnet
# Status monitoring + public status page. caddy proxies status. -> :3001.
# Joins mcnet so monitors probe the stack by internal service name.
uptime-kuma:
image: louislam/uptime-kuma:1
container_name: uptime-kuma
restart: unless-stopped
volumes:
- kuma_data:/app/data
networks:
- mcnet
volumes:
drasl_state:
mc_data:
kuma_data:
filestash_state:
networks:
mcnet:
driver: bridge