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 HEADb2831d33132967b4c4fb69e6068b2b05fff8fdffchanges onlyflake.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.yamlcreation 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 |