docs(files): document filestash across plan/README/deploy skill

Fold files./filestash into the docs that carry the service list, the vhost
map and the deploy procedure — README stack table, 00-overview, 02-caddy,
12-build-order, 14-deploy, and the deploy skill.

Two real gaps the filestash deploy exposed, now guardrails in the skill:
- update-all.sh never touches host nginx, so a NEW SUBDOMAIN silently 404s
  after deploy. It needs issue-letsencrypt.sh + render-nginx.sh --install.
- a new service with ${FOO:?} guards fails the WHOLE compose file, so a
  missing .env var means `up` starts nothing — and --hard has already run
  `down`. Check the host's .env BEFORE tearing the stack down.

Also: filestash/config.json is a single-file bind mount re-rendered every
run, so a rolling update-all leaves it on a stale inode (same trap as the
caddy conf) — force-recreate after any FILES_* change.

Correct the cochi divergence section: `git diff HEAD` on the host is now
empty (verified this deploy — the ff-pull succeeded). The documented
docker-compose.yml/package.json divergence was converged; only untracked
dnsmasq/, caddy-root-ca.crt and a stale pack/ remain. The stash-pull-pop
procedure it prescribed is no longer needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-14 18:23:18 +02:00
parent 25df9f9398
commit cd79b0e540
7 changed files with 164 additions and 41 deletions

View File

@@ -14,7 +14,7 @@ Redeploy the running stack on the production host. Full reference:
- Path: `/home/ubuntu/mc/ulicraft-server-v1`
- Branch: `main`
- Stack: single unified `docker-compose.yml` (drasl, minecraft, mc-backup,
caddy, nmsr, uptime-kuma)
caddy, nmsr, mc-status, uptime-kuma, filestash)
- Auth: Drasl
- Restart: **hard** (down → up) — players are disconnected for a few minutes
@@ -31,6 +31,18 @@ invoking this skill is the authorization to proceed, but:
already up to date and ask whether to restart anyway (they may want a plain
restart). If `git fetch` fails (host unreachable, auth), STOP and report.
**If the incoming commits ADD A SERVICE, check `.env` on the host first.**
A new service with `${FOO:?...}` guards (filestash uses them) fails the
**whole compose file**, not just that service — so a missing var means
`up` refuses to start *anything*, and a `--hard` deploy has already run
`down`. The stack stays down. Check before tearing it down:
```bash
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && grep -c "^FILES_" .env'
```
Same for a new **subdomain**: it needs a cert (`LE_SUBDOMAINS` +
`issue-letsencrypt.sh`) and an nginx vhost — see step 2b, which the deploy
does NOT do for you.
2. **Pull, ensure authlib, then `update-all.sh --hard`** (single SSH session,
halts on any error):
```bash
@@ -58,16 +70,31 @@ invoking this skill is the authorization to proceed, but:
landing rebuild is unconditional (the gitignored `www/` is never updated by
`git pull`), so no separate landing step is needed.
2b. **Only if the deploy changed `nginx/ulicraft-caddy.conf.tmpl`** (a new
subdomain, a new body-size/rate-limit rule, a changed `CADDY_HTTP_PORT`).
`update-all.sh` does **not** touch the host nginx, so a new vhost simply
won't exist and the subdomain 404s / hits the wrong server block:
```bash
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && tooling/render-nginx.sh --install'
```
It runs `nginx -t` before reloading, so a bad render fails safe. The cert
must already exist (`certs/<name>/cert.pem`) or nginx won't load the block —
issue it first with `tooling/issue-letsencrypt.sh` (reads `LE_SUBDOMAINS`).
3. **Verify.**
```bash
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && docker compose ps'
```
Confirm `caddy`, `drasl`, `minecraft`, `mc-backup`, `nmsr`, `uptime-kuma` are
Up. Tail the minecraft log briefly and confirm it reaches `Done` (server
ready):
Confirm `caddy`, `drasl`, `minecraft`, `mc-backup`, `nmsr`, `mc-status`,
`uptime-kuma`, `filestash` are Up. Tail the minecraft log briefly and confirm
it reaches the ready marker (match `Done (Ns)! For help` — a bare `Done`
also matches unrelated mod-loading lines):
```bash
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && timeout 120 docker compose logs -f minecraft' 2>/dev/null | grep -m1 "Done"
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && timeout 180 docker compose logs -f minecraft' 2>/dev/null | grep -m1 'Done ([0-9.]*s)! For help'
```
If a public vhost changed, verify it end-to-end from your workstation rather
than trusting `compose ps` (a container can be Up while nginx never routes to
it): `curl -s -o /dev/null -w '%{http_code}' https://<vhost>/`.
## Guardrails
@@ -81,6 +108,18 @@ invoking this skill is the authorization to proceed, but:
`docker compose up -d --force-recreate caddy`, then verify with
`docker compose exec caddy cat /etc/caddy/conf.d/<file>`. The `--hard` path
(full `down`/`up`) already recreates, so it's unaffected.
- **Same trap for `filestash/config.json`** — also a single-file bind mount, and
it is *re-rendered on every run* by `render-config.sh`, so a rolling
`update-all.sh` leaves filestash reading the stale inode after any `FILES_*`
change (rotating the shared password, flipping `FILES_AUTH`). Apply with
`docker compose up -d --force-recreate filestash`. `--hard` is unaffected.
- **Never let `filestash/config.json` be missing when compose runs.** It is
gitignored and rendered; if it doesn't exist, Docker creates a *directory* at
that path and filestash breaks in a confusing way. `update-all.sh` renders
before `up`, so the normal path is safe — just don't hand-run `docker compose
up` on the host without rendering first. If it happened: `rm -rf
filestash/config.json && tooling/render-config.sh && docker compose up -d
--force-recreate filestash`.
- Volumes (`mc_data`, `drasl_state`, `kuma_data`) persist across `down`; the
world and monitors are safe. Do NOT pass `-v`/`--volumes` to `down`.
- If a step fails, STOP and surface the exact error + the failing command. Do not

View File

@@ -175,6 +175,10 @@ Full design settled across WS1WS6; all tasks unchecked, ready to execute.
distribution forgemods (`tooling/build-modlist.py`), flat alphabetical, pretty
names, no categories/links; feeds the "50+" stat tile.
- [ ] Server-side perf mods (C2ME, etc.).
- [x] **Filestash file share** (`files.`, 2026-07-14) — live in prod. One shared
htpasswd login, writable, rendered `:ro` config. See `plan/20-files.md`.
Follow-ups: add the Uptime Kuma HTTP monitor for `files.`; the share is
**outside mc-backup** and unquota'd (accepted — eyeballed).
- [ ] **Verify NeoForge version** against current stable before first deploy.
- [x] **Bootstrap Drasl admin** account (`admin`, 2026-06-10). Key is
`DefaultAdmins` (not `DefaultAdminUsernames`); first admin needs an invite-flip.

View File

@@ -15,24 +15,27 @@ One compose file, one `docker compose up -d`:
| `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 at `files.${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
- 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`. See `.env.example`.
`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. distribution. www.`) at the host running nginx.
(`auth. avatar. status. files. distribution. www.`) at the host running nginx.
## Prerequisites
@@ -121,6 +124,7 @@ persists in the `kuma_data` volume):
| Landing (apex) | HTTP(s) | `https://${BASE_DOMAIN}` |
| Distribution | HTTP(s) | `https://distribution.${BASE_DOMAIN}` |
| Avatar (NMSR) | HTTP(s) | `https://avatar.${BASE_DOMAIN}` |
| Files (Filestash) | HTTP(s) | `https://files.${BASE_DOMAIN}` (expect 200 — the login page is a 200) |
Internal-name targets (`minecraft`, `drasl:25585`, `nmsr:8080`) isolate "service
down" from "ingress/TLS/DNS down"; public-URL targets test the whole chain.
@@ -153,6 +157,41 @@ curl -H "Host: ulicraft.net" \
Wired in `landing/src/data/site.ts` (`statusApi`) and `caddy/conf.d/10-static.caddy`.
## Player file share (Filestash)
`files.${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. 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.
Three traps that cost real time (all enforced or documented in the tooling):
- **`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.
## Join (guests)
1. Open `https://${BASE_DOMAIN}`, download FjordLauncherUnlocked for your OS.

View File

@@ -13,9 +13,10 @@ Everything is driven by one env var in `.env`:
BASE_DOMAIN=ulicraft.net # all services are subdomains of this
```
`render-config.sh` substitutes **only** `${BASE_DOMAIN}` into the templates.
`render-config.sh` substitutes `${BASE_DOMAIN}` into the templates (plus the
derived `REGISTRATION_*` and `FILES_*` values — see `20-files.md`).
DNS is **not** this repo's concern: point `${BASE_DOMAIN}` and all subdomains
(`auth.` `avatar.` `status.` `distribution.` `www.`) at the host running
(`auth.` `avatar.` `status.` `files.` `distribution.` `www.`) at the host running
nginx; that is configured outside this repo.
## Subdomain / ingress map
@@ -25,6 +26,7 @@ ulicraft.net → nginx → caddy → landing page (/srv/www) + /la
auth.ulicraft.net → nginx → caddy → drasl:25585 (auth web UI + Yggdrasil API)
avatar.ulicraft.net → nginx → caddy → nmsr:8080 (skin/avatar renderer)
status.ulicraft.net → nginx → caddy → uptime-kuma:3001 (status page)
files.ulicraft.net → nginx → caddy → filestash:8334 (player file share, shared login)
distribution.ulicraft.net → nginx → caddy → /srv/distribution (static, another repo)
mc.ulicraft.net:25565 → minecraft (raw MC protocol, not HTTP)
```
@@ -90,8 +92,8 @@ UP:
```
Bring-up is a single command. There are no run modes and no override compose
files — one `docker-compose.yml` holds drasl, minecraft, mc-backup, caddy, nmsr
and uptime-kuma.
files — one `docker-compose.yml` holds drasl, minecraft, mc-backup, caddy, nmsr,
mc-status, uptime-kuma and filestash.
## Why each hard call was made
@@ -119,6 +121,9 @@ and uptime-kuma.
│ ├── config.toml.tmpl # render-config.sh source
│ └── config.toml # generated (gitignored)
├── nmsr/ # avatar renderer config + Dockerfile context
├── filestash/
│ ├── config.json.tmpl # render-config.sh source (files. share)
│ └── config.json # generated, mounted :ro (gitignored — holds hashes)
├── docker/nmsr/ # nmsr image build context
├── server-mods/ # filtered server modset (synced, gitignored)
├── launcher/ # vendored Fjord launcher releases (gitignored)
@@ -145,9 +150,11 @@ and uptime-kuma.
- `15-letsencrypt.md` — Let's Encrypt (OVH DNS-01) + host nginx ingress
- `17-mod-shortlist.md` — kitchen-sink gap doc + landing mod-list dataset (PLANNED)
- `18-landing-rework.md` — join flow / rosters / account / mod list / footer (PLANNED)
- `19-routes.md` — HTTP route reference (nmsr / landing / auth + internal)
- `19-routes.md` — HTTP route reference (nmsr / landing / auth / files + internal)
- `20-files.md` — Filestash player file share (`files.`, one shared login)
## Boot/dependency order
`caddy` (aliases) → `drasl``minecraft` (depends on drasl) → `mc-backup`.
`nmsr` and `uptime-kuma` are always-on and join `mcnet`.
`nmsr`, `mc-status`, `uptime-kuma` and `filestash` are always-on and join `mcnet`
(no ordering constraints — none of them are on the auth/boot path).

View File

@@ -7,7 +7,8 @@ nginx (which terminates TLS — see `15-letsencrypt.md`). caddy is published onl
on a localhost-only port (`CADDY_HTTP_BIND=127.0.0.1`, `CADDY_HTTP_PORT=8880`);
nginx reverse-proxies every public vhost to it by Host header. Roles:
1. **Reverse proxy**`auth.` → drasl, `avatar.` → nmsr, `status.` → uptime-kuma.
1. **Reverse proxy**`auth.` → drasl, `avatar.` → nmsr, `status.` → uptime-kuma,
`files.` → filestash.
2. **Static** — apex serves the landing page + `/launcher/` downloads;
`distribution.` serves a static site from another repo.
@@ -36,10 +37,17 @@ The Caddyfile imports per-vhost snippets from `caddy/conf.d/`:
`DISTRIBUTION_WEB_ROOT`, another repo).
- `40-avatar.caddy``avatar.``nmsr:8080` (skin/avatar renderer).
- `50-status.caddy``status.``uptime-kuma:3001` (status page).
- `60-files.caddy``files.``filestash:8334` (player file share, `20-files.md`).
All vhosts are plain `http://…`; TLS is nginx's job. Mounts: `./www:/srv/www:ro`,
`./launcher:/srv/launcher:ro`, `${DISTRIBUTION_WEB_ROOT}:/srv/distribution:ro`.
> **Don't rewrite the `Host` header for `files.`** — filestash rejects any
> request whose Host doesn't match its configured `general.host`, with *"only
> traffic from X is allowed"* (a 403 that reads like an auth failure). nginx
> forwards the original Host and caddy passes it through untouched. Keep it that
> way.
## Notes / risks
- drasl behind a proxy: with matching `BaseURL` and nginx terminating TLS it is

View File

@@ -50,6 +50,15 @@ Dependency-ordered. Each numbered item = one git commit. Operational steps
17. `feat(nginx): add ulicraft-caddy.conf.tmpl + render-nginx.sh`
18. `feat(tls): add issue-letsencrypt.sh (OVH DNS-01) + LE env block`
## Phase 11 — file share (DONE, 2026-07-14)
19. `feat(files): add Filestash player file share at files.${BASE_DOMAIN}`
compose service (digest-pinned) + `60-files.caddy` + nginx vhost (2g uploads,
`/admin` rate limit) + `filestash/config.json.tmpl` rendered `:ro` +
`FILES_*` env block. One shared htpasswd login, writable. See `20-files.md`.
- [ops] `files` added to `LE_SUBDOMAINS``issue-letsencrypt.sh`, then
`render-nginx.sh --install` (a new subdomain needs BOTH; `update-all.sh`
does not touch host nginx).
## Phase 10 — operational (no commits)
- [ops] Prep online (once): `tooling/render-config.sh`, build landing
(`cd landing && set -a && . ../.env && set +a && pnpm run build` — sources

View File

@@ -56,8 +56,19 @@ Everyday landing/config tweak (no world downtime):
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && git pull --ff-only && ./update-all.sh'
```
Steps `update-all.sh` runs, in order: `render-config.sh` (drasl/nmsr from `.env`;
halts on a bad `REGISTRATION_MODE`) → `sync-server-mods.sh` (mirror the
> **Same inode trap for `filestash/config.json`.** It is a single-file bind mount
> *and* it is re-rendered on every `update-all.sh` run, so a **rolling** update
> after any `FILES_*` change (rotating the shared password, flipping
> `FILES_AUTH`) leaves filestash reading the stale inode. Apply with
> `docker compose up -d --force-recreate filestash`. `--hard` is unaffected.
> Also: never run `docker compose up` on the host without rendering first — a
> missing `filestash/config.json` makes Docker create a **directory** at that
> path. See `plan/20-files.md`.
Steps `update-all.sh` runs, in order: `render-config.sh` (drasl/nmsr/filestash
from `.env`; halts on a bad `REGISTRATION_MODE`, a bad `FILES_AUTH`, or a
`FILES_PASSWORD_HASH` in a format the htpasswd plugin can't verify) →
`sync-server-mods.sh` (mirror the
`both`/`server` subset from `mods-sides.json` out of `$DISTRIBUTION_WEB_ROOT`
into `./server-mods`) → `build-modlist.py` (regen landing `mods.json` + logos;
**run under `python>=3.11`** — cochi's default `python3` is 3.10, but
@@ -75,30 +86,25 @@ pull` never updates them. `update-all.sh` regenerates both every run
`render-config.sh` changed its config (Drasl's `RequireInvite`/CORS/RateLimit) —
no world downtime.
## Known host-local divergence on `cochi` (uncommitted)
## Host-local divergence on `cochi` — RESOLVED (verified 2026-07-14)
`cochi` carries deliberate local edits that are **not in the repo**, so
`git pull --ff-only` **aborts** ("local changes would be overwritten") whenever
an incoming commit also touches `docker-compose.yml`. As of 2026-06-09 the host
has, uncommitted:
**There are no longer any tracked modifications on the host.** `git diff HEAD`
is empty; `git pull --ff-only` fast-forwards cleanly (confirmed on the filestash
deploy). The old divergence — `docker-compose.yml` forced `http`→`https`,
`landing/package.json`, `landing/pnpm-workspace.yaml` — has been converged into
the repo. The stashpullpop dance previously documented here is **no longer
needed**.
- `docker-compose.yml` — internal URLs forced `http`→`https`
(`JVM_OPTS` authlib-injector), for the in-container HTTPS path. (The old
`PACKWIZ_URL` divergence is moot — packwiz was removed; see `04-mods.md`.)
- `dnsmasq/` (new), `caddy/caddy-root-ca.crt`, `landing/pnpm-workspace.yaml` —
internal DNS + private CA so containers resolve/trust `https://auth.` etc.
- `landing/package.json` modified. (The former `pack/` divergence is resolved —
`pack/` and `custom/` were deleted with the packwiz removal.)
What remains on the host is **untracked only**, so it never blocks a pull:
**Do not steamroll it.** Before a deploy that edits `docker-compose.yml`, either:
1. **Converge** — commit the host edits to `main` + push, then a clean ff-pull
deploys (preferred — ends the divergence). The https/dnsmasq/CA setup belongs
in the repo.
2. **Stashpullpop** — `git stash` → `git pull --ff-only` → `git stash pop`.
Non-destructive; safe only when the incoming and host edits are in different
regions of the file. Stop on a pop conflict.
- `dnsmasq/`, `caddy/caddy-root-ca.crt` — internal DNS + private CA so containers
resolve/trust `https://auth.` etc. Host-local infra, deliberately not in git.
- `pack/` — **stale leftover** from the packwiz era (packwiz was removed; see
`04-mods.md`). Nothing reads it. Safe to delete.
Never `git reset --hard` / discard on the host without confirming first.
Still true, and worth keeping: never `git reset --hard` or discard on the host
without confirming first, and re-check `git status` there before any deploy that
touches tracked files — a *new* host-local edit would reintroduce the abort.
## What survives a hard restart
@@ -116,8 +122,15 @@ restart (a few minutes); they reconnect once `minecraft` is healthy again.
- SSH access as the `cochi` alias.
- `.env` present and complete: `BASE_DOMAIN`, `RCON_PASSWORD`, `CADDY_HTTP_PORT`,
`CADDY_HTTP_BIND`, `DISTRIBUTION_WEB_ROOT`, `REGISTRATION_MODE` (`invite`|`open`,
defaults `invite`), and the Let's Encrypt block (`render-config.sh` halts on an
unset `BASE_DOMAIN` or an invalid `REGISTRATION_MODE`).
defaults `invite`), the `FILES_*` block (`plan/20-files.md`), and the Let's
Encrypt block. `render-config.sh` halts on an unset `BASE_DOMAIN`, an invalid
`REGISTRATION_MODE`/`FILES_AUTH`, or a `FILES_PASSWORD_HASH` the htpasswd
plugin can't verify.
> **A missing `FILES_*` var takes the WHOLE stack down, not just filestash.**
> The compose service uses `${FILES_DATA_DIR:?}` / `${FILES_LOCAL_SECRET:?}`
> guards, and compose fails the entire file on an unset required var — so
> `up` starts *nothing*, and a `--hard` deploy has already run `down`. Check
> `.env` on the host **before** deploying a commit that adds a guarded service.
- Docker + compose plugin, `envsubst`, `jq` (used by `sync-server-mods.sh`;
falls back to `python3` if absent), `git`.
- Host nginx + Let's Encrypt certs installed once — see `15-letsencrypt.md`.
@@ -127,11 +140,15 @@ restart (a few minutes); they reconnect once `minecraft` is healthy again.
## Verify
```bash
docker compose ps # caddy, drasl, minecraft, mc-backup, nmsr, mc-status, uptime-kuma up
docker compose ps # caddy drasl minecraft mc-backup nmsr mc-status uptime-kuma filestash
# self-hosted live status feed (plan/15-mc-status.md):
curl -fsS "https://$(grep '^BASE_DOMAIN=' .env | cut -d= -f2)/api/mcstatus/v2/status/java/$(grep '^BASE_DOMAIN=' .env | cut -d= -f2)" | jq .online
docker compose logs -f minecraft # watch for "Done"
# server ready — match the full marker, not a bare "Done" (that also hits mod-load lines)
docker compose logs -f minecraft | grep -m1 'Done ([0-9.]*s)! For help'
curl -fsS "https://auth.$(grep '^BASE_DOMAIN=' .env | cut -d= -f2)/" >/dev/null && echo "auth ok"
# files. — 200 = live; anonymous API must be REFUSED (auth is on)
curl -s -o /dev/null -w 'files: %{http_code}\n' "https://files.$(grep '^BASE_DOMAIN=' .env | cut -d= -f2)/"
curl -s -H 'X-Requested-With: XmlHttpRequest' "https://files.$(grep '^BASE_DOMAIN=' .env | cut -d= -f2)/api/session" # want "Not authorised"
```
## Rollback