Deployment Postures — choosing how much to confine plugins¶
Diátaxis register: explanation. This page builds the theory you need to choose a deployment shape. It deliberately holds no install steps — once you have chosen, the procedure lives in Deployment §5b (how-to) and Deployment §5c (config reference). Theory here; need served by the links.
You can run ductile three ways. They differ in one thing only: how much authority a plugin inherits when it runs. Everything else — the event bus, pipelines, the CLI, the API — is identical. Pick the posture that matches who wrote your plugins and what they're allowed to touch, then follow the linked how-to to stand it up.
All three are real and proven. The third (hybrid trust-tier) is field-proven on a live homelab gateway — see Posture 3 below.
The one idea everything turns on: secret zero¶
Privsep exists for exactly one reason: so that a plugin cannot read secret zero — the age
private key (secrets.age_key_file, e.g. /etc/ductile/secret/age.key, mode 0600) from which the
whole vault decrypts. A plugin must not be able to read that key off disk, and must not be able to
ptrace the gateway process (attach to it and read its memory) to lift the key out.
Two consequences follow, and they explain every choice below:
- The threat model is your own fallible code, not a hostile stranger. You wrote (or vouched for) every plugin. The wall is there to contain a bug or a swapped file — a plugin that does more than you intended — not to survive a determined attacker who already owns the box.
- The vault protects against plugins, never against the operator. The operator is root-equivalent by design (they hold the key, the config, and the host). No posture tries to wall the operator off from their own secrets — that would be incoherent.
A posture is just a decision about how far down you push that wall.
A few terms, defined before you need them¶
- cap-only — the gateway runs as a non-root
ductileuser holding just two Linux kernel capabilities,CAP_SETUIDandCAP_SETGID(granted by the init system). That's exactly enough to drop a plugin to an unprivileged uid and nothing more — no root, no other power. - Three words that name three unrelated things — don't conflate them:
- account — a privsep OS user a plugin is dropped to (the
accounts:map,run_as:). About uid. - worker — a concurrency slot (
service.max_workers, a general service setting, not a privsep key). About how many run at once. Nothing to do with identity. - principal — a vault identity that may be granted secrets. About who may decrypt what.
- account — a privsep OS user a plugin is dropped to (the
Choose in one glance¶
| 1. Unconfined | 2. Full privsep | 3. Hybrid trust-tier | |
|---|---|---|---|
| Gateway runs as | service user or root | cap-only ductile (no root) |
cap-only ductile (no root) |
| Plugins run as | the gateway (no uid drop) | confined accounts (default 1001 / untrusted 1002) |
confined by default, plus a credentialed tier that drops to your uid |
| Secret zero protected from plugins? | No | Yes | Yes |
Plugins can act as you (~/.ssh, gh)? |
yes (everything can) | no | only the credentialed tier |
| Isolation between plugins | none | per-account 0700 walls |
per-account walls; trusted tier shares your home |
| Root needed at runtime | optional | never (Linux) | never (Linux) |
| Platform | any | Linux-proven; macOS-pending | Linux-proven; macOS-pending |
| Pick when | a single trusted dev box — or, as a deliberate two-instance topology, the admin-glue instance paired with an enforced data-plane | multi-source / untrusted plugins / production | homelab: confined by default, a few vouched plugins act as you |
| Stand it up | §5 / §5b escape hatch | §5b enforce | §5b enforce + group ACL |
A picture helps here. The architecture diagram for these three postures is operator-owned (mermaid) and lives alongside this page — it is intentionally not drawn in ASCII.
1. Unconfined (hygiene-only)¶
What it is. The gateway runs as an ordinary service user (or root), and plugins run as the gateway — there is no uid drop. This is the simplest shape and the historical default.
What it protects. Nothing, with respect to secret zero. A plugin running as the gateway can read
the age key on disk and can ptrace the gateway. There is also no isolation between plugins. You
get ductile's operational hygiene (the queue, retries, the ledger) but no privilege wall.
When to pick it.
- A fully-trusted single-user or dev box, where every plugin is yours and the threat model is empty.
- The deliberate admin-glue instance (runbook, card #106): one instance left intentionally unconfined to do host-entangled work (docker, system glue), paired with a separate enforced data-plane instance. Here "unconfined" is a considered choice, not a default — the dangerous instance is the one that does not face untrusted input.
Setup essentials. No accounts: map, no enforce. On a cap-only or non-root setup it is the
default; if the gateway runs as root, the boot gate refuses to boot unconfined unless you set
the loud escape hatch service.unconfined: true. See §5 and the §5b escape hatch.
2. Full privsep (FHS enforce, all-confined)¶
What it is. The gateway is cap-only: a non-root system user ductile granted just
CAP_SETUID + CAP_SETGID (systemd AmbientCapabilities), enough to drop to an unprivileged uid
and nothing more. Every plugin drops to a dedicated confined account and runs walled inside a
private 0700 state directory.
What it protects. Secret zero is safe: the age key is ductile-owned 0600, and a confined
account is neither the gateway user nor in any group that can read the key or ptrace the gateway.
Plugins are also isolated from each other — each account owns only its own 0700 state_dir.
The mechanics (theory, not steps).
- Confined runtime contract: a confined plugin's
HOME, cache, and cwd are all set to its account's ownstate_dir; it cannot see another account's state. Per-account dirs are0700;/var/lib/ductileand/var/lib/ductile/accountssit at a0711traversal floor (traverse-only, not listable). Full rationale: confined-plugin-runtime-contract ADR. - Code, not creds: plugin code lives at
/opt/ductile/plugins(root:root, worldr-x, attested and locked — its hash is checked before spawn). Plugins never receive secrets via env or config — they get them from the vault at spawn, gated by that same attestation check.
When to pick it. Multiple sources of plugins, any untrusted or third-party plugin, or production where isolation matters most. This posture is deploy-as-new onto an FHS (Filesystem Hierarchy Standard) layout (filesystem-layout ADR); see the enforce how-to (§5b).
Platform note: enforce is Linux-proven, macOS-pending. macOS has no cap-only model — the daemon runs as root and drops — so treat macOS enforce as not-yet-field-proven (tracked as card #95).
3. Hybrid trust-tier (the recommended homelab shape)¶
What it is. The same cap-only gateway as posture 2 — one instance, one event bus, so
native pipelines still chain across all your plugins. Plugins are confined by default (walled +
vault, exactly as posture 2), plus a credentialed trusted tier that drops to your real
uid and runs with your real home — so a vouched-for plugin can reach ~/.ssh, ~/.config/gh,
~/.config/fabric and genuinely act as you (e.g. git push).
How the trusted tier reaches your files without opening your home.
- Creds come from your home, code comes via a group ACL. Trusted plugin code lives in your home
(e.g.
~/ductile/plugins). The gateway reads that code through aductile-group ACL — the authoritative entry form is "g:ductile:rXon the plugin dir,g:ductile:xon the home" (credentialed-runtime ADR). That grants the gateway code-read only — never the creds, and never to the confined accounts. No/homeis thrown open. (The exactsetfaclinvocation is in the ThinkPad enforce runbook.) - groups-minimal: the trusted account gets your files, not your group memberships. Docker is not granted by default — it's a per-plugin opt-in, parked until needed.
What it protects. Secret zero is still safe (the age key remains ductile-owned; confined
accounts and the trusted account alike are walled off from it). The residual risk is intrinsic: a
trusted plugin runs as you, and if you are root-equivalent on that host (e.g. a member of the
docker group), so is that plugin for the moment it runs. That is accepted operational
insecurity, surfaced — not hidden — by the tier-aware, warn-loud root-sidedoor boot audit
(tracked as card #111): confined + a sidedoor is a fail-closed lie about the wall; trusted + a
sidedoor is an informed-consent log line.
When to pick it. A homelab where a few vouched-for plugins must act as you, but you don't want
a second instance and you don't want root anywhere. This is the recommended homelab posture.
Current guidance: run_as: <you> for the trusted plugins now, with a per-plugin tier review later.
Field-proven. Live on the ThinkPad homelab gateway, 2026-06-08: one cap-only gateway, three accounts, confined and credentialed plugins coexisting on one event bus — a trusted plugin cloned a repo into a directory only your uid can write (proving it ran as you), while confined plugins remained provably unable to reach your ssh key.
The road not taken: root gateway¶
A root gateway was considered and rejected. Cap-only's CAP_SETUID already drops to any
uid — including your own login uid for the trusted tier — and the ductile-group ACL already gives
the gateway code-read without root. Root buys nothing the cap-only model lacks, and it re-enlarges
the blast radius of the one process that faces the network. Every posture above runs without root
at runtime; the only root needed anywhere is the one-time install.
Cross-cutting notes (true in every posture)¶
No root except one-time install¶
None of the three postures runs the gateway as root at runtime (on Linux). Root appears only during the initial install/provisioning. The cap-only model is what makes that possible.
Tier vocabulary, and why "needs a secret" ≠ "trusted"¶
The per-account mode is the AccountMode enum: unconfined | confined | credentialed. The
tempting mistake is to assume a plugin that needs a secret must be trusted. It does not: a read-only
plugin that holds a vault token stays confined — the vault delivers its one credential without
ever giving it your home or your uid. github_repo_sync is the worked example: it carries a token
yet remains confined. Reserve credentialed/trusted for plugins that must act as you.
Postures and tiers are orthogonal axes¶
A posture is a deployment-wide choice (this page). A tier is a per-plugin choice — which
account a given plugin's run_as: grants. Posture 3 is precisely the case where both axes are in
play at once. For per-plugin tier guidance see
PLUGIN_DEVELOPMENT §10.6 — Runtime contract (privsep) and the working
plugins/_template/.
Where to go next¶
| You want to… | Read |
|---|---|
| Stand up an enforcing host (steps) | Deployment §5b (how-to) |
| Look up a privsep config key or boot-gate outcome | Deployment §5c (reference) |
| Understand why confined plugins are shaped this way | confined-plugin-runtime-contract ADR (explanation) |
| Understand why the trusted tier is safe | credentialed-runtime-contract ADR (explanation) |
| See the FHS install layout | filesystem-layout ADR |
| Follow the first live enforce cutover | privsep-thinkpad-enforce runbook |
| Run the deliberate unconfined admin instance | ductile-admin-instance runbook |