Nix Fleet, part 3. Part 1 covered the architecture; Part 2 made builds painless. This part handles the thing you must not commit to a public config repo: secrets.
My whole system is declared in a Git repository, and most of it is public. But a fleet needs secrets — API keys, SMB credentials, a WireGuard config — and those obviously can't sit in plaintext next to the rest of the config. The constraint that shapes everything here: I want secrets to be versioned, shared across every machine, and reproducible, with the same Git-reviewable discipline as the rest of the system — without any secret ever appearing in plaintext, in the repo, or in the Nix store.
agenix gets me there. Here's the setup.
Two repos, one boundary
The cleanest decision I made was to put secrets in their own separate repo (nix-secrets) and pull it into the system flake as an input. I keep my real one private, but I've published a public example repo so you can see the shape:
# flake.nix
inputs.secrets = {
url = "git+ssh://git@github.com/parallaxisjones/nix-secrets.git";
flake = false;
};
That boundary pays off operationally: I can lock or revoke access to the secrets repo independently of the (public) config, rotate recipients without touching application code, and grant a machine read-only deploy access to just the secrets it needs. The public config can stay public because the sensitive bits live behind a separate door.
How agenix encrypts
agenix is age encryption wired into Nix. You declare, per secret, which identities are allowed to decrypt it. That declaration lives in secrets.nix in the private repo:
let
# macOS (pjones) and NixOS (parallaxis) decrypt with the
# same ~/.ssh/parallaxis identity.
pjones = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...oqt7Aq";
users = [ pjones ];
in
{
"openai-key.age".publicKeys = users;
"anthropic-api-key.age".publicKeys = users;
"datadog-api-key.age".publicKeys = users;
"datadog-app-key.age".publicKeys = users;
"smb-credentials.age".publicKeys = users;
"protonvpn-wireguard.age".publicKeys = users;
}
Each .age file is encrypted to the public keys listed. The matching private identity — and only that identity — can decrypt it. The encrypted files are safe to commit (to the private repo); the identity never is.
One identity for the whole fleet
Notice that users is a single key. That's deliberate: both my macOS and NixOS machines decrypt with the same ~/.ssh/parallaxis ed25519 identity. agenix can use an age-native key (age-keygen) or reuse an existing SSH ed25519 key via age-plugin-ssh; I reuse the SSH key so there's exactly one secret-decrypting identity to manage across the fleet.
The trade-off is real and worth stating: one identity is one thing to protect and one thing to rotate, but it's also a single point of compromise. For a personal fleet that's the right call — fewer keys means I actually keep the rotation story straight instead of letting five per-host keys drift. If this were a team, I'd encrypt each secret to multiple recipients (one per person/host) so revoking one doesn't force re-keying everyone. agenix supports exactly that; it's just a longer publicKeys list.
How decryption works at runtime
This is the part that makes agenix better than "stash a .env somewhere." At activation time, NixOS/nix-darwin decrypts each declared secret to a path under /run (tmpfs), owned by the user/service that needs it, with the permissions you specify. The decrypted value:
- never lands in the Nix store (which is world-readable and gets copied to your binary cache),
- never sits in the repo in plaintext,
- exists only on the machine authorized to decrypt it, only while the system is running.
So a service reads its API key from a file at /run/agenix/..., that file only exists because this machine holds the identity that can decrypt it, and the policy for who-can-read-what is reviewable in secrets.nix. Secrets management becomes declarative and auditable, like the rest of the config.
Bootstrapping a brand-new machine
The chicken-and-egg problem: a fresh machine needs secrets to be useful, but doesn't yet have the identity to decrypt them. My flake exposes helper apps for exactly this lifecycle:
nix run .#create-keys # generate keys on a new machine
nix run .#copy-keys # place an identity where agenix expects it
nix run .#check-keys # verify the identity can decrypt
nix run .#install-with-secrets # first install WITH secrets provisioned
install-with-secrets is the interesting one. On a first NixOS install it runs disko to partition, stages the repo to the new root, and installs via the flake — and crucially, it relies on SSH agent forwarding so the installer can fetch the private secrets input without ever copying a key onto the installer media. Nothing persistent is left behind on the installer once it's done. The machine comes up on first boot already matching repo state, with its secrets provisioned where they're declared.
Why this beats the alternatives
- vs. plaintext
.env/ committed config: secrets are encrypted at rest, never in the repo, never in the store. - vs. a cloud secrets manager: no external dependency, no per-machine bootstrapping against a third-party API, and the policy is in Git where I review everything else.
- vs. copying keys around by hand: the recipient list is declarative; adding or removing who-can-decrypt is a reviewable diff, not a tribal-knowledge ritual.
The throughline of this whole series is "manage it declaratively, in Git, reproducibly." Secrets are the case where people usually give up and do something ad-hoc. agenix is how I kept them inside the same discipline as everything else. Next, in Part 4, I put all of this to work standing up an ARM NAS from bare metal — disko for the disks, agenix for the backup credentials, and nixos-anywhere to tie it together.
A public example secrets repo is at parallaxisjones/nix-secrets; the fleet config that consumes it is parallaxisjones/dotfiles.
— Parker Jones, parkerjones.dev