Secrets
Table of Contents
Secrets are age-encrypted in the repository and decrypted at activation by each host's own SSH host key. Nothing sensitive is stored in plaintext, and nothing sensitive is fetched at runtime from a service that has to be up.
1. The model
- Each secret is a
.agefile committed to the flake. secrets/secrets.nixlists, per secret, which host keys may decrypt it.- At activation the host decrypts what it is a recipient for, into a root-owned runtime directory, with an explicit owner and mode.
- Modules that need the value point at the decrypted path — they never carry the value itself.
The consequence worth stating plainly: a host is a recipient or it is not. Adding a machine to the fleet means adding its host key as a recipient and re-encrypting; the machine cannot help itself to a secret it was not granted.
2. Recipient status is not the same as use
This trips people, so it is worth being exact.
A host can be a decryption recipient of a secret without using it. A headless box that holds an agent's state directory only as a sync peer is the usual case: it decrypts the API credential at every activation, because recipient status is granted by the secret declarations — full stop, no flag involved. But it never runs the agent, so there is no reason to deploy the symlink that points the agent at the credential.
A gate on that symlink therefore gates the symlink, and nothing else. Turning
it off does not remove the host from the recipient list. If you want a host to
stop being able to decrypt a secret, remove it from your secrets.nix and
re-encrypt.
Where this lives. Emanix wires the agenix module through mkHost, so the
mechanism is the distribution's. Which secrets exist, who may read them, and
which hosts deploy them are all declared by your flake — including any option
gating a symlink like the one above. The distribution supplies the safe;
you decide what goes in it and who holds a key.
3. Don't let the secret store create the directory
The secret decrypts to the default runtime path, readable by the primary user. It deliberately does not decrypt straight into the consuming application's config directory.
The reason is a real failure, caught by a boot test: pointing a secret at a path
inside the home directory makes the secret store mkdir -p that directory as
root during activation. Home Manager owns that directory. The two collide, and
Home Manager activation fails on first boot — on a fresh machine, before anyone
is watching.
So the consuming module symlinks from its own config location to the decrypted path instead. The ownership boundary stays clean: the secret store owns the runtime path, Home Manager owns the home directory, and neither reaches into the other.
4. Editing a secret
nix run github:ryantm/agenix -- -e secrets/<name>.age -i ~/.ssh/id_ed25519
Then commit the re-encrypted file. The .age file is safe to commit — that is
the entire point of the scheme.
5. Rotation
Changing ENCRYPTION_KEY-style material means re-encrypting every affected
secret and redeploying every recipient. Design accordingly: prefer few secrets
with clear ownership over many with overlapping recipients.
A useful property falls out of the "never decrypt on a read-only screen" rule: a page that never reads a secret cannot be broken by a secret — an undecryptable value cannot take down a surface that does not touch it.