ADR: Filesystem layout for the v1.0 prod release¶
Status¶
Accepted (2026-06-07). Validated by the first live privsep-enforce deployment on the Thinkpad (2026-06-07): the layout below is what made the uid drop physically possible.
Context¶
ductile is a long-running daemon that, under privsep (PrivSec ADR Layer 1b), runs as an unprivileged
gateway service user holding only CAP_SETUID+CAP_SETGID, and drops each plugin to a distinct
unprivileged account uid at spawn. For that wall to exist, the filesystem must cooperate: the
gateway user, the account users, and root each need different, non-overlapping access to the binary,
config, secrets, state, and plugin code.
The homelab grew up as a $HOME-rooted install — binary in ~/.local/bin, config in
~/.config/ductile, plugin code in ~/Projects/*, secrets in ~/.config/secrets/*. That layout made
privsep impossible to retrofit: a system service running as a non-matt user cannot traverse
/home/matt (0700), so it could read neither the plugin code nor the secret files — the gateway
boots empty, not degraded. Confirmed live (kanban #83). The fix was a clean deploy-as-new into
FHS paths, not a migration.
This ADR fixes the canonical v1.0 footprint so the privilege model and the directory model are the same model.
Decision¶
v1.0 ships as an FHS system package. Files are separated along two axes simultaneously —
mutability (immutable code/config vs. churning state) and privilege (root / gateway / account
/ world) — such that every directory boundary is also a uid+permission boundary. Nothing the
service depends on lives under /home.
Precedent¶
This is a solved problem; we copy the daemons that hold secrets and drop privilege.
| Concern | postgres | nginx | sshd | ductile v1.0 |
|---|---|---|---|---|
| binary | /usr/lib/postgresql/*/bin |
/usr/sbin/nginx |
/usr/sbin/sshd |
/usr/bin/ductile — never setuid |
| config | /etc/postgresql |
/etc/nginx |
/etc/ssh |
/etc/ductile |
| secret-zero | — | /etc/ssl/private 0600 |
/etc/ssh/ssh_host_* 0600 |
/etc/ductile/secret/age.key 0600 |
| mutable state | /var/lib/postgresql |
/var/cache/nginx |
— | /var/lib/ductile |
| runtime | /run/postgresql |
/run/nginx.pid |
/run/sshd.pid |
/run/ductile |
| logs | journald | /var/log/nginx |
journald | journald |
| privilege model | postmaster + backends | master root + unpriv workers | priv-sep child | gateway (caps) + per-plugin account uids |
ductile is the nginx/sshd privilege-separation model crossed with postgres's "a stable service user owns a persistent data dir."
Canonical Linux layout¶
| Path | Purpose | owner:group | mode | Rationale |
|---|---|---|---|---|
/usr/bin/ductile |
binary | root:root |
0755 |
immutable; runnable as an unprivileged utility (config validate, secrets keygen); never setuid — privilege is init-conferred, not file-borne (PrivSec §11) |
/etc/ductile/ |
admin-authored config (config.yaml, includes, .checksums) |
ductile:ductile |
0750 |
gateway reads; plugin accounts cannot traverse in; admin edits via sudo |
/etc/ductile/secret/age.key |
secret-zero (age key) | ductile:ductile |
0600 |
sshd /etc/ssh co-location pattern; readable by the (unprivileged) gateway, by no one else. Backup-safety is an enforced invariant, see below |
/var/lib/ductile/ |
mutable state root | ductile:ductile |
0700 |
postgres-PGDATA pattern |
/var/lib/ductile/ductile.db |
SQLite state | ductile:ductile |
0600 |
gateway-private |
/var/lib/ductile/vault.age |
encrypted secret store | ductile:ductile |
0600 |
the vault is mutable state (daemon sole-writer; secrets added/rolled at runtime) → /var/lib, not /etc |
/var/lib/ductile/accounts/<tier>/ |
per-plugin scratch/state | ductile-<tier>:… |
0700 |
each account owns its own — this boundary is the wall (cross-account = EACCES) |
/opt/ductile/plugins/ |
plugin code (polyglot add-ons) | root:root |
0755 (world r-x) |
code is a root-owned immutable add-on: a popped plugin can read+exec but cannot rewrite what it runs; /opt is FHS's home for self-contained add-on software |
/run/ductile/ |
pid, sockets | ductile:ductile |
0700 |
ephemeral (tmpfs); pid/lock belongs neither in config nor state |
| logs | journald | — | — | StandardOutput=journal; no logfiles unless an explicit sink is required |
/var/backups/ductile/ |
snapshots | operator | 0700 |
outside /var/lib — never nest backups in the state they capture; key custody stays separate |
Provisioning¶
Shipped with the package, never done at runtime: sysusers.d creates ductile +
ductile-default + ductile-untrusted; tmpfiles.d creates the directories with the ownership
above; the systemd unit runs User=ductile + AmbientCapabilities=CAP_SETUID CAP_SETGID. The daemon
never useradds (the nginx/postgres package pattern).
The three load-bearing opinions¶
- Plugin code in
/opt, root-owned, world-r-x — never under$HOME, never writable by the gateway or the accounts. A compromised plugin cannot tamper with sibling code or its own next run, and attestation fingerprints immutable bytes. vault.ageis state (/var/lib);/etcis static. A runtime-mutated, daemon-sole-written file does not belong in/etc; the split keeps/etc/ductilereviewable/lockable and/var/libas the churn zone.- Secret-zero lives at
/etc/ductile/secret/age.key 0600(sshd-style co-location), and its backup-safety is a tested invariant, not a comment. See below.
Secret-zero placement (the age key) — resolved¶
sshd keeps ssh_host_*_key inside /etc/ssh at 0600; the control is permissions, not location.
We adopt the same: /etc/ductile/secret/age.key, 0600, ductile-owned (the gateway is
unprivileged, so unlike root-run sshd it must be owner-readable; plugin accounts can't reach it
because /etc/ductile is 0750).
The one ductile-specific hazard: ductile system backup --scope config archives the config tree into
a portable bundle, and age-at-rest's value is "a leaked bundle is useless without the separately
custodied key." Co-locating the key therefore makes secret-zero's backup-safety depend on the
backup tool's exclude rule. We accept co-location only because that exclusion is promoted to an
enforced invariant: a regression test builds a --scope config archive and asserts that
secret/ / the age key is absent. (PrivSec §11 principle: a control's integrity must live with its
enforcer — so the exclusion is code, not a glob nobody re-checks.) Absent that test, the key must move
to a sibling outside the archived tree (e.g. systemd LoadCredential / /etc/credstore.encrypted).
macOS mapping (homelab spans both)¶
No FHS, but the four boundaries map: binary /usr/local/bin/ductile; config /Library/Application
Support/ductile; state /usr/local/var/ductile; plugin code /usr/local/lib/ductile/plugins
(root-owned); age key in the Keychain or a root 0600 file; service in /Library/LaunchDaemons
(root — launchd has no cap-only model, so macOS enforce runs the daemon as root and drops).
Consequences¶
Positive - The privilege model and the directory model are one model; the privsep wall is buildable by construction (validated live, Thinkpad 2026-06-07). - A popped plugin cannot rewrite plugin code, read the config/secret surface, or reach a sibling's state — each is a different uid/permission domain. - Standard ops tooling (systemd, sysusers/tmpfiles, journald, packaging) applies directly.
Negative / costs
- A real migration off the $HOME-rooted homelab install (deploy-as-new; see runbook). Admin
automation that genuinely needs privilege (docker/apt/fabric) does not fit this footprint and
must run as a separate, unconfined service with its own working area (kanban #103).
- The backup-exclusion of secret-zero is load-bearing and must stay tested (see above), or it
silently rots.
Enforced invariants (tests that must exist)
- --scope config backup archive never contains secret/ or the age key.
- Boot fails closed if /etc/ductile is not gateway-owned or an account state_dir is not
account-owned (already enforced by the privsep boot reconcile).