Skip to content

Secrets Management

Overview

The Nix store is readable to all local users. Anything evaluation or a build copies into /nix/store—plaintext strings, builtins.readFile of a credential file, or a derivation that embeds secrets—is effectively public on that machine, and may also land on binary caches or remote builders. Prefer keeping ciphertext or path references in the repo and decrypting or injecting secrets at deployment / activation / runtime, not at evaluation time.

This page is the trust-model overview. Concrete NixOS option patterns live in secrets strategies; tool wiring for agenix and sops-nix lives in agenix / sops-nix.

Details

Why the store leaks secrets

The Nix reference manual states the store is readable to all users and discourages letting secrets into it. Store paths are shared across users; permissions on /nix/store are not a vault. On multi-user systems every local account can read them; on single-user hosts, service isolation still weakens if every unit can read every secret. Substituters and remote builds can copy the same paths elsewhere.

Treat every evaluated string and every file pulled into a derivation as public. Hashed passwords in config are a narrow exception (hash ≠ plaintext); see secrets strategies.

Preferred delivery patterns

Pattern When Where secret material appears
Deployment-time decrypt (agenix, sops-nix) Secrets in Git as ciphertext; host keys decrypt on the target Encrypted blob in repo (and optionally ciphertext in the store); plaintext only after activation (typically /run/agenix/… or /run/secrets/…)
systemd credentials (LoadCredential= / related) Unit needs a secret at start Runtime dir via $CREDENTIALS_DIRECTORY (often /run/credentials/<unit>/…), not the store
Runtime / deploy-time files Option accepts *File / path outside evaluation Absolute path populated on the host; never readFile’d into Nix
Hashed account passwords Local login only Hash in config; plaintext never evaluated

Shared rule from the Nix manual: organize so secrets are read from the filesystem (with access control) at run time, or keep them encrypted in the store and decrypt with access control on system activation.

Evaluation purity vs impure secrets

Flake evaluation is pure by default: no ambient builtins.getEnv, no reading arbitrary host paths, no undeclared network. That is good for reproducibility and bad for “sneak the secret in at eval time.”

  • Do not rely on --impure plus getEnv or host files to inject secrets into a flake build. That fights purity, breaks CI hermeticity, and still risks landing material in the store.
  • Do keep encrypted secrets (or non-secret hashes/paths) as declared inputs; decrypt with host-held keys during NixOS/Home Manager activation, or pass credentials via systemd / runtime files after the store closure is built.
  • Pure eval and secret delivery are complementary: purity stops undeclared host state from shaping the build; deployment-time decrypt keeps plaintext out of that build.

Trust boundaries

Secrets management intersects other trust controls: who may ask the daemon to build (trusted users), and whether dependencies themselves are trustworthy (supply chain). A correct decrypt-at-activation setup still fails if an untrusted input can rewrite units or steal the age/SOPS identity on the host.

Home Manager and user config

Home Manager often symlinks config into the store. Do not put API tokens or private keys in home.file / xdg.configFile sources that evaluate into store paths. Use the same out-of-store or decrypt-at-activation patterns; see dotfiles patterns.

Examples

Avoid — plaintext (or readFile of plaintext) in evaluated config:

# Do not do this — string lands in the world-readable store
services.myapp.apiToken = "sk-live-…";

# Also do not — copies file bytes into the store at eval time
services.myapp.apiToken = builtins.readFile ./secret-token.txt;

Prefer — encrypted repo secret, path after activation (conceptual; real options from agenix/sops-nix):

# Ciphertext in Git; decrypt during activation; service reads the resulting path
# agenix: config.age.secrets.<name>.path  → typically /run/agenix/<name>
# sops-nix: config.sops.secrets.<name>.path → typically /run/secrets/<name>
services.myapp = {
  enable = true;
  credentialsFile = config.sops.secrets.myapp-token.path; # illustrative option name
};

Never builtins.readFile a decrypted activation path back into an evaluated string—that reintroduces plaintext into the store.

Prefer — systemd credential injection (unit-level; secret file provided on the host or by a decrypt step):

systemd.services.myapp.serviceConfig = {
  LoadCredential = "token:/run/agenix/myapp-token"; # path after decrypt
  # Unit reads $CREDENTIALS_DIRECTORY/token (or %d/token), not a store path
};

Exact LoadCredential= / agenix / sops-nix option names come from upstream docs and the NixOS module system—do not invent APIs. For NixOS account and *File patterns, see secrets strategies.

References

See also