Files
ulicraft-server-v1/CLAUDE.md
Oier Bravo Urtasun c233b1bfbc docs(drasl): record admin bootstrap + config-key/CORS/NMSR gotchas
Capture what this session learned the hard way, in plan/03-drasl.md and CLAUDE.md:

- Correct admin key is DefaultAdmins (not DefaultAdminUsernames); wrong/unknown
  keys are silently ignored by drasl (logged as "unknown config option").
- CORSAllowOrigins must be top-level, before any [Section], or it's ignored.
- First-admin bootstrap under invite mode is a chicken-egg → temp-flip
  RequireInvite=false, register, revert.
- NMSR needs the auth. host-gateway pin + https mojank endpoints or avatars
  render the default skin (cross-ref plan/19-routes.md).
- Fix the stale example config and mark the admin-bootstrap task done.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 21:20:54 +02:00

9.8 KiB
Raw Blame History

Ulicraft Server — Project Context

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. Public, internet-present deployment behind the host's nginx + TLS.

plan/ is the source of truth for the design. Start at plan/00-overview.md. This file is a fast orientation + the durable decisions/gotchas; when it disagrees with plan/ or the code, those win.

Goals & Constraints

  • Players: 410 friends
  • Modpack vibe: Kitchen-sink (tech + magic + exploration + QoL)
  • Pack approach: Manually curated (~50100 mods), NOT a forked existing pack
  • Minecraft version: 1.21.1, NeoForge (not classic Forge)
  • Auth/skins: Drasl (Yggdrasil-compatible), password login — no OIDC
  • DNS: handled OUTSIDE this repo; point ${BASE_DOMAIN} + subdomains at the host
  • Persistence horizon: long-term service, not disposable

Architecture

One unified docker-compose.yml. The host's own nginx terminates TLS (Let's Encrypt) and reverse-proxies every vhost to caddy (published localhost-only) by Host header. caddy is the internal router; containers reach the stack's own names via caddy's mcnet aliases (auth., pack.).

Internet ── host nginx (TLS, LE certs, HSTS) ──► caddy (127.0.0.1:8880)
                                                   │ routes by Host:
   ${BASE_DOMAIN}        ─► /srv/www landing + /launcher/ downloads
   auth.${BASE_DOMAIN}   ─► drasl        (Yggdrasil API + web UI)
   avatar.${BASE_DOMAIN} ─► nmsr         (skin/avatar renderer, Drasl-backed)
   status.${BASE_DOMAIN} ─► uptime-kuma  (status page + monitors)
   distribution.${BASE}  ─► static site from ANOTHER repo (DISTRIBUTION_WEB_ROOT)

minecraft :25565 (+ 24454/udp Simple Voice Chat)
   └ ONLINE_MODE=false, -javaagent authlib-injector → http://auth.${BASE_DOMAIN}/authlib-injector
   └ mods from ./server-mods volume (/mods, REMOVE_OLD_MODS) — see plan/04-mods.md
mc-backup ── RCON → minecraft (6h interval, prune 14d, world-only)

Bring-up: render configs → sync server mods → build landing → fetch launcher → docker compose up -d --build. See plan/12-build-order.md and plan/14-deploy.md. Deploy to prod host cochi via the deploy skill.

Key Decisions

  • NeoForge over Forge — the 1.21.x kitchen-sink ecosystem is overwhelmingly NeoForge; the Forge team migrated. itzg/minecraft-server via TYPE=NEOFORGE.
  • Drasl over Ely.by / vanilla offline — self-hosted Go service, Yggdrasil drop-in, skins + capes, actively maintained. Run with password login (no OIDC/Keycloak in this stack).
  • Manual curation (user choice, flagged expensive) — if it gets painful, fork-and-trim Leaking Kitchen Sink is still on the table (closest vibe match).
  • itzg/minecraft-server base image — de-facto standard, auto-installs NeoForge.
  • Distribution-fed mod volume (replaced packwiz) — the modset's source of truth is the HeliosLauncher distribution repo ($DISTRIBUTION_WEB_ROOT). Clients install the full set via distribution.json. The SERVER loads a filtered subset (both/server) from a mounted ./server-mods volume (REMOVE_OLD_MODS=TRUE). See plan/04-mods.md.

Critical Gotchas

Minecraft 1.21+ secure profile

  • Server: ENFORCE_SECURE_PROFILE=FALSE (enforce-secure-profile=false)
  • Drasl: SignPublicKeys = false
  • Linked. Drasl docs: "Mixed authentication does not work with SignPublicKeys = true on Minecraft 1.21+."

authlib-injector JVM agent

  • Syntax: -javaagent:/extras/authlib-injector.jar=<yggdrasil-api-url>
  • URL after = is the API root = <BaseURL>/authlib-injector. For this stack: http://auth.${BASE_DOMAIN}/authlib-injector (internal, over mcnet).
  • Must match between server and client (clients use the public https://auth.${BASE_DOMAIN}/authlib-injector), else session validation fails silently with "Invalid session".
  • Releases: https://github.com/yushijinhun/authlib-injector — jar lives in runtime/ (mounted at /extras/authlib-injector.jar).

NeoForge version pinning

Mods are picky about exact minor versions. Compose pins NEOFORGE_VERSION: "21.1.233"must match the distribution's NeoForge (distribution.json), since mods are built against it (e.g. ferritecore≥218, farmersdelight≥219). Verify against https://projects.neoforged.net/neoforged/neoforge before deploying. Never "latest" for a stable server.

Mod source + server filtering

The full modset lives in the distribution repo ($DISTRIBUTION_WEB_ROOT/servers/ ulicraft-1.21.1/forgemods/required/*.jar), managed by Nebula. Clients get everything via distribution.json. The server runs a filtered subset: tooling/classify-mods.py reads each jar's neoforge.mods.toml with tomllib and seeds mods-sides.json (client|server|both, default both), then tooling/sync-server-mods.sh copies the both/server jars into ./server-mods (mounted at /mods). Hard client-only mods (sodium/iris) classify as client automatically; cosmetic-client mods that declare BOTH stay both until you hand-edit mods-sides.json to client. No Modrinth/packwiz, no network at classify/sync time. See plan/04-mods.md.

Memory sizing

~50100 mods, 8 players, 1.21.1: INIT_MEMORY: 4G, MAX_MEMORY: 10G, USE_AIKAR_FLAGS: TRUE.

Config (.env)

BASE_DOMAIN, RCON_PASSWORD, CADDY_HTTP_PORT/CADDY_HTTP_BIND, DISTRIBUTION_WEB_ROOT (source of the modset + caddy distribution. mount), the Let's Encrypt block (LE_EMAIL, LE_SUBDOMAINS, OVH_*). Rendered configs (drasl, nmsr) come from *.tmpl via tooling/render-config.sh (substitutes ${BASE_DOMAIN} only). Server mods are synced (not rendered) by tooling/sync-server-mods.sh.

Client Distribution (guests)

Launcher: FjordLauncherUnlocked (Prism fork, first-class authlib-injector), mirrored at apex /launcher/. Per guest:

  1. Download from https://${BASE_DOMAIN}/launcher/.
  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 (distribution.json). (NOTE: packwiz/pack. removed — the landing join step still references a packwiz URL and needs reworking around the launcher/distribution flow.)
  4. Connect to ${BASE_DOMAIN} (:25565).

Open / Pending Work

Roadmap — mod migration follow-ups (post packwiz→04-mods)

  • Curate mods-sides.json cosmetic-client mods. tomllib auto-flagged 11 hard client-only mods; the other 65 default both, including client-cosmetic ones (BetterF3, entityculling, MouseTweaks, ponderjs, fancymenu, drippy, melody, welcomescreen, Searchables, JustEnoughResources, TravelersTitles, yeetus…) that load harmlessly on the server but waste RAM. Hand-edit them to client so sync-server-mods.sh drops them from ./server-mods. (Also review appleskin, flagged client — flip to both if server-side food data wanted.)

Landing rework + mod list (PLANNED — see plan/18-landing-rework.md)

Full design settled across WS1WS6; all tasks unchecked, ready to execute.

  • WS1 — Join flow off packwiz. Homepage = UlicraftLauncher, 3 steps (register → download launcher → join); drop packwizUrl + packwiz step. Per-OS downloads from a synced launcher-manifest.json (contract: landing/src/data/launcher-manifest.schema.json, produced by the custom-launcher repo). Fjord moves to its own /fjord page.

  • WS2/3 — Player rosters. Online (mc-status, live) + all-registered (new mc-status /api/mcstatus/players, admin-login → Drasl GET /players, ~60s cache). Shared avatar tile, AVATAR_MODE env (default headiso). Admin creds → mc-status only.

  • WS4 — Account page (/account): skin CRUD via browser-direct Drasl (reuses register.astro skin JS). Skins only, in-memory token, players[0].

  • WS5 — Footer status link to status.${BASE_DOMAIN} (nav unchanged).

  • WS6 — Mod shortlist + list component (plan/17-mod-shortlist.md). Light gap doc: current 76-jar pack is vanilla+ QoL/perf; empty on tech & magic, thin worldgen, no map; orphan deps ponderjs/Necronomicon. Landing mod-list component = auto-generated mods.json + extracted logos from the distribution forgemods (tooling/build-modlist.py), flat alphabetical, pretty names, no categories/links; feeds the "50+" stat tile.

  • Server-side perf mods (C2ME, etc.).

  • Verify NeoForge version against current stable before first deploy.

  • Bootstrap Drasl admin account (admin, 2026-06-10). Key is DefaultAdmins (not DefaultAdminUsernames); first admin needs an invite-flip. See plan/03-drasl.md gotchas.

Reference URLs

Communication Preferences (carry-over)

  • Concise and direct; diagnose + resolve without demanding repro steps.
  • Prefer iterative single-change updates over large rewrites.
  • Cautious scripts that halt on ambiguity rather than proceeding silently.
  • Avoid over-engineering when a simple answer suffices.
  • Spanish or English both fine.