Per-host toggle for the read-only, no-auth browse of the shared upload dir,
so it can stay on for the LAN-party host (oxps) and be off on the
internet-facing one (cochi). Defaults to `on` — existing hosts unchanged.
Neither layer has conditionals, so each gets the value substituted in:
- caddy: docker-compose mounts 62-files.${FILES_BROWSE:-on}.caddy at the
fixed conf.d target (62-files.caddy -> 62-files.on.caddy, plus a
comments-only 62-files.off.caddy stub — caddy needs a parseable file
there). Same trick as FILES_MOUNT_MODE.
- nginx: render-nginx.sh splices nginx/files-browse.server.tmpl into
${FILES_BROWSE_SERVER} and adds/omits the name in the port-80 redirect.
This is the gate that matters; caddy is bound to 127.0.0.1.
Applying a change needs render-nginx.sh --install plus a caddy
--force-recreate (single-file bind mount pins the inode). With `off`,
drop `files` from LE_SUBDOMAINS on that host. upload. is unaffected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
17 KiB
20 — Filestash (upload.${BASE_DOMAIN})
Web file share for player media (screenshots, schematics, maps, guides). One Filestash container behind caddy at
upload., one shared username/password for everybody, writable. Config is rendered read-only from a template, like drasl/nmsr.The same
${FILES_DATA_DIR}is also exposed atfiles.${BASE_DOMAIN}as a plain caddyfile_server browse— read-only, no auth, every uploaded file publicly listable (62-files.on.caddy, toggled per host byFILES_BROWSE). Filestash gets its own subdomain because it cannot be served under a subpath (SPA hardcodes/api,/assets,/loginat root).Not a backup browser, not a mod mirror. See "What goes in it".
Everything below was verified against the real image (digest pinned in compose) and against the upstream source, not inferred from the docs — the docs omit most of it.
Shape
Internet ── host nginx (TLS, LE cert upload.) ──► caddy ──► filestash:8334 (rw)
│
${FILES_DATA_DIR} ──┤ (bind mount)
│
Internet ── host nginx (TLS, LE cert files.) ──► caddy file_server (ro, no auth)
| Thing | Value |
|---|---|
| Vhost (filestash) | upload.${BASE_DOMAIN} |
| Vhost (static browse) | files.${BASE_DOMAIN} — read-only, no auth, 62-files.on.caddy, per-host FILES_BROWSE=on|off |
| Image | machines/filestash pinned by digest (upstream ships only latest) |
| Container port | 8334, runs as uid/gid 1000 (filestash) |
| Auth | htpasswd, one shared account, FILES_USER / FILES_PASSWORD_HASH |
| Access | read + write for anyone with the password |
| Data | ${FILES_DATA_DIR} on the host, outside the repo |
| State | named volume filestash_state → /app/data/state |
| Config | filestash/config.json rendered from .tmpl, mounted :ro |
| Backups | none — outside mc-backup's scope. Treat as disposable. |
| Quota | none — eyeballed |
Decisions (settled — don't relitigate)
Auth is htpasswd with one shared credential, not SSO. Filestash's OIDC and
SAML middlewares are enterprise-only; the community build compiles in
htpasswd, ldap, passthrough, wordpress, local only (verified in
server/plugin/index.go). Keycloak was considered and dropped — it would need an
oauth2-proxy sidecar in front + passthrough. That path stays open but is not
built.
One instance, everything behind the password. No anonymous read tier. Filestash's auth middleware is global to the instance, so "anonymous read + authenticated write" is impossible in one container (that's the enterprise RBAC). A two-instance split was designed and rejected as not worth it.
FILES_AUTH=none is gated on the host being internal-only. The off-switch
exists for when this server moves behind a LAN/VPN. Do not set it while
upload. is internet-reachable — auth-off on a writable public share is an
open upload relay: someone parks malware or a phishing kit on your domain, under
your LE cert, and the abuse mail, the bandwidth, and a possible domain
blocklisting (which would take auth. and the apex down with it, breaking logins
and the launcher for every player) all land on you. render-config.sh prints a
loud warning when it renders this mode.
files. is public read wherever it is enabled. Independent of FILES_AUTH,
the static browse serves the whole share with no auth — anyone who reaches
files.${BASE_DOMAIN} can list and download every uploaded file. This is
deliberate (chosen 2026-07-24). The htpasswd login only gates writes (via
upload.); it never gated reads at this URL. Don't put anything private in the
share.
FILES_BROWSE=on|off turns the whole files. vhost off per host (added
2026-07-30) — the answer to "I want it on oxps (LAN party) but not on cochi
(internet-facing)". One var, two layers, because each has its own conditional
problem:
| Layer | Mechanism |
|---|---|
| caddy | docker-compose.yml substitutes the value into the mount source: ./caddy/conf.d/62-files.${FILES_BROWSE:-on}.caddy → /etc/caddy/conf.d/62-files.caddy. 62-files.off.caddy is a comments-only stub (compose has no conditionals, and caddy needs a parseable file at the fixed mount target — same trick as FILES_MOUNT_MODE). |
| nginx | render-nginx.sh splices nginx/files-browse.server.tmpl into ${FILES_BROWSE_SERVER} when on, or a comment when off, and adds/omits the name in the port-80 redirect (nginx has no conditionals either). |
nginx is the gate that matters — with no TLS vhost the name is unreachable from
the internet regardless of caddy (caddy is bound to 127.0.0.1). The caddy stub
is the second layer, and it also matters on hosts reached directly on caddy's
port rather than through nginx (oxps on the LAN). Applying a change:
tooling/render-nginx.sh --install # nginx vhost in/out + reload
docker compose up -d --force-recreate caddy # single-file bind mount pins the inode
restart caddy is NOT enough (the conf mount is a single file — see
plan/14-deploy.md). With off, files also needs dropping from
LE_SUBDOMAINS on that host: no vhost, no cert to renew. upload. (filestash,
authenticated, writable) is untouched by this var.
Per-host auth status (the two prod envs diverge):
| Host | upload. reachability |
FILES_AUTH |
Notes |
|---|---|---|---|
cochi |
internet-facing | htpasswd |
shared uli login, $6$ hash |
oxps |
LAN-only | none (passthrough) |
auth off — gate satisfied (2026-07-23) |
oxps runs auth-off deliberately: it is unreachable from the internet, so the
open-upload-relay risk above does not apply. Applied host-locally (.env
FILES_AUTH=none → render-config.sh → docker compose up -d --force-recreate filestash); nothing committed — .env and the rendered config.json are both
gitignored per host. If oxps ever gains an internet path, flip it back to
htpasswd (or set FILES_MOUNT_MODE=ro) before exposing it.
Shared credential ⇒ no audit trail, no per-person revocation. You will never know who deleted a folder; rotating means re-telling everyone. Fine for a friends' media share — but nothing here is backed up, so treat the share as disposable and keep anything you'd miss elsewhere.
What goes in it
| Content | Verdict |
|---|---|
| Screenshots, schematics, maps, guides | ✅ the point |
World downloads / ./backups/*.tgz |
❌ contains playerdata/, stats/, the full map — every player's inventory and coords, handed to anyone with the shared password |
| Mods / jars | ❌ redistribution; distribution.${BASE_DOMAIN} already serves the modset to launchers |
.env
See .env.example for the annotated block. Summary:
| Var | What |
|---|---|
FILES_DATA_DIR |
host path of the share, outside the repo |
FILES_MOUNT_MODE |
literal mount flag, rw | ro |
FILES_BROWSE |
on | off — serve the files. read-only browse on this host |
FILES_AUTH |
htpasswd | passthrough | none |
FILES_USER |
shared login name |
FILES_PASSWORD_HASH |
$6$, from openssl passwd -6 '<pw>' |
FILES_ADMIN_PASSWORD_HASH |
bcrypt, from docker run --rm caddy:alpine caddy hash-password --plaintext '<pw>' |
FILES_SECRET_KEY |
openssl rand -hex 16, must be non-empty |
FILES_LOCAL_SECRET |
openssl rand -hex 24, unlocks the local backend |
LE_SUBDOMAINS |
must include upload, plus files when FILES_BROWSE=on |
filestash/config.json is rendered and gitignored — it holds every hash and
both secrets. Only the .tmpl is tracked.
Deploy
tooling/render-config.sh # -> filestash/config.json (validates the JSON)
tooling/issue-letsencrypt.sh # picks up `upload`+`files` from LE_SUBDOMAINS
tooling/render-nginx.sh --install # adds the upload.+files. vhosts + reloads nginx
mkdir -p "$FILES_DATA_DIR" # uid 1000 must be able to write it
docker compose up -d filestash caddy
Then add an Uptime Kuma HTTP monitor for https://upload.${BASE_DOMAIN} (expect
200 — the login page is a 200) and put it on the status page with the others.
Gotchas (all of these were hit for real)
The local backend is admin-gated — this is the one that wastes an evening
plg_backend_local.Init() refuses to start unless the connection params
carry $LOCAL_BACKEND_SECRET or the bcrypt admin password. Miss it and every
login dies at backend error - Not Allowed, which reads like a credentials
problem and isn't. config.json's attribute_mapping injects the secret
server-side (Filestash calls this the "facade pattern"), so players never see it.
FILES_LOCAL_SECRET must be identical in the compose env and the rendered
config — render-config.sh renders both from the same var.
$2y$ bcrypt silently fails every login
The htpasswd plugin's bcrypt branch matches on the literal prefix $2a$.
htpasswd -B emits $2y$, which falls through to return false — so every
login fails with correct credentials and no useful log line. Use
openssl passwd -6 ($6$, sha512) for FILES_PASSWORD_HASH.
render-config.sh hard-fails on a $2y$/$2b$ hash rather than shipping a
config nobody can log into. (The admin password is checked by Go's bcrypt
directly, which does accept $2y$ — but caddy hash-password gives $2a$
anyway, so just use that for both.)
general.host is a BARE HOSTNAME — a scheme breaks the browser only
"host": "upload.ulicraft.net", not "https://upload.ulicraft.net", plus
"force_ssl": true. The SPA computes its redirect origin as
(force_ssl ? "https://" : "http://") + host, so a scheme in host yields
http://https://upload.ulicraft.net and every client-side redirect dies —
the page loads, then silently fails to route anywhere.
The vicious part: the server-side Host check (SecureOrigin) strips the
scheme before comparing, so the server is perfectly happy. curl / returns 200,
the API answers, and the Uptime Kuma monitor stays green — only real browsers
break. Symptom in the log is a POST /report?...msg=Redirecting to http://https://... line. Hit this on 2026-07-14; fixed in config.json.tmpl.
force_ssl only sets an HSTS header — it does not redirect, so it can't loop
behind the nginx→caddy (plain http) hop. It must be true or the SPA builds
http:// origins on an https page.
general.host must also match the incoming Host header
Filestash blocks every non-/admin/ request whose Host differs from
general.host, with "only traffic from X is allowed" — a 403 that looks like
an auth failure. nginx forwards the original Host and caddy passes it through —
don't rewrite it anywhere.
The login form only renders at ?action=redirect
GET /api/session/auth/ renders the htpasswd form only when
action=redirect is present; any other request falls through to the credential
check, fails with empty creds, and 303s back to ?action=redirect. So the real
form URL is /api/session/auth/?action=redirect&label=files. A bare
/api/session/auth/?label=files returning a 303 is normal, not a bug.
config.json is a single-file bind mount → stale inode
Same shape as the caddy conf.d gotcha. The mount pins the inode, so
re-rendering (or a git pull) writes a new file while the container keeps
reading the old one.
After re-rendering: docker compose up -d --force-recreate filestash.
restart is not enough. Rotating the shared password needs a recreate too.
Don't set APPLICATION_URL or ADMIN_PASSWORD env
Either one makes Filestash rewrite config.json at boot, which fails against
the :ro mount. Both live in the rendered config instead (general.host /
auth.admin). general.secret_key must likewise be non-empty, or Filestash
generates one and tries to save that. With all three set in the file, Filestash
never attempts a boot write and the :ro mount is clean — you get one harmless
cannot chmod config file WARN in the log, and nothing else.
The admin console cannot save
/admin loads and its login works, but config.json is :ro, so every save
silently fails. That's the deliberate trade for keeping git authoritative
(drasl/nmsr never write their configs; Filestash does, which is exactly why it
needs pinning). To change config: edit the .tmpl, re-render, recreate. To
re-derive the schema after an upstream upgrade: run the image locally against a
writable state volume, click it into shape, docker cp the result out.
The API needs X-Requested-With: XmlHttpRequest
The SPA sends it; anything else (curl, a naive monitor) trips intrusion
detection and gets a 403 that looks like an auth bug. Only matters when probing
by hand — the Kuma monitor hits /, which is fine.
Share dir must be owned by uid 1000 or writes silently 403
filestash runs as uid/gid 1000. FILES_MOUNT_MODE=rw and FILES_AUTH=none
(or a valid login) are not enough — the kernel still enforces the share
directory's own perms. If ${FILES_DATA_DIR} is owned by a different uid with
mode 0775, uid 1000 lands in other (r-x) and every upload/mkdir/delete dies
with Permission denied, while the UI, curl /, and the Kuma monitor all stay
green (same false-green trap as the SPA gotchas). Hit on oxps 2026-07-24: the
share (/home/oier/mc/distribution-ulicraft/files) was 1002:1002 0775.
Fix — give the share to filestash's uid:
sudo chown -R 1000:1000 "$FILES_DATA_DIR"
sudo chmod g+s "$FILES_DATA_DIR" # new files inherit the group
docker exec -u 1000 filestash sh -c 'touch /data/files/.wtest && echo OK && rm /data/files/.wtest'
Reversible (chown -R <old> "$FILES_DATA_DIR"). If another account also writes
the share directly, use a shared group + g+rwx instead of chowning it away.
Cleaner: run filestash as the owning uid (share files become oier:oier)
Chowning the share to filestash's 1000 works, but then everything filestash
writes is owned by an anonymous 1000 nobody logs in as. To make the share
files owned by a real host account (oxps oier = uid 1002) so you can manage
them over ssh, run the container as that uid. Compose exposes it:
user: "${FILES_UID:-1000}:${FILES_GID:-1000}"
group_add: ["1000"]
Two things make a bare uid override fail, both handled above:
- The image binary
/app/filestashis0730 filestash:filestashand/app/datais0770— a non-1000 uid is other and can neither exec nor traverse. Thegroup_add: ["1000"]restores that via the filestash group. - The
filestash_statevolume (config, sqlite, search index) is owned1000:1000. Running as another uid without chowning it → filestash can't write its own state and crash-loops. Chown the state volume too.
Do all of it with tooling/filestash-set-run-user.sh (reads FILES_UID/
FILES_GID from .env, chowns the state volume + FILES_DATA_DIR, recreates,
write-tests). oxps: set FILES_UID=FILES_GID=1002 in .env, run it. Default
1000:1000 leaves cochi untouched. Per-host, host-local — nothing but the
compose knob and this script is committed.
Accessing the share files directly (bypassing the web UI)
${FILES_DATA_DIR} is a plain host directory — the web UI is just a view of it.
- On the host:
ls "$FILES_DATA_DIR"(oxps:/home/oier/mc/distribution-ulicraft/files). Write as uid 1000 to match filestash ownership:sudo -u '#1000' cp foo "$FILES_DATA_DIR"/. - From your machine (oxps is LAN-only):
scp file oxps:/home/oier/mc/distribution-ulicraft/files/thenssh oxps 'sudo chown 1000:1000 /home/oier/mc/distribution-ulicraft/files/file', or mount over sshfs:sshfs oxps:/home/oier/mc/distribution-ulicraft/files ~/mnt/ulifiles. - Via the container:
docker exec -u 1000 filestash ls /data/files. - Web (LAN):
http://upload.${BASE_DOMAIN}(filestash, no login on oxps, auth=none) orhttp://files.${BASE_DOMAIN}(read-only static browse, no auth).
Disk is the shared failure domain
FILES_DATA_DIR grows without a quota, driven by whoever has the password. A
full disk on cochi does not just break files. — it takes Minecraft, Drasl,
and mc-backup down with it. Eyeballing is the accepted plan. Revisit (quota,
separate partition, or a Kuma disk monitor) if the share sees real use.
Pinned digest, not latest
machines/filestash publishes essentially only latest. Unpinned, any
docker compose pull silently ships a new upstream build of an internet-facing,
writable file manager. Pin the digest; upgrade deliberately — same discipline as
NEOFORGE_VERSION.
Deferred
- Keycloak SSO — needs oauth2-proxy +
FILES_AUTH=passthrough. Not built. - Anonymous access (
FILES_AUTH=none) — only once the host is internal-only. Done onoxps(LAN-only, 2026-07-23); stillhtpasswdoncochi. - Landing page link — deliberately none on day one;
plan/18-landing-rework.mdis mid-rework, so revisit with WS5 (footer) rather than merging into a moving design. - Per-user accounts / roles — htpasswd RBAC is enterprise-only.
- Backups / quota — none.