oierandClaude Opus 5 fca6747bcc feat(monitor): admin-only latest.log stream at monitor.
Phase 1 of plan/23-monitor.md: a small Go service that tails the Minecraft
server's latest.log into an in-memory ring buffer and serves it to an embedded
single-page UI (autoscroll, substring/regex filter, level chips).

Read-only by construction — /data is mounted :ro, there is no mutating route,
and the container holds no credentials. It publishes NO port: caddy's
basic_auth vhost is the only way in, which is the point, since the service has
no auth of its own and the log carries player IPs, chat and admin commands.

Two non-obvious choices, both to avoid prod-only failures:

- Polling, not SSE. The chain is host nginx -> caddy -> app, and nginx buffers
  proxied responses by default, so an EventSource would stall on cochi (whose
  vhost is an untracked per-host file) and not reproduce locally. Delay was
  accepted at design time, so polling is strictly better.

- The cursor is a sequence number, not a byte offset, and the tailer reopens on
  inode change or size shrink. itzg rotates latest.log on every server restart,
  i.e. every deploy — a naive tailer goes silent afterwards while still
  answering HTTP 200. Verified against rotate, truncate, delete/recreate,
  partial-line and ring-overflow cases.

MONITOR_ENABLED (on|off) gates both layers per host, mirroring FILES_BROWSE.

MONITOR_PASSWORD_HASH must be bcrypt AND single-quoted in .env: Compose
interpolates $-sequences in .env values, so a bare hash is silently mangled
($2a$14$abc... -> $$2a$$14...) and every login fails with no hint why.
Confirmed empirically, and it is the opposite of filestash, which needs $6$.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:51:39 +02:00

Ulicraft Server

Self-hosted modded Minecraft (NeoForge 1.21.1) with self-hosted auth/skins (Drasl), a distribution-fed modpack, avatar rendering, a guest landing page, and uptime monitoring. One unified docker-compose.yml runs the whole stack behind the host's nginx + Let's Encrypt.

Stack

One compose file, one docker compose up -d:

Service Image Role
minecraft itzg/minecraft-server NeoForge 1.21.1 server, :25565
drasl unmojang/drasl Yggdrasil auth + skins (password login)
caddy caddy:alpine internal ingress: routes every vhost by Host header
nmsr built from source skin/avatar renderer at avatar.${BASE_DOMAIN}
mc-status built from source live server-status JSON for the landing card
uptime-kuma louislam/uptime-kuma status page at status.${BASE_DOMAIN}
filestash machines/filestash (digest-pinned) player file share (upload) at upload.${BASE_DOMAIN}
mc-backup itzg/mc-backup world backups every 6h via RCON

Vhosts (all proxied through the host nginx → caddy):

  • apex ${BASE_DOMAIN} — landing page + /launcher/ downloads + /api/mcstatus/*
  • auth. — Drasl
  • avatar. — NMSR
  • status. — Uptime Kuma
  • files. — Filestash player file share (one shared login, writable)
  • distribution. — static site from another repo (DISTRIBUTION_WEB_ROOT)

Config lives in .env: BASE_DOMAIN, RCON_PASSWORD, CADDY_HTTP_PORT / CADDY_HTTP_BIND, DISTRIBUTION_WEB_ROOT, the FILES_* block. See .env.example.

DNS

Handled outside this repo. Point ${BASE_DOMAIN} and every subdomain (auth. avatar. status. files. distribution. www.) at the host running nginx.

Prerequisites

Docker + Docker Compose; for prep also pnpm, jq, curl, envsubst.

Launch

cp .env.example .env            # set BASE_DOMAIN, RCON_PASSWORD, DISTRIBUTION_WEB_ROOT

# One-time / on change — render configs + build content:
tooling/render-config.sh        # drasl + nmsr configs from *.tmpl
tooling/sync-server-mods.sh     # filtered server mods → ./server-mods
( cd landing && pnpm install && BASE_DOMAIN="$BASE_DOMAIN" pnpm run build )   # → www/
tooling/fetch-launcher.sh       # FjordLauncher assets → launcher/ (served at /launcher/)
tooling/fetch-authlib.sh        # authlib-injector.jar → runtime/ (server JVM agent)

docker compose up -d --build    # first run installs NeoForge + mods, builds nmsr
docker compose down             # stop

First boot: itzg installs NeoForge + the synced server mods into the mc_data volume; nmsr does a one-time Rust compile. Watch docker compose logs -f minecraft until Done.

Public TLS (host nginx in front of caddy)

caddy is published on a localhost-only port (CADDY_HTTP_BIND=127.0.0.1, CADDY_HTTP_PORT=8880). The host's own nginx terminates TLS with Let's Encrypt certs and reverse-proxies every vhost to caddy by Host header. 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 distribution www avatar status"
    tooling/issue-letsencrypt.sh        # → certs/<name>/{cert,key}.pem
    
  2. 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
    

    Override APP_DIR=/path if the repo isn't at the cert location. Re-run 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 (caddy alias auth.${BASE_DOMAIN} → drasl). caddy reverse-proxies avatar. → nmsr:8080.
  • Endpoints: e.g. https://avatar.${BASE_DOMAIN}/skin/<player>, /face/<player>, /fullbody/<player> — by username or UUID.

First build is heavy (Rust compile). Headless rendering uses lavapipe (software Vulkan); if renders fail, check docker logs nmsr for Vulkan device init.

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. It joins mcnet, so monitors can probe the stack by internal service name. caddy reverse-proxies status. → uptime-kuma:3001.

First run, open https://status.${BASE_DOMAIN}, 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}
Distribution HTTP(s) https://distribution.${BASE_DOMAIN}
Avatar (NMSR) HTTP(s) https://avatar.${BASE_DOMAIN}
Upload (Filestash) HTTP(s) https://upload.${BASE_DOMAIN} (expect 200 — the login page is a 200)
Files (static browse) HTTP(s) https://files.${BASE_DOMAIN} (caddy dir listing of the share, no auth)

Internal-name targets (minecraft, drasl:25585, nmsr:8080) isolate "service down" from "ingress/TLS/DNS down"; public-URL targets test the whole chain. status is in the default LE_SUBDOMAINS; the nginx vhost adds websocket upgrade headers for Kuma's live UI.

Server status JSON (mc-status)

mc-status is a self-hosted SLP→JSON pinger (built from docker/mc-status, an mcutil wrapper) that the landing's ServerCard reads for live player count + MOTD. It emits mcstatus.io v2 JSON, so the card consumes it unchanged — but same-origin, with no CORS and no third-party calls. caddy proxies the apex /api/mcstatus/* path to mc-status:8080 (strips the prefix); the container has no published port. The pinger only ever probes its configured MC_STATUS_TARGET, so the path after the prefix is ignored.

Endpoint (apex, same-origin):

https://${BASE_DOMAIN}/api/mcstatus/v2/status/java/${BASE_DOMAIN}

Hitting caddy directly (localhost-only, routes by Host header) — e.g. ulicraft.net on 127.0.0.1:8880:

curl -H "Host: ulicraft.net" \
  http://127.0.0.1:8880/api/mcstatus/v2/status/java/ulicraft.net

Wired in landing/src/data/site.ts (statusApi) and caddy/conf.d/10-static.caddy.

Player file share (Filestash)

upload.${BASE_DOMAIN} is a web file share for player media — screenshots, schematics, maps, guides. One shared username/password for everybody (FILES_USER / FILES_PASSWORD_HASH), read and write. The same share is also exposed read-only, no-auth as a plain caddy directory listing at files.${BASE_DOMAIN} — every uploaded file is publicly downloadable there. It is not a backup browser and not a mod mirror: world snapshots contain every player's inventory and coordinates, and the modset already ships via distribution..

Full design, deploy steps and gotchas: plan/20-files.md. The short version:

  • Config is filestash/config.json, rendered from a template and mounted :ro — git is the source of truth. The admin console at /admin loads but cannot save; that's deliberate. Change config by editing the .tmpl, re-rendering, and recreating the container (up -d --force-recreate filestash — a single-file bind mount pins the inode, so restart reads the stale file).
  • Files live in ${FILES_DATA_DIR} on the host, outside the repo. Not backed up by mc-backup — treat the share as disposable.
  • FILES_MOUNT_MODE=ro|rw is the literal mount flag: ro makes the share read-only at the kernel, whatever the UI thinks.
  • FILES_AUTH=htpasswd|passthrough — passthrough means no login at all. Only valid once the host is off the public internet: an unauthenticated writable share on a public domain is an open upload relay.

Four traps that cost real time (all enforced or documented in the tooling):

  • general.host is a bare hostname, no scheme (upload.${BASE_DOMAIN}), with force_ssl: true. The SPA builds its redirect origin as scheme + host, so a URL there produces http://https://upload... and every client-side redirect breaks. The server strips the scheme before its own Host check, so curl and the uptime monitor stay green while every browser is broken.
  • FILES_PASSWORD_HASH must be $6$ (openssl passwd -6). The htpasswd plugin's bcrypt branch only matches $2a$, so a $2y$ hash from htpasswd -B fails every login, silently. render-config.sh rejects it.
  • The local storage backend is admin-gated — without FILES_LOCAL_SECRET reaching it through the connection params, every login dies with backend error - Not Allowed, which looks like bad credentials and isn't.
  • Don't set APPLICATION_URL/ADMIN_PASSWORD env — either makes Filestash rewrite config.json at boot, which fails against the :ro mount.

Verifying this service: a 200 on / only proves the server is up. Check the browser-facing origin — curl -s -H 'X-Requested-With: XmlHttpRequest https://upload.${BASE_DOMAIN}/api/config | jq -r .result.origin must print https://upload.${BASE_DOMAIN} — and re-read docker compose logs filestash.

Join (guests)

  1. Open https://${BASE_DOMAIN}, download FjordLauncherUnlocked for your OS.
  2. Add an authlib-injector account, URL https://auth.${BASE_DOMAIN}/authlib-injector (register/login at https://auth.${BASE_DOMAIN}).
  3. The launcher installs the modpack from the distribution automatically.
  4. Connect to ${BASE_DOMAIN} (Minecraft, :25565).

Layout

docker-compose.yml                    # the whole stack (one file)
caddy/Caddyfile + conf.d/*.caddy      # ingress vhost snippets
                                      #   (core/static/distribution/avatar/status)
drasl/config/config.toml.tmpl         # auth config (rendered)
nmsr/config.toml.tmpl                 # avatar renderer config, Drasl-backed (rendered)
docker/nmsr/                          # built-from-source NMSR image
landing/                              # Astro site → www/
launcher/                             # FjordLauncher assets (gitignored)
server-mods/                          # filtered server mod jars (synced, gitignored)
tooling/                             # render-config, sync-server-mods, render-nginx,
                                      #   issue-letsencrypt, fetch-launcher
nginx/ulicraft-caddy.conf.tmpl        # host-nginx TLS vhost template (front of caddy)
plan/                                 # design docs

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

S
Description
No description provided
Readme
78 MiB
0 Stars 1 Watchers 0 Forks
Languages
Shell 18.1%
Astro 17.3%
CSS 15.2%
Go 11.6%
JavaScript 11.2%
Other 26.6%