Secrets management

A real system needs passwords, API tokens, Wi-Fi keys, and TLS private keys. None of them belong in your Nix files as plain text. This guide explains why, then walks through wiring up encrypted secrets with the two tools the community relies on, sops-nix and agenix. Both let you commit an encrypted file to git and have NixOS decrypt it on the host at activation time, so the plaintext never touches the Nix store.

The core problem

Anything you write into configuration.nix is copied verbatim into the Nix store at build time. The store lives at /nix/store, and every path under it is world-readable by design. That is what makes builds shareable and reproducible, but it means a secret written into a config is a secret handed to every user on the machine. The official NixOS manual flags this directly under the heading "Nix store is world-readable", and the NixOS Wiki on Comparison of secret managing schemes collects the community tools built to work around it.

Here is the footgun in concrete form. Do not do this.

# WRONG. The token ends up in a world-readable store path.
services.myapp.environment = {
  API_TOKEN = "sk-live-1234567890abcdef";
};

After a rebuild you can watch the secret leak out of the store with no special privileges.

# Any unprivileged user can grep the store for your "secret"
grep -r "sk-live" /nix/store
# /nix/store/abcd...-unit-myapp.service/...:Environment="API_TOKEN=sk-live-1234567890abcdef"

The same trap catches builtins.readFile ./token.txt. Reading a plaintext file during evaluation copies its contents into the store exactly as if you had typed them inline. The fix is not to hide the value better. It is to keep the value encrypted in the repository and decrypt it on the host, outside the store, where file permissions actually protect it.

NixOS itself ships no built-in secrets manager. The tooling below is community-maintained, actively used, and the standard answer to this problem.

The two main tools

Two projects dominate. They solve the same problem with different ergonomics.

sops-nix wraps Mozilla SOPS. You keep one encrypted YAML or JSON file (often secrets.yaml) holding many secrets, and SOPS encrypts only the values, leaving the keys readable so a git diff still shows which entries changed. It supports age keys and GPG keys, and it can derive an age key from an existing SSH host key, which means a freshly installed machine can often decrypt without you generating anything new.

agenix is smaller and built around age and SSH keys. Each secret is its own .age file, encrypted to a list of recipient public keys you declare in a secrets.nix file. One secret per file keeps the model simple and the audit trail obvious.

Reach for sops-nix when you have many secrets, want them grouped in one file, need GPG, or like that structured diffs stay legible. Reach for agenix when you want the smallest possible tool, prefer one file per secret, and are happy encrypting to SSH and age keys only. Both keep plaintext out of the store, so the choice is about workflow rather than safety.

A minimal agenix setup

Add the input and pull in its NixOS module.

{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    agenix.url = "github:ryantm/agenix";
    agenix.inputs.nixpkgs.follows = "nixpkgs";
  };
 
  outputs = { self, nixpkgs, agenix, ... }: {
    nixosConfigurations.nixos-host = nixpkgs.lib.nixosSystem {
      system = "x86_64-linux";
      modules = [
        agenix.nixosModules.default
        ./configuration.nix
      ];
    };
  };
}

agenix decides who may decrypt a secret from a secrets.nix file at the repo root. It lists recipient public keys, normally your personal age key plus each host's SSH public key.

# secrets.nix
let
  # your personal age public key
  user = "age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p";
  # the host SSH public key, from /etc/ssh/ssh_host_ed25519_key.pub
  host = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... root@nixos-host";
in
{
  "secrets/wifi.age".publicKeys = [ user host ];
}

Generate a key, then encrypt the secret against the recipients above. agenix -e opens your editor and writes the encrypted .age file when you save.

# Generate a personal age key, then copy its public line into secrets.nix
nix shell nixpkgs#age --command age-keygen -o ~/.config/age/keys.txt
 
# Edit (create) the encrypted secret. agenix reads recipients from secrets.nix
nix run github:ryantm/agenix -- -e secrets/wifi.age

In your module, declare the secret and point at the .age file. agenix decrypts it during activation and exposes the cleartext path through config.age.secrets.<name>.path.

# configuration.nix
age.secrets.wifi.file = ./secrets/wifi.age;
 
# the decrypted file lives at config.age.secrets.wifi.path, default
# /run/agenix/wifi, owned by root with mode 0400 unless you set owner/mode

A minimal sops-nix setup

Add the input and module the same way.

{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    sops-nix.url = "github:Mic92/sops-nix";
    sops-nix.inputs.nixpkgs.follows = "nixpkgs";
  };
 
  outputs = { self, nixpkgs, sops-nix, ... }: {
    nixosConfigurations.nixos-host = nixpkgs.lib.nixosSystem {
      system = "x86_64-linux";
      modules = [
        sops-nix.nixosModules.sops
        ./configuration.nix
      ];
    };
  };
}

SOPS needs a .sops.yaml at the repo root that maps which keys may decrypt which files. This is where age recipients live for sops-nix.

# .sops.yaml
keys:
  - &admin age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
creation_rules:
  - path_regex: secrets/[^/]+\.yaml$
    key_groups:
      - age:
          - *admin

Create and edit the encrypted file with the sops CLI. It encrypts the values and leaves the keys readable.

# Opens $EDITOR, encrypts on save using .sops.yaml rules
nix run nixpkgs#sops -- secrets/secrets.yaml

Point the module at the encrypted file, tell it where the host's age key is, and declare each secret. The decrypted path is config.sops.secrets.<name>.path.

# configuration.nix
sops.defaultSopsFile = ./secrets/secrets.yaml;
 
# Derive the host age key from its SSH host key, so no extra key to manage
sops.age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ];
 
# A secret named "wifi_password", read from the default sops file.
# Decrypted to config.sops.secrets.wifi_password.path, default
# /run/secrets/wifi_password
sops.secrets.wifi_password = { };

Using a secret in a service

The rule is always the same. Pass the decrypted path into the service, never the value. Most upstream modules expose a *File option for exactly this, because the daemon reads the file at runtime rather than baking the secret into a unit.

With agenix feeding a password file option.

age.secrets.userpw.file = ./secrets/userpw.age;
 
# the user's hashed password is read from the decrypted file at login time
users.users.alice.hashedPasswordFile = config.age.secrets.userpw.path;

With sops-nix feeding a systemd EnvironmentFile, so the token reaches the process through the environment without ever entering the store.

sops.secrets.api_token = { };
 
systemd.services.myapp = {
  wantedBy = [ "multi-user.target" ];
  serviceConfig = {
    # The file holds a line like API_TOKEN=sk-live-...
    EnvironmentFile = config.sops.secrets.api_token.path;
    ExecStart = "${pkgs.myapp}/bin/myapp";
  };
};

If a service needs the secret owned by its own user, set owner and mode on the secret. Both tools support this and default to root-owned mode 0400.

sops.secrets.api_token = {
  owner = "myapp";
  mode = "0400";
};

What NOT to do

A few habits defeat the whole exercise. Avoid all of them.

# 1. Never commit plaintext to the repo and read it at eval time.
#    This copies the cleartext straight into the store.
services.myapp.tokenFile = builtins.toFile "token" (builtins.readFile ./token.txt);
 
# 2. Never put the secret in a plain string option.
#    The literal lands in a world-readable unit file.
services.myapp.environment.API_TOKEN = "sk-live-1234567890abcdef";
 
# 3. Never use builtins.readFile on a plaintext secret to "load" it.
#    Same store leak as writing it inline.
services.myapp.password = builtins.readFile ./password.txt;

The pattern that is safe is the opposite of these. Keep the ciphertext in git, hand the service a path produced by config.age.secrets.<name>.path or config.sops.secrets.<name>.path, and let the tool decrypt into /run at activation. Plaintext files that must exist on disk during development belong in .gitignore, never in a commit.

Advanced

Both tools encrypt to a list of recipients, which is what makes per-host keys natural. Add each machine's SSH host public key as a recipient for the secrets it needs, and that host can decrypt them while others cannot. In agenix you list the host key in secrets.nix per file. In sops-nix you add it under a creation_rules entry in .sops.yaml and scope the path_regex to that host's files.

Rotation has two distinct meanings, and you usually want both. Re-keying changes the set of recipients, for example when you add a machine or retire a key, and it does not change the secret value. After editing the recipient list, run agenix -r to re-encrypt every .age file to the new recipients, or sops updatekeys secrets/secrets.yaml for SOPS. Rotating the value itself means generating a new password or token, re-encrypting the file with agenix -e or sops, then rebuilding so the host picks up the new cleartext. Because the encrypted file is versioned, both kinds of change show up as ordinary commits you can review and roll back.

# Re-key every agenix secret after changing recipients in secrets.nix
nix run github:ryantm/agenix -- -r
 
# Re-key a sops file after editing .sops.yaml
nix run nixpkgs#sops -- updatekeys secrets/secrets.yaml

Next

  • Flakes explains inputs and the lock file, which is how you add agenix or sops-nix to a config.
  • Examples has complete, copy-paste-ready modules you can drop a secret into.
  • Remote deploy covers pushing a config to another machine, where per-host keys matter most.
  • Editing your config shows how to change what a host installs and runs.