Using SOPS for Encrypted Configuration (sops-nix)

Updated

September 12, 2026

Using SOPS for Encrypted Configuration (sops-nix)

Secrets that live in a wiki are missing on the next host. The boring default is: SOPS YAML in git (keys visible, values encrypted) and sops-nix decrypting to /run/secrets at boot.

Mental model

Mozilla SOPS encrypts values in YAML/JSON. Key names stay plaintext so diffs stay reviewable (database/password: still reads as that path). Recipients are Age keys (or SSH host keys converted with ssh-to-age).

sops-nix is a NixOS module:

  1. Reads defaultSopsFile.
  2. At activation/boot, decrypts with the host Age key (age.keyFile or SSH host key).
  3. Writes files under /run/secrets/… with owner / mode you set.
  4. Units reference config.sops.secrets.<name>.path.
secrets/desk.yaml   (cipher in git)
        │  sops-nix + host age key
        ▼
/run/secrets/database/password   (0400, tmpfs)
        │
        ▼
desk-api EnvironmentFile=

Worked examples

Case 1: Recipients and a secrets file

Save as .sops.yaml:

# .sops.yaml
creation_rules:
  - path_regex: secrets/.*\.yaml$
    key_groups:
      - age:
          - age1deskadminxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
          - age1desknode01xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
mkdir -p secrets
sops secrets/desk.yaml

Example decrypted editor view (what sops shows you):

database:
  password: please-change-me

On disk in git, the value is ENC[AES256_GCM,data:…]. Commit both .sops.yaml and secrets/desk.yaml.

Case 2: NixOS module

Save as secrets-module.nix:

# secrets-module.nix
{ config, inputs, ... }:

{
  imports = [ inputs.sops-nix.nixosModules.sops ];

  sops = {
    defaultSopsFile = ./secrets/desk.yaml;
    defaultSopsFormat = "yaml";
    age.keyFile = "/var/lib/sops-nix/key.txt";

    secrets."database/password" = {
      owner = "desk-api";
      mode = "0400";
    };
  };

  systemd.services.desk-api.serviceConfig.EnvironmentFile =
    config.sops.secrets."database/password".path;
}

If the YAML value is a raw password, EnvironmentFile is the wrong shape (that file is KEY=value lines). Store a dotenv in sops (DESK_DB=…) or use a template:

# sops-template.nix fragment
{
  sops.templates."desk-api.env" = {
    content = ''
      DATABASE_PASSWORD=${config.sops.placeholder."database/password"}
    '';
    owner = "desk-api";
    mode = "0400";
  };
  systemd.services.desk-api.serviceConfig.EnvironmentFile =
    config.sops.templates."desk-api.env".path;
}

placeholder is ciphertext-safe at eval: Nix never sees the live password. restartUnits = [ "desk-api.service" ]; on the secret so a rekey + switch restarts the unit.

nix eval --raw .#nixosConfigurations.desk-server.config.sops.secrets.\"database/password\".path

Output:

/run/secrets/database/password

Case 3: Age key from SSH host key

# sops-ssh.nix
{
  sops.age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ];
}

No extra key to distribute if the host already has an Ed25519 host key and that key is in .sops.yaml (via ssh-to-age). Persist the host key under impermanence or every reboot is a new recipient.

ssh-to-age < /etc/ssh/ssh_host_ed25519_key.pub

Paste the age1… line into .sops.yaml, then sops updatekeys secrets/desk.yaml.

Case 4: Flake input

# flake.nix fragment
{
  inputs.sops-nix = {
    url = "github:Mic92/sops-nix";
    inputs.nixpkgs.follows = "nixpkgs";
  };
}

follows so sops-nix does not pull a second nixpkgs.

Case 5: Missing owner

If desk-api runs as DynamicUser and the secret is 0400 root, the unit fails with Permission denied. Set owner to the service user, or use sops.secrets.x.restartUnits = [ "desk-api.service" ]; plus a group both can share.

sudo ls -l /run/secrets/database/password
sudo systemctl status desk-api

The trap

The trap is encrypting only to your laptop Age key. The server cannot decrypt at boot. Always include the host recipient (and the admin laptop, and CI if CI must eval decrypt).

The other trap is age.keyFile on a path that impermanence wipes. Persist /var/lib/sops-nix or use the SSH host key that you already persist.

The boring rule

  • Encrypted YAML in git. Recipients: admins + each host.
  • Units consume .path or sops.templates. Never decrypted strings in Nix. CI flake check must not need the private key.
  • Host Age key = SSH host key when you can. Persist that key.
  • Set owner and mode to match the unit.
  • sops updatekeys when a machine or person is added or revoked.

Try this

  1. age-keygen -o /tmp/k.txt, add the public key to .sops.yaml, sops secrets/demo.yaml with hello: world, sops -d secrets/demo.yaml.
  2. ssh-to-age < ~/.ssh/id_ed25519.pub and encrypt a file to that recipient.
  3. Eval .path from a flake that imports sops-nix. Confirm it starts with /run/secrets.
  4. Intentionally omit the host from .sops.yaml, rebuild a VM, read the sops-nix unit log. Add the host and updatekeys.