Working with secrets
Secrets are age-encrypted files in the private repository ~/codes/nix-secrets
(git@github.com:ryan4yin/nix-secrets.git), pulled in as the mysecrets flake input and declared
here in secrets/nixos.nix and secrets/darwin.nix. No secret value or ciphertext is ever stored
in this public repository.
Read secrets/README.md for the concepts and the recipient rule.
Which flow a file belongs to
Two age flows live in ~/codes/nix-secrets, and a file belongs to exactly one.
- agenix (
secrets.nix): static and one-way, repository → host, decrypted at activation. Consumed as/run/agenix/<name>or anenvironment.etccopy; needs aflake.lockbump. - dotfiles sync (
dotfiles/): changes on the host;save/restoreare manual, outside Nix. Consumed as a real file under$HOME, edited by hand or by its tool; no lock bump.
agenix is for the secrets that barely change and that a Nix module or service reads from the
decrypted path. Everything else is a synced dotfile: Nix does not read it, it changes often, or it
is a config file its tool keeps rewriting that happens to carry a few secrets inside. The pin
decides it, not the encryption: flake.lock fixes the agenix revision, so every edit costs a
rebuild and switch on every desktop.
Nothing in this flake evaluates the sync flow: no secrets.nix entry, no agenix -r, no lock bump,
so the three-step order in §2 does not apply to it. Its manifest and file list live in
~/codes/nix-secrets/dotfiles/; this repository carries the pointer, never the inventory. Do not
list synced paths here or copy one into a Nix module. The flow covers the desktops only; do not set
it up on a server or a cluster node.
nushell-secrets.nu already moved to ~/.secrets/nushell-secrets.nu. It has no agenix entry and
must not get one back.
Core rules
- Never read a decrypted secret. Reference its path; do not
cat, copy, or print it. Prove a change with metadata (mode, owner, timestamps), never with content. The same holds for a file the sync flow owns: it is a live credential under$HOME, and its path is listed in the private repository, not here. - Three steps, in order. The private repository changes first, then this repository's
flake.lockmovesmysecretsto that commit, then the declaration and consumer change here.file = "${mysecrets}/x.age"resolves against the locked revision, so a declaration that lands before the lock bump points at a file that does not exist yet. - Every secret stays decryptable by the desktops. A recipient set is
desktop_keys ++ <the hosts that need it>;recovery_keyis a member ofdesktop_keys. The one exception isrestic-password-desktop.age, readable bydesktop_keysalone. - A consumer sits behind the same gate as its secret. A secret declared under
modules.secrets.desktoponly exists on desktops, so only a desktop-gated module may reference it. Desktops and servers are mutually exclusive:secrets/nixos.nixasserts that a host never enablesdesktoptogether with a server group, so a value both sides need takes a deliberate choice rather than enabling both groups. - Least privilege on the decrypted file. Pick the narrowest mode that works, and set the owner together with the mode.
1. Where the pieces live
- Recipient list, encrypted files,
agenixCLI:~/codes/nix-secrets(private;git@github.com:ryan4yin/nix-secrets.git) - The pinned revision of that repository:
flake.lock, inputmysecrets - Declaration (file, mode/owner,
/etccopy):secrets/nixos.nix,secrets/darwin.nix - Which host gets which group:
modules.secrets.<group>.enableinoutputs/<system>/src/<name>.nixor a host module - Consumers: modules reading
config.age.secrets."<name>".path(default/run/agenix/<name>) - Decryption key:
age.identityPaths— the host's SSH host key;/persistent/etc/ssh/...on a preservation host - Secret-bearing dotfiles edited in place:
~/codes/nix-secrets/dotfiles/— private, not a flake input, no entry insecrets.nix
The private repository keeps its .age files in desktop/ (only desktops decrypt them), server/
(any server), and certs/; public/ holds publishable plaintext, not .age.
2. Add or change a secret
Do the mechanical work yourself: edit secrets.nix in ~/codes/nix-secrets, run the
non-interactive steps, bump the lock, and add the declaration and consumer. For a new secret, or a
full replacement, pipe the plaintext into the replace recipe: it removes the target first, so
agenix only encrypts and needs no sudo. Hand back only what needs a human: the partial edit and
the rekey recipes in secrets/Justfile (they decrypt the current
value, so they handle key material, $EDITOR, and sudo) and anything else under sudo. Commits
and pushes follow the global git rules.
- In
~/codes/nix-secrets, add the file underdesktop/orserver/with a matchingsecrets.nixentry, keyed by that exact path (e.g."./desktop/xxx.age"), and the recipient set from core rule 3. Then encrypt it with thereplacerecipe (§3); ask the user only when the plaintext has to come from an interactive session. - Here:
just upp mysecrets(commits the lock) ornix flake update mysecrets(leaves it for you to commit).git diff flake.lockshould show onlymysecretsmoving. - Declare it in
secrets/nixos.nixorsecrets/darwin.nixunder the rightmodules.secrets.<group>gate, with a mode/owner preset (§4). - Point the consumer at the runtime path, from a module behind the same gate (core rule 4).
just testandjust build-host <host>, deploy, then verify as in §5.
Changing only a secret's value is steps 1, 2, and the deploy: use replace for a full replacement
(the agent can run it) or edit for a partial change (needs the host key).
3. The private repository
The agenix operations run against ~/codes/nix-secrets. Their single source is
secrets/Justfile, which pins the identity, option order, and
$EDITOR; run them from this repository's root and keep each path identical to its key in
secrets.nix:
bashjust -f secrets/Justfile replace ./desktop/xxx.age < plaintext # add or fully replace (no sudo) just -f secrets/Justfile edit ./desktop/xxx.age # partial edit (sudo, $EDITOR) just -f secrets/Justfile rekey # re-encrypt after a recipient change
edit and rekey decrypt the current value with the host key, so they need sudo; replace only
encrypts to the recipients in secrets.nix, so it needs no sudo. edit passes EDITOR=hx to the
root agenix process, because sudo resets the environment and the invoking user's EDITOR would
not reach it; do not add sudo -E or set EDITOR yourself.
4. Modes and ownership
Use one of the presets in secrets/nixos.nix (noaccess, high_security, user_readable); the
table and the environment.etc copy trap are in
secrets/README.md. The rule to remember:
whenever an environment.etc entry sets mode, it also sets user. Never widen a mode to make
a root-owned copy readable.
5. Verify without reading
bashstat -c '%a %U:%G' /run/agenix/<name> # mode and owner, not content (the default path) ls -l /run/agenix/ # /etc/agenix/ holds only the environment.etc copies journalctl -b | grep -5 agenix # NixOS tail -n 100 /Library/Logs/org.nixos.activate-agenix.stderr.log # macOS
A successful activation is silent. Check that the access changed as intended (the mode and owner you set, an old copy gone) instead of assuming activation did it.
6. Remove or rename a secret
-
Search all references before changing it:
bashgrep -Rni '<secret-name>' --include='*.nix' --include='*.toml' .Check
secrets/,home/,modules/,hosts/, outputs, tests, and the private repository. -
Delete the
age.secretsentry, itsenvironment.etcplacement, and every consumer in one change; a declaration whose file no longer exists breaks activation. On darwin also drop the path from thechown/chmodlist insystem.activationScripts.postActivation. -
Remove it from
secrets.nixand delete the file in the private repository, then bump the lock. -
A rename changes the attribute name and every consumer. The
.agefilename is separate. -
Moving a secret out of agenix into the sync flow is that deletion plus what agenix used to guarantee: the consumer points at a
$HOMEpath, and on a preservation host the directory joinspreservation.preserveAt, which also sets its mode. A sourced file that may be missing is left to fail: nushell has no conditionalsource(nushell#8214), and a host without the file is misconfigured, not half-working.
7. Failure modes
- Decryption fails on one host: its host key is not a recipient. Add its
/etc/ssh/ssh_host_ed25519_key.pubtosecrets.nix, rekey (§3), push, bump the lock, redeploy. - Eval or activation cannot find the file: the lock still points at a
mysecretsrevision without it (core rule 2). permission deniedin a user service: it reads a root-only secret. Fix that secret's owner; do not widen the mode.- A preservation host cannot decrypt at boot:
age.identityPathsmust use the/persistent/etc/ssh/...path, which exists before preservation mounts/etc. - The first macOS activation fails on the
/etc/agenixcopies, because they run beforeactivate-agenixhas decrypted anything. Run it again. - Nushell exits 1 with
File not foundand the shell has no aliases: the sourced secret file is missing. agenix guaranteed the path; the sync flow does not, and the failure is left loud on purpose. Restore the file on that host. Never silence it with an empty placeholder: the sync tool reads that as a local edit and reports a conflict. - A secret is gone after a reboot on a desktop: it moved out of agenix and its
$HOMEdirectory is not inpreservation.preserveAt, so the tmpfs root drops it.


