Oier Bravo Urtasun 08fe8c9c53 docs(files): record the general.host trap + that a green monitor proves nothing
Propagate the bare-hostname fix (10b86ae) to the docs that still said "three
traps", and write down the meta-lesson it exposed.

The bug was invisible to every check we had: Filestash strips the scheme
before its own Host check, so `curl /` returned 200, the API answered, wrong
passwords were rejected, and the brand-new Uptime Kuma HTTP monitor stayed
green — while the SPA computed `http://https://files...` and no browser could
use the site. An HTTP monitor asserts "server returned 200", which is a much
weaker claim than "the app works".

- CLAUDE.md / README: four traps now, host-scheme first; add the post-change
  check (`/api/config` → origin must be the real https URL).
- 19-routes: login form only renders at ?action=redirect (a bare 303 is
  normal, not a bug); assert origin after config changes.
- 10-uptime-kuma: an HTTP monitor cannot see an SPA break — use a Keyword
  monitor if you want teeth, and don't read green as "users can log in".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 18:31:46 +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 at files.${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}
Files (Filestash) HTTP(s) https://files.${BASE_DOMAIN} (expect 200 — the login page is a 200)

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)

files.${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. 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|passthroughpassthrough 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 (files.${BASE_DOMAIN}), with force_ssl: true. The SPA builds its redirect origin as scheme + host, so a URL there produces http://https://files... 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://files.${BASE_DOMAIN}/api/config | jq -r .result.origin must print https://files.${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.

Description
No description provided
Readme 79 MiB
Languages
Astro 24.6%
CSS 21.7%
JavaScript 16.1%
TypeScript 15%
Shell 11.3%
Other 11.3%