Files
nixos/docs/secrets.md
finn.markwitz 621ca5dde6 docs: sops-nix secrets workflow
Operator runbook for adding a secret, adding a new host, and rotating
keys, plus a pointer from CLAUDE.md's architecture overview.
2026-09-08 15:43:50 +02:00

3.5 KiB

Secrets (sops-nix)

Secrets are encrypted with sops and decrypted at activation by sops-nix. Each host decrypts with its own SSH host key (converted to an age key in-memory — no separate host private key is ever stored on disk).

Tooling (sops, age, ssh-to-age) is available via nix develop in this repo, and installed system-wide on every machine via machines/core/sops.nix.

Adding a secret to an existing host

nix develop -c sops secrets/<host>.yaml

This opens $EDITOR on the decrypted content. Add your key, save, quit — sops re-encrypts on write. Then declare it in machines/<host>/secrets.nix:

sops.secrets.my-secret = {
  owner = "someuser";   # defaults to root
  group = "someuser";
  mode = "0400";
  restartUnits = [ "foo.service" ];  # if a running service needs to pick it up
};

Consume it in the owning module as config.sops.secrets.my-secret.path (resolves to /run/secrets/my-secret) — never inline the decrypted value into a Nix string, since that copies it into the world-readable /nix/store.

Then:

nix build '.#nixosConfigurations.<host>.config.system.build.toplevel'
sudo nixos-rebuild test --flake '.#<host>'
# verify, then:
sudo nixos-rebuild switch --flake '.#<host>'

The secrets file must be git added before nix build — untracked files are invisible to flake evaluation, and sops.validateSopsFiles needs it in the Nix store.

Adding a new host

  1. On the new host: ssh-to-age < /etc/ssh/ssh_host_ed25519_key.pub to get its age public key.
  2. In .sops.yaml: add a &host_<name> anchor with that key, and a creation_rules entry for secrets/<name>.yaml including both the new host key and *admin_finn.
  3. Create machines/<name>/secrets.nix, add it to that machine's configuration.nix imports.
  4. If the host needs to read an existing shared file, run sops updatekeys secrets/<file>.yaml after step 2 so it's re-encrypted to include the new recipient.

Rotating keys

# after editing .sops.yaml (new/removed recipient):
nix develop -c sops updatekeys secrets/<file>.yaml

for every file the change affects. updatekeys requires you to already be able to decrypt the file — this is why *admin_finn is a recipient on every rule, as the standing fallback.

Reinstalling a host regenerates its SSH host key, which changes its age identity. Any file previously encrypted to that host must be updatekeys'd with the new key, or the host's next activation fails to decrypt.

Where private keys live

  • Operator (you): ~/.config/sops/age/keys.txt — sops' default lookup path, so SOPS_AGE_KEY_FILE never needs to be set. Never committed. Back this up out-of-band (password manager, offline copy) — if it's lost, only the surviving host keys can still decrypt their own files, and any file that was only ever readable by an operator-only rule becomes permanently unreadable.
  • Hosts: /etc/ssh/ssh_host_ed25519_key — already managed by NixOS, converted to age in-memory at activation time. Nothing extra to back up.

Layout

.sops.yaml              # age recipients + creation rules (which keys can decrypt which files)
secrets/<host>.yaml      # per-host encrypted secrets (default sopsFile for that host)
machines/core/sops.nix   # applied to every machine: decryption config, sops/age/ssh-to-age on PATH
machines/<host>/secrets.nix   # per-host: which secrets exist, owner/group/mode