Why Secrets Are Hard with Declarative Config

Updated

September 12, 2026

Why Secrets Are Hard with Declarative Config

Nix wants every input in the expression. Attackers want every input too. The boring default is: treat /nix/store as public, never put secret bytes in Nix, and decrypt only onto /run at boot.

Mental model

/nix/store items are world-readable. Binary caches replicate them. CI logs echo eval results. A private git repo does not make a string in configuration.nix private — the next nix copy to a cache, a coworker, or a backup disk copies the secret.

Safe in the flake Never in the flake
Path /run/secrets/db Password string
Age public keys Age private keys
sops file names Decrypted YAML
hashedPasswordFile = "/persist/…" initialPassword = "hunter2"

Builds are sandboxed: they cannot prompt you. If a derivation “needs” the production DB password, the design is wrong. Fetch private source with access-tokens. Fetch private runtime secrets on the machine that runs the unit.

git  →  encrypted blob  →  eval knows the path
boot →  decrypt to tmpfs /run/secrets  →  unit EnvironmentFile

Worked examples

Case 1: Prove the leak

Save as leak.nix:

# leak.nix
{ pkgs ? import <nixpkgs> { } }:

pkgs.writeText "database.conf" ''
  password = SuperSecretPassword123
''
nix-build leak.nix
cat result
stat -c '%a %n' "$(readlink -f result)"

Output:

password = SuperSecretPassword123
444 /nix/store/…-database.conf

Mode r--r--r--. Any local uid can cat it. nix copy --to ssh://untrusted sends it off-box.

Case 2: builtins.readFile is the same leak

Save as read.nix:

# read.nix
builtins.readFile ./prod.password
echo 'hunter2' > prod.password
nix-instantiate --eval read.nix

Output:

"hunter2\n"

The string is now in the eval. If a module interpolates it into a unit, it is in the store. Use readFile only with public files (a banner, a JSON schema).

Case 3: Reference a runtime path

Save as safe.nix:

# safe.nix
{ config, pkgs, ... }:

{
  systemd.services.desk-api = {
    wantedBy = [ "multi-user.target" ];
    serviceConfig = {
      ExecStart = "${pkgs.hello}/bin/hello";
      EnvironmentFile = "/run/secrets/desk-api.env";
      DynamicUser = true;
    };
  };
}

Eval contains the path. The file is created at boot by sops-nix / agenix / Vault agent. Check:

nix why-depends /run/current-system /nix/store/…-database.conf || echo 'not in the OS closure (good)'

ExecStart=… --token hunter2 is the same leak as Case 1. EnvironmentFile / LoadCredential are the boring APIs.

Case 4: Hashes versus plaintext

hashedPassword = "$6$…" is still in the store. It is an offline crack target, not a live password. Prefer hashedPasswordFile on a 0400 persist path. Never initialPassword. Same for users.users.root.hashedPassword.

# hash-ok-ish.nix
{
  users.users.deskadmin.hashedPasswordFile = "/persist/secrets/deskadmin.hash";
}
sudo install -m 0400 /dev/null /persist/secrets/deskadmin.hash
# write the mkpasswd hash there; not into git

Case 5: Hunt the closure

# after a switch, on a lab VM — dummy strings only
sudo grep -R "SuperSecret" /nix/store 2>/dev/null | head

If grep hits, that generation is tainted. Roll back, rotate, GC, and if you pushed a cache, consider the secret public. nix flake check must not need production secret values.

nix why-depends /run/current-system /nix/store/…-database.conf

A hit means the OS closure still references the leak. Deleting the file from git is not enough until a new generation GC’s that path — and every cache that already copied it.

The trap

The trap is private GitHub = safe. Clones, Actions artifacts, nix copy to the team cache, and a laptop left at security all see the store. Encrypt in git (sops/age) so the repo is safe to copy. Decrypt on the host.

The other trap is putting the sops private key in the flake so CI can decrypt. Then CI logs are the leak. CI uses a runner identity; laptops use laptop keys.

A third: systemd.services.*.environment.DB_PASSWORD = "…" or extraConfig interpolating builtins.readFile. LoadCredential / EnvironmentFile of a path is the API.

The boring rule

  • Store is public. Write configs as if a stranger can cat every NAR.
  • Paths in Nix. Bytes on /run (tmpfs) or a 0400 persist file.
  • No readFile of secrets. No secret strings in environment or argv.
  • After a leak: rotate, then assume copies exist (cache, clones, backups).
  • nix flake check must not need production secret values.

Try this

  1. Case 1, then stat -c '%a %n' $(readlink -f result) — expect 444.
  2. Put a dummy token in a .nix file, nixos-rebuild dry-build, grep the built unit in the store. Remove it.
  3. git grep -nE 'initialPassword|privateKey =|password =' -- '*.nix' on the desk repo.
  4. Confirm /run/secrets is tmpfs (findmnt /run) on a sops-enabled host.