OFFSET偏移
← All articlesOFFSET / FIELD NOTESNO. 099

sops-nix integration: runtime, ordering, and SSH host keys

Connect encrypted source files, runtime secret files, and service dependencies.

English translation · Code and commands follow the Chinese original.

IN THESE READING PATHSSOPS encrypted configuration and key lifecycles ↗
About 6 min readStart reading

Three Roles: How Secrets Become Process Environment Variables#

sops-install-secrets.service        example-app.service
        │                                │
        │ ① 解密写入                      │ ② 读取环境变量
        ▼                                ▼
   /run/secrets/app_env   ──▶   example-app 进程 + 它的 envsubst

① sops writes a file: after sops-install-secrets decrypts secrets.yaml, it writes the contents of app_env to /run/secrets/app_env. The usual settings are root:root and 0400; the actual values come from the secret's owner/group/mode. On Linux, sops-nix places secrets in ramfs at /run/secrets.d by default and exposes them through the /run/secrets link. useTmpfs changes the storage mechanism. These are runtime artifacts, regenerated after a reboot.

② systemd injects environment variables (the crucial step):

serviceConfig.EnvironmentFile = "/run/secrets/app_env";

EnvironmentFile is a systemd feature, not an example-app feature: before starting the service, systemd reads the file and injects KEY=value into the process environment. This happens before the process is created; example-app itself knows nothing about sops.

③ The process consumes them: example-app's ExecStartPre uses envsubst to render config.yaml from environment variables (${APP_SECRET} and ${APP_ENDPOINT}), then ExecStart reads the rendered configuration to start the application.

Decrypted Output: One File per Key#

After decryption, sops-nix creates a separate file for each secret; it does not combine them. In the source, the default path is /run/secrets/<secret名> (with neededForUsers, it is /run/secrets-for-users/). Each secret declares its own output path and permissions.

Define three secrets and you get three files:

sops.secrets = {
  app_env = { };                    # → /run/secrets/app_env
  another = { sopsFile = ./secrets/another.env; format = "dotenv"; key = "TOKEN"; }; # → /run/secrets/another
  blob = { sopsFile = ./secrets/blob.enc; format = "binary"; }; # → /run/secrets/blob
};

format can be set per secret (yaml/json/dotenv/ini/binary), but it specifies how to parse the source sopsFile, not the format of an individual value. Secrets from the same YAML source should be read as YAML. For a dotenv or binary source, point to a separate encrypted file in that format. The default format inherits defaultSopsFormat; it is not automatically inferred from the file extension.

Storage Structure vs. Runtime Organization When encrypting, all keys go into one secrets.yaml file (the storage layer). When decrypting, they are split by secret name into multiple separate files (the runtime layer). format selects how the encrypted source is parsed, and key selects the value to export. A YAML string value can itself contain a multiline environment file or configuration text. key = "" exports the entire decrypted file; binary ignores key.

useSystemdActivation: Two Deployment Paths for Regular Secrets#

sops-nix can deploy regular secrets through a NixOS activation script or a systemd unit:

activation script useSystemdActivation = true
Entry Point setupSecrets during NixOS activation sops-install-secrets.service
Execution Conditions Affected by boot and switch activation, user creation, and mount availability Ordering is controlled through unit dependencies
Typical Choice The default depends on the user-management mechanism and module version Useful when mount dependencies are required or when using sysusers/userborn
What to Check Whether keys, users, and directories are available at activation time After, RequiresMountsFor, and dependencies of consuming services

Not all NixOS activation scripts are initrd scripts. Check the boot and activation paths against the configuration version and logs; a missing secret file alone does not identify the stage at which something failed. The current upstream module orders the systemd path against targets such as local-fs.target and sets RequiresMountsFor based on key paths. For an actual deployment, use the code at the project's locked revision and the generated unit as the authority. neededForUsers uses an earlier path, before user creation, and must be checked separately; ordinary service waiting mechanisms cannot fix that path.

Mount Ordering: Why Timing Matters#

During decryption, the process running that stage must be able to read the key. Mount availability depends on the boot configuration; it cannot be inferred solely from a directory name or Btrfs subvolume name:

Example Path Possible Contents Conditions to Check
/、/etc/ssh/ Root filesystem and host key Whether root is persistent and the host key has already been generated or restored
/nix Nix store Whether the mounts needed for boot are already available
/home User identity and configuration Whether the user partition is mounted and the service has access
/persist Persistent keys and machine state Mount ordering, restoration bindings, and dependencies of the decryption stage

If the key is on a separate mount point, first check the generated unit and its mount dependencies. Secrets needed before user creation require the restored key to be available at that early stage. Ordinary service secrets can use appropriate systemd mount dependencies. Choose the deployment path first, then decide where to store the key.

Example Failure Chain: Key Path Unavailable#

部署 secrets → 尝试读取 /persist/secrets/age-key.txt
  → 文件缺失、挂载未就绪或权限不允许
  → 解密失败,/run/secrets/app_env 未生成
  → 消费服务的 EnvironmentFile 加载失败

Representative error format (illustrative, not a log from an actual run here):

cannot read keyfile '/persist/secrets/age-key.txt': no such file or directory
Activation script snippet 'setupSecrets' failed

Check existence, permissions, and timing first, and avoid printing private keys or decrypted contents. With systemd-based decryption, the consuming service should refer to the actual sops-install-secrets.service and declare appropriate After ordering and dependencies. After only specifies order; it does not start a unit. Requires can express that the consuming service must not start if the decryption service fails.

Why sops-nix Can Reuse an SSH Host Key#

When OpenSSH or host-key generation is enabled, the current upstream module imports Ed25519 keys (for age) or RSA keys (for GnuPG) from services.openssh.hostKeys by default, while excluding keys in /run/secrets to avoid circular dependencies. If the relevant functionality is not enabled, the default list can be empty. Not every NixOS machine has a host key.

SSH host key User id_ed25519
Common Location /etc/ssh/, which can also be changed through system configuration The user's ~/.ssh/
Identity Usually a machine identity managed by root User identity
Decryption Prerequisites Generated or restored, with the file and mount available and readable The user key exists, with suitable permissions and passphrase handling

Reasons to reuse a host key include aligning machine services with the machine identity's lifecycle, reducing the number of independent keys, and readability with root privileges. These are operational choices, not proof that a host key is mandatory or that it will automatically survive a reinstall. Reuse increases the impact of a key leak; a dedicated age key separates SSH identity from the ability to decrypt secrets.

Configuration Example: Move from a Dedicated age Key to a Host Key#

sops = {
  age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ];
  useSystemdActivation = true;
};

The standard host-key paths here are generic examples and contain no actual keys. First add the new recipient to the ciphertext and verify independent decryption, then change the deployment source. Keep the old source until the new path is fully verified. SSH host keys also need a backup and recovery plan; reusing one does not remove the need for migration.

Keys can be stored in a persistent directory suited to the system design, provided it is readable during the relevant decryption stage and dependencies are correct. Do not categorically rule out /persist or /home. Likewise, do not assume /etc is available early simply because it is under the root path.

Related#


Service environment variables are a convenient interface, but they are not appropriate for every secret: anyone able to inspect the process environment or the trusted service's execution context may be able to read them. If the application supports credential files, prefer passing a path. Plaintext must not be exposed through Nix evaluation, the store, or build logs.

References & further reading

  1. Official SOPS Documentation ↗
  2. SOPS CLI Source ↗
  3. SOPS Configuration Source ↗
  4. Official sops-nix Documentation ↗
  5. sops-nix Module ↗
  6. sops-nix Templates Module ↗
  7. Official ssh-to-age Documentation ↗

Adapted from technical notes. Examples use generic names.

Continue along a reading path

SOPS encrypted configuration and key lifecycles

  1. 01sops principles: hybrid encryption and recipients
  2. 02sops-nix integration: runtime, ordering, and SSH host keys
  3. 03sops usage guide: command-line operations
  4. 04sops configuration templates and runtime rendering
  5. 05sops key migration and rotation
CONTINUE READING / NEXT ARTICLEsops usage guide: command-line operations

Edit encrypted files, inspect their status, and manage recipients and data keys.