Mounting the whole kubejs dir read-only made KubeJS's BaseProperties.save() throw NPE on boot (it writes config/cache/generated/logs under kubejs/), so KubeJS never initialised. Bind only the source subdirs (server_scripts, startup_scripts, data, assets) read-only and let the kubejs root live writable in mc_data. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
230 lines
10 KiB
YAML
230 lines
10 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: offline mode + authlib-injector pointing at Drasl ─
|
|
ONLINE_MODE: "FALSE"
|
|
# 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
|
|
- ./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}"
|
|
|
|
# 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
|
|
|
|
# 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:
|
|
|
|
networks:
|
|
mcnet:
|
|
driver: bridge
|