Skip to content

Secrets Management

Deploy sensitive files and directories to your machines with explicit ownership and permissions, accumulated down the fleet hierarchy.

The secrets phase transfers plain files and directories via rsync, or streams the stdout of a command through a private staging file beside the destination and renames it into place. Every field (source, destination, ownership, permissions) is configured per secret entry.

Secrets defined at the fleet, flake, and installable levels append down to machines. They are never replaced, only accumulated:

fleet:
secrets: # Inherited by all machines
- local_path: ./secrets/common.key
remote_path: /var/secrets/common.key
permissions: "0600" # File permissions (default: "0700")
flakes:
my-flake:
secrets: # APPENDED to inherited secrets
- local_path: ./secrets/api.key
remote_path: /var/secrets/api.key
installables:
nixosConfigurations:
webserver:
machines:
web-01:
secrets: # Further appended secrets
- local_path: ./secrets/web-01/cert.pem
remote_path: /etc/ssl/cert.pem
uid: 1000 # User ID (when unset, ownership is whatever the receiving rsync runs as)
gid: 1000 # Group ID (when unset, ownership is whatever the receiving rsync runs as)
permissions: "0644"
FieldDescription
local_pathLocal file or directory to transfer. Point at a directory to transfer it whole. With command, it is only validated and exported as PANIX_SECRET_LOCAL_PATH, not transferred.
commandCommand whose stdout is the secret. Runs on the control host, from the directory panix was started in.
remote_pathAbsolute destination path on the target.
uid / gidRemote file ownership (must be set together). When unset, ownership is whatever the receiving rsync runs as (the SSH user when it is root, otherwise root via sudo).
permissionsFour-digit octal file permissions (e.g. "0600", "0644"). Default: "0700".

At least one of local_path or command is required, and both can be combined to declare the path once: local_path is exported to the command as PANIX_SECRET_LOCAL_PATH and is not transferred by rsync.

A command source runs the command on the control host with sh -u -c, from the directory panix was started in. Its stdout is the secret; its stderr is shown when the command fails.

Combine it with local_path to keep the path in one place:

secrets:
- local_path: ./secrets/age.key
command: age --decrypt -i "$PANIX_SECRET_LOCAL_PATH" ./secrets/backup.age
remote_path: /var/secrets/backup.tar
permissions: "0600"

Nothing is written on the control host. On the target, stdout is staged in a random temporary file beside the destination (.panix-secret.<name>.<random>.tmp), and the staged file is renamed into place only after the command exits 0. The destination directory is created if needed, and ownership and mode are applied to the staged file before commit. A symlink or directory destination is rejected, and the staging path is created exclusively, so a planted entry is never followed.

A command that fails leaves the previous file untouched: staged data is removed and never renamed into place. An interrupted write can leave only the staging file, which is cleaned up on failure; a partial file never appears at the final path.

Matching content is not rewritten only when the existing file already has the configured ownership and mode: panix hashes the command output and the target file, and skips the write when both the content and metadata match, keeping the file’s inode and mtime. If the content matches but the metadata differs, panix rewrites the file through the same staging and rename so the configured ownership and mode are enforced. The command itself runs on every deploy, since its output cannot be known otherwise.

Output up to 4 MiB is buffered for the comparison; anything larger is streamed into the staging file and still committed only after the command succeeds. Commands with non-deterministic output always rewrite. The comparison needs sha256sum on the target; without it, panix simply rewrites every time.

sh -u makes unset variable references fail loudly: a command using $PANIX_SECRET_LOCAL_PATH without a local_path fails instead of writing an empty secret.

Hardcoding where a decryption key lives defeats the purpose of keeping the key itself out of the config. Panix never needs to know the location; the command just has to find it. How you arrange that depends on the tool.

Tools that read their own environment, such as sops with SOPS_AGE_KEY_FILE, only need that variable exported before panix runs:

Terminal window
SOPS_AGE_KEY_FILE=~/.config/sops/age/keys.txt panix deploy

The command itself stays plain, and the config never mentions where the key lives.

Tools that take the identity as an argument need it inside the command. The env template function pulls it in at render time:

secrets:
- local_path: ./secrets/api.age
command: 'age --decrypt -i "{{ env "AGE_IDENTITY_FILE" }}" "$PANIX_SECRET_LOCAL_PATH"'
remote_path: /run/secrets/api
Terminal window
AGE_IDENTITY_FILE=~/.config/age/keys.txt panix deploy

Because templating runs before YAML parsing, the substituted text must be a bare path: no whitespace, no quotes of its own. Single-quote the YAML scalar around the command and double-quote the interpolation inside it. An unset variable renders empty, and the command fails rather than writing a secret from a broken invocation.

Never paste private key material into the config: point at a key file, or use SOPS_AGE_KEY, which sops reads itself.

SOPS decrypts with the age identity named by SOPS_AGE_KEY_FILE; export it in the environment that runs panix:

secrets:
- command: sops --decrypt ./secrets/prod.enc.yaml
remote_path: /var/secrets/prod.env
permissions: "0600"

Extract a single value from a SOPS file. The sops path selector contains double quotes, so the whole command is a YAML double-quoted scalar (single quotes inside it are literal):

secrets:
- command: "sops --decrypt --extract '[\"database_password\"]' ./secrets/prod.enc.yaml"
remote_path: /var/secrets/database-password
permissions: "0600"

age with an identity file passed through PANIX_SECRET_LOCAL_PATH:

secrets:
- local_path: ./secrets/age.key
command: age --decrypt -i "$PANIX_SECRET_LOCAL_PATH" ./secrets/backup.age
remote_path: /var/secrets/backup.tar
permissions: "0600"

pass and op read their own credentials (GPG agent, 1Password session), so no local_path is needed:

secrets:
- command: pass show servers/web-01/tls.key
remote_path: /etc/ssl/private/web-01.key
permissions: "0600"
- command: op read op://infra/web-01/tls-key
remote_path: /etc/ssl/private/web-02.key
permissions: "0600"

Regular secrets are transferred during the Secrets phase (after Transfer, before Activate).

To update secrets without a full deploy, run panix secrets: it executes only the Inspect and Secrets phases (no build, transfer, or activation).

Disk encryption keys are transferred during Bootstrap, before disko runs, so they are available for disk encryption setup. See the Bootstrap guide for where they fit in the flow.

File secrets are transferred directly to the target machine via rsync; command secrets stream stdout through a pipe into a private staging file beside the destination, then rename it into place. Neither path goes through the Nix store, so you can safely deploy unencrypted secrets without them being stored in /nix/store (world-readable by default).

Command output is staged in a private temporary file beside the destination and never logged: only the opaque destination appears in the log, and staged bytes are removed on failure or committed atomically on success.

Secrets reach the correct path whether targeting a running NixOS system or a bootstrapped machine: during bootstrap the target root is mounted at /mnt, so Panix automatically prefixes paths (e.g. /var/secrets/key becomes /mnt/var/secrets/key).