Secrets with sops-nix
Prerequisite: NixOS and the module system.
Outcome: you understand what
.sops.yaml, an age key, andsops.secrets.*are; you know how a secret gets from your laptop to/run/secrets/<name>on a NixOS host; you know how this repo’sinstall-hostandupdate-secretsscripts orchestrate the flow.
Managing secrets on Nix is a solved problem, and sops-nix is the
solution this fleet base picks. If you have used HashiCorp Vault,
Ansible Vault, or age on its own, the mental model is not far
off. If not, this chapter builds it from scratch.
Why this problem is hard
You have secrets: an API token, a TLS private key, a database password. You want:
- to keep them in git alongside the rest of your infrastructure (so history and review work), but
- never to have them in plaintext there, and
- to have them appear as plaintext files on the target host so that services can read them, and
- to control which host can decrypt which file (a compromised web server should not be able to decrypt the database node’s secrets).
sops (Secrets OPerationS,
originally by Mozilla, now maintained under the CNCF getsops org)
provides (1) and (2). age is the modern encryption format sops
uses (small keys, no PKI ceremony). sops-nix glues sops into
NixOS’s module system to give you (3) and (4).
The 90-second picture
.sops.yaml
(recipients + rules)
|
sops encrypts v
plaintext values ----> secrets/.yaml ---- committed to git
|
| (nixos-anywhere ships host's
| age key at first install;
| sops-nix reads it on boot)
v
/run/secrets/
(plaintext, on the target host, root-only)
|
v
consumed by services
(e.g. systemd EnvironmentFile,
sshd, wireguard, ...)
age, briefly
age is a modern file-encryption tool built by Filippo Valsorda.
Keys are short strings starting with AGE-SECRET-KEY- (private) or
age1 (public / recipient). No PKI, no key servers, no expirations
– you generate a key, share the public half, keep the private
half.
$ age-keygen -o ~/.config/sops/age/keys.txt
Public key: age1abc123...
The public key (“recipient”) is what you hand out. The private key lives in that file; nothing else needs to know it.
Never cat or read the private-key file directly. To display
the public key from an existing private-key file:
$ age-keygen -y ~/.config/sops/age/keys.txt
age1abc123...
age-keygen -y derives the public part without echoing the private
key. This repo’s scripts and this tutorial follow that rule; you
should too.
Reference: age(1).
Deriving age from SSH host keys
Here is a clever trick this repo relies on: NixOS servers already
have an ed25519 host key
(/etc/ssh/ssh_host_ed25519_key) generated at install time. That
key is mathematically compatible with age, and the tool
ssh-to-age converts it to
an age recipient in one line:
$ ssh-to-age -i /etc/ssh/ssh_host_ed25519_key.pub
age1def456...
That means every NixOS host can be an age recipient without any
extra key material: sops-nix decrypts at boot using the SSH host
key that is already there. The modules/sops.nix module says
exactly that:
sops.age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ];
install-host bootstraps this by generating the host’s ed25519 key
locally, deriving its age recipient, adding it to .sops.yaml, and
shipping the private key to the target as --extra-files during
nixos-anywhere. That first-boot has the key in place, so
sops-nix can decrypt from the very first boot.
.sops.yaml
The rulebook. Two sections:
keys:– a list of anchor-named recipients you can reference by alias further down.creation_rules:– for each path pattern, which recipients can decrypt files created there.
The scaffold from templates/default/.sops.yaml:
keys:
- &admin_you age1REPLACE_WITH_YOUR_LAPTOP_AGE_PUBLIC_KEY
creation_rules: []
After install-host web-1 runs, the file grows into something like:
keys:
- &admin_you age1youdefinitely...
- &web-1 age1web1derivedfromitssshkey...
creation_rules:
- path_regex: secrets/web-1\.yaml$
key_groups:
- age: [ *admin_you, *web-1 ]
Read: “files matching secrets/web-1.yaml are encrypted for the
admin recipient and for web-1’s own age recipient”. You (on your
laptop) can decrypt because your admin key is a recipient; web-1
can decrypt at boot because it holds the private half of its age
identity.
If you later add a database node, install-host db-1 appends a
similar block with secrets/db-1\.yaml and web-1 will not be a
recipient there. Least-privilege is the default.
Reference: getsops/sops – README and Mic92/sops-nix – README.
Encrypted files
An encrypted secrets/web-1.yaml looks like:
# somewhat trimmed
db_password: ENC[AES256_GCM,data:...,iv:...,tag:...]
api_token: ENC[AES256_GCM,...]
sops:
age:
- recipient: age1youdefinitely...
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
...
-----END AGE ENCRYPTED FILE-----
- recipient: age1web1derivedfromitssshkey...
enc: |
...
lastmodified: ...
mac: ENC[...]
- Each secret value is encrypted with a randomly-generated data key.
- The data key is then wrapped for every recipient in the
sops:block.
To decrypt as an admin from your laptop:
$ sops secrets/web-1.yaml
You get an interactive editor with the plaintext. Save and exit; sops re-encrypts. Adding, editing, or removing individual entries this way is safe.
Adding secrets in this repo
Two workflows, pick per situation:
- From a file (
set-secret <host> <key> <file>). Reads the value from a file (or-for stdin) and writes it throughsops --set. This is the recommended path: multi-line values (PEMs, private keys, JSON blobs) survive intact, and scripted bootstraps have a single-line command. If.sops.yamlhas nocreation_rulefor the target file yet,set-secretadds one with the admin recipient(s), so it works even before the firstinstall-hostfor that host. - Manual with
sops secrets/<host>.yaml. Full editor, edits every field at once. Handy for rare surgical fixes; make sure the file exists and.sops.yamlhas a matchingcreation_rule.
Both orderings work: set-secret before or after install-host.
When install-host runs, it adds the host’s own recipient to the
creation_rule and calls sops updatekeys – any values you set
earlier are re-encrypted for the new recipient set.
sops.secrets.* – the NixOS-side view
On the NixOS side, sops-nix gives you a module option:
sops.secrets.db_password = {
sopsFile = ./secrets/web-1.yaml;
owner = "myservice";
group = "myservice";
mode = "0400";
};
At boot, sops-nix will:
- read
/etc/ssh/ssh_host_ed25519_key; - decrypt every
sops.secrets.<key>value from the given file; - write plaintext to
/run/secrets/<key>with the requested owner, group, and mode.
Your service module then references the plaintext path:
systemd.services.myservice.serviceConfig.EnvironmentFile =
config.sops.secrets.db_password.path;
config.sops.secrets.db_password.path evaluates to
/run/secrets/db_password. Never hard-code the path yourself; let
sops-nix give you the option value.
Full option reference: sops-nix README – NixOS options.
Shared secrets across hosts (manual)
The default topology in this repo is one file per host
(secrets/<host>.yaml). Every secret a host needs lives in that
file, encrypted for that host alone. That is the safest posture:
least-privilege by default, and rotating a host’s SSH key rotates
its recipient without touching anything else.
Sometimes you have a secret several hosts need: a shared CloudFlare API token for DNS, a monitoring token, a backup encryption passphrase. Duplicating the plaintext into every per-host file works but rotates poorly. sops supports a shared-file pattern natively; the repo doesn’t automate it (yet), so this section is the manual recipe.
Step 1 – declare a shared file in .sops.yaml. Add a keys
group and a creation_rule listing every host that should be
able to decrypt it:
keys:
- &admin_you age1youradminkey...
- &web-1 age1web1derivedfromssh...
- &web-2 age1web2derivedfromssh...
- &db-1 age1db1derivedfromssh...
creation_rules:
# per-host files (managed by install-host, unchanged)
- path_regex: secrets/web-1\.yaml$
key_groups:
- age: [ *admin_you, *web-1 ]
- path_regex: secrets/web-2\.yaml$
key_groups:
- age: [ *admin_you, *web-2 ]
- path_regex: secrets/db-1\.yaml$
key_groups:
- age: [ *admin_you, *db-1 ]
# shared file -- readable by whichever hosts you list here
- path_regex: secrets/shared\.yaml$
key_groups:
- age: [ *admin_you, *web-1, *web-2 ]
Step 2 – create the file and set values. set-secret’s
first argument is normally a host name; for shared files pass the
basename you used in the path_regex above:
$ set-secret shared cloudflare_api_token cf-token.txt
Step 3 – reference it from any host module that needs it:
sops.secrets."cloudflare_api_token" = {
sopsFile = ../../secrets/shared.yaml;
owner = "caddy";
group = "caddy";
mode = "0400";
};
Both web-1 and web-2 will decrypt the same file at boot; db-1 will not (it isn’t in the shared rule).
Adding a new host to a shared file. When you add-host web-3
and want it to see secrets/shared.yaml too:
- Run
install-host web-3first – gets the&web-3anchor into.sops.yaml. - Hand-edit the shared rule’s
age: [...]list to include*web-3. sops updatekeys secrets/shared.yaml– re-encrypts the file for the new recipient set.deploy web-3– the new host now has the secret at boot.
Removing a host from shared. Reverse: remove *web-3 from
the shared rule, sops updatekeys secrets/shared.yaml. The next
deploy web-3 (or a rebuild) will fail sops-nix decryption for
that key – move any dependent modules off shared before revoking.
Why not automate this? The one manual step (editing the
age: list) forces the operator to consciously grant a new host
access to shared material. Automation here would silently expand
the blast radius on every add-host.
update-secrets – recipient resync
update-secrets [host] in the devShell is a thin wrapper over
sops updatekeys --yes secrets/<host>.yaml. It re-encrypts a
file’s recipient wrappers to match the current recipient set in
.sops.yaml; values themselves are untouched.
Called without an argument, it walks every secrets/*.yaml in
the fleet and resyncs each.
Use it when:
- You rotate an admin key (edit
.sops.yaml, thenupdate-secrets– every file gets re-encrypted for the new admin). - You add or remove a shared-file recipient by hand (edit the
rule’s
age: [...]list, thenupdate-secrets shared). .sops.yamland an encrypted file’ssops:block have drifted (for example after a merge conflict).
install-host calls sops updatekeys for you when it modifies
.sops.yaml, so you rarely need to reach for update-secrets
directly.
Rotating a recipient
Two cases:
- A host’s age recipient changed (rare – would mean
regenerating its SSH host key). Re-run
install-host --forceafter updating.sops.yaml, or runsops updatekeys secrets/<host>.yamlyourself. - An admin key was added or removed. Edit
.sops.yaml, thensops updatekeys secrets/*.yaml. Every file gets re-encrypted for the new recipient set. Commit.
install-host does the updatekeys step for you when it modifies
.sops.yaml.
Reference: getsops/sops – Adding and removing keys.
Common gotchas
sops-nixfails at boot with “no age key found”. The host’s SSH host key is missing or does not match the recipient in.sops.yaml. Checkssh-to-age -i /etc/ssh/ssh_host_ed25519_key.pubagainst what.sops.yamllists.- “Recipient not found in creation rule”. You created a new
encrypted file whose path does not match any
creation_rulespath_regex. Add a rule to.sops.yaml. - “Cannot decrypt on the laptop”. Your
~/.config/sops/age/keys.txtis either missing or holds a different key than.sops.yaml’s admin recipient expects. Fix the recipient (andsops updatekeysevery file) or fix which key you use. - Committing an unencrypted secrets file. Never; use
git diffbefore commits.sopsshows the encrypted form; if the file has raw plaintext values,sopswas skipped somewhere.
Alternative secret backends
sops-nix also supports GnuPG, AWS KMS, GCP KMS, Azure Key Vault,
and HashiCorp Vault – you list them in .sops.yaml alongside age.
This tutorial sticks to age because it is the simplest, needs no
external service, and works for the “small fleet, personal admin
laptop” case this repo targets.
For a cluster with strict compliance where the encryption key must
live in an HSM, swap age for KMS in .sops.yaml; the NixOS side
does not change. Details: getsops/sops – KMS.
What next
You know the secret pipeline. Next is the first-boot pipeline: how a bare Linux target becomes a NixOS host without a rescue image.
Next: Remote install with nixos-anywhere
References for this chapter
- Mic92/sops-nix – the canonical source. Read the README end-to-end at some point.
- getsops/sops – upstream sops CLI.
- FiloSottile/age – age format and CLI.
- Mic92/ssh-to-age – the converter this repo uses.
- Zero to Nix – concepts – ecosystem context.
- NixOS Wiki – Comparison of secret managing schemes – if you want to know why we picked sops over agenix/git-crypt/etc.
- NixOS Discourse – “secrets” tag – ongoing community discussions.