Secrets Management in nixfiles
This document explains the secrets management architecture in nixfiles using ragenix (a Rust-based re-implementation of agenix powered by rage).
For lower-level commands and key generation details for rage, TPM, and YubiKey, see Age Documentation.
Architecture Principles
- No Cleartext in
/nix/storeor Git: Cleartext secret values never enter the world-readable/nix/storeor the public Git repository. Only encrypted.agefiles are tracked in Git and evaluated into/nix/store. - Runtime RAM Decryption: During NixOS system activation/boot, secrets are decrypted into a temporary RAM filesystem (
/run/secrets/<name>) with strict ownership (0400/0444) and cleared on reboot. - Automatic Identity Provisioning: Decryption identity handles (YubiKey and host-specific TPM handle stubs) are stored in
secrets/identities.nixand automatically deployed to~/.config/age/identitieson every NixOS system during activation. - Host Filtering & Identity Ordering: Host-specific identity handles (
<host>-*, e.g.ixo-tpmortitan-yubikey-10300902) are deployed only to their matching host, avoiding unnecessary plugin errors or prompts. Portable identities (portable-yubikey-*) are deployed to all hosts, but placed AFTER host-specific ones in~/.config/age/identities. - Machine-Readable Keys: Keys are named (
portable-yubikey-9599730,ixo-tpm,ixo, etc.) insecrets/identities.nixandsecrets/recipients.nix, allowing structured key management. - Forever Access (Multi-Recipient Encryption): To prevent loss of access if a machine disk is lost or reinstalled (invalidating
/etc/ssh/ssh_host_ed25519_key), every secret file is encrypted to both: - Host Keys: Raw OpenSSH host public key strings (
ssh-ed25519 AAAAC3...) from/etc/ssh/ssh_host_ed25519_key.pubfor unattended system boot decryption. - Master / Admin Keys: Your personal YubiKey address (
age1yubikey...) or personal TPM public key.
1. Directory Structure & Key Definitions
Secret management is grouped in the secrets/ directory:
secrets/
├── identities.nix # Human-edited identity handles & OpenSSH host keys
├── recipients.nix # Auto-generated recipient map (nix run .#write-recipients)
├── rules.nix # Assigns .age files to public recipient keys
└── encrypted/ # Encrypted .age secret files
└── hello-secret.age
secrets/identities.nix: Human-edited source of truth containing named decryption identity handles (portable-yubikey-9599730,ixo-tpm) and OpenSSH host keys (ixo,titan).secrets/recipients.nix: Auto-generated recipient map (nix run .#write-recipients) imported bysecrets/rules.nix.secrets/rules.nix: Evaluated byragenix(ragenix --rules secrets/rules.nix).
To regenerate recipients.nix dynamically after editing secrets/identities.nix:
nix run .#write-recipients
secrets/rules.nix
secrets/rules.nix dynamically collects flake.secretRules declarations defined across modules/**/*.nix (e.g., modules/packages/h.nix). Feature modules can use the mkSecret helper (lib/mk-secret.nix) to declare their secrets and rules compactly:
let
inherit (import ../../lib/mk-secret.nix) mkSecret;
in
mkSecret {
name = "hello";
moduleName = "h";
mode = "0444";
}
When evaluated by ragenix, secrets/rules.nix imports recipients.nix and merges all secret recipient rules (defaulting to encrypting to all recipients).
2. Adding a New Key
- Pull out any other previous YK
- Generate Key Identity; e.g.
age-plugin-yubikey - Edit
secrets/identities.nixto add it in there - Run
nix run .#write-recipientsto updatesecrets/recipients.nix - Plug in previous registered YK (so now BOTH YKs are in)
- Run
ragenix --rekeyto update allsecrets/encrypted/*.age, type PIN of previous YK - Pull out any other previous YK
- Commit:
git commit -a -m "secrets: Add 🔑 Key" - Switch:
nh os switch . - Test with
raged secrets/encrypted/hello-secret.age - Test with
h - Push
3. Onboarding a New Host (e.g. titan)
When setting up a brand-new host machine (titan), system activation during nixos-rebuild switch will initially fail to decrypt secrets if titan's host SSH key has not yet been added to secrets/identities.nix and re-encrypted into the .age files.
Recommended Method: Onboard from an Existing Active Workstation (e.g. ixo)
If you have an existing active machine (ixo) where your YubiKey is plugged in:
- Get Host Key on
titan(via SSH):
cat /etc/ssh/ssh_host_ed25519_key.pub
- Add Key & Rekey on
ixo: Addtitan = "ssh-ed25519 AAAAC3...";tosecrets/identities.nixonixo.
nix run .#write-recipients
ragenix --rekey
git commit -am "secrets: Add titan host key" && git push
- Switch on
titan(via SSH):
git pull && nixos-rebuild switch
Standalone Method: Direct Onboarding on titan (YubiKey plugged directly into titan)
If no other active workstation is up and running, you can onboard directly on titan with your YubiKey plugged into titan:
-
Add
titan's Host SSH Key: Viewcat /etc/ssh/ssh_host_ed25519_key.puband add it tohostKeysinsecrets/identities.nix. -
Manually Provision
~/.config/age/identities: Before initialswitch, manually copy your YubiKey identity handle block into~/.config/age/identities:
mkdir -p ~/.config/age
cat <<'EOF' > ~/.config/age/identities
# Recipient: age1yubikey1qd5rn4s8d04pjkhqe4xq8nspc883gm7jnnk3pucsr33yg6eq00v9uq5tsas
AGE-PLUGIN-YUBIKEY-17FAFYQYZ4MD0W7CZP5JUV
EOF
chmod 0600 ~/.config/age/identities
- Resolve Smartcard Locks (if accessing over SSH):
If accessing
titanremotely over SSH while the YubiKey is plugged intotitan, ensuregpg-agent/scdaemonhas not exclusively locked the card:
gpgconf --kill gpg-agent || true
sudo systemctl restart pcscd
- Regenerate Recipients & Rekey Secrets:
Run with explicit flags (
--rulesand-i):
nix run .#write-recipients
ragenix --rules secrets/rules.nix -i ~/.config/age/identities --rekey
- Commit and Switch:
git commit -am "secrets: Add titan host key"
nixos-rebuild switch
4. Managing Secrets with ragenix CLI
ragenix CLI is available in the default devShell and on all NixOS hosts running nixfiles.
Create or Edit a Secret File
ragenix -e secrets/encrypted/hello-secret.age
This opens your $EDITOR securely, allowing you to edit the secret in cleartext. Upon saving, it re-encrypts the file automatically using the keys in secrets/rules.nix.
Note on User Identities and
ragenixwrapper: TheragenixFish shell alias automatically passes--rules secrets/rules.nixand-i $HOME/.config/age/identitiesif available.Why
rageremains unaliased: Encryption commands (rage -e -r ...) cannot receive-iidentity files, as identity files contain private key material meant only for decryption. (Passing-iduring encryption causes plugins likeage-plugin-tpmto crash, see Foxboron/age-plugin-tpm#46).rageis kept unaliased so standardrage -eencryption commands operate cleanly.
ragedDecryption Shortcut: For quick standalone decryption using your default~/.config/age/identitiesfile, you can use theragedFish alias (raged secret.txt.age), which automatically passes-d -i $HOME/.config/age/identities.
Rekeying Secrets (e.g. after adding a new host key)
When a new host key or master key is added to secrets/identities.nix:
- Run
nix run .#write-recipientsto updatesecrets/recipients.nix. - Run
ragenix --rekey.
5. Using Secrets in NixOS Modules & Services
To declare a secret for a feature or service module (e.g. modules/services/monitoring.nix), use the mkSecret helper (lib/mk-secret.nix).
Module Merging Pattern
When a module exposes both secret rules (mkSecret) and service definitions (mkService), combine them using lib.recursiveUpdate and merge secret.flake.nixosModules.<name> inside mkService's content function.
This ensures that age.secrets.<name> is only enabled when services.<name>.enable = true, preventing decryption or missing group errors on hosts where the service is disabled:
let
inherit (import ../../lib/mk-secret.nix) mkSecret;
inherit (import ../../lib/mk-service.nix) mkService;
secret = mkSecret {
name = "grafana";
moduleName = "monitoring";
mode = "0440";
group = "grafana";
};
in
{ lib, ... }:
lib.recursiveUpdate secret {
flake.nixosModules.monitoring = mkService {
name = "monitoring";
description = "Grafana service";
content =
{ pkgs, ... }:
lib.recursiveUpdate secret.flake.nixosModules.monitoring {
services.grafana.settings.security.secret_key = "$__file{/run/secrets/grafana}";
};
};
}
NixOS will automatically decrypt the file to /run/secrets/<name> (e.g., /run/secrets/grafana) at runtime with the specified ownership/permissions.
Creating the Encrypted .age Secret File
After declaring mkSecret in your module, create the encrypted .age file using ragenix:
secrets/rules.nixdynamically evaluatesflake.secretRulesacross all modules. Make sure your module function accepts{ lib, ... }or compatible arguments sosecrets/rules.nixcan import it without error.- Run
ragenixspecifying the rules file (or ensure environment variableRULES=secrets/rules.nixis passed if outside Fish shell):
ragenix --rules secrets/rules.nix -e secrets/encrypted/grafana.age
Note on
ragenixEditor & Flags: If editing programmatically or outside an interactive shell, pass--rules secrets/rules.nixand setEDITORappropriately (e.g.EDITOR="nano"). Using--rulesensuresragenixmatches the file path to the rule generated bymkSecret.
6. Demo: h CLI
The modules/packages/h.nix defines flake.nixosModules.h (which registers age.secrets.hello-secret) and provides the h CLI command which checks for /run/secrets/hello-secret:
$ h
hello, world
hello, secret