Execute plan/18 (WS1-6) + plan/17.
- WS1: homepage join = 3 steps off packwiz (register -> UlicraftLauncher
-> join); per-OS downloads from a synced launcher-manifest.json (schema +
example committed); Fjord moved to its own /fjord page. Drop packwizUrl.
- WS2/3: mc-status gains an isolated /players endpoint (admin login ->
Drasl GET /players, own client+TTL+token cache, never blocks the status
path); online + registered rosters render shared name+avatar tiles;
AVATAR_MODE env (default headiso).
- WS4: /account skin CRUD via browser-direct Drasl (login -> upload ->
reset), in-memory token, players[0]; register relinks "Manage it here".
- WS5: footer link to status.${BASE_DOMAIN} in every footer.
- WS6: tooling/build-modlist.py generates mods.json + extracted logos from
the distribution forgemods; ModList.astro renders a flat list; stat tile
fed from the count.
Manifest/mods loaders read at build with a process.cwd() fallback (the
import.meta.url-only path does not resolve in Astro's bundled build).
New env: AVATAR_MODE, DRASL_ADMIN_USERNAME, DRASL_ADMIN_PASSWORD (mc-status
only). Generated mods.json / launcher-manifest.json / logos are gitignored;
.example.json files document the shapes. Build: 12 pages; mc-status builds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
9.7 KiB
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: 4–10 friends
- Modpack vibe: Kitchen-sink (tech + magic + exploration + QoL)
- Pack approach: Manually curated (~50–100 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-serverviaTYPE=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 viadistribution.json. The SERVER loads a filtered subset (both/server) from a mounted./server-modsvolume (REMOVE_OLD_MODS=TRUE). Seeplan/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 = trueon 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
~50–100 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:
- Download from
https://${BASE_DOMAIN}→/launcher/. - 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 (
distribution.json). (NOTE: packwiz/pack.removed — the landing join step still references a packwiz URL and needs reworking around the launcher/distribution flow.) - Connect to
${BASE_DOMAIN}(:25565).
Open / Pending Work
Roadmap — mod migration follow-ups (post packwiz→04-mods)
- Curate
mods-sides.jsoncosmetic-client mods. tomllib auto-flagged 11 hard client-only mods; the other 65 defaultboth, 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 toclientsosync-server-mods.shdrops them from./server-mods. (Also reviewappleskin, flaggedclient— flip tobothif server-side food data wanted.)
Landing rework + mod list (PLANNED — see plan/18-landing-rework.md)
Full design settled across WS1–WS6; 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 syncedlauncher-manifest.json(contract:landing/src/data/launcher-manifest.schema.json, produced by thecustom-launcherrepo). Fjord moves to its own/fjordpage. -
WS2/3 — Player rosters. Online (mc-status, live) + all-registered (new
mc-status /api/mcstatus/players, admin-login → DraslGET /players, ~60s cache). Shared avatar tile,AVATAR_MODEenv (defaultheadiso). Admin creds → mc-status only. -
WS4 — Account page (
/account): skin CRUD via browser-direct Drasl (reusesregister.astroskin 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 depsponderjs/Necronomicon. Landing mod-list component = auto-generatedmods.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.
Reference URLs
- Drasl: https://github.com/unmojang/drasl — config docs: https://github.com/unmojang/drasl/blob/master/doc/configuration.md
- authlib-injector: https://github.com/yushijinhun/authlib-injector
- itzg/minecraft-server: https://github.com/itzg/docker-minecraft-server (NeoForge: …/docs/types-and-platforms/server-types/forge.md)
- itzg/mc-backup: https://github.com/itzg/docker-mc-backup
- NeoForge versions: https://projects.neoforged.net/neoforged/neoforge
- NMSR: https://github.com/NickAcPT/nmsr-rs
- Uptime Kuma: https://github.com/louislam/uptime-kuma
- FjordLauncherUnlocked: https://github.com/hero-persson/FjordLauncherUnlocked
- Reference packs (inspiration, NOT forking): ATM10, Leaking Kitchen Sink.
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.