Oier Bravo Urtasun db270a9695 fix(mods): mark cwb (Cubes Without Borders) client — crashes dedicated server
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>
2026-06-11 01:23:40 +02:00

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. — Drasl
  • pack. — packwiz metadata + /custom/ jars
  • avatar. — NMSR
  • status. — Uptime Kuma
  • distribution. — 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.

  1. 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
    
  2. Render + install the nginx vhost with tooling/render-nginx.sh. It pulls BASE_DOMAIN + CADDY_HTTP_PORT from .env, defaults APP_DIR to the repo checkout (where certs/ live), and renders nginx/ulicraft-caddy.conf.tmpl:

    tooling/render-nginx.sh                       # preview to stdout (dry run)
    tooling/render-nginx.sh --install             # write, symlink, nginx -t, reload
    

    Override APP_DIR=/path if 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 to nmsr/config.toml by tooling/render-config.sh. [mojank] points every Mojang endpoint at http://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 alias auth.${BASE_DOMAIN} → drasl). caddy reverse-proxies avatar.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)

  1. Open https://${BASE_DOMAIN}, download FjordLauncherUnlocked for your OS.
  2. Add an authlib-injector account, URL https://auth.${BASE_DOMAIN}/authlib-injector (register/login at https://auth.${BASE_DOMAIN}).
  3. Import the pack: https://pack.${BASE_DOMAIN}/pack.toml.
  4. 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.

Description
No description provided
Readme 79 MiB
Languages
Astro 24.6%
CSS 21.7%
JavaScript 16.1%
TypeScript 15%
Shell 11.3%
Other 11.3%