Age Encryption for Simple Secrets

Updated

September 12, 2026

Age Encryption for Simple Secrets

GPG keyrings and expiry theatre do not belong in a desk CI job. The boring default is: age (X25519) for files, ssh-to-age so SSH keys are recipients, and agenix only if you want NixOS decryption without SOPS’s YAML.

This book already uses sops-nix as the default for structured secrets. Age is the primitive underneath. Agenix is the alternative when you want one file per secret and less YAML.

Mental model

Tool Role
age Encrypt/decrypt bytes to age1… or SSH recipients
ssh-to-age Convert ssh-ed25519 pubkey → age1…
agenix NixOS module: .age files in git → /run/agenix/… at boot

No keyserver. A public key is a line of text. Lose the private key, lose the files (unless another recipient remains).

desk-token.age   (cipher, git)
   recipients: alice's ssh, desk-node host ssh
        │  agenix at boot
        ▼
/run/agenix/desk-token

Private keys never enter /nix/store. Ciphertext always lives in git.

git check-ignore -v key.txt .age/key.txt 2>/dev/null || true
git ls-files '*.age' | head

.age files are tracked. key.txt is not. If git ls-files shows key.txt, rotate that key now.

Worked examples

Case 1: Encrypt a file to yourself

mkdir -p "$HOME/.age"
age-keygen -o "$HOME/.age/key.txt"
# public key printed on stderr: age1…
echo 'desk-token-v1' | age -r age1youxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx > token.age
age -d -i "$HOME/.age/key.txt" token.age

Output:

desk-token-v1

Commit token.age. Do not commit key.txt. chmod 600 the key file.

SOPS_AGE_KEY_FILE=$HOME/.age/key.txt is how sops finds the same identity. age -d -i is the explicit form. Do not export SOPS_AGE_KEY (the raw key) in a shell history. Armor (age -a) is optional; sops YAML already stores ciphertext as ASCII.

Case 2: SSH public key as recipient

ssh-to-age < ~/.ssh/id_ed25519.pub
echo 'desk-token-v1' | age -R ~/.ssh/id_ed25519.pub -o token.age
age -d -i ~/.ssh/id_ed25519 token.age

Hosts:

ssh-to-age < /etc/ssh/ssh_host_ed25519_key.pub

Every NixOS machine already has a recipient if you persist host keys (impermanence: persist /etc/ssh/ssh_host_ed25519_key or decryption fails every boot).

age -R recipients.txt reads many age1… / ssh-ed25519 lines. Keep recipients.txt in git (public keys only). A passphrase (age -p) is a human backup, not a fleet identity — sops/agenix will not type it at boot.

Case 3: agenix rules file

Save as secrets.nix:

# secrets.nix
let
  alice = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleUserKey alice@desk";
  server = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExampleHostKey root@desk-node";
in
{
  "desk-token.age".publicKeys = [ alice server ];
}
nix shell nixpkgs#agenix --command agenix -e desk-token.age

$EDITOR opens the plaintext. Save; the .age file is updated. Recipients are only those publicKeys. Adding a host later requires agenix -r (rekey).

Case 4: Consume on NixOS

Save as host.nix:

# host.nix
{ config, inputs, ... }:

{
  imports = [ inputs.agenix.nixosModules.default ];

  age.secrets.desk-token = {
    file = ./desk-token.age;
    owner = "desk-api";
    group = "desk-api";
    mode = "0400";
  };

  systemd.services.desk-api.serviceConfig.LoadCredential =
    "token:${config.age.secrets.desk-token.path}";
}

Boot decrypts to /run/agenix/desk-token (path may be the credential copy). The unit never sees a Nix string. EnvironmentFile pointing at the secret path is also fine; argv is not (ExecStart=… --token hunter2).

age.identityPaths defaults to the host ed25519 key. If you also decrypt as a user, list that user’s key — and do not put it in the store. owner = "desk-api" requires that user to exist at decrypt time; with DynamicUser, keep the file root-owned 0400 and LoadCredential.

Case 5: Rekey when the fleet grows

# add bob to secrets.nix publicKeys, then:
agenix -r
git diff desk-token.age

The ciphertext changes; plaintext is the same. If you skip rekey, bob’s laptop cannot decrypt, and a new server fails at boot with an age error in the agenix unit.

journalctl -u agenix -n 40 --no-pager

The trap

The trap is one recipient: your laptop. The server is not you. Always include the host SSH key.

The other trap is using both sops-nix and agenix for the same secret. Pick one tree: sops YAML for many keys, agenix for a handful of files. Two systems means two rekey rituals.

A third: putting key.txt in the ISO or in isoImage.contents. Ciphertext in git; identity keys on disk / sops.

A fourth: ssh-to-age of a private key (it wants the .pub). A fifth: RSA host keys — prefer ed25519; ssh-to-age wants the modern key type.

The boring rule

  • Age, not GPG, for desk secrets.
  • Recipients = people + hosts (ssh-to-age of .pub). Persist host keys on ephemeral roots.
  • Rekey when keys are added or revoked (agenix -r / sops updatekeys).
  • Private keys never in git (SOPS_AGE_KEY_FILE, not SOPS_AGE_KEY in history). Ciphertext always in git.
  • Prefer sops-nix if you already have YAML; agenix if you want one-file-one-secret. Not both for the same token.

Try this

  1. ssh-to-age < ~/.ssh/id_ed25519.pub, encrypt a one-line file, decrypt with -i ~/.ssh/id_ed25519.
  2. Encrypt to two recipients, remove your key from the list, confirm decrypt fails, put it back.
  3. agenix -e a dummy secret in a throwaway repo and git add only the .age file.
  4. On a VM, omit the host from publicKeys, switch, read journalctl -u agenix. Add the host, rekey, switch again.