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>
7.5 KiB
Ulicraft Server — Plan Overview
Self-hosted modded Minecraft stack — a single Docker Compose file holds the entire thing, fronted by the host's own nginx (Let's Encrypt TLS) and a caddy internal router. Built for a LAN party but runs as a persistent homelab service. Assumes the internet is present.
Single source of configuration
Everything is driven by one env var in .env:
BASE_DOMAIN=ulicraft.net # all services are subdomains of this
render-config.sh substitutes ${BASE_DOMAIN} into the templates (plus the
derived REGISTRATION_* and FILES_* values — see 20-files.md).
DNS is not this repo's concern: point ${BASE_DOMAIN} and all subdomains
(auth. avatar. status. files. distribution. www.) at the host running
nginx; that is configured outside this repo.
Subdomain / ingress map
ulicraft.net → nginx → caddy → landing page (/srv/www) + /launcher/
auth.ulicraft.net → nginx → caddy → drasl:25585 (auth web UI + Yggdrasil API)
avatar.ulicraft.net → nginx → caddy → nmsr:8080 (skin/avatar renderer)
status.ulicraft.net → nginx → caddy → uptime-kuma:3001 (status page)
files.ulicraft.net → nginx → caddy → filestash:8334 (player file share, shared login)
distribution.ulicraft.net → nginx → caddy → /srv/distribution (static, another repo)
mc.ulicraft.net:25565 → minecraft (raw MC protocol, not HTTP)
The host's own nginx is the only public ingress: it terminates TLS with Let's
Encrypt certs and reverse-proxies every vhost to caddy by Host header. caddy is
published on a localhost-only port (CADDY_HTTP_BIND=127.0.0.1,
CADDY_HTTP_PORT=8880) and acts as the internal router. drasl, nmsr and
uptime-kuma have no host ports — they are reached only through caddy.
Internal resolution trick
Containers use the Docker resolver. caddy gets network aliases so the stack resolves its own subdomains internally:
caddy:
networks:
mcnet:
aliases: [ "auth.${BASE_DOMAIN}" ]
Result: http://auth.ulicraft.net/authlib-injector resolves identically inside
the stack (minecraft → caddy) as the public name does outside it (client → nginx
→ caddy).
HTTPS variant (extra_hosts: host-gateway)
The caddy alias only serves http on mcnet (port 80). Once the stack moved
to https URLs (drasl BaseURL; the authlib-injector agent must match the
client's https endpoint), the internal http alias is no longer enough — the
container needs to reach a TLS terminator holding a valid cert.
Caddy does not terminate TLS internally; the host's nginx does (on 0.0.0.0:443).
So pin the public name to the host in the service, not to caddy:
minecraft:
extra_hosts:
- "auth.${BASE_DOMAIN}:host-gateway"
/etc/hosts (extra_hosts) wins over the Docker resolver, so the container reaches
the host's nginx (valid LE cert) → caddy → service. This is required because
containers do not inherit the host's /etc/hosts — on cochi the host resolves
auth. to the public IP, but inside a container the LAN resolver returns a
dead address (192.168.0.3:443, connection refused), which crash-loops session
validation on boot. host-gateway sidesteps DNS entirely.
Build / run flow
PREP (run once, online):
1. render configs → tooling/render-config.sh (08-tooling.md)
2. build landing page → cd landing && pnpm run build → www/ (09-landing.md)
3. download launcher releases → tooling/fetch-launcher.sh (06-launcher.md)
4. sync server mods → tooling/sync-server-mods.sh (04-mods.md)
5. (TLS) issue Let's Encrypt certs → tooling/issue-letsencrypt.sh (15-letsencrypt.md)
6. (ingress) render+install nginx → tooling/render-nginx.sh --install (15-letsencrypt.md)
UP:
docker compose up -d --build
Bring-up is a single command. There are no run modes and no override compose
files — one docker-compose.yml holds drasl, minecraft, mc-backup, caddy, nmsr,
mc-status, uptime-kuma and filestash.
Why each hard call was made
- No OIDC/Keycloak (for now) — drasl password login. Keycloak left out of this stack's scope. Re-add later if desired.
- Distribution-fed mods — the modset's source of truth is the HeliosLauncher
distribution repo; clients install the full set, the server loads a filtered
both/serversubset from./server-mods. See04-mods.md. - Fjord launcher has authlib-injector built in → no manual JVM agent on clients.
- host nginx in front of caddy — real Let's Encrypt certs (JVM trusts them with no CA import) and a single public TLS terminator; caddy stays an internal Host-routed multiplexer for the stack's own services.
Target folder layout
.
├── docker-compose.yml # the entire stack (no overrides)
├── .env / .env.example # BASE_DOMAIN, RCON_PASSWORD, CADDY_*, LE block
├── caddy/conf.d/ # per-vhost snippets (00-core, 10-static, …)
├── nginx/ulicraft-caddy.conf.tmpl # host nginx → caddy template (TLS terminator)
├── certs/<name>/{cert,key}.pem # Let's Encrypt output (gitignored)
├── drasl/config/
│ ├── config.toml.tmpl # render-config.sh source
│ └── config.toml # generated (gitignored)
├── nmsr/ # avatar renderer config + Dockerfile context
├── filestash/
│ ├── config.json.tmpl # render-config.sh source (files. share)
│ └── config.json # generated, mounted :ro (gitignored — holds hashes)
├── docker/nmsr/ # nmsr image build context
├── server-mods/ # filtered server modset (synced, gitignored)
├── launcher/ # vendored Fjord launcher releases (gitignored)
├── runtime/ # authlib-injector.jar
├── tooling/ # render/fetch/issue scripts (see 08-tooling.md)
├── landing/ # Astro+TS landing source (build → www/)
├── www/ # landing build output — caddy apex (gitignored)
├── backups/
└── plan/ # these files
Service plan files
02-caddy.md— internal router (vhost conf.d snippets) behind host nginx03-drasl.md— auth (password login)04-mods.md— distribution-fed mod volume + server filtering (supersedes04-packwiz.md)05-minecraft.md— itzg NeoForge server06-launcher.md— Fjord launcher fetch + distribution07-mc-backup.md— world backups08-tooling.md— render/fetch/issue scripts09-landing.md— Astro+TS guest onboarding page → www/12-build-order.md— commit-per-task build sequence14-deploy.md— redeploy to the production host15-letsencrypt.md— Let's Encrypt (OVH DNS-01) + host nginx ingress17-mod-shortlist.md— kitchen-sink gap doc + landing mod-list dataset (PLANNED)18-landing-rework.md— join flow / rosters / account / mod list / footer (PLANNED)19-routes.md— HTTP route reference (nmsr / landing / auth / files + internal)20-files.md— Filestash player file share (files., one shared login)23-monitor.md— admin log stream + dashboard (monitor., basic auth) (PLANNED)
Boot/dependency order
caddy (aliases) → drasl → minecraft (depends on drasl) → mc-backup.
nmsr, mc-status, uptime-kuma and filestash are always-on and join mcnet
(no ordering constraints — none of them are on the auth/boot path).