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>
15 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.caddy). 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.caddy |
| 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 unconditionally public read. Independent of FILES_AUTH, the
62-files.caddy static browse serves the whole share with no auth — anyone
who knows 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.
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_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 and files (both are vhosts) |
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.