Files
ulicraft-server-v1/plan/00-overview.md
T
oierandClaude Opus 5 bac2372c95 docs: fold the monitor service into the stack-level docs
plan/23-monitor.md already covered the design; this makes the monitor
discoverable from the places someone actually starts reading.

- README: stack table, vhost list, DNS names, layout, and a setup section
  covering the bcrypt credential, the no-published-port model, and the
  --hard-on-first-deploy requirement.
- plan/00-overview.md: drop (PLANNED), add to the always-on service list, and
  note that it starts fine with Minecraft down (missing log and refused RCON
  are reported states, not fatal).
- plan/19-routes.md: vhost row, the four GET routes with their contracts, and
  the internal mcnet edges (mc-status for rosters so it needs no Drasl creds,
  RCON for TPS, Kuma probing healthz around caddy).
- caddy/Caddyfile: the snippet index had drifted — it listed five of nine
  vhosts. Added the four missing ones.

Two gotchas promoted to CLAUDE.md because they cost real time and will recur:

- Compose interpolates $-sequences in .env VALUES, so a bare bcrypt hash is
  silently corrupted before reaching the container, and the same bug bites
  again when writing one to a remote .env over ssh. Also records that caddy
  and filestash want opposite hash formats.
- The monitor container runs as root deliberately: /data is drwxr-x--- owned
  by uid 1000, so a USER directive would make the tailer silently read nothing
  while still returning 200.

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

7.7 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)

Boot/dependency order

caddy (aliases) → drasl → minecraft (depends on drasl) → mc-backup. nmsr, mc-status, uptime-kuma, filestash and monitor are always-on and join mcnet (no ordering constraints — none of them are on the auth/boot path). monitor reads Minecraft's log volume and queries its RCON, but starts fine without either: a missing log file and a refused RCON dial are ordinary states it reports rather than errors it dies on.