Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

secret-manager

Declarative secret management for NixOS — resolve and render age/agenix secrets from shared Nix declarations.

secret-manager is the engine that generates agenix secret modules, manages .age files in a Nix data repository (a “store”), and pushes decrypted secrets to Codeberg/Forgejo, Codefloe, and GitHub Actions secret stores.

It was extracted from the canix CLI; the canix secret command family is a thin shim over this library.

Repository

Installation

From source (Nix)

nix build git+https://github.com/caniko/secret-manager

Or add it as a flake input:

inputs.secret-manager.url = "git+https://github.com/caniko/secret-manager";

From source (Rust)

Requires Rust 1.88 or newer.

git clone https://github.com/caniko/secret-manager.git
cd secret-manager
cargo build --release

The registry package is caniko-secret-manager; installing it provides the same secret-manager executable:

cargo install caniko-secret-manager --version 0.4.0 --locked

Prerequisites

  • agenix with agenix edit, agenix generate, and agenix rekey
  • age identity files at age/master-*.pub in the store repository
  • (for push or sync) fj or gh authentication for Codeberg, Codefloe, or GitHub API access

Quick Start

All commands operate on a Nix store repository — the directory that owns age/secrets/** and the master identity stubs (age/master-*.pub).

List existing secrets

cd /path/to/store
secret-manager list

Add a home-manager passphrase secret

secret-manager hm password --name app-token --env APP_TOKEN --user can

This generates a 32-byte passphrase, creates age/secrets/users/can/app-token.age, writes the Nix module at home/user/can/repositories/app-token.nix, and splices it into the default.nix imports.

Decrypt a secret to stdout

secret-manager decrypt age/secrets/users/can/app-token.age

Push a secret to Codeberg Actions

secret-manager push age/secrets/users/can/app-token.age \
  --name APP_TOKEN \
  --codeberg caniko/my-repo

Adding a Home-Manager Secret

The hm subcommand manages secrets for home-manager profiles. Every secret targets either a specific user or the shared profile (--shared).

Secret types

SubcommandSourceDelivery
sshGenerated Ed25519--file path
passwordGenerated passphrase--env vars or --file
textPlaintext from stdin/file--env vars or bare
filePlaintext or existing .age--file path

SSH key (file delivery)

secret-manager hm ssh \
  --name deploy-key \
  --user can \
  --file ~/.ssh/deploy-key \
  --algorithm ed25519

Passphrase (env vars)

secret-manager hm password \
  --name app-token \
  --user can \
  --env APP_TOKEN \
  --length 48

Text from stdin

echo "my-secret-value" | secret-manager hm text \
  --env MY_SECRET \
  --user can

Bare source (no env wiring)

Use --bare to create only the encrypted source, for consumers that read the .age file directly (for example a services.secretSync Kubernetes target) instead of a home-profile env var. --name is required:

echo "my-secret-value" | secret-manager hm text \
  --bare \
  --name app-token \
  --user can

The source is written to age/secrets/users/<user>/<name>.age. Re-running the same command refuses to overwrite an existing source; use agenix edit to replace the value.

Shared secrets

Use --shared to store under age/secrets/users/shared/ and optionally register as shared home-manager env vars:

secret-manager hm text \
  --shared \
  --env DEEPSEEK_API_KEY

Pass --name explicitly when no env var can provide a default slug.

Adding a NixOS Host Secret

The nixos subcommand manages secrets for NixOS hosts. Each secret targets a specific host via --host.

Secret types

SubcommandSourceDelivery
sshGenerated Ed25519--file path
passwordGenerated passphrase--env + --service or --file
textPlaintext from stdin/file--env + --service
filePlaintext or existing .age--file path

SSH key

secret-manager nixos ssh \
  --name service-key \
  --host thething \
  --file /run/secrets/service-key

Passphrase (systemd env file)

secret-manager nixos password \
  --name vikunja-mailer \
  --host thething \
  --env VIKUNJA_MAILER_PASSWORD \
  --service vikunja \
  --length 48

This generates a passphrase and creates a systemd oneshot unit that writes an environment file consumed by the vikunja.service unit.

Text from stdin

secret-manager nixos text \
  --host thething \
  --env API_TOKEN \
  --service demo

File from plaintext or existing .age

# Encrypt plaintext from stdin
secret-manager nixos file \
  --name kubeconfig \
  --host thething \
  --file /etc/kubeconfig

# Adopt an existing encrypted .age file
secret-manager nixos file \
  --name kubeconfig \
  --host thething \
  --file /etc/kubeconfig \
  --from-age ./existing-kubeconfig.age

Adding a Forgejo Secret

The forgejo subcommand manages secrets for Forgejo Actions runners and Codeberg/Forgejo CI credential stores.

Secret types

SubcommandSourceDelivery
sshGenerated Ed25519Runner credential or source only
gpg-key-pairGenerated or adopted OpenPGP private keySource only plus public metadata
passwordGenerated passphraseRunner credential
textPlaintext from stdin/fileRunner credential
filePlaintext or existing .ageRunner credential

SSH deploy key

Generate an Ed25519 key for a Forgejo Actions runner, optionally inject the public key as a runner credential:

# Source only (no runner credential)
secret-manager forgejo ssh \
  --name aur-ssh-key \
  --cred AUR_SSH_KEY

# With runner credential injection on instances
secret-manager forgejo ssh \
  --name aur-ssh-key \
  --cred AUR_SSH_KEY \
  --instance codeberg --instance nixTrusted

The public key is printed to stdout and embedded as a Nix comment in the generated module.

GPG key pair

Store only the armored OpenPGP private key as an agenix secret. The command derives plaintext .asc and .fingerprint files beside the encrypted source.

secret-manager forgejo gpg-key-pair \
  --name modde-apt-repo-gpg-key \
  --cred modde_apt_repo_gpg_key \
  --from-age age/secrets/modules/repos/apt/modde-apt-repo-gpg-key.age

New keys are generated when neither --from-age nor --from-file is passed. Use --rotate to replace an existing private-key source.

A Nix mkSecret { generator = "gpg-key-pair"; userId = "Name <email>"; … } passes userId to the generator as age.secrets.<name>.settings.userId. Forges mark a signature verified only when the key UID carries the committer’s verified email, so set it for signing identities.

Passphrase credential

secret-manager forgejo password \
  --name copr-token \
  --cred copr-token \
  --instance codeberg \
  --length 64

Text credential

echo "my-token" | secret-manager forgejo text \
  --cred COPR_TOKEN \
  --instance codeberg

The slug defaults from the --cred value (e.g. COPR_TOKEN becomes copr).

File credential

secret-manager forgejo file \
  --name copr-token \
  --cred COPR_TOKEN \
  --instance codeberg \
  --from-file ./token.txt

Key rotation

Pass --rotate to force regeneration of an existing SSH key:

secret-manager forgejo ssh \
  --name aur-ssh-key \
  --cred AUR_SSH_KEY \
  --rotate

Importing from Bitwarden

The hm file, nixos file, and forgejo file commands accept --from-rbw with an exact Bitwarden item UUID. Unlocking belongs to rbw and pinentry; the bridge never asks for or stores the vault password. Install rbw 1.15 or later and authenticate it before importing.

secret-manager hm file --shared --name service-token \
  --from-rbw 12345678-1234-1234-1234-123456789abc --rbw-field password

The default field is password. Built-ins include username, password, totp, and notes. Select custom fields with custom:NAME; ambiguous or empty fields fail. totp returns the stored seed or URI, not a current one-time code.

Build a JSON document from one item snapshot with repeated mappings:

secret-manager hm file --shared --name application-account \
  --from-rbw 12345678-1234-1234-1234-123456789abc \
  --rbw-map username=username --rbw-map password=password \
  --rbw-map totpSecret=totp

Only mapped fields enter the document. JSON escaping is automatic. All mapped fields are required; omit an optional mapping when it is absent. Application adapters own validation of their document and TOTP parameters before encryption. This generic projection preserves field values exactly.

By default rbw uses its encrypted local cache. --rbw-sync explicitly syncs first; a failed sync prevents importing stale data. --rbw-timeout-seconds sets a 1–300 second deadline per invocation, including pinentry interaction (default 120 seconds). Helper output is capped at 1 MiB and a selected document at 512 KiB. Child diagnostics are suppressed because they may contain secrets.

Encryption policy

Consumers export an agenix-stream-encryptor package using secret-manager.lib.mkAgenixStreamEncryptor:

packages.agenix-stream-encryptor = secret-manager.lib.mkAgenixStreamEncryptor {
  inherit pkgs;
  agenixRekey = inputs.agenix-rekey;
  userFlake = self;
  nixosConfigurations = self.nixosConfigurations;
};

This adapter uses agenix-rekey’s native master-encryption backend, including master identities, extra encryption recipients and configured age plugins. Select the same nodes and agePackage as the existing rekey workflow. Unsupported backend contracts fail during evaluation.

Set SECRET_MANAGER_AGE_ENCRYPTOR to its absolute executable path for the standalone CLI. Embedders can instead implement StoreEnv::stream_encryptor; resolve and realize public encryption policy before reading the vault, and release any Nix evaluation lease before subprocess work.

The bridge uses private stdout/stdin pipes and zeroizing owned credential buffers, disables core dumps, and writes only ciphertext to a mode-0600 temporary file beside the destination. Installation is atomic and refuses an existing source by default. Vault contents never enter command arguments, Nix expressions, or a plaintext temporary file. Scoped staging and agenix rekey follow encryption; --no-stage and --no-rekey retain their existing meaning. Inherited agenix auto-staging is disabled; only newly distributed copies are staged, and existing work in the distribution directory is preserved.

Standalone RBW imports reexecute the original command in a no-swap user scope when needed, then verify the effective kernel limit and acquire a logind sleep inhibitor before vault lookup. The guard remains held through field projection, encryption, installation and rekey; unavailable protection fails before vault effects. Public encryption-policy preparation precedes this boundary.

Rotation and recovery

Repeat the same import with --rbw-rotate to replace an existing source. Rotation requires an existing binary age v1 file and preserves module wiring. It cannot add a new home/system/runner target. RBW imports, generated rotations and adapters using with_store_write_lock share the existing secret-manager kernel lock.

Missing fields, lookup failure and encryption failure leave existing ciphertext intact. Source changes observed before vault lookup, after acquiring the lock, or before installation abort replacement. Direct editors and raw agenix calls do not automatically acquire this lock; concurrent writes through those paths are outside the cooperative preservation guarantee. Installation is an atomic rename, not filesystem compare-and-swap. A failed rekey retains the newly encrypted source: retry staging and agenix rekey -a before deploying. Never restore the old source after distributing a new one to some consumers.

Library adapters must establish no-swap protection and acquire protection::CredentialProtection before RbwSource::read(), retain the guard until plaintext and credential helpers have been dropped, select RbwItem::field(), validate their own document in memory, then call StreamEncryptor::encrypt_new() or encrypt_replace(). Use encryption::with_store_write_lock(root, operation) to serialize installation, staging and rekey with the generic import/rotation path. Constrain destination paths to the intended store.

Pushing Secrets to CI

The push command decrypts an agenix-encrypted .age file and pushes the plaintext to Codeberg/Forgejo, Codefloe, and GitHub Actions secret stores. The plaintext never touches disk — it lives only in process memory.

secret-manager push age/secrets/users/can/app-token.age \
  --name APP_TOKEN \
  --codeberg caniko/my-repo \
  --codefloe caniko/my-repo \
  --github my-org/other-repo

Decrypt-only

To print a decrypted secret to stdout (for piping into other tools):

secret-manager decrypt age/secrets/users/can/app-token.age

Diagnostics and hardware-key prompts stay on stderr.

Authentication

  • Codeberg: the fj auth store at ${XDG_DATA_HOME:-$HOME/.local/share}/forgejo-cli/keys.json (set up with fj auth login --host codeberg.org or fj auth add-key <user>).
  • Codefloe: the same fj auth store (set up with fj -H codefloe.com auth login or fj -H codefloe.com auth add-key <user>).
  • GitHub: requires gh authenticated.

Identity resolution

Identities are resolved in precedence order:

  1. Explicit --identity flags (repeatable).
  2. The SECRET_MANAGER_AGE_IDENTITIES environment variable — a colon-separated list of identity paths, mirroring the DNS flow’s CANIX_DNS_AGE_IDENTITIES contract.
  3. Master identity stubs discovered from age/master-*.pub / age/master_*.pub in the store root.

Relative paths resolve against the store root.

secret-manager decrypt secret.age --identity age/my-key.pub
# or, once per shell:
export SECRET_MANAGER_AGE_IDENTITIES=age/master_nitro3c_identity.pub:age/master_nitro3_2_identity.pub
secret-manager sync

WireGuard Key Generation

Generate a WireGuard keypair, store the private key as an agenix secret, and print the public key to stdout:

secret-manager wg-keygen age/secrets/hosts/myhost/wireguard-private.age

Requires wg (WireGuard tools) on PATH.

Legacy workflows

Rauthy env-file bootstrap

The rauthy-env command bootstraps a complete Rauthy OIDC provider environment file:

secret-manager rauthy-env age/secrets/hosts/thething/rauthy-env.age

This generates:

  • Encryption keys (via rauthy generate-enc-key)
  • Cluster secrets (via rauthy generate-secrets)
  • An Argon2id password hash (interactive prompt)
  • A bootstrap API key secret

Gerrit cookies

Encrypt Gerrit .gitcookies into an agenix secret:

secret-manager gerrit-cookies ~/.gitcookies age/secrets/gerrit-cookies.age

Cookie lines are normalized (commas → tabs) before encryption.

The Store

A “store” is the Nix data repository that owns the agenix secret tree. secret-manager operates on a store; it does not host secrets itself.

Store layout

<store-root>/
  flake.nix
  age/
    master-identity.pub       # Master identity stubs for decryption
    master-yubikey.pub
    secrets/
      users/
        can/
          app-token.age       # Per-user encrypted secrets
        shared/
          deepseek.age        # Shared profile secrets
      hosts/
        thething/
          vikunja-mailer.age  # Per-host encrypted secrets
      modules/
        forgejo-runner/
          copr-token.age      # Module-scoped secrets
    rekeyed/                  # Rekeyed per-host copies (managed by agenix)

Discovery

The store root is discovered at runtime:

  1. $SECRET_MANAGER_STORE environment variable, if set
  2. Walking up from the current directory to the nearest flake.nix

Master identities

age/master-*.pub files are the trust anchors for decryption. They are typically hardware-key (age plugin) identity stubs — rage prompts on the controlling terminal when decryption is needed.

Target Stacks

Every secret targets a stack that determines where the Nix module is generated and how the secret is delivered to consumers.

Home stack

Targets home-manager profiles — per-user or shared. Secrets can be delivered as environment variables or files on the home filesystem.

FieldPurpose
userHome-manager user profile
envShell environment variable names
fileFile path within the home profile

Generated module path: home/user/<user>/repositories/<slug>.nix
Secret path: age/secrets/users/<user>/<slug>.age

System stack

Targets NixOS hosts. Secrets can be delivered as systemd environment files or files on the host filesystem.

FieldPurpose
hostNixOS host name
envEnvironment variable names
servicesSystemd services receiving the env file
fileFile path on the host

Generated module path: root/hosts/<host>/server/<slug>.nix
Secret path: age/secrets/hosts/<host>/<slug>.age

Forgejo stack

Targets Forgejo Actions runner credentials. Secrets can be delivered as runner credentials or source-only (e.g., SSH keys without credential binding).

FieldPurpose
credentialCredential name in the runner store
instancesRunner instances receiving the credential

Generated module path: root/modules/server/forgejo-runner-secrets/<slug>.nix
Secret path: age/secrets/modules/forgejo-runner/<slug>.age

Source-only secrets

When a secret has no delivery target (e.g., a shared SSH key stored for manual use), the engine still generates the .age file and optionally its Nix module, but does not register delivery targets.

Nix Declarations

The engine generates Nix modules that call mkSecret to declare each secret. These modules are consumed by the NixOS/home-manager configuration that calls agenix.

mkSecret

The core function that declares a single agenix secret:

canixLib.mkSecret {
  name = "can-app-token";
  stack = "home";
  source = secrets.user "can" "app-token";
  targets.home.env = ["APP_TOKEN"];
}

Parameters

ParameterDescription
nameagenix secret name (maps to age file identity)
stack"home", "system", or "forgejo"
sourcePath expression resolving to the .age file
generatorOptional: "passphrase" or "ssh-key"
lengthPassphrase length (32, 48, or 64)
algorithmSSH key algorithm ("ed25519" or "rsa")
targetsDelivery target configuration (stack-specific)
ownerFile owner (for system file delivery)
groupFile group
modeFile mode

Target shapes

Home env delivery:

targets.home.env = ["APP_TOKEN" "API_KEY"];

Home file delivery:

targets.home.file = {
  path = "~/.ssh/deploy-key";
  mode = "600";
};

System env delivery:

targets.system.env = {
  vars = ["VIKUNJA_MAILER_PASSWORD"];
  services = ["vikunja"];
};

System file delivery:

targets.system.file = {
  path = "/run/secrets/vikunja-mailer";
  owner = "vikunja";
  mode = "0400";
};

Forgejo credential delivery:

targets.forgejo.credential = {
  name = "copr-token";
  instances = ["codeberg"];
};

mkSharedSecret

A convenience wrapper that produces a secret declaration for all three stacks from a single source file. Each stack is optional:

canixLib.mkSharedSecret {
  source = ./age/secrets/shared-token.age;
  generator = "passphrase";
  length = 48;

  home = {
    targets.home.env = ["SHARED_TOKEN"];
  };

  system = {
    targets.system.env = {
      vars = ["SHARED_TOKEN"];
      services = ["demo"];
    };
  };
}

Source expressions

The source parameter resolves the .age file path:

# User-scoped secret
secrets.user "can" "app-token"

# Host-scoped secret
secrets.host "thething" "vikunja-mailer"

# Module-scoped secret
secrets.module "forgejo-runner/copr-token"

Generators

lib.mkGenerators {rotateCommand ? "secret-manager rotate"} returns the agenix-rekey generators behind the names mkSecret emits (strong-passphrase, strong-passphrase-48, strong-passphrase-64, ssh-ed25519-pub, ssh-rsa-pub, gpg-key-pair) plus minisign-key-pair. Register them on every agenix module that evaluates these secrets:

age.generators = secret-manager.lib.mkGenerators {};

Passphrases avoid every character special to a systemd EnvironmentFile or a shell export. minisign-key-pair depends on exactly one password secret and encrypts the key with it; rotateCommand is quoted in its error when that password has not been generated yet.

Sync Targets

secret-manager can push encrypted values to Codeberg/Forgejo, Codefloe, and GitHub Actions secret stores and to Kubernetes Secrets, and plaintext non-secret values to Actions variables.

Sync targets are declared declaratively via Nix module options — the services.secretSync namespace — rather than passed as ad-hoc CLI flags. A host must opt in with enable = true; its targets are then collected into a JSON document that secret-manager sync reads.

secret-manager sync keeps a store-local TOML state file at <secret-store>/.secret-manager/sync-state.toml. The state records only non-secret destination metadata so later syncs can identify stale entries that the tool previously managed. Stale entries are reported by default and deleted only when sync --prune is used.

Declaring sync targets

{ ... }: {
  imports = [ inputs.secret-manager.nixosModules.default ];

  services.secretSync = {
    enable = true;

    targets = {
      "ci-token" = {
        secret   = "age/secrets/ci-token.age";
        name     = "CI_TOKEN";
        codeberg = ["caniko/my-repo"];
        codefloe = ["caniko/my-repo"];
        github   = ["caniko/my-repo"];
      };

      "publisher-token" = {
        secret = "age/secrets/publisher-token.age";
        name = "CRATES_IO_API_TOKEN";
        githubEnvironments = [{
          repository = "caniko/my-repo";
          environment = "simit-crates-io";
        }];
      };

      "org-token" = {
        secret       = "age/secrets/org-token.age";
        name         = "ORG_TOKEN";
        codebergOrgs = ["caniko"];
      };

      "public-key" = {
        source       = "age/secrets/public-key.asc";
        name         = "PUBLIC_KEY";
        codeberg     = ["caniko/my-repo" "caniko/other-repo"];
        codebergOrgs = ["caniko"];
        codebergUser = true;
      };
    };
  };
}

Target fields

githubEnvironments selects environment-scoped GitHub Actions secrets (encrypted sources) or variables (plaintext sources). Enroll the environment and its branch protection before syncing. Sync verifies the environment exists before writing and reads back destination metadata afterward; it never creates an environment or falls back to repository scope. Values are passed to gh on stdin.

Encrypted sync acquires credential protection before resolving identities or decrypting: it reexecutes the current frontend in a no-swap user scope when needed, replaying the filtered public configuration on stdin with the frontend’s original arguments. The child verifies that swap is disabled in its effective cgroup; an active logind sleep inhibitor must be held through upload, readback, pruning and state commit. Missing protection fails before those effects. Cached plaintext and temporary copies are zeroized when the handler exits. GitHub input helpers use bounded, redacted pipes, scrub credential-debug settings, inherit active inhibitors across exec and terminate/reap their process group on pipe or timeout failure. Public variables and read-only planning do not require a credential inhibitor.

Repository and environment copies have distinct persisted identities. Environment names are case-insensitive, so a case-only rename does not schedule a destructive prune. Old repository entries remain stale until an explicit authorized --prune after the replacement environment upload succeeds. Older binaries reject the new environment scope in persisted state; they cannot reinterpret it as a repository entry. Never reuse such a state file with an older Secret Manager.

OptionTypeDefaultDescription
secretnull or strnull.age path pushed as an Actions secret
sourcenull or strnullplaintext path pushed as an Actions variable
namestrrequiredActions secret/variable name
codeberglist of str[]owner/repo Forgejo repository targets
codefloelist of str[]owner/repo Codefloe repository targets
githublist of str[]owner/repo GitHub repository targets
githubEnvironmentslist of {repository, environment}[]GitHub environment targets
kuberneteslist of {context, namespace, ...}[]Kubernetes Secret targets (secret only)
codebergOrgslist of str[]Forgejo organization/account-scope targets
codebergUserboolfalseForgejo authenticated-user account target
hoststr"codeberg.org"Forge host for codeberg* token lookup

Each target must set exactly one of secret or source. Codefloe targets are repository-scoped; GitHub supports repository and environment destinations. Authenticate Codefloe with fj -H codefloe.com and GitHub with gh auth login.

Kubernetes Secrets

kubernetes writes a decrypted secret value to a Kubernetes Secret with kubectl apply --server-side --force-conflicts. Authenticate kubectl for the named context beforehand; secret-manager never reads kubeconfig itself.

services.secretSync.targets."registry-pull" = {
  secret = "age/secrets/registry-pull.age";
  name = "REGISTRY_PASSWORD";
  kubernetes = [
    {
      context = "nautilus";
      namespace = "syndb";
      secret = "registry-pull";
      format = "dockerconfigjson";
      username = "robot";
      registry = "registry.example"; # default https://index.docker.io/v1/
    }
    {
      context = "nautilus";
      namespace = "syndb";
      secret = "api-token";
      format = "opaque";
      key = "token"; # default: the target `name`
    }
  ];
};
FieldApplies toDescription
contextbothkubectl --context; required
namespacebothRFC 1123 subdomain; required
secretbothSecret name, RFC 1123 subdomain; required
formatbothopaque or dockerconfigjson; required
keyopaqueData key [-._a-zA-Z0-9]+; defaults to the target name
registrydockerconfigjsonRegistry key in auths; defaults to https://index.docker.io/v1/
usernamedockerconfigjsonRequired; must not contain :
fieldManagerbothServer-side apply manager; defaults to secret-manager

opaque stores the value base64-encoded under key with type: Opaque. dockerconfigjson stores {"auths":{"<registry>":{"username","password","auth"}}} under .dockerconfigjson with type: kubernetes.io/dockerconfigjson, using the decrypted value as the password. Invalid names, mixed fields (for example registry on an opaque destination), plaintext source values and a Secret declared twice are rejected before any decryption or kubectl call. Server-side apply by one field manager owns the whole data map, so one Secret has exactly one declaration. --prune deletes a Secret that is no longer declared.

Leak guarantees

The decrypted value is only ever in zeroized buffers and in the manifest piped to kubectl on stdin. Specifically:

  • It is never in kubectl’s argv or environment, and -o/--output and -v/--v are never passed, so kubectl has nothing verbose to echo.
  • kubectl’s stdout and stderr are captured through the same bounded private pipes as the other credential helpers (1 MiB cap, 90 s deadline, no core dumps, inherited sleep inhibitor, process-group cleanup). Nothing is forwarded to the terminal.
  • On failure the report is one line of at most 400 bytes. Before it is built, every occurrence of the plaintext, its JSON-escaped form, its base64, and for dockerconfigjson the user:password string, its base64 and the whole config JSON and its base64 is replaced with [redacted]. Errors from the capture layer pass through the same redaction.
  • On success the only output is kubernetes <context>/<namespace>/secret/<name>: applied. --dry-run and --check print the destination and the data key names only, and never decrypt.
  • The sync state file records destination metadata (context, namespace, Secret name, key, registry, username) and never a value.

Redaction is a backstop, not the control: the value is kept out of every channel that could echo it. A value shorter than a few characters makes redaction over-eager; use real credentials.

Relation to mkSecret

Sync targets are separate from mkSecret’s delivery targets (home env/file, system env/file, forgejo credential). The two concepts address different concerns:

  • mkSecret delivery: how a decrypted secret reaches a consumer on the host (as an environment variable, file, or systemd LoadCredential).
  • Sync targets: which external forge repositories, organizations, or authenticated-user account scopes receive a decrypted agenix value as an Actions secret, or a plaintext source file as an Actions variable.

A single agenix secret can have both local delivery and remote sync targets, neither, or either.

Collection contract

The lib.collect function gathers all enabled hosts’ targets into a JSON document of the shape:

{
  "hosts": [
    {
      "targets": {
        "ci-token": {
          "secret": "age/secrets/ci-token.age",
          "name": "CI_TOKEN",
          "codeberg": ["caniko/my-repo"],
          "codefloe": ["caniko/my-repo"],
          "github": ["caniko/my-repo"],
          "codebergOrgs": ["caniko"],
          "codebergUser": true,
          "host": "codeberg.org"
        },
        "registry-pull": {
          "secret": "age/secrets/registry-pull.age",
          "name": "REGISTRY_PASSWORD",
          "kubernetes": [
            {
              "context": "nautilus",
              "namespace": "syndb",
              "secret": "registry-pull",
              "format": "dockerconfigjson",
              "username": "robot",
              "registry": "registry.example"
            }
          ]
        },
        "public-key": {
          "source": "age/secrets/public-key.asc",
          "name": "PUBLIC_KEY",
          "codeberg": ["caniko/my-repo"],
          "codefloe": [],
          "github": [],
          "codebergOrgs": ["caniko"],
          "codebergUser": false,
          "host": "codeberg.org"
        }
      }
    }
  ]
}

This JSON is the Nix↔Rust contract. The secret-manager binary reads it via the SyncDocument deserialization type. In normal flake-root use, secret-manager sync evaluates this collection automatically; --config and piped stdin remain available for scripts that want to provide the JSON explicitly.

CLI Reference

secret-manager <COMMAND>

Declarative secret management for Nix store repositories.

Commands

github app — GitHub-issued App credentials

enroll serves a local manifest form and callback on 127.0.0.1. Open the printed URL in an authenticated GitHub browser, register the app, then authorize its selected-repository installation. The conversion response is encrypted before parsing or publication; the CLI never generates an app private key.

secret-manager github app enroll --config review-app.json \
  --store /path/to/canix --transaction /private/state/review-app \
  --identity age/master_identities/master_nitro3c_identity.pub

The transaction’s parent directory must already exist. The CLI creates the transaction directory with mode 0700; files use 0600. Keep it outside tracked source trees. The manifest callback must complete within one hour; the default listener deadline is 900 seconds (--timeout-seconds, maximum 3500). --port chooses a loopback port. Retries reuse the original port and policy/store binding.

The public policy is JSON:

{
  "version": 1,
  "slug": "example-review",
  "owner": "example",
  "repository": "example/project",
  "homepage": "https://github.com/example/project",
  "source": "age/secrets/review-app.age",
  "recipients": ["age1..."],
  "permissions": {
    "contents": "read",
    "pull_requests": "write",
    "checks": "write"
  },
  "keySecretName": "COMMITPERCLIP_KEY",
  "appIdVariable": "COMMITPERCLIP_APP_ID",
  "appSlugVariable": "COMMITPERCLIP_APP_SLUG"
}

Use real public age recipients. The app owner must match the repository owner; permissions must match the declaration, with only GitHub’s automatic metadata: read permitted additionally. The issued slug must match the declared slug. If GitHub assigns a different slug, retain the encrypted checkpoint and import a settings-issued key with a corrected policy in a fresh transaction.

For an already registered app or a settings-issued replacement key:

secret-manager github app import --config review-app.json --store /path/to/canix \
  --transaction /private/state/review-app --app-id 12345 --pem-file /private/key.pem
secret-manager github app installation --config review-app.json \
  --store /path/to/canix --transaction /private/state/review-app
secret-manager github app publish --config review-app.json --store /path/to/canix \
  --transaction /private/state/review-app --identity /private/age-identity
secret-manager github app verify --config review-app.json --store /path/to/canix \
  --transaction /private/state/review-app --identity /private/age-identity

import requires an operator-owned private PEM file and verifies its JWT against GitHub /app. It retains the original file; remove it through your normal private credential handling after encrypted adoption. App-key generation/revocation is a GitHub settings operation; this CLI automates adoption and publication.

If an import stops after saving issued.age but before saving its digest, retry with the same transaction, PEM and age identity. Recovery binds the retained ciphertext to the pending transaction and checks the original app identity and key fingerprint before completing adoption. A recorded digest mismatch or an unbound checkpoint in a completed transaction is refused.

installation prints the authorization URL. With --installation-id, it can add the declared repository to an existing selected installation using the operator’s authenticated gh account. It verifies app/installation ownership and repository administration before mutation, then verifies token scope afterward.

publish verifies the encrypted checkpoint, authenticates the app, checks the installation, mints an exactly-one-repository token and revokes that verification token. It writes the encrypted source and adjacent .app-id / .app-slug public files, then uses gh stdin to set the named repository secret and variables. Failed token revocation refuses publication or a successful verification receipt. If verification and revocation both fail, the diagnostic reports both outcomes. Partial publication is retryable using the same transaction. --replace-key permits an explicit verified rotation and retains the previous ciphertext in previous.age. Publication is serialized per source.

The JSON receipt contains IDs, public-key fingerprint, policy/checkpoint/source hashes, token expiry and successful slot names. It contains no PEM, JWT or token. Its actionsSlotQualified stays false: GitHub does not return stored Actions secret plaintext, so the trusted hosted workflow must prove destination use. verify checks the encrypted source credential, not the installed Actions slot.

If a crash follows manifest exchange, retry enroll with the same transaction: an encrypted response can be recovered. If no ciphertext exists, the consumed code has an ambiguous outcome; inspect GitHub settings and import an issued key in a fresh transaction. Never replay that conversion blindly.

gh must already be authenticated to github.com; age identities use the existing --identity / SECRET_MANAGER_AGE_IDENTITIES contract. Canix hardware identity stubs under age/master_identities/ should be passed explicitly. Declaration of services.secretSync.targets does not schedule execution.

github token — Account tokens through the device flow

Mint a GitHub OAuth token for an account (for example a bot user) and store it as agenix sources without plaintext touching the store:

SECRET_MANAGER_AGE_ENCRYPTOR=$(nix build --no-link --print-out-paths .#agenix-stream-encryptor)/bin/agenix-stream-encryptor \
secret-manager github token --login canikroid \
  --scope repo --scope write:gpg_key --scope admin:public_key \
  --out age/secrets/users/can/canikroid_github_token.age \
  --env-out age/secrets/users/can/canikroid_github_env.age

The command prints a one-time code; authorize it at https://github.com/login/device in a browser signed in as --login. It then verifies the token authenticates as that login with every requested scope (admin:x implies write:x/read:x), encrypts the bare token to --out and <ENV_NAME>=<token> to --env-out, and exits. Nothing is stored on a login or scope mismatch.

gh runs in a private temporary config directory under $XDG_RUNTIME_DIR, removed on exit, so the token never reaches your own gh login or keyring. Existing sources are refused unless --rotate is given. Stage the sources and run agenix rekey afterwards.

FlagDescription
--login LOGINAccount the token must belong to
--scope SCOPERequested and required OAuth scope; repeatable
--out PATHEncrypted source for the bare token
--env-out PATHOptional encrypted ENV_NAME=token source for EnvironmentFile consumers
--env-name NAMEVariable name for --env-out (default GH_TOKEN)
--rotateReplace existing sources
--encryptor PATHlib.mkAgenixStreamEncryptor executable (default SECRET_MANAGER_AGE_ENCRYPTOR)
--timeout-seconds NAuthorization wait, 30–3600 (default 900)

claude token — Claude Code subscription tokens

Store a long-lived Claude Code token for headless runners (CLAUDE_CODE_OAUTH_TOKEN) in one step:

SECRET_MANAGER_AGE_ENCRYPTOR=$(nix build --no-link --print-out-paths .#agenix-stream-encryptor)/bin/agenix-stream-encryptor \
secret-manager claude token --out age/secrets/root/hosts/atlas/paperclip_operations_claude_oauth.age

The command runs native claude setup-token and waits up to 15 minutes for browser authorization. Only browser interaction is needed: both output channels are captured privately in bounded, zeroizing memory, never printed or saved as plaintext. No token copy/paste or controlling terminal is required. Native output must contain one unique, valid sk-ant-oat01- token; changed, ambiguous or malformed output fails closed without storage. The token is encrypted with the store’s agenix master policy, the source is staged, and agenix rekey distributes it. With --secret NAME (the secret’s agenix attribute) only that secret’s rekeyed copies are staged, so unrelated uncommitted rekeyed changes do not block it. Tokens last one year; renew with --rotate.

Native terminal framing outside the token paragraph is ignored. Color styling and private display modes are handled without changing token text; editing controls inside token data, incomplete sequences and ambiguous tokens are rejected.

Encryption/rekey may still require security-key or pinentry approval. A native browser-launch or authorization failure does not fall back to printing a URL or token. If rekey fails after encryption, retain the encrypted source and retry agenix rekey -a, rather than minting again. Host deployment remains separate.

FlagDescription
--out PATHEncrypted source for the token
--rotateReplace an existing source
--claude PATHClaude Code executable (default claude)
--no-rekeySkip agenix rekey; run agenix rekey -a before deploying
--no-stageLeave staging of the source and rekeyed copies to you
--secret NAMEAgenix attribute name; stage only its rekeyed copies, ignoring other dirty ones
--encryptor PATHlib.mkAgenixStreamEncryptor executable (default SECRET_MANAGER_AGE_ENCRYPTOR)

gpg publish — Account signing keys

Register an existing armored public key on authenticated forge accounts:

secret-manager gpg publish nomad.asc --codefloe --github --codeberg
FlagDescription
--codefloeRegister on the Forgejo account at codefloe.com
--githubRegister on the GitHub account at github.com
--codebergRegister on the Forgejo account at codeberg.org
--forgejo HOSTAdd another Forgejo destination; repeatable
--dry-runValidate the public key and print destinations without network calls
--checkRead back registration; fail if any selected account is missing the key
--expected-fingerprint FINGERPRINTRequire this full fingerprint before contacting a forge

Choose at least one destination. The command accepts exactly one public primary key in one ASCII-armored block, rejects private-key packets, and inspects it with GnuPG in an isolated temporary keyring. It does not import into your keyring. Relative file paths resolve from the working directory; no secret store is needed.

Forgejo destinations reuse fj authentication in its existing auth store. Tokens need read:user for listing and write:user for registration. GitHub reuses gh authentication; classic/OAuth tokens need read:gpg_key and write:gpg_key, respectively.

Retries are idempotent: an existing key is accepted only after matching its full fingerprint from the API’s public-key packets. Every destination is attempted even if another fails, and incomplete publication exits nonzero. After a failed POST, a read-back checks whether registration succeeded before reporting failure.

Output reports registration, signing capability, email verification, and Forgejo’s key-ownership verification separately. Uploading a key does not perform Forgejo’s proof-of-possession challenge or verify an account email. Codefloe account registration uses its Forgejo API, independently of Crow CI credentials.

hm — Home-manager secrets

Add an agenix secret for a home-manager target.

Subcommands:

CommandDescription
sshGenerate an SSH key and expose as a home-manager file
passwordGenerate a passphrase and expose as env vars or a file
textEncrypt text from editor/stdin/–from-file as env vars
fileEncrypt a file payload and expose as a home-manager file

Options (shared):

FlagDescription
--name <SLUG>Secret slug
--user <USER>Home-manager user (default: current login user)
--sharedStore under age/secrets/users/shared/
--env <VAR>Environment variable names
--file <PATH>File path within the home profile
--from-file <PATH>Read plaintext from file
--from-age <PATH>Adopt an existing encrypted .age file
--algorithm <ALGO>SSH key algorithm (ed25519, rsa)
--length <N>Passphrase length (32, 48, 64)
--no-rekeySkip agenix rekey -a
--no-stageSkip git add

nixos — NixOS host secrets

Add an agenix secret for a NixOS host target.

Subcommands:

CommandDescription
sshGenerate an SSH key and expose as a NixOS file
passwordGenerate a passphrase for service env vars or a file
textEncrypt text as service env vars
fileEncrypt a file payload and expose as a NixOS file

Options (shared):

FlagDescription
--name <SLUG>Secret slug
--host <HOST>NixOS host name
--env <VAR>Environment variable names
--service <NAME>Systemd service names (required with --env)
--file <PATH>File path on the host
--from-file <PATH>Read plaintext from file
--from-age <PATH>Adopt an existing encrypted .age file
--algorithm <ALGO>SSH key algorithm
--length <N>Passphrase length
--no-rekeySkip agenix rekey -a
--no-stageSkip git add

forgejo — Forgejo Actions secrets

Add an agenix secret for Forgejo Actions or runner credentials.

Subcommands:

CommandDescription
sshGenerate or refresh a Forgejo Actions SSH deploy key
gpg-key-pairGenerate or adopt an OpenPGP private key and derive public metadata
passwordGenerate a passphrase as a runner credential
textEncrypt text as a runner credential
fileEncrypt a file payload as a runner credential

Options (shared):

FlagDescription
--name <SLUG>Secret slug
--cred <NAME>Runner credential name
--instance <INSTANCE>Runner instance (repeatable)
--pubkey-var <VAR>Nix variable name for the generated public key
--module-dir <DIR>Module-secret directory for gpg-key-pair sources
--user-id <UID>OpenPGP user ID for newly generated keys
--rotateForce SSH key rotation
--from-file <PATH>Read plaintext from file
--from-age <PATH>Adopt an existing encrypted .age file
--length <N>Passphrase length
--no-rekeySkip agenix rekey -a
--no-stageSkip git add

list — List secrets

List every *.age file tracked in the repository.

secret-manager list

push — Push to CI secret stores

Decrypt an agenix secret and push it to repository Actions secret stores.

secret-manager push <SECRET.age> \
  --name <SECRET_NAME> \
  --codeberg <OWNER/REPO> \
  --codefloe <OWNER/REPO> \
  --github <OWNER/REPO> \
  --identity <IDENTITY.pub>

sync — Push declared sync targets

Read a collected services.secretSync JSON document and push each declared target to Codeberg/Forgejo, Codefloe, or GitHub Actions scopes, or to Kubernetes Secrets. Codeberg/Forgejo targets support repository, organization, and authenticated-user scopes; Codefloe and GitHub targets are repository-scoped; Kubernetes targets name a kubectl context, namespace and Secret (see Sync Targets). Encrypted secret targets are pushed as Actions secrets; plaintext source targets are pushed as Actions variables. Kubernetes accepts encrypted secret targets only.

secret-manager sync --dry-run
secret-manager sync --identity <IDENTITY.pub>

By default, sync discovers the nearest flake root and evaluates that flake’s secret-manager.lib.collect output over nixosConfigurations. Use --dry-run to print the planned pushes without decrypting or contacting the forge.

For portable scripts, pass an explicit collected JSON document:

secret-manager sync --config sync-targets.json --identity <IDENTITY.pub>

If --config is omitted and stdin is not a terminal, sync reads the JSON document from stdin. Pass --no-flake to disable flake auto-detection and require one of those explicit input paths.

sync records the remote Actions secrets and variables it manages in <secret-store>/.secret-manager/sync-state.toml by default. Pass --state <PATH> to use a different state file. Entries that were previously managed but no longer appear in the collected config are reported as stale. They are only deleted from their configured forge when --prune is passed. --target only narrows which targets are pushed: staleness is always judged against the complete collected config, and --prune cannot be combined with --target.

decrypt — Decrypt to stdout

Decrypt an agenix secret and print plaintext to stdout.

secret-manager decrypt <SECRET.age> \
  --identity <IDENTITY.pub>

wg-keygen — WireGuard keypair

Generate a WireGuard keypair, store the private key as an agenix secret, print the public key.

secret-manager wg-keygen <SECRET.age>

rauthy-env — Rauthy bootstrap

Bootstrap the Rauthy env-file secret end to end.

secret-manager rauthy-env [SECRET.age]

gerrit-cookies — Gerrit cookies

Encrypt a Gerrit .gitcookies payload into an agenix secret.

secret-manager gerrit-cookies <INPUT_FILE> <SECRET.age>

Global flags

FlagDescription
-h, --helpPrint help
-V, --versionPrint version

Library API

Schema crate

Registry data types live in a separate, dependency-light crate so data repositories can decode them without the engine:

[dependencies]
secret-manager-schema = { package = "caniko-secret-manager-schema", version = "0.4.7" }

secret_manager_schema::accounts::AccountCatalogue decodes switchable service accounts: several accounts per credential service, a fleet default, and per-host and per-user overrides. select(service, host, user) returns the host override, else the user override, else the default; validate checks that every default and override names a registered account. The matching Pkl classes (Account, AccountService, AccountHost, AccountCatalogue) are in pkl/SecretRegistry.pkl.

Library API

The secret_manager library provides the building blocks used by the CLI.

The registry package is caniko-secret-manager. Use a Cargo package alias to retain the existing library imports:

[dependencies]
secret-manager = { package = "caniko-secret-manager", version = "0.4.0" }

The minimum supported Rust compiler is 1.88. Public Forgejo authentication types now use Forgejo API 0.11; callers exchanging those types must use the same generation. The Reqwest 0.13 blocking client retains JSON, Rustls, HTTP/2, charset decoding and system proxy discovery. Persisted version-1 sync-state records retain their destination identities across the TOML 1.1 migration, including GitHub environment ownership.

Modules

ModuleDescription
addCLI argument types and plan-building for all secret types
envStoreEnv trait for host validation and user resolution
gpgGpgCmd / PublishArgs: validate and publish public account signing keys on Forgejo/GitHub
ioFile I/O, temp dirs, editor integration, validation
legacyLegacy workflows (WireGuard, Rauthy, Gerrit cookies)
planPlan execution engine — source prep, module writing, rekey
pushDecrypt and push secrets to Codeberg/Codefloe/GitHub Actions
renderNix module rendering — TargetSpec, render_body, camel
storeStore discovery, master identity resolution
ageRe-export of nix_manager_core::age — rage decryption and the shared flags → env → stubs identity resolution

Core types

Store

pub struct Store {
    pub root: PathBuf,
}

Methods:

  • Store::current_dir() — root at cwd
  • Store::discover() — from $SECRET_MANAGER_STORE or nearest flake.nix
  • store.master_identities() — sorted age/master-*.pub / age/master_*.pub stubs
  • store.resolve_identities(flags) — flags → SECRET_MANAGER_AGE_IDENTITIES → stubs

TargetSpec

pub enum TargetSpec {
    Home(HomeTarget),
    SharedHome(SharedHomeTarget),
    System(SystemTarget),
    Forgejo(ForgejoTarget),
}

Each variant encodes the source expression, secret path, module path, and delivery targets.

RenderedModule

The output of rendering a target spec into a Nix module file:

pub struct RenderedModule {
    pub path: PathBuf,       // Module file path relative to store root
    pub body: String,        // Nix module content
    pub default_nix: PathBuf, // Parent default.nix for import splicing
    pub secret_path: PathBuf, // .age file path relative to store root
    pub stack: Stack,
}

StoreEnv trait

pub trait StoreEnv {
    fn validate_host(&self, host: &str) -> Result<()>;
    fn resolve_home_user(&self) -> Result<String>;
}
  • LocalEnv — permissive implementation for standalone use
  • TestEnv — test implementation (resolves user to "can")

Key functions

render_modules

pub fn render_modules(
    targets: &[TargetSpec],
    generator: Option<&GeneratorSpec>,
) -> Vec<RenderedModule>

Renders a set of targets into Nix modules by calling mkSecret with the appropriate source expressions and delivery targets.

render_body

pub fn render_body(target: &TargetSpec, generator: Option<&GeneratorSpec>) -> String

Renders a single target into the body of a Nix module.

run_plan

pub fn run_plan(args: &CommonSourceArgs, plan: AddPlan) -> Result<()>

Executes an AddPlan: validates destinations, prepares secret sources, writes modules, runs generation or encryption, stages, and rekegs.

camel

pub fn camel(s: &str) -> String

Converts kebab-case and snake_case to CamelCase used for age name derivation.

Example: "nix-trusted" → "NixTrusted", "copr-token" → "CoprToken".

default_forgejo_age_name

pub fn default_forgejo_age_name(instance: &str, slug: &str) -> String

Derives the default agenix secret name for a Forgejo credential.

Example: ("codeberg", "copr-token") → "forgejoRunnerCodebergCoprToken"

default_forgejo_ssh_key_age_name

pub fn default_forgejo_ssh_key_age_name(slug: &str) -> String

Derives the default agenix secret name for a Forgejo SSH key.

Example: "aur-ssh-key" → "forgejoActionsAurSshKey"