acme.sh persists the --install-cert paths and replays them on every cron renewal, where cwd is $HOME rather than the repo. The relative default resolved to ~/certs/... so renewals wrote nothing: acme.sh kept renewing cleanly while nginx served the stale copy until it expired. Apex, auth. and pack. served expired certs on 2026-09-06. Absolutise CERT_DIR in issue-letsencrypt.sh and document the failure mode, the recovery command, and off-host verification in plan/15-letsencrypt.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018UJzZrBaZUoU6qHMLpLz64
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 |
monitor |
built from source | admin-only log stream + dashboard at monitor.${BASE_DOMAIN} |
Vhosts (all proxied through the host nginx → caddy):
- apex
${BASE_DOMAIN}— landing page +/launcher/downloads +/api/mcstatus/* auth.— Draslavatar.— NMSRstatus.— Uptime Kumafiles.— Filestash player file share (one shared login, writable)monitor.— admin only, log stream + dashboard (caddy basic_auth)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. monitor. 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.
-
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 -
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, reloadOverride
APP_DIR=/pathif 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 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(caddy aliasauth.${BASE_DOMAIN}→ drasl). caddy reverse-proxiesavatar.→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.
Monitor (admin log stream + dashboard)
monitor.${BASE_DOMAIN} — a small Go service (docker/monitor/) that tails the
Minecraft server's latest.log and shows it live with autoscroll, a filter and
ERROR/WARN/INFO chips, above a row of tiles: server status, TPS, players online
(with NMSR heads), registered accounts, disk free, backup freshness and recent
joins/leaves.
The bell in the header turns on browser notifications for player join/leave. Opt-in, off by default, remembered per browser; it fires only while the page is open (a background tab counts, a closed one doesn't).
Admin only, and read-only. The log carries player IPs, chat and admin
commands verbatim and nothing sanitizes it, so the whole vhost sits behind caddy
basic_auth. There are no controls in the UI — every route is a GET.
# generate the credential (BCRYPT — the opposite of FILES_PASSWORD_HASH, which needs $6$)
docker run --rm caddy:alpine caddy hash-password --plaintext 'your-password'
Then in .env — single-quote the hash, Compose mangles bare $-sequences:
MONITOR_ENABLED=on # per-host on|off, like FILES_BROWSE
MONITOR_USER=admin
MONITOR_PASSWORD_HASH='$2a$14$...'
The container publishes no port: caddy is the only way in, so there is no
host listener to firewall. It holds one credential (RCON_PASSWORD, for the TPS
tile) and can only send commands from a compile-time allowlist that no HTTP
input can reach.
Adding it needs a cert (monitor in LE_SUBDOMAINS →
tooling/issue-letsencrypt.sh) and an nginx vhost (tooling/render-nginx.sh --install). Because it adds a caddy conf file, the first deploy needs
./update-all.sh --hard (world downtime); later changes are rolling.
Full design, gotchas and verification: plan/23-monitor.md.
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/adminloads 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, sorestartreads 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|rwis the literal mount flag:romakes the share read-only at the kernel, whatever the UI thinks.FILES_AUTH=htpasswd|passthrough—passthroughmeans 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.hostis a bare hostname, no scheme (upload.${BASE_DOMAIN}), withforce_ssl: true. The SPA builds its redirect origin asscheme + host, so a URL there produceshttp://https://upload...and every client-side redirect breaks. The server strips the scheme before its own Host check, socurland the uptime monitor stay green while every browser is broken.FILES_PASSWORD_HASHmust be$6$(openssl passwd -6). The htpasswd plugin's bcrypt branch only matches$2a$, so a$2y$hash fromhtpasswd -Bfails every login, silently.render-config.shrejects it.- The
localstorage backend is admin-gated — withoutFILES_LOCAL_SECRETreaching it through the connection params, every login dies withbackend error - Not Allowed, which looks like bad credentials and isn't. - Don't set
APPLICATION_URL/ADMIN_PASSWORDenv — either makes Filestash rewriteconfig.jsonat boot, which fails against the:romount.
Verifying this service: a 200 on
/only proves the server is up. Check the browser-facing origin —curl -s -H 'X-Requested-With: XmlHttpRequesthttps://upload.${BASE_DOMAIN}/api/config | jq -r .result.originmust printhttps://upload.${BASE_DOMAIN}— and re-readdocker compose logs filestash.
Join (guests)
- Open
https://${BASE_DOMAIN}, download FjordLauncherUnlocked for your OS. - Add an authlib-injector account, URL
https://auth.${BASE_DOMAIN}/authlib-injector(register/login athttps://auth.${BASE_DOMAIN}). - The launcher installs the modpack from the distribution automatically.
- 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
docker/mc-status/ # built-from-source SLP→JSON pinger
docker/monitor/ # built-from-source admin log stream + dashboard
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.
Ban IPs
docker compose exec minecraft rcon-cli ban-ip 1.1.1.1