# 20 — Filestash (`upload.${BASE_DOMAIN}`) > Web file share for player media (screenshots, schematics, maps, guides). > One [Filestash](https://github.com/mickael-kerjean/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 at **`files.${BASE_DOMAIN}`** as a > plain caddy `file_server browse` — **read-only, no auth**, every uploaded file > publicly listable (`62-files.on.caddy`, toggled per host by `FILES_BROWSE`). > Filestash gets its own subdomain because it cannot be served under a subpath > (SPA hardcodes `/api`, `/assets`, `/login` at 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: ```sh 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 ''` | | `FILES_ADMIN_PASSWORD_HASH` | bcrypt, from `docker run --rm caddy:alpine caddy hash-password --plaintext ''` | | `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 ```sh 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: ```sh 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 "$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: ```yaml 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/filestash` is `0730 filestash:filestash` and `/app/data` is `0770` — a non-1000 uid is *other* and can neither exec nor traverse. The `group_add: ["1000"]` restores that via the filestash group. - The `filestash_state` volume (config, sqlite, search index) is owned `1000: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/` then `ssh 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) or `http://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 on `oxps`** (LAN-only, 2026-07-23); still `htpasswd` on `cochi`. - **Landing page link** — deliberately none on day one; `plan/18-landing-rework.md` is 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.