The open-source plan flagged three references as "the real gaps for app developers", and the docs index named them as not-yet-written. Written now, each from the code rather than stubbed: - secrets.md — generated_secrets/secret_env: the two halves, the four kinds (hex16/hex32/base64/bcrypt) and which files each writes, the idempotent self-healing 0600 materialisation, and the rules a developer must not break (no hardcoded fallbacks, one canonical name, right encoding). From container/secrets.rs and the manifest schema. - quadlet-compilation.md — manifest -> .container unit: the full directive mapping (including Secret= by reference, never value, and Pull=never), where units land (~/.config/containers/systemd, systemctl --user), the render/write/enable/disable lifecycle with write-if-changed, and how to inspect one. From container/quadlet.rs, scoped accurately to the companion-UI path it drives today. - container-lifecycle.md — the level-triggered 30s reconciler: desired state from user-stopped/user-uninstalled/manifest set, the operations table, the self-heal-vs-respect-a-deliberate-stop rule, and migrations-never-destroy-data. From prod_orchestrator.rs and boot_reconciler.rs. Index updated to link all three under App development and the "known gap" note removed. Every link across the docs tree resolves. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
5.2 KiB
App secrets
How an app declares a secret, how Archipelago materialises it, and how it reaches the container — with the rules a developer must not break.
The whole point: an app never ships a credential. It declares the shape of
the secrets it needs, and the node generates a fresh, per-install value that
never leaves the node and is never logged. Source of truth:
core/archipelago/src/container/secrets.rs and the manifest schema in
core/container/src/manifest.rs.
The two halves
A secret has a producer and a consumer, and they are separate manifest fields:
generated_secrets— produce a random value into a file.secret_env— inject a file's contents into the container as an env var.
An app can use either alone. A generated secret with no consumer is just a file
on the node; a secret_env with no matching generated_secrets reads a file
that some other component (or the daemon) is expected to have written.
Declaring a generated secret
container:
generated_secrets:
- name: btcpay-db-password
kind: hex16
- name: fedimint-gateway-hash
kind: bcrypt
name is a bare filename under the node's secrets directory
(/var/lib/archipelago/secrets/). It is validated at manifest-load time — no
/, no .. — so a manifest cannot write outside that directory.
kind chooses how the value is produced. Each kind is deterministic in shape
(the orchestrator knows exactly which files it will create) but random in value:
kind |
Value | Files written | Use for |
|---|---|---|---|
hex16 |
16 random bytes, lowercase hex (32 ch) | <name> |
service passwords, API tokens |
hex32 |
32 random bytes, lowercase hex (64 ch) | <name> |
longer keys/cookies |
base64 |
32 random bytes, standard base64 (44 ch) | <name> |
services that base64-decode their key (e.g. netbird relay authSecret) |
bcrypt |
a random password and its bcrypt hash | <name> (hash) + <name>.pw (plaintext) |
server configured with a hash, client needs the plaintext |
bcrypt is the only kind that writes two files: <name> holds the bcrypt hash a
server is configured with, and <name>.pw holds the plaintext for any client
that must authenticate against it. A secret_env injects whichever of the two it
references.
Injecting a secret into the container
container:
secret_env:
- key: BTCPAY_DB_PASS
secret_file: btcpay-db-password
At apply time the orchestrator reads /var/lib/archipelago/secrets/<secret_file>
and sets <key> in the container's environment to its contents. The value is
never written into the manifest, the Quadlet unit, or any log line.
How materialisation works
ensure_generated_secrets() runs on every install and reconcile tick, before
secret_env is resolved. It is idempotent and self-healing:
- Fast path. If every target file for a secret already exists, is readable by the service user, and is non-empty, it is left untouched. A secret is generated once and then persists across restarts, updates and reinstalls — this is what makes credentials stable (migrations never regenerate a working secret out from under a database).
- Self-heal. A target file that exists but is unreadable or empty — e.g. left root-owned by a botched earlier write — is removed and recreated, owned by the service user. The unlink uses the secrets directory's own write bit, so recovery needs no privilege escalation.
- Write. New values are written through an atomic
0600writer: a temp file in the same directory, fsynced, then renamed over the target, so a reader never sees a half-written secret and the file is only ever readable by its owner.
Because it runs every tick and no-ops when the secret is healthy, calling it is always safe; there is no separate "provision secrets" step to forget.
Rules a developer must not break
- Never hardcode a credential, in the manifest or in code, even as a
fallback. A shared fallback password means everyone holding a copy of the repo
holds that credential. Declare
generated_secretsinstead. - Never log a secret.
secret_envvalues and the files under the secrets directory stay out of logs, error messages and status output. - One canonical name. The orchestrator, first-boot script, reconcile path and
any deploy tooling must all reference a secret by the same filename. A
producer writing
<app>-passwordwhile the consumer reads<app>-hashyields a service that authenticates against a credential nothing generated. - Pick the encoding the service expects.
hex*andbase64decode to different bytes; a service that base64-decodes its configured key must be given abase64secret, or it will run with the wrong key material.
Related
- App Manifest Specification — the full manifest schema
- ADR-009: Manifest-Level Container Security
- Entropy Enforcement (KEY-05) — why secret generation draws from an explicitly-named CSPRNG