Files
ulicraft-server-v1/plan/00-overview.md
T
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

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/server subset from ./server-mods. See 04-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 nginx
  • 03-drasl.md — auth (password login)
  • 04-mods.md — distribution-fed mod volume + server filtering (supersedes 04-packwiz.md)
  • 05-minecraft.md — itzg NeoForge server
  • 06-launcher.md — Fjord launcher fetch + distribution
  • 07-mc-backup.md — world backups
  • 08-tooling.md — render/fetch/issue scripts
  • 09-landing.md — Astro+TS guest onboarding page → www/
  • 12-build-order.md — commit-per-task build sequence
  • 14-deploy.md — redeploy to the production host
  • 15-letsencrypt.md — Let's Encrypt (OVH DNS-01) + host nginx ingress
  • 17-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).