20 -- Read-Only Rootfs & Controlled Writes
Prerequisite: 13 -- Production Checklist You will need: Running Hangar, Docker or Podman Time: 20 minutes Adds: A read-only-by-default deployment where writes are granted only where genuinely needed, and the audit/event trail is durable or fails fast
The Problem
A production MCP fleet is a soft target: a compromised provider that can write to its own root filesystem can drop a binary, tamper with libraries, or persist between restarts. The safe default is a read-only root filesystem for every container, and a writable volume granted only to the specific stateful providers that genuinely need one.
Two failure modes bite operators here:
- You harden everything read-only, then a stateful provider (a database, a
memory store) fails at runtime with
EROFS-- "Read-only file system" -- because it has nowhere to write. - You mount Hangar itself read-only and its SQLite audit/event store silently turns into an in-memory store, so your audit trail evaporates on the next restart.
This recipe shows the boundary: read-only by default, a :rw volume exactly
where required, and an event store that is either durable or refuses to start.
The Config
# config.yaml -- Recipe 20: read-only rootfs + controlled writes
mcp_servers:
# Stateless provider -- read-only, no volume, fully sandboxed.
# read_only defaults to true; network none is the default. Both are shown
# here for clarity.
fetch:
mode: container
image: localhost/mcp-fetch:latest
read_only: true # NEW: read-only root filesystem (default)
network: none # NEW: no network namespace (default)
resources:
memory: 256m # NEW: memory ceiling
cpu: "0.5" # NEW: CPU ceiling
idle_ttl_s: 180
# Stateful provider -- STILL read-only root, but one writable volume where
# the process genuinely persists data. Everything else stays locked down.
sqlite:
mode: container
image: localhost/mcp-sqlite:latest
read_only: true # NEW: root stays read-only
volumes:
- "/srv/hangar/sqlite:/data:rw" # NEW: the ONLY writable path (absolute)
network: bridge # NEW: this provider needs egress
idle_ttl_s: 300
# Durable audit/event store. On a read-only Hangar deploy the path below MUST
# be a writable mount, or startup fails fast (see "Durability", below).
event_store:
enabled: true
driver: sqlite # NEW: durable driver
path: /app/data/events.db # NEW: must be writable
allow_memory_fallback: false # NEW: never silently lose the audit trail
Try It
-
Provision the one writable host directory the stateful provider needs, and the directory that backs the durable event store:
sudo mkdir -p /srv/hangar/sqlite /srv/hangar/events -
Start Hangar with a read-only root filesystem, granting write access only to the event-store directory (mounted at
/app/data, which the image owns as the non-roothangaruser):docker run --rm \ --read-only \ --tmpfs /tmp:rw,noexec,nosuid,size=64m \ --security-opt no-new-privileges \ -v /srv/hangar/events:/app/data:rw \ -v "$PWD/config.yaml:/app/config.yaml:ro" \ -p 8000:8080 \ mcp-hangar:1.3.0 serve --http --port 8080 -
Confirm the event store is durable.
/health/readyincludes theevent_store_durabilitycheck; a durable store returns200:curl -s http://localhost:8000/health/ready{"status": "ready", "checks": {"event_store_durability": "healthy"}} -
Now prove the fail-closed behaviour. Point the event store at a path under the read-only root (no writable mount) and start again:
docker run --rm --read-only \ --tmpfs /tmp:rw,noexec,nosuid,size=64m \ -e MCP_JSON_LOGS=true \ mcp-hangar:1.3.0 serve --httpStartup aborts instead of silently degrading:
EventStoreConfigurationError: event store path '/app/data/events.db' is not writable ([Errno 30] Read-only file system); set event_store.driver: memory to explicitly opt into a non-durable store, or event_store.allow_memory_fallback: true to accept a non-durable in-memory fallback -
Prove the same boundary for a stateful provider. Remove its
:rwvolume and invoke a tool that writes -- the provider fails withEROFS:sqlite3.OperationalError: unable to open database file OSError: [Errno 30] Read-only file system: '/data/store.db'Restore the
volumes:entry and the write succeeds -- writes are now confined to/data, and nowhere else.
What Just Happened
Hangar launches container providers through a hardened command builder. For every container it applies the same sandbox, independent of the provider:
--read-onlywhenread_only: true(the default -- you opt out, never in).--tmpfs /tmp:rw,noexec,nosuid,size=64mso a locked-down root still has a scratch/tmpthat never touches the image layer.--security-opt no-new-privilegesso no child process can gain privileges via setuid binaries.--network noneby default (bridge/hostonly when a provider declares it).--memoryand--cpusfromresources:(defaults512m/1.0).
The only writes a provider can perform go to the paths you list under
volumes:, in host:container:rw form with an absolute host path. A
stateful provider without such a mount hits EROFS the moment it tries to
persist -- which is the point: the failure is loud and local, not a silent
tamper surface.
The event store enforces the same discipline for Hangar's own audit trail. When
driver: sqlite is configured but the path is not writable (the classic
read-only-deploy mistake), initialization raises EventStoreConfigurationError
and Hangar fails fast rather than swapping in a non-durable in-memory store
and quietly losing history. A non-durable store is only ever used when you ask
for it explicitly -- either driver: memory, or allow_memory_fallback: true.
If the fallback is taken, the degraded posture is recorded and the
event_store_durability readiness check turns critical, so /health/ready
reports 503 and your orchestrator refuses to route traffic to a node that has
lost durability.
Finally, the stock Hangar image runs as a non-root user (hangar), and its
writable data directory is /app/data. Mount your durable volume there and the
non-root process can write to it while everything else stays read-only.
Where To Grant Writes (and Where Not)
| Component | Root filesystem | Writable mount | Why |
|---|---|---|---|
| Stateless provider (fetch, calculators) | read-only | none | Never persists; a write is a red flag |
| Stateful provider (sqlite, memory) | read-only | one :rw volume | Persists to a single audited path |
| Hangar event store | read-only | /app/data (:rw) | Durable audit/event-sourcing trail |
Any provider's /tmp | read-only | tmpfs (automatic) | Scratch space, wiped on exit |
Rule of thumb: default everything to read-only, then add exactly one :rw
volume per component that has a durability requirement you can name. If you
cannot name what it persists, it does not get a volume.
Verify
- Read-only holds:
docker inspect --format '{{.HostConfig.ReadonlyRootfs}}' <container>returnstruefor every provider container. - Writes are confined: the only
:rwbind mounts indocker inspectare the ones you declared;/tmpis a tmpfs. - Durability is live:
/health/readyshowsevent_store_durability: healthy; intentionally breaking the mount flips it to503. - Fail-fast works: launching with an unwritable
event_store.pathandallow_memory_fallback: falseaborts startup withEventStoreConfigurationError.
Key Config Reference
| Key | Type | Default | Description |
|---|---|---|---|
mcp_servers.<id>.read_only | bool | true | Read-only container root filesystem |
mcp_servers.<id>.volumes | list | [] | host:container[:ro|rw]; host path absolute |
mcp_servers.<id>.network | string | none | none, bridge, or host |
mcp_servers.<id>.resources.memory | string | 512m | Memory ceiling (--memory) |
mcp_servers.<id>.resources.cpu | string | 1.0 | CPU ceiling (--cpus) |
event_store.driver | string | sqlite | sqlite (durable) or memory (non-durable) |
event_store.path | string | data/events.db | SQLite file; must be writable |
event_store.allow_memory_fallback | bool | false | Opt in to a non-durable fallback |
What's Next
You now have a read-only baseline with named, audited write surfaces. Wire the
/health/ready durability signal into your orchestrator's readiness gate so a
node that loses durability is pulled from rotation automatically.