1 48 Secrets Implementation Conventions
Nimmo edited this page 2026-09-13 12:54:02 +01:00

Secrets Implementation Conventions

  • Type: Reference
  • Status: Current
  • Scope: Adding secret consumers to NixOS modules and OCI services in nixos-config
  • Canonical sources: modules/common/sops-host.nix; modules/common/base.nix; .sops.yaml; secrets/README.md; modules/services/backrest.nix; modules/services/bookstack.nix; modules/services/navidrome-mcp.nix; modules/services/redview.nix; modules/containers/synthseek.nix; ADR-006; Secret Editing and Recipient Rotation
  • Last verified: Source commit 556df88494686003b1c4f20c8e0b99b5afc16a6e (2026-09-12); source HEAD b2831d33132967b4c4fb69e6068b2b05fff8fdff changes only flake.lock. Host-key, recipient-rule, and runtime-delivery examples checked 2026-09-13; no secret was decrypted or changed.
  • Review triggers: SOPS module, host identity, .sops.yaml creation rules, new consumers/files, runtime credential patterns, secret helper repairs, or secret recovery policy

Purpose and boundaries

Implement a new secret consumer without leaking plaintext into Git, the Nix store, or a broader group of hosts than required. This is the engineering contract. ADR-006 explains the scoped-file decision; Secret Editing and Recipient Rotation owns actual edits, recipient/key rotation, and decryption recovery. The Security Baseline owns the fleet review gates.

flake.nix imports sops-nix for registered hosts, and modules/common/base.nix imports modules/common/sops-host.nix. The latter uses /etc/ssh/ssh_host_ed25519_key as the age identity and enforces its root-only mode. Encrypted secrets/*.yaml files may be committed; their plaintext is materialised by sops-nix at activation under /run/secrets or as a runtime SOPS template. A Git-tracked encrypted file is still sensitive metadata: filenames, nested key names, and recipient lists reveal structure.

Choose the file and recipient scope first

SOPS grants access per file, not per nested YAML key. Place values with different consumers or privilege levels in different encrypted files. For example, secrets/trilium-server.yaml contains Vega-only OIDC configuration, while secrets/trilium.yaml has credentials consumed by AI clients. Do not reuse a broad client file merely to save a creation rule.

For a new file, add a narrow, anchored path_regex to .sops.yaml before the fallback secrets/.*\.yaml$ rule. Include only actual host recipients and deliberate recovery identities. Check rule order and the metadata in the encrypted file; a comment is not the enforcement boundary. Existing rules have exceptions: the fallback is broad, and the nebula-ca.yaml rule includes Vega/Lyra despite a comment claiming otherwise. Neither is a template for a new file. Public age keys in .sops.yaml are not private decryption material; never commit host SSH private keys or recovery keys.

Declare the consumer in its owning module

Give every runtime value an explicit sops.secrets declaration with the encrypted sopsFile and nested key. The secret name is the Nix/runtime identifier; the key selects the YAML field. Use a stable, service-specific name and avoid coupling a consuming service to a guessed default file.

sops.secrets."example-api-token" = {
  sopsFile = ../../secrets/example.yaml;
  key = "example/api-token";
};

This is a shape, not an existing file or value. Stage a newly created encrypted YAML and any new .nix file before nix flake check: Git-backed flakes do not see untracked paths. Some modules use builtins.pathExists to gate optional secret declarations, so an unstaged file can make an evaluation appear to succeed while silently omitting the consumer. Review whether a secret is genuinely optional; a production service that requires it should fail clearly when it is absent.

Choose runtime delivery according to the application's interface:

Application interface Repository pattern Example
Accepts a filename Point its file-path option at config.sops.secrets.<name>.path; set owner/group/mode for the reader modules/services/bookstack.nix uses APP_KEY_FILE, DB_PASSWORD_FILE, and OIDC file options
Runs under systemd and can consume credentials Use LoadCredential = [ "name:${config.sops.secrets.<name>.path}" ]; and read the resulting private credential at runtime modules/services/backrest.nix, modules/services/navidrome-mcp.nix
Needs a generated config or dotenv file Use sops.templates with config.sops.placeholder.<name> and pass the rendered runtime path to the service/container modules/services/redview.nix, modules/containers/synthseek.nix
Accepts an environment file containing existing secret material Pass the SOPS secret path as an environment file and review process exposure/ownership modules/services/redview.nix

Do not interpolate decrypted values directly into Nix strings, package derivations, environment.etc.*.text, command arguments, or logs. Nix may write such strings into the world-readable store; process arguments and environment may be visible to unintended readers. A path to a protected runtime file is different from its contents. For SOPS templates, use config.sops.placeholder rather than putting plaintext in the Nix source. Check service ordering, permissions, and the runtime user's actual ability to read the selected file. Giving a unit a path it cannot read is not a working secret integration.

Validate and hand off

Before review, confirm the new file matches the intended creation rule, recipient set, and nested key names without printing decrypted values. Run just check and build the affected host closure. A successful build proves only evaluation/construction, not that the target host has the matching SSH identity or can decrypt on activation. Use an authorised host and recovery identity to test decryption privately, then just test on an affected NixOS host under the usual hold/canary rules. Verify the actual service auth path and inspect only non-secret error metadata; never paste tokens or decrypted YAML into the issue, wiki, agent memory, or logs.

If activation says a key is missing, distinguish a wrong key path, wrong SOPS rule/recipients, unstaged encrypted file, absent host identity, and file permissions. If the app fails despite successful decryption, inspect the delivery interface and service's expected file format. Do not broaden all recipients to make one host work. Revert the source integration before deployment if needed; after a live credential or host-key rotation, use the staged recovery procedure in Secret Editing and Recipient Rotation, not a Git revert alone.

The current just add-secret, just add-secret-remote, and just remove-secret-key helpers attempt bulk rule edits/re-encryption and do not replace per-file scope review. Issue #224 tracks their repair. secrets/README.md still mentions a nonexistent just migrate-host-key recipe; use the current rotation runbook instead.

Canonical source map

Concern Source
Host decryption identity and common import modules/common/sops-host.nix, modules/common/base.nix, flake.nix
Per-file recipients and rule ordering .sops.yaml, ADR-006
Secret file paths and systemd credentials modules/services/bookstack.nix, modules/services/backrest.nix, modules/services/navidrome-mcp.nix
Runtime template and environment-file patterns modules/services/redview.nix, modules/containers/synthseek.nix
Actual editing, rotation, and recovery Secret Editing and Recipient Rotation, justfiles/secrets.just