Files
ulicraft-server-v1/README.md
Oier Bravo Urtasun ff645de2c8 feat(status): Uptime Kuma monitoring at status.${BASE_DOMAIN}
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>
2026-06-09 01:49:39 +02:00

10 KiB
Raw Blame History

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 at avatar.${BASE_DOMAIN}, sourced from Drasl. See Avatar renderer below.
  • distribution — extra static vhost at distribution.${BASE_DOMAIN} whose web root (DISTRIBUTION_WEB_ROOT) lives in another repo.
  • status (louislam/uptime-kuma) — uptime monitoring + public status page at status.${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/hosts has no wildcard — only the listed names resolve.
  • airgap adds 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 use online.
  • 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.

  1. 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
    
  2. Bind caddy to a localhost-only port so only nginx reaches it (.env):

    CADDY_HTTP_PORT=8880
    CADDY_HTTP_BIND=127.0.0.1
    
  3. 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
    
  4. Render + install the nginx vhost with tooling/render-nginx.sh. It pulls BASE_DOMAIN + CADDY_HTTP_PORT from .env, defaults APP_DIR to the repo checkout (where certs/ live), and renders nginx/ulicraft-caddy.conf.tmpl:

    tooling/render-nginx.sh                       # preview to stdout (dry run)
    tooling/render-nginx.sh --install             # write, symlink, nginx -t, reload
    

    Overrides 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 to nmsr/config.toml by tooling/render-config.sh. [mojank] points every Mojang endpoint at http://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 alias auth.${BASE_DOMAIN} → drasl) — no public round-trip. caddy reverse-proxies avatar.nmsr:8080. The host port is published on 127.0.0.1:9898 only (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)

  1. Open http://ulicraft.lan, download FjordLauncherUnlocked for your OS.
  2. Add an authlib-injector account, URL http://auth.ulicraft.lan/authlib-injector (register/login at http://auth.ulicraft.lan).
  3. Air-gap only: trust the local CA — download http://ulicraft.lan/ca.crt and add it to your OS trust store (so the HTTPS asset mirror is trusted).
  4. Import the pack: http://packwiz.ulicraft.lan/pack.toml.
  5. 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 (0012)

See plan/00-overview.md for the full design.