cwb declares @Mod(dist=CLIENT); classify-mods.py read its toml side as BOTH (the dist annotation isn't in the manifest), so it synced to the server and RuntimeDistCleaner FATAL'd on CubesWithoutBordersImpl. Flip to client so sync drops it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ulicraft Server
Self-hosted modded Minecraft (NeoForge 1.21.1) with self-hosted auth/skins
(Drasl), a packwiz modpack, avatar rendering, a guest landing page, and uptime
monitoring. One unified docker-compose.yml runs the whole stack behind the
host's nginx + Let's Encrypt.
Stack
One compose file, one docker compose up -d:
| Service | Image | Role |
|---|---|---|
minecraft |
itzg/minecraft-server | NeoForge 1.21.1 server, :25565 |
drasl |
unmojang/drasl | Yggdrasil auth + skins (password login) |
caddy |
caddy:alpine | internal ingress: routes every vhost by Host header |
nmsr |
built from source | skin/avatar renderer at avatar.${BASE_DOMAIN} |
uptime-kuma |
louislam/uptime-kuma | status page at status.${BASE_DOMAIN} |
mc-backup |
itzg/mc-backup | world backups every 6h via RCON |
Vhosts (all proxied through the host nginx → caddy):
- apex
${BASE_DOMAIN}— landing page +/launcher/downloads auth.— Draslpack.— packwiz metadata +/custom/jarsavatar.— NMSRstatus.— Uptime Kumadistribution.— static site from another repo (DISTRIBUTION_WEB_ROOT)
Config lives in .env: BASE_DOMAIN, RCON_PASSWORD, CADDY_HTTP_PORT /
CADDY_HTTP_BIND, DISTRIBUTION_WEB_ROOT. See .env.example.
DNS
Handled outside this repo. Point ${BASE_DOMAIN} and every subdomain
(auth. pack. avatar. status. distribution. www.) at the host running nginx.
Prerequisites
Docker + Docker Compose; for prep also pnpm, packwiz, jq, curl,
envsubst.
Launch
cp .env.example .env # set BASE_DOMAIN, RCON_PASSWORD, DISTRIBUTION_WEB_ROOT
# One-time / on change — render configs + build content:
tooling/render-config.sh # drasl + nmsr + packwiz configs from *.tmpl
( cd landing && pnpm install && BASE_DOMAIN="$BASE_DOMAIN" pnpm run build ) # → www/
tooling/fetch-launcher.sh # FjordLauncher assets → launcher/ (served at /launcher/)
tooling/fetch-authlib.sh # authlib-injector.jar → runtime/ (server JVM agent)
docker compose up -d --build # first run installs NeoForge + mods, builds nmsr
docker compose down # stop
First boot: itzg installs NeoForge + the packwiz mods into the mc_data volume;
nmsr does a one-time Rust compile. Watch docker compose logs -f minecraft until
Done.
Public TLS (host nginx in front of caddy)
caddy is published on a localhost-only port (CADDY_HTTP_BIND=127.0.0.1,
CADDY_HTTP_PORT=8880). The host's own nginx terminates TLS with Let's Encrypt
certs and reverse-proxies every vhost to caddy by Host header. HTTPS-only: HTTP
301s to HTTPS and every TLS vhost sends HSTS.
-
Issue certs (OVH DNS-01, no inbound ports needed):
# .env: BASE_DOMAIN, LE_EMAIL, OVH_* creds, # LE_SUBDOMAINS="auth pack distribution www avatar status" tooling/issue-letsencrypt.sh # → certs/<name>/{cert,key}.pem -
Render + install the nginx vhost with
tooling/render-nginx.sh. It pullsBASE_DOMAIN+CADDY_HTTP_PORTfrom.env, defaultsAPP_DIRto the repo checkout (wherecerts/live), and rendersnginx/ulicraft-caddy.conf.tmpl:tooling/render-nginx.sh # preview to stdout (dry run) tooling/render-nginx.sh --install # write, symlink, nginx -t, reloadOverride
APP_DIR=/pathif the repo isn't at the cert location. Re-run whenever you add a subdomain so its server block is rendered.
Avatar renderer (NMSR)
avatar.${BASE_DOMAIN} renders players' skins/avatars via
NMSR-aas, sourced from Drasl (not
Mojang). No upstream image exists, so it's built from source — docker/nmsr/
pins a commit; bump ARG NMSR_REF to update.
- Config:
nmsr/config.toml.tmpl→ rendered tonmsr/config.tomlbytooling/render-config.sh.[mojank]points every Mojang endpoint athttp://auth.${BASE_DOMAIN}; Drasl serves the Mojang-compatible routes at its bare BaseURL and embeds absolute skin URLs that NMSR fetches directly.allow_offline_mode_uuids = true(Drasl issues offline v3 UUIDs). - Network: skin resolution stays internal over
mcnet(caddy aliasauth.${BASE_DOMAIN}→ drasl). caddy reverse-proxiesavatar.→nmsr:8080. - Endpoints: e.g.
https://avatar.${BASE_DOMAIN}/skin/<player>,/face/<player>,/fullbody/<player>— by username or UUID.
First build is heavy (Rust compile). Headless rendering uses lavapipe (software
Vulkan); if renders fail, check docker logs nmsr for Vulkan device init.
Status monitoring (Uptime Kuma)
status.${BASE_DOMAIN} runs Uptime Kuma
— uptime probes for every vhost and a native Minecraft-protocol ping (player
count + up/down), plus a public status page. It joins mcnet, so monitors can
probe the stack by internal service name. caddy reverse-proxies status. →
uptime-kuma:3001.
First run, open https://status.${BASE_DOMAIN}, create the admin account, and
add monitors. Kuma has no config-as-code — add these once via the UI (data
persists in the kuma_data volume):
| Monitor | Type | Target |
|---|---|---|
| Minecraft | Minecraft Server | minecraft : 25565 |
| Drasl auth | HTTP(s) | https://auth.${BASE_DOMAIN} |
| Landing (apex) | HTTP(s) | https://${BASE_DOMAIN} |
| Packwiz | HTTP(s) | https://pack.${BASE_DOMAIN} |
| Distribution | HTTP(s) | https://distribution.${BASE_DOMAIN} |
| Avatar (NMSR) | HTTP(s) | https://avatar.${BASE_DOMAIN} |
Internal-name targets (minecraft, drasl:25585, nmsr:8080) isolate "service
down" from "ingress/TLS/DNS down"; public-URL targets test the whole chain.
status is in the default LE_SUBDOMAINS; the nginx vhost adds websocket
upgrade headers for Kuma's live UI.
Join (guests)
- Open
https://${BASE_DOMAIN}, download FjordLauncherUnlocked for your OS. - Add an authlib-injector account, URL
https://auth.${BASE_DOMAIN}/authlib-injector(register/login athttps://auth.${BASE_DOMAIN}). - Import the pack:
https://pack.${BASE_DOMAIN}/pack.toml. - Connect to
${BASE_DOMAIN}(Minecraft,:25565).
Layout
docker-compose.yml # the whole stack (one file)
caddy/Caddyfile + conf.d/*.caddy # ingress vhost snippets
# (core/static/distribution/avatar/status)
drasl/config/config.toml.tmpl # auth config (rendered)
nmsr/config.toml.tmpl # avatar renderer config, Drasl-backed (rendered)
docker/nmsr/ # built-from-source NMSR image
pack/ # packwiz modpack source
landing/ # Astro site → www/
launcher/ # FjordLauncher assets (gitignored)
tooling/ # render-config, render-pack, render-nginx,
# issue-letsencrypt, fetch-launcher, add-custom-mod
nginx/ulicraft-caddy.conf.tmpl # host-nginx TLS vhost template (front of caddy)
plan/ # design docs
See plan/00-overview.md for the full design.