Bootstrap — from zero to a serving gateway¶
This is the from-scratch path: a fresh machine, no vault, no tokens, ending with a locked, vault-native gateway serving the API. It works identically on Linux and macOS; the platform sections at the end cover service-manager wiring. For upgrades, operations, and privsep see DEPLOYMENT.md; for the secrets model see SECRETS.md; for the design rationale see the credential-ladder ADR.
The shape: a credential ladder¶
Every credential is issued by the one above it, and nothing is ever written into a config file:
- The age key (filesystem, 0600) decrypts the vault.
- The vault admin token (printed once at genesis) operates
/vault/*. - The API bearer token (minted by the admin token) operates the gateway.
A config with the API enabled but zero api.auth.tokens is not an error when
a vault is present — it is the bootstrap state. The daemon boots into the
management posture: it serves only /vault/* on a local unix socket, with no
public listener, no webhooks, no scheduler, no dispatcher. You mint the API token
through that socket, reference it in config, and restart — the gateway activates.
If the API token is ever lost, removing the api.auth.tokens block returns the
daemon to the management posture to mint a new one; the same mechanism is the
recovery path.
Steps¶
CFG=~/.config/ductile # your config directory
BIN=ductile # the installed binary (see platform sections)
# 1. Config WITHOUT any token. The repo's config/ folder is a working copy of
# exactly this state (cp -R config "$CFG"). Required pieces:
# secrets:
# age_key_file: age.key
# vault_file: vault.age
# plugin_roots:
# - plugins # must list at least one EXISTING dir; relative paths
# # resolve against $CFG, "~" expands to your home
# api:
# enabled: true
# listen: "127.0.0.1:8081"
# management_socket: /tmp/ductile-admin.sock # set it explicitly to a short
# # path — the default lives beside the state DB, not in $CFG
# Do NOT add api.auth.tokens yet. Webhooks may reference secret_refs that do
# not exist yet — during bootstrap that is a warning, not an error.
# 2. Genesis: age key + vault. Daemon stopped. The admin token prints ONCE —
# capture it into 0600 custody, then shred the capture file.
$BIN secrets keygen --out "$CFG/age.key"
$BIN vault init --vault "$CFG/vault.age" --key "$CFG/age.key"
# 3. Seal, then validate. Lock comes first because scope files (webhooks.yaml)
# are checksum-verified even by `config check`. Zero tokens + a genesis vault
# IS clean — the doctor reaches the same verdict the daemon does.
$BIN config lock --config "$CFG"
$BIN config check --config "$CFG"
# 4. First boot: the management posture.
$BIN system start --config "$CFG" &
$BIN system status --config "$CFG" # expect: boot_posture: management-only (live)
# 5. Mint the API bearer token over the unix socket (value via stdin, never argv).
printf '%s' "$API_TOKEN" | $BIN vault set --api-url "unix:///tmp/ductile-admin.sock" \
--token "$ADMIN_TOKEN" --name core-api-token --pattern manual
# 6. Stop the daemon, then reference the minted secret in config:
# api:
# auth:
# tokens:
# - secret_ref: core-api-token
# scopes: ["*"]
# 7. Seal config and plugins, in this order: `config lock` first (it writes the
# .checksums file that `plugin lock` extends), and both after genesis
# (attestation is keyed by the vault nonce).
$BIN config lock --config "$CFG"
$BIN plugin lock --all --config "$CFG" # prints a confirm code
$BIN plugin lock --all <code> --config "$CFG"
# 8. Boot the gateway and verify.
$BIN system start --config "$CFG" # now under your service manager
curl -s localhost:8081/healthz # "posture": "gateway"
$BIN system vault-audit --config "$CFG" # genesis + mint recorded
macOS (launchd)¶
- Install:
deploy/install-macos.shlays out/opt/ductile/{etc,var,log}, copies the binary to/usr/local/bin, and loads the LaunchDaemon fromdeploy/launchd/com.mattjoyce.ductile.plist. - A locally built binary is codesigned by
make build; release artifacts are unsigned — either codesign them yourself or clear quarantine after download. - Privilege separation on macOS runs in deploy+verify posture (the uid-drop enforcement wall is Linux-only today); see DEPLOYMENT.md §5b.
Linux (systemd)¶
- Install:
deploy/install.shlays out FHS paths (/etc/ductile,/var/lib/ductile,/opt/ductile/plugins) and installs the units indeploy/systemd/(ductile.service, sysusers and tmpfiles fragments). - For the enforced privsep posture (CAP_SETUID gateway, per-plugin worker accounts) follow DEPLOYMENT.md §5b before first boot.
Sanity rules worth knowing¶
- Unix socket paths are capped near 104 bytes — keep
api.management_socketshort (/tmp/...or beside the state DB). - The management socket path must not point at an existing non-socket file; the daemon refuses to boot rather than remove it.
- A config cannot be activated (gateway posture) while any configured
secret_refis unminted — mint everything you reference before the restart in step 6, or stage those includes until after bootstrap.