Let the container run with nothing writable #117

Closed
opened 2026-08-28 04:48:39 +00:00 by jcoffey-dev · 0 comments
Owner

Prep for the immutability roadmap. No behaviour changes for any existing deployment; the new mode is opt-in and off by default.

What's here

VOLUME ["/data"] is gone from the Dockerfile. It reads like documentation, but Docker acts on it — a container started without -v gets an anonymous volume mounted at /data, and that mount stays writable even under --read-only. So it put a writable hole in a container meant to be immutable, and orphaned a volume every time one was replaced, while never persisting anything across a redeploy (each new container got a fresh empty volume). docker-compose.yml and deploy.example.sh both mount a named volume and are unaffected.

IMMUTABLE=1 asserts the instance is running immutably, and the server checks it. It refuses to start if SESSION_FILE is still set, or if the filesystem it is installed on turns out to be writable. Unchecked, the misconfiguration is silent: persisting sessions is best-effort, so a read-only /data costs one warning at the first sign-in and nothing else until the instance is replaced and everyone is signed out.

SessionBackend names what the rest of the server asks of a session store, and sessions in app.ts is typed as it. SessionStore is still the only implementation and nothing changes today. It exists so the OAuth work is written against an interface rather than a class, and so the interface can record which methods a stateless backend could satisfy alone — create/resolve/reseal/destroy each touch one session, while listForUser and destroyAllForUser have to reach sessions other than the caller's. The latter is what makes "changing your password signs out your other sessions" true (app.ts:335, app.ts:429), which is why it needs a registry.

Why this is possible at all

sessions.ts is the only thing in server/src/ that writes to disk — lines 1, 97, 99, 100. static.ts and config.ts only read, and uploads stream through byteCap without touching disk.

Verified

Built and run as a container in all five shapes:

result
--read-only --tmpfs /tmp -e IMMUTABLE=1 -e SESSION_FILE= healthy, Mounts: [], /dataEROFS
IMMUTABLE=1, SESSION_FILE left at the image default refuses: "SESSION_FILE is /data/sessions.json …"
IMMUTABLE=1, --read-only forgotten refuses: "/app/ is writable …"
no IMMUTABLE, named volume (today's deploy) healthy, ihm-v-vol -> /data RW=true — unchanged
no IMMUTABLE, no -v healthy, Mounts: [] — no more anonymous volume

npm run typecheck, npm test (80 pass, 3 new), npm run build, and docker build all clean.

Not here

The stateless session backend. Today IMMUTABLE=1 means a restart signs everyone out, because sessions have nowhere to live. Removing that cost means moving the session upstream into a token Stalwart issues and can revoke — the OAuth work in ROADMAP.md, which also restores the two registry-dependent methods above.

Merged 2026-08-27 as coffey-labs/ihasmail@37bf96409d

Rebuilt from: git history, session transcript.

Prep for the immutability roadmap. No behaviour changes for any existing deployment; the new mode is opt-in and off by default. ### What's here **`VOLUME ["/data"]` is gone from the Dockerfile.** It reads like documentation, but Docker acts on it — a container started without `-v` gets an anonymous volume mounted at `/data`, and that mount stays writable even under `--read-only`. So it put a writable hole in a container meant to be immutable, and orphaned a volume every time one was replaced, while never persisting anything across a redeploy (each new container got a fresh empty volume). `docker-compose.yml` and `deploy.example.sh` both mount a *named* volume and are unaffected. **`IMMUTABLE=1` asserts the instance is running immutably, and the server checks it.** It refuses to start if `SESSION_FILE` is still set, or if the filesystem it is installed on turns out to be writable. Unchecked, the misconfiguration is silent: persisting sessions is best-effort, so a read-only `/data` costs one warning at the first sign-in and nothing else until the instance is replaced and everyone is signed out. **`SessionBackend` names what the rest of the server asks of a session store**, and `sessions` in `app.ts` is typed as it. `SessionStore` is still the only implementation and nothing changes today. It exists so the OAuth work is written against an interface rather than a class, and so the interface can record which methods a stateless backend could satisfy alone — `create`/`resolve`/`reseal`/`destroy` each touch one session, while `listForUser` and `destroyAllForUser` have to reach sessions other than the caller's. The latter is what makes "changing your password signs out your other sessions" true (`app.ts:335`, `app.ts:429`), which is why it needs a registry. ### Why this is possible at all `sessions.ts` is the only thing in `server/src/` that writes to disk — lines 1, 97, 99, 100. `static.ts` and `config.ts` only read, and uploads stream through `byteCap` without touching disk. ### Verified Built and run as a container in all five shapes: | | result | |---|---| | `--read-only --tmpfs /tmp -e IMMUTABLE=1 -e SESSION_FILE=` | healthy, `Mounts: []`, `/data` → `EROFS` | | `IMMUTABLE=1`, `SESSION_FILE` left at the image default | refuses: *"SESSION_FILE is /data/sessions.json …"* | | `IMMUTABLE=1`, `--read-only` forgotten | refuses: *"/app/ is writable …"* | | no `IMMUTABLE`, named volume (today's deploy) | healthy, `ihm-v-vol -> /data RW=true` — unchanged | | no `IMMUTABLE`, no `-v` | healthy, `Mounts: []` — no more anonymous volume | `npm run typecheck`, `npm test` (80 pass, 3 new), `npm run build`, and `docker build` all clean. ### Not here The stateless session backend. Today `IMMUTABLE=1` means a restart signs everyone out, because sessions have nowhere to live. Removing that cost means moving the session upstream into a token Stalwart issues and can revoke — the OAuth work in ROADMAP.md, which also restores the two registry-dependent methods above. **Merged** 2026-08-27 as coffey-labs/ihasmail@37bf96409d22 <sub>Rebuilt from: git history, session transcript.</sub>
This repo is archived. You cannot comment on issues.