Keystone SystemsKS Systems

Secrets management

Keystone deployments require secrets such as database passwords, API tokens, and private keys. The Nix store is world-readable by design, so these values cannot appear in configuration files directly. This page describes how to manage encrypted secrets using agenix with age encryption.

For YubiKey-backed keys and SSH agent configuration, see Hardware keys.

How agenix works

Each secret is an .age file encrypted for a specific set of public keys. Age natively supports SSH ed25519 keys, so both users and systems use the same key type:

  • User keys -- SSH ed25519 public keys (e.g., ~/.ssh/id_ed25519.pub) or YubiKey-backed age public keys (e.g., age1yubikey1q...).
  • System keys -- SSH host ed25519 public keys from each NixOS machine (from /etc/ssh/ssh_host_ed25519_key.pub). These allow the host to decrypt its own secrets during activation.

A secrets.nix file defines the access control: each secret maps to the list of public keys that can decrypt it. When agenix -e creates a secret, it encrypts for exactly those keys. At NixOS activation time, the agenix module reads the host's SSH private key from /etc/ssh/ssh_host_ed25519_key and decrypts only the secrets encrypted for that host. The decrypted files appear in /run/agenix/, a tmpfs that never touches disk.

Setting up agenix

Add agenix to the flake

Add agenix as a flake input and import its NixOS module:

{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
    agenix = {
      url = "github:ryantm/agenix";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };

  outputs = { nixpkgs, agenix, ... }: {
    nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
      modules = [
        agenix.nixosModules.default
        {
          environment.systemPackages = [
            agenix.packages.x86_64-linux.default
          ];
        }
        ./configuration.nix
      ];
    };
  };
}

Collect SSH public keys

Obtain the user SSH public key for the users section of secrets.nix:

cat ~/.ssh/id_ed25519.pub

If no ed25519 key exists, generate one:

ssh-keygen -t ed25519

For each NixOS host that needs secrets, obtain its SSH host public key:

# Remote host
ssh-keyscan myhost | grep ssh-ed25519

# Local host
cat /etc/ssh/ssh_host_ed25519_key.pub

Configuring secrets.nix

The secrets.nix file defines which public keys can decrypt which secrets. A minimal example with one user and one host:

let
  users = {
    alice = "ssh-ed25519 AAAAC3Nz... alice@workstation";
  };

  systems = {
    myhost = "ssh-ed25519 AAAAC3Nz...";
  };

  adminKeys = builtins.attrValues users;
in
{
  "secrets/db-password.age".publicKeys = adminKeys ++ [ systems.myhost ];
  "secrets/api-token.age".publicKeys = adminKeys ++ [ systems.myhost ];
}

A real-world example with multiple hosts and group aliases:

let
  users = {
    ncrmro = "ssh-ed25519 AAAAC3Nz... ncrmro@workstation";
  };

  systems = {
    ocean = "ssh-ed25519 AAAAC3Nz...";
    workstation = "ssh-ed25519 AAAAC3Nz...";
    laptop = "ssh-ed25519 AAAAC3Nz...";
  };

  adminKeys = [ users.ncrmro ];
  desktops = [ systems.workstation systems.laptop ];
  k3sServers = [ systems.ocean ];
in
{
  "secrets/k3s-server-token.age".publicKeys = adminKeys ++ k3sServers;
  "secrets/mail-password.age".publicKeys = adminKeys ++ desktops;
  "secrets/cloudflare-api-token.age".publicKeys = adminKeys ++ [ systems.ocean ];
}

For YubiKey-backed keys, add the age public key (e.g., age1yubikey1q...) to users alongside or instead of the SSH key. See Hardware keys for YubiKey setup and identity management.

Creating and managing secrets

Create or edit an encrypted secret:

agenix -e secrets/db-password.age

This opens $EDITOR. Type the secret value, save, and close. Agenix encrypts the content for the public keys listed in secrets.nix for that file path.

View a decrypted secret:

agenix -d secrets/db-password.age

After changing which keys have access in secrets.nix, re-encrypt all secrets:

agenix -r

All .age files are safe to commit to version control. They can only be decrypted by keys listed in secrets.nix.

Using secrets in NixOS

Define secrets in the host configuration with age.secrets:

{
  age.secrets.db-password = {
    file = ../../secrets/db-password.age;
    owner = "myapp";
    group = "myapp";
    mode = "0400";
  };
}

At activation, this decrypts to /run/agenix/db-password owned by myapp:myapp with mode 0400. Reference the path in service configurations:

{
  services.k3s = {
    enable = true;
    tokenFile = config.age.secrets.k3s-server-token.path;
  };

  systemd.services.myapp.serviceConfig = {
    EnvironmentFile = config.age.secrets.api-token.path;
  };
}

Common patterns include:

  • Service tokens: tokenFile = config.age.secrets.<name>.path;
  • Environment files: EnvironmentFile = config.age.secrets.<name>.path; for KEY=value formatted secrets
  • Password files: passwordFile = config.age.secrets.<name>.path; for single-value secrets

Adding a new host

When deploying a new NixOS machine:

  1. Boot the machine and obtain its SSH host public key:
    ssh-keyscan new-host | grep ssh-ed25519
  2. Add the key to systems in secrets.nix.
  3. Add it to the publicKeys list for each secret the host needs.
  4. Re-key all affected secrets:
    agenix -r
  5. Rebuild the new host. It can now decrypt its secrets.

Repository layout

Secrets inline (private configuration)

If the NixOS configuration repository is private, keep secrets alongside host configurations:

nixos-config/
├── flake.nix
├── hosts/
│   └── myhost/
│       └── default.nix
├── secrets.nix
└── secrets/
    ├── db-password.age
    └── api-token.age

Private secrets submodule (public configuration)

If the NixOS configuration is public, encrypted .age files should live in a separate private repository. Even though the files are encrypted, publishing them carries risks:

  • Metadata exposure -- File names and secrets.nix reveal what secrets exist, which hosts use them, and the infrastructure topology.
  • Harvest-now, decrypt-later -- An attacker can archive encrypted secrets and decrypt them if age/X25519 is broken by future cryptanalysis or quantum computing.

A private Git submodule keeps secrets out of the public repository while maintaining a single nixos-rebuild workflow:

git submodule add git@your-server:myuser/agenix-secrets.git agenix-secrets
nixos-config/              # public repo
├── flake.nix
├── hosts/
│   └── myhost/
│       └── default.nix
└── agenix-secrets/        # private submodule
    ├── secrets.nix
    └── secrets/
        ├── db-password.age
        └── k3s-token.age

For additional security, host the private repository on infrastructure under direct control (e.g., Forgejo on a Tailscale-only network) rather than a public Git hosting service.

Troubleshooting

Secret not decrypting on host

The host's SSH public key may be missing from secrets.nix, or secrets were not re-keyed after adding it. Verify the key is in the correct publicKeys list, run agenix -r, commit, and rebuild.

Re-key fails

Agenix needs access to a private key corresponding to one of the public keys in secrets.nix. By default it checks ~/.ssh/id_ed25519 and ~/.config/age/keys.txt. If the SSH key is in a non-standard location, set the AGENIX_IDENTITY environment variable:

AGENIX_IDENTITY=~/.ssh/my_ed25519_key agenix -r

For YubiKey-related troubleshooting (detection issues, AGE_IDENTITIES_FILE, SSH agent conflicts), see Hardware keys — Troubleshooting.

See also

  • Hardware keys -- YubiKey setup, SSH agents, and key inventory
  • Disk encryption -- LUKS and TPM-based key management for storage encryption
  • Architecture -- Module system overview and security model context
  • Getting started -- Secrets configuration during initial deployment
  • agenix -- Age-encrypted secrets for NixOS
  • age -- The underlying encryption tool