Add docker-compose.status.yml (louislam/uptime-kuma) layered on the caddy ingress, joining mcnet so it probes the stack by internal service name and the public vhosts end-to-end. Host port bound to 127.0.0.1:3001 only; public access via nginx -> caddy. - caddy/conf.d/50-status.caddy: status. -> uptime-kuma:3001 - nginx caddy-front template: status. TLS vhost + websocket upgrade headers for Kuma's live UI; status. added to the HTTP->HTTPS redirect list - status added to LE_SUBDOMAINS defaults (.env.example, issue-letsencrypt.sh) - README: Status monitoring section + recommended monitor list (incl. native Minecraft-protocol ping) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ulicraft Server
Self-hosted modded Minecraft (NeoForge 1.21.1) for a LAN party. Runs online (internet present) or fully air-gapped (offline). Self-hosted auth/skins (Drasl), a packwiz modpack, a guest landing page, and a transparent mirror of Mojang/launcher/NeoForge assets so guest laptops need no internet.
Stack
| Service | Image | Role |
|---|---|---|
minecraft |
itzg/minecraft-server | NeoForge 1.21.1 server, :25565 |
drasl |
unmojang/drasl | Yggdrasil auth + skins (password login) |
caddy |
caddy:alpine | ingress: landing, auth/packwiz proxy, asset mirror (tls internal) |
mc-backup |
itzg/mc-backup | world backups every 6h via RCON |
Optional layers (each its own docker-compose.<name>.yml):
avahi(mDNS.local),dnsmasq(turnkey DNS) — only if you don't run your own DNS.nmsr(NickAcPT/nmsr-rs, built from source) — skin/avatar renderer atavatar.${BASE_DOMAIN}, sourced from Drasl. See Avatar renderer below.distribution— extra static vhost atdistribution.${BASE_DOMAIN}whose web root (DISTRIBUTION_WEB_ROOT) lives in another repo.status(louislam/uptime-kuma) — uptime monitoring + public status page atstatus.${BASE_DOMAIN}. See Status monitoring below.
Config lives in .env: BASE_DOMAIN (default ulicraft.lan), HOST_LAN_IP,
RCON_PASSWORD, ENABLE_MDNS. Services are subdomains: auth., packwiz.,
mc., plus the apex landing page.
DNS (pick one)
Names must resolve to HOST_LAN_IP. Use your own party DNS server (recommended):
tooling/dns-records.sh online # service names only (internet present)
tooling/dns-records.sh airgap # + Mojang/launcher/NeoForge spoofs (offline)
Paste the printed records into your DNS. Use .lan, not .local — .local
is intercepted by mDNS and won't resolve via unicast DNS.
Alternatives: ENABLE_MDNS=true + BASE_DOMAIN=*.local (zero-config mDNS,
macOS/Linux native, Windows needs Bonjour) — but mDNS can't serve the air-gap
upstream spoofs. Or layer in docker-compose.dnsmasq.yml for a turnkey DNS.
Simulate the DNS via /etc/hosts (single machine, testing)
No DNS server? Append the records straight to /etc/hosts (marker-wrapped):
tooling/dns-records.sh online hosts | sudo tee -a /etc/hosts # services only
tooling/dns-records.sh airgap hosts | sudo tee -a /etc/hosts # + Mojang spoofs
Remove them later:
sudo sed -i '/# >>> ulicraft/,/# <<< ulicraft/d' /etc/hosts
Notes:
/etc/hostshas no wildcard — only the listed names resolve.airgapadds the Mojang/launcher/NeoForge hostnames pointing at the LAN; this breaks normal internet access to those domains while present. Use it only while offline; for an online box useonline.mc.<domain>works (client defaults to :25565); SRV isn't used via/etc/hosts.
Prerequisites
Docker + Docker Compose; for prep also pnpm, packwiz, jq, curl,
envsubst, sha1sum.
Launch
cp .env.example .env # set BASE_DOMAIN, HOST_LAN_IP, RCON_PASSWORD
tooling/build-stack.sh --prep # ONLINE, once: render, build landing,
# mirror mods+assets, fetch launcher +
# authlib, pre-bake the server volume
tooling/build-stack.sh --up online # internet present, no mirror
tooling/build-stack.sh --up airgap # offline; full asset mirror on :443 (default)
tooling/build-stack.sh --up core # base only (debugging)
--up prints the DNS records to add for that mode. Down:
docker compose -f docker-compose.yml -f docker-compose.static.yml -f docker-compose.mirror.yml down
Public TLS (host nginx in front of caddy)
The LAN path above runs caddy on :80. To expose the stack publicly with real
certs, the machine's own nginx terminates TLS and reverse-proxies every vhost to
caddy by Host header. Caddy stays the single router (apex landing, www. →
apex, auth. → drasl, pack. packwiz, distribution. static, avatar. →
nmsr). HTTPS-only: HTTP 301s to HTTPS and every TLS vhost sends HSTS.
-
Issue certs (OVH DNS-01, no inbound ports needed):
# .env: BASE_DOMAIN, LE_EMAIL, OVH_* creds, # LE_SUBDOMAINS="auth pack distribution www avatar status" tooling/issue-letsencrypt.sh # → certs/<name>/{cert,key}.pem -
Bind caddy to a localhost-only port so only nginx reaches it (
.env):CADDY_HTTP_PORT=8880 CADDY_HTTP_BIND=127.0.0.1 -
Bring up the caddy ingress (+ static, + distribution/nmsr if used):
docker compose -f docker-compose.yml -f docker-compose.caddy.yml \ -f docker-compose.static.yml -f docker-compose.distribution.yml \ -f docker-compose.nmsr.yml -f docker-compose.status.yml up -d --build -
Render + install the nginx vhost with
tooling/render-nginx.sh. It pullsBASE_DOMAIN+CADDY_HTTP_PORTfrom.env, defaultsAPP_DIRto the repo checkout (wherecerts/live), and rendersnginx/ulicraft-caddy.conf.tmpl:tooling/render-nginx.sh # preview to stdout (dry run) tooling/render-nginx.sh --install # write, symlink, nginx -t, reloadOverrides via env:
APP_DIR=/path(if the repo isn't at the cert location),SITE_NAME=foo.conf,TEMPLATE=nginx/ulicraft.conf.tmpl(the static variant — nginx serves the files itself + proxies only drasl; pick one, not both).
Re-run step 4 whenever you add a subdomain so its server block is rendered.
Avatar renderer (NMSR)
avatar.${BASE_DOMAIN} renders players' skins/avatars via
NMSR-aas, sourced from Drasl (not
Mojang). No upstream image exists, so it's built from source — docker/nmsr/
pins a commit; bump ARG NMSR_REF to update.
- Config:
nmsr/config.toml.tmpl→ rendered tonmsr/config.tomlbytooling/render-config.sh.[mojank]points every Mojang endpoint athttp://auth.${BASE_DOMAIN}; Drasl serves the Mojang-compatible routes at its bare BaseURL and embeds absolute skin URLs that NMSR fetches directly.allow_offline_mode_uuids = true(Drasl issues offline v3 UUIDs). - Network: skin resolution stays internal over
mcnet(the caddy aliasauth.${BASE_DOMAIN}→ drasl) — no public round-trip. caddy reverse-proxiesavatar.→nmsr:8080. The host port is published on127.0.0.1:9898only (local debugging; not reachable from outside). - Endpoints (NMSR): e.g.
https://avatar.${BASE_DOMAIN}/skin/<player>,/face/<player>,/fullbody/<player>— by username or UUID.
Bring up: add -f docker-compose.nmsr.yml (with --build) as shown above. The
first build is heavy (Rust compile). Headless rendering uses lavapipe (software
Vulkan); if renders fail, check docker logs nmsr for Vulkan device init.
distribution.${BASE_DOMAIN} serves a static site whose web root lives in
another repo: set DISTRIBUTION_WEB_ROOT (absolute host path) in .env; it's
bind-mounted read-only via docker-compose.distribution.yml.
Status monitoring (Uptime Kuma)
status.${BASE_DOMAIN} runs Uptime Kuma
— uptime probes for every vhost and a native Minecraft-protocol ping (player
count + up/down), plus a public status page. The container joins mcnet, so it
can probe the stack by internal service name without leaving the host. caddy
reverse-proxies status. → uptime-kuma:3001; the host port is published on
127.0.0.1:3001 only (first-run admin setup / local debugging).
Bring up (layer on the caddy ingress):
docker compose -f docker-compose.yml -f docker-compose.caddy.yml \
-f docker-compose.status.yml up -d
Then open https://status.${BASE_DOMAIN} (or http://127.0.0.1:3001 first run),
create the admin account, and add monitors. Kuma has no config-as-code — add
these once via the UI (data persists in the kuma_data volume):
| Monitor | Type | Target |
|---|---|---|
| Minecraft | Minecraft Server | minecraft : 25565 |
| Drasl auth | HTTP(s) | https://auth.${BASE_DOMAIN} |
| Landing (apex) | HTTP(s) | https://${BASE_DOMAIN} |
| Packwiz | HTTP(s) | https://pack.${BASE_DOMAIN} |
| Distribution | HTTP(s) | https://distribution.${BASE_DOMAIN} |
| Avatar (NMSR) | HTTP(s) | https://avatar.${BASE_DOMAIN} |
Internal-name targets (minecraft, drasl:25585, nmsr:8080) isolate "service
down" from "ingress/TLS/DNS down"; public-URL targets test the whole chain. Mix
as you like. status is in the default LE_SUBDOMAINS so its TLS cert issues
with the rest; the nginx vhost adds websocket upgrade headers for Kuma's live UI.
Join (guests)
- Open
http://ulicraft.lan, download FjordLauncherUnlocked for your OS. - Add an authlib-injector account, URL
http://auth.ulicraft.lan/authlib-injector(register/login athttp://auth.ulicraft.lan). - Air-gap only: trust the local CA — download
http://ulicraft.lan/ca.crtand add it to your OS trust store (so the HTTPS asset mirror is trusted). - Import the pack:
http://packwiz.ulicraft.lan/pack.toml. - Connect to
mc.ulicraft.lan(no port needed).
Layout
docker-compose.yml # core: drasl, minecraft, mc-backup, caddy
.static.yml .mirror.yml # online/airgap layers
.caddy.yml .nginx.yml # ingress paths (caddy-front vs nginx static)
.nmsr.yml .distribution.yml # avatar renderer / extra static vhost
.avahi.yml .dnsmasq.yml # optional DNS layers
caddy/Caddyfile + conf.d/*.caddy # ingress vhost snippets (core/static/mirror/
# distribution/avatar)
drasl/config/config.toml.tmpl # auth config (rendered)
nmsr/config.toml.tmpl # avatar renderer config, Drasl-backed (rendered)
dnsmasq/dnsmasq.conf.tmpl # optional DNS config (rendered)
docker/avahi/ docker/nmsr/ # built-from-source images (mDNS, NMSR)
pack/ # packwiz modpack source
landing/ # Astro site → www/
tooling/ # build-stack, render-config, render-nginx,
# dns-records, issue-letsencrypt,
# mirror-mods, mirror-airgap, fetch-launcher
nginx/*.conf.tmpl # host-nginx TLS vhost templates
mirror/ # vendored jars + launcher + asset mirror (gitignored)
plan/ # full design docs (00–12)
See plan/00-overview.md for the full design.