The Nix Store Problem
The Nix store (/nix/store) is world-readable. Every derivation, config file, and script that gets built lands there, and any local user on the machine can read it. More importantly, the store is typically committed to a git repo when you're managing your flake, which means anything in your configuration.nix is also in your git history.
That rules out putting passwords, API tokens, or private keys directly in your config. The common mistake is doing something like:
# Don't do this
services.nextcloud.config.adminpass = "hunter2";
This ends up in a derivation in the Nix store, readable by anyone with a shell on the box. Worse if you've pushed the config to a public repo.
agenix
agenix solves this cleanly. It encrypts secrets with age using the SSH host key (or your personal SSH key) as the recipient. The encrypted ciphertext is committed to your repo. At activation time, NixOS decrypts the secrets to /run/agenix/ using the host's private key, which never leaves the machine.
The workflow has three moving parts: the age keys, the secrets.nix file that maps secrets to recipients, and the per-secret declarations in your config.
Setting Up Age Keys
agenix uses SSH keys as age recipients via the ssh-to-age conversion. Your NixOS host needs an SSH host key (it will have one by default at /etc/ssh/ssh_host_ed25519_key). Get its public key in age format:
nix run nixpkgs#ssh-to-age -- -i /etc/ssh/ssh_host_ed25519_key.pub
Copy that output. You'll also want your own SSH public key converted for access from your workstation:
# on your workstation
ssh-to-age < ~/.ssh/id_ed25519.pub
secrets.nix
secrets.nix lives in your config repo and maps each secret file to the list of age public keys that can decrypt it. It's not a NixOS module, just a plain Nix file that the agenix CLI reads:
# secrets/secrets.nix
let
homeserver = "age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
eli = "age1yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy";
in
{
"cloudflare-api-token.age".publicKeys = [ homeserver eli ];
"nextcloud-admin-pass.age".publicKeys = [ homeserver eli ];
"restic-password.age".publicKeys = [ homeserver eli ];
}
Include both the host key and your personal key so you can re-encrypt secrets from your workstation without needing to be on the server.
Encrypting and Editing Secrets
With agenix in your dev shell or PATH:
# create or edit a secret
cd secrets/
agenix -e cloudflare-api-token.age
This opens $EDITOR with the decrypted content (empty if new). Save and quit, and agenix writes the encrypted .age file. Commit the .age file to your repo. The plaintext never touches disk.
Wiring Secrets Into Your Config
In configuration.nix (or a module), declare which secrets you want decrypted at activation:
age.secrets = {
cloudflare-api-token = {
file = ../../secrets/cloudflare-api-token.age;
owner = "ddclient";
group = "ddclient";
mode = "0400";
};
nextcloud-admin-pass = {
file = ../../secrets/nextcloud-admin-pass.age;
owner = "nextcloud";
mode = "0400";
};
};
At activation, NixOS decrypts each .age file to /run/agenix/<name> using the host's SSH key. The path is then available as config.age.secrets.<name>.path anywhere in your config:
services.ddclient.passwordFile = config.age.secrets.cloudflare-api-token.path;
services.nextcloud.config.adminpassFile = config.age.secrets.nextcloud-admin-pass.path;
The service reads the decrypted file at runtime. If the secret changes, re-encrypt, commit, and rebuild.
SSH Host Key Bootstrapping
There's a chicken-and-egg problem on a fresh install: you need the host's SSH public key to encrypt secrets for it, but the host doesn't have its key until it boots.
The cleanest approach is to pre-generate the host key. Before installing, generate an ed25519 keypair:
ssh-keygen -t ed25519 -f ./hosts/homeserver/ssh_host_ed25519_key -N "" -C ""
Encrypt the private key with agenix (using your personal key as the only recipient initially) and reference it in your config:
services.openssh.hostKeys = [{
path = config.age.secrets.ssh-host-key.path;
type = "ed25519";
}];
Then you can encrypt all other secrets for that host's key before it ever boots, and everything works from first boot without a two-phase setup.
Next up: the day-to-day deployment workflow.