# ────────────────────────────────────────────────────────────────── # 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 ENABLE_COMMAND_BLOCK: "TRUE" # ── 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 # Writable (no :ro) so edits round-trip: the container can write back into # the host dir. All of these must be writable by the server's uid (itzg runs # as 1000) — see plan/05-minecraft.md "Writable bind mounts". - ./server-mods:/mods # itzg auto-syncs /mods → /data/mods # Mod configs. NOT in mc_data anymore — bind-mounted so they are editable # from the host without root. Seed the dir from the volume BEFORE the first # boot with this mount (tooling/export-server-config.sh), otherwise an empty # host dir shadows the existing /data/config and every mod regenerates # defaults. - ./server-config:/data/config # 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 — 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 makes BaseProperties.save() NPE # and kubejs never initialises. # These write back into the distribution checkout — an in-game /kubejs # reload + edit lands in DISTRIBUTION_WEB_ROOT, i.e. in git. Intentional. - ${DISTRIBUTION_WEB_ROOT:?DISTRIBUTION_WEB_ROOT unset in .env}/servers/ulicraft-1.21.1/files/kubejs/server_scripts:/data/kubejs/server_scripts - ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/kubejs/startup_scripts:/data/kubejs/startup_scripts - ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/kubejs/data:/data/kubejs/data - ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/kubejs/assets:/data/kubejs/assets - ${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} # basic_auth credentials for monitor.${BASE_DOMAIN} (80-monitor.on.caddy). # MONITOR_PASSWORD_HASH must be BCRYPT (`caddy hash-password`) and must be # SINGLE-QUOTED in .env — compose interpolates $-sequences in .env values, # so a bare bcrypt hash gets silently mangled ($2a$14$abc… -> $$2a$$14…) # and every login fails. See plan/23-monitor.md. MONITOR_USER: ${MONITOR_USER:-admin} MONITOR_PASSWORD_HASH: ${MONITOR_PASSWORD_HASH:-} 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-upload.caddy:/etc/caddy/conf.d/60-upload.caddy:ro # files.${BASE_DOMAIN} read-only browse, gated by FILES_BROWSE (on|off). # The value is substituted straight into the source path — `on` mounts the # real vhost, `off` a comment-only stub — because compose has no # conditionals (same trick as FILES_MOUNT_MODE). Changing it needs # `docker compose up -d --force-recreate caddy`: a single-file bind mount # pins the inode. The public gate is the nginx vhost (render-nginx.sh # reads the same var); this one keeps caddy itself from serving the share. - ./caddy/conf.d/62-files.${FILES_BROWSE:-on}.caddy:/etc/caddy/conf.d/62-files.caddy:ro - ./caddy/conf.d/70-generators.caddy:/etc/caddy/conf.d/70-generators.caddy:ro # monitor.${BASE_DOMAIN} admin log stream, gated by MONITOR_ENABLED # (on|off) — same substitute-into-the-source-path trick as FILES_BROWSE # above, and the same recreate requirement when the value changes. - ./caddy/conf.d/80-monitor.${MONITOR_ENABLED:-on}.caddy:/etc/caddy/conf.d/80-monitor.caddy:ro - ./www:/srv/www:ro - ./launcher:/srv/launcher:ro # Read-only browse of the shared upload dir at files.${BASE_DOMAIN} # (62-files.caddy). Same host path filestash writes to via /data/files. - ${FILES_DATA_DIR:?FILES_DATA_DIR unset in .env}:/srv/files: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 upload.${BASE_DOMAIN}. # ONE shared login for everybody (FILES_AUTH=htpasswd), writable. The same # share is browsable read-only + no-auth at files.${BASE_DOMAIN} (via caddy). 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 # Runtime uid/gid of the filestash process. Default 1000:1000 = the image's # own `filestash` user (cochi runs this — files in the share end up 1000). # A host can override so share files are owned by a host account instead # (oxps: FILES_UID/FILES_GID=1002 → files owned by `oier`). When overriding # to a non-1000 uid you MUST also chown the filestash_state volume + the # FILES_DATA_DIR share to that uid — see plan/20-files.md. group_add keeps # the image binary (/app/filestash is 0730, group filestash) executable and # /app/data (0770) traversable no matter which uid runs; on cochi it is the # process's own gid, harmlessly redundant. user: "${FILES_UID:-1000}:${FILES_GID:-1000}" group_add: - "1000" 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 # Admin-only log stream at monitor.${BASE_DOMAIN} (built from docker/monitor). # Phase 1 of plan/23-monitor.md: tails the Minecraft server's latest.log into # an in-memory ring buffer and serves it to a small embedded UI by polling. # # Read-only in every sense: /data is mounted :ro, the service exposes no # mutating route, and it holds no credentials. It publishes NO port — the only # way in is caddy's basic_auth vhost, which is exactly the point (the container # has no auth of its own, so an accidental port publish would expose the raw # log — chat, IPs, admin commands — to the internet). Keep it portless. # # Uptime Kuma can probe http://monitor:8080/healthz directly over mcnet, # bypassing caddy, so no auth exemption is needed on the vhost. monitor: build: context: ./docker/monitor image: ulicraft/monitor:local container_name: monitor restart: unless-stopped environment: MONITOR_LOG_PATH: "/data/logs/latest.log" MONITOR_BUFFER_LINES: "5000" # Dashboard tiles. Server status + rosters are read from mc-status over # mcnet rather than from Drasl directly — mc-status already holds the # admin credentials and exposes a sanitized list, so this container does # not need a second copy of that secret. MONITOR_MCSTATUS_URL: "http://mc-status:8080" # Player heads. Rendered by NMSR on its own PUBLIC origin, so the browser # fetches them cross-origin (no auth involved, avatars are public anyway). MONITOR_AVATAR_URL: "https://avatar.${BASE_DOMAIN}" MONITOR_AVATAR_MODE: "${AVATAR_MODE:-headiso}" # TPS via RCON. This is the ONLY credential the monitor holds, and the # only command it can send is the compile-time allowlist in rcon.go # (`neoforge tps`, `list`) — no HTTP input reaches a command string, so # the UI stays incapable of running anything. NeoForge 1.21.1 uses # `neoforge tps`; `forge tps` does not exist here. MONITOR_RCON_ADDR: "minecraft:25575" MONITOR_RCON_PASSWORD: ${RCON_PASSWORD} MONITOR_TPS_INTERVAL: "30s" volumes: # The whole Minecraft data dir, read-only. Only logs/latest.log is read; # mounting /data rather than /data/logs keeps the path stable if itzg ever # moves the log dir, and :ro means it cannot touch the world either way. # It is also what the disk-free tile statfs's. - mc_data:/data:ro # Backup freshness tile. Same dir mc-backup writes to, read-only here. - ./backups:/backups: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:2 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