Filestash cannot be served under a subpath (its SPA hardcodes /api, /assets,
/login at root), so it moves to its own subdomain upload.${BASE_DOMAIN}. The
apex-level ask was a static, no-auth file browser at files.${BASE_DOMAIN}: caddy
now file_server browse's the same ${FILES_DATA_DIR} share read-only there.
- filestash general.host -> upload.; nginx upload. vhost keeps the 2g body +
upload streaming + /admin throttle; new files. vhost is a plain static proxy.
- caddy: 60-files.caddy split into 60-upload.caddy (filestash) + 62-files.caddy
(file_server browse of /srv/files); caddy mounts FILES_DATA_DIR :ro.
- LE_SUBDOMAINS += upload (both upload. and files. are vhosts needing certs).
Security: files. is unconditionally public read (no auth), independent of
FILES_AUTH. The htpasswd login only gates writes via upload. Documented.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
237 lines
11 KiB
Markdown
237 lines
11 KiB
Markdown
# Ulicraft Server
|
|
|
|
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. 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}` |
|
|
| `mc-status` | built from source | live server-status JSON for the landing card |
|
|
| `uptime-kuma` | louislam/uptime-kuma | status page at `status.${BASE_DOMAIN}` |
|
|
| `filestash` | machines/filestash (digest-pinned) | player file share (upload) at `upload.${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 + `/api/mcstatus/*`
|
|
- `auth.` — Drasl
|
|
- `avatar.` — NMSR
|
|
- `status.` — Uptime Kuma
|
|
- `files.` — Filestash player file share (one shared login, writable)
|
|
- `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`, the `FILES_*` block. See `.env.example`.
|
|
|
|
## DNS
|
|
|
|
Handled **outside this repo**. Point `${BASE_DOMAIN}` and every subdomain
|
|
(`auth. avatar. status. files. distribution. www.`) at the host running nginx.
|
|
|
|
## Prerequisites
|
|
|
|
Docker + Docker Compose; for prep also `pnpm`, `jq`, `curl`, `envsubst`.
|
|
|
|
## Launch
|
|
|
|
```sh
|
|
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 configs from *.tmpl
|
|
tooling/sync-server-mods.sh # filtered server mods → ./server-mods
|
|
( 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 synced server 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):
|
|
```sh
|
|
# .env: BASE_DOMAIN, LE_EMAIL, OVH_* creds,
|
|
# LE_SUBDOMAINS="auth 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`:
|
|
```sh
|
|
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](https://github.com/NickAcPT/nmsr-rs), 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](https://github.com/louislam/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}` |
|
|
| Distribution | HTTP(s) | `https://distribution.${BASE_DOMAIN}` |
|
|
| Avatar (NMSR) | HTTP(s) | `https://avatar.${BASE_DOMAIN}` |
|
|
| Upload (Filestash) | HTTP(s) | `https://upload.${BASE_DOMAIN}` (expect 200 — the login page is a 200) |
|
|
| Files (static browse) | HTTP(s) | `https://files.${BASE_DOMAIN}` (caddy dir listing of the share, no auth) |
|
|
|
|
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.
|
|
|
|
## Server status JSON (mc-status)
|
|
|
|
`mc-status` is a self-hosted SLP→JSON pinger (built from `docker/mc-status`, an
|
|
mcutil wrapper) that the landing's `ServerCard` reads for live player count + MOTD.
|
|
It emits **mcstatus.io v2** JSON, so the card consumes it unchanged — but
|
|
same-origin, with no CORS and no third-party calls. caddy proxies the apex
|
|
`/api/mcstatus/*` path to `mc-status:8080` (strips the prefix); the container has
|
|
no published port. The pinger only ever probes its configured `MC_STATUS_TARGET`,
|
|
so the path after the prefix is ignored.
|
|
|
|
Endpoint (apex, same-origin):
|
|
|
|
```
|
|
https://${BASE_DOMAIN}/api/mcstatus/v2/status/java/${BASE_DOMAIN}
|
|
```
|
|
|
|
Hitting caddy directly (localhost-only, routes by Host header) — e.g. `ulicraft.net`
|
|
on `127.0.0.1:8880`:
|
|
|
|
```sh
|
|
curl -H "Host: ulicraft.net" \
|
|
http://127.0.0.1:8880/api/mcstatus/v2/status/java/ulicraft.net
|
|
```
|
|
|
|
Wired in `landing/src/data/site.ts` (`statusApi`) and `caddy/conf.d/10-static.caddy`.
|
|
|
|
## Player file share (Filestash)
|
|
|
|
`upload.${BASE_DOMAIN}` is a web file share for player media — screenshots,
|
|
schematics, maps, guides. **One shared username/password for everybody**
|
|
(`FILES_USER` / `FILES_PASSWORD_HASH`), read **and** write. The same share is
|
|
also exposed **read-only, no-auth** as a plain caddy directory listing at
|
|
`files.${BASE_DOMAIN}` — every uploaded file is publicly downloadable there.
|
|
It is *not* a backup
|
|
browser and *not* a mod mirror: world snapshots contain every player's inventory
|
|
and coordinates, and the modset already ships via `distribution.`.
|
|
|
|
Full design, deploy steps and gotchas: **`plan/20-files.md`**. The short version:
|
|
|
|
- Config is `filestash/config.json`, **rendered from a template and mounted
|
|
`:ro`** — git is the source of truth. The admin console at `/admin` loads but
|
|
**cannot save**; that's deliberate. Change config by editing the `.tmpl`,
|
|
re-rendering, and **recreating** the container (`up -d --force-recreate
|
|
filestash` — a single-file bind mount pins the inode, so `restart` reads the
|
|
stale file).
|
|
- Files live in `${FILES_DATA_DIR}` on the host, outside the repo. **Not backed
|
|
up** by mc-backup — treat the share as disposable.
|
|
- `FILES_MOUNT_MODE=ro|rw` is the literal mount flag: `ro` makes the share
|
|
read-only at the *kernel*, whatever the UI thinks.
|
|
- `FILES_AUTH=htpasswd|passthrough` — `passthrough` means **no login at all**.
|
|
Only valid once the host is off the public internet: an unauthenticated
|
|
*writable* share on a public domain is an open upload relay.
|
|
|
|
Four traps that cost real time (all enforced or documented in the tooling):
|
|
|
|
- **`general.host` is a bare hostname**, no scheme (`upload.${BASE_DOMAIN}`), with
|
|
`force_ssl: true`. The SPA builds its redirect origin as `scheme + host`, so a
|
|
URL there produces `http://https://upload...` and every client-side redirect
|
|
breaks. The server strips the scheme before its *own* Host check, so `curl`
|
|
and the uptime monitor stay **green while every browser is broken**.
|
|
- **`FILES_PASSWORD_HASH` must be `$6$`** (`openssl passwd -6`). The htpasswd
|
|
plugin's bcrypt branch only matches `$2a$`, so a `$2y$` hash from
|
|
`htpasswd -B` fails *every* login, silently. `render-config.sh` rejects it.
|
|
- **The `local` storage backend is admin-gated** — without `FILES_LOCAL_SECRET`
|
|
reaching it through the connection params, every login dies with
|
|
`backend error - Not Allowed`, which looks like bad credentials and isn't.
|
|
- **Don't set `APPLICATION_URL`/`ADMIN_PASSWORD` env** — either makes Filestash
|
|
rewrite `config.json` at boot, which fails against the `:ro` mount.
|
|
|
|
> Verifying this service: a 200 on `/` only proves the server is up. Check the
|
|
> browser-facing origin — `curl -s -H 'X-Requested-With: XmlHttpRequest`
|
|
> `https://upload.${BASE_DOMAIN}/api/config | jq -r .result.origin` must print
|
|
> `https://upload.${BASE_DOMAIN}` — and re-read `docker compose logs filestash`.
|
|
|
|
## 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. The launcher installs the modpack from the distribution automatically.
|
|
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
|
|
landing/ # Astro site → www/
|
|
launcher/ # FjordLauncher assets (gitignored)
|
|
server-mods/ # filtered server mod jars (synced, gitignored)
|
|
tooling/ # render-config, sync-server-mods, render-nginx,
|
|
# issue-letsencrypt, fetch-launcher
|
|
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.
|