Keep NixOS Secrets in Git with agenix: A Step-by-Step Guide

Encrypt API keys for a NixOS Git repo, use them as private runtime files, test the setup, and recover them on a new installation with agenix.

On this page

My NixOS repo could rebuild the desktop. Install my applications, and put the settings back where I wanted them. But a few applications still needed an API key from a file in my home directory.

That was another thing to remember during a restore. I wanted the encrypted key beside the configuration that used it, while keeping the private decryption keys outside Git.

I went with agenix (opens in a new tab). It was a small addition to my existing flake, and it did not need another server. I started with a harmless test value, checked recovery, then moved one real application key. After a normal reboot, I checked the file permissions and used the application again. Only then did I remove the old plaintext copy.

This guide follows that order. You can do most of it with a fake value before trusting it with anything important.

TL;DR

  • Git keeps encrypted .age files and the public keys allowed to open them.
  • A private machine key, kept outside Git, lets NixOS decrypt them during activation and boot.
  • Applications read restricted files under /run/agenix/.
  • A separate personal key lets you edit secrets and recover them after reinstalling.
  • Test a dummy first, then file permissions, the actual application, and a normal reboot.
  • On a new machine, restore the machine key or use your personal recovery key to encrypt the files for a new machine key.

What agenix changes

A secret is simply a value you should keep private. Whether it is an API key, a service password, or a token.

Agenix uses age encryption (opens in a new tab) and can encrypt files for SSH public keys. A public key can be shared. Its matching private key opens the encrypted file. Agenix calls a key allowed to open a file a recipient.

There are two jobs here:

JobWhat does it
Create and edit encrypted filesThe agenix command you run locally
Decrypt and set file ownership during activationThe agenix NixOS module

The CLI creates the encrypted file. The module puts its decrypted contents where the application needs them. We need both.

The agenix documentation (opens in a new tab) covers its NixOS and Home Manager modules. This article uses the NixOS module for a desktop with one normal user.

The decrypted value still exists while an application needs it. Encryption in Git protects the stored copy. File permissions control who can read the runtime copy. Root can still read it, and a process running as your user can read a secret owned by your user.

Before you start

Start from a known working configuration and keep an older boot generation available.

These are the example names used below:

NameMeaning
~/nixos-configYour Git checkout
workstationThe name under nixosConfigurations in your flake
alexYour existing Linux user
usersThat user’s group in this example
~/.ssh/id_ed25519Your personal editing/recovery private key

Replace those names with your own. Check the user and group with id. The flake name is the attribute in flake.nix, which may be different from your hostname.

The configuration that worked, uses NixOS 26.05 and the agenix revision used on my workstation. Keep your existing Nixpkgs and Home Manager pins while adding agenix.

All commands below are ordinary commands. My own nixcfg and ns helpers are not needed.

Step 1: add agenix to your flake

In your existing flake.nix, add this inside inputs. Keep the other inputs:

flake.nix: add inside inputs
agenix = {
url = "github:ryantm/agenix/5e5b88a0c8e56f291a108c2fe9934054d9745b2e";
inputs.nixpkgs.follows = "nixpkgs";
};

The long revision pins the source used for this guide. flake.lock records its exact contents too.

Add agenix to the arguments of your existing outputs function. Then extend the existing host’s specialArgs and modules list. The relevant part looks like this:

flake.nix: merge into your existing outputs
outputs = { nixpkgs, agenix, ... }: {
nixosConfigurations.workstation = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
specialArgs = {
inherit agenix;
};
modules = [
agenix.nixosModules.default
./configuration.nix
];
};
};

This is a shortened example. Keep your other output arguments, special arguments, Home Manager module, hardware imports, and other modules. Add to the lists and attribute sets you already have.

My packages live in packages.nix, imported by configuration.nix. Add agenix to that file’s existing module arguments, then add its CLI to the existing package list:

packages.nix: merge with your existing declarations
{ pkgs, agenix, ... }:
{
environment.systemPackages = [
agenix.packages.${pkgs.stdenv.hostPlatform.system}.default
];
}

If you keep packages directly in configuration.nix, put the package there instead. You do not need a second installation owner or a new module just for this.

From the repo root, update the lock file:

Terminal window
cd ~/nixos-config
nix flake lock
git diff -- flake.nix flake.lock packages.nix

Expect an agenix input in the lock file. Check that unrelated inputs did not change.

Step 2: keep a personal key and a machine key

I already had a personal Ed25519 SSH key. I used its public half as the editing and recovery recipient.

Check whether yours exists:

Terminal window
ls -l ~/.ssh/id_ed25519 ~/.ssh/id_ed25519.pub
ssh-keygen -lf ~/.ssh/id_ed25519.pub

Do not show the private file. If you do not have a key, create a separate one for this purpose (with a passphrase):

Terminal window
install -d -m 700 ~/.ssh
ssh-keygen -t ed25519 -C agenix-recovery -f ~/.ssh/agenix-recovery

For that new key, replace id_ed25519 with agenix-recovery everywhere below. Do not overwrite an existing key.

The machine gets its own key. That lets system activation work without waiting for your personal passphrase or home-directory login.

In your terminal, create a dedicated machine identity outside the repo:

Terminal window
install -d -m 700 ~/.local/state/agenix
ssh-keygen -t ed25519 -N '' -C agenix-workstation \
-f ~/.local/state/agenix/machine-key

Stop if that path already exists. The empty passphrase here is for unattended system use. Protect the private file and your disk. It is not your personal recovery key.

Install it where the NixOS module will read it:

Terminal window
sudo install -d -m 700 /var/lib/agenix
sudo install -m 600 -o root -g root \
~/.local/state/agenix/machine-key /var/lib/agenix/machine-key
sudo stat -c '%U:%G %a' /var/lib/agenix/machine-key

Expected:

root:root 600

This does not enable an SSH server. A personal SSH key, an SSH server’s host key, and this dedicated key are separate files. My workstation had the first one and no SSH host key, so I chose a dedicated key.

Step 3: add public recipients and Git rules

Create secrets/ in the repo. The relevant files will look like this:

  • nixos-config/
    • flake.nix
    • flake.lock
    • configuration.nix
    • packages.nix
    • secrets/
      • agenix-rules.nix
      • agenix-test.age
      • api-token.age

The two .age files will be created in later steps.

Print only the public keys:

Terminal window
cat ~/.local/state/agenix/machine-key.pub
cat ~/.ssh/id_ed25519.pub

Create secrets/agenix-rules.nix. Replace the placeholder strings with your public keys, starting with ssh-ed25519. The trailing key comment is optional.

secrets/agenix-rules.nix
let
workstation = "ssh-ed25519 REPLACE_WITH_MACHINE_PUBLIC_KEY";
recovery = "ssh-ed25519 REPLACE_WITH_PERSONAL_PUBLIC_KEY";
in {
"agenix-test.age".publicKeys = [ workstation recovery ];
}

Each recipient can open the file by itself. This is not a two-key approval system.

Add these lines to your existing .gitignore:

.gitignore
secrets/*
!secrets/*.age
!secrets/agenix-rules.nix

The ! means “allow this through.” We ignore the directory’s contents by default, then allow encrypted files and the public rules file.

These rules help prevent mistakes. They cannot tell whether you put plaintext in a file ending with .age. Review what you stage. Never put the machine private key or your personal private key in this directory.

Step 4: encrypt a harmless test value

You have not activated the package yet, so agenix may not be installed in your current shell. Build the CLI from your pinned flake input first:

Terminal window
cd ~/nixos-config
nix build --impure --expr \
'(builtins.getFlake (toString ./.)).inputs.agenix.packages.x86_64-linux.default' \
--out-link /tmp/agenix-guide-cli

This example is for x86_64-linux. Use aarch64-linux instead on an ARM Linux machine.

Now create the encrypted dummy file:

Terminal window
cd ~/nixos-config/secrets
printf '%s\n' 'agenix dummy secret - safe test value' \
| /tmp/agenix-guide-cli/bin/agenix -e agenix-test.age
/tmp/agenix-guide-cli/bin/agenix -c

Expected check output:

✓ agenix-test.age

With redirected or piped input, this CLI encrypts the input as the replacement value. You do not need to open an editor. Use only a dummy in this command. Read real credentials from a local file or enter them in the editor.

Check the encrypted file’s two decryption paths while it contains only that dummy:

Terminal window
/tmp/agenix-guide-cli/bin/agenix -d agenix-test.age \
-i ~/.local/state/agenix/machine-key
/tmp/agenix-guide-cli/bin/agenix -d agenix-test.age \
-i ~/.ssh/id_ed25519

Both should print:

agenix dummy secret - safe test value

The personal key may ask for its passphrase. Testing both keys now makes a later mistake easier to find. Also check that the recovery copy in protected storage is the same key, or test it against this dummy locally.

agenix -c checks recipients. It does not prove that you still have working private keys. That is why we just checked decryption too.

Step 5: declare the dummy and build

In configuration.nix, merge these assignments into the existing module body:

configuration.nix: add inside the existing configuration
age.identityPaths = [ "/var/lib/agenix/machine-key" ];
age.secrets.agenix-test = {
file = ./secrets/agenix-test.age;
owner = "root";
group = "root";
mode = "0400";
};

Keep the quotes around the private identity path. It is a runtime location on the installed machine. The encrypted .age file is the source file Nix may copy into its store.

0400 means the owner can read the file. Everyone else do not access through those file permissions.

Stage the new public and encrypted files so the Git-backed flake can see them, then build:

Terminal window
cd ~/nixos-config
git add flake.nix flake.lock packages.nix configuration.nix .gitignore \
secrets/agenix-rules.nix secrets/agenix-test.age
git diff --cached --check
nix build .#nixosConfigurations.workstation.config.system.build.toplevel --no-link

If your package list is in configuration.nix, leave packages.nix out of that git add command.

A successful build checks the system configuration. It has not used the private key to install the secret yet.

Step 6: activate, check permissions, then reboot

Test-activate the configuration:

Terminal window
sudo nixos-rebuild test --flake .#workstation

This changes the running system. Watch for agenix decryption errors. Do not continue to a persistent switch if activation fails.

Check the installed dummy and follow its symlink when checking permissions:

Terminal window
sudo cat /run/agenix/agenix-test
sudo stat -Lc '%U:%G %a' /run/agenix/agenix-test

Expected:

agenix dummy secret - safe test value
root:root 400

An ordinary user should not be able to read this root-owned dummy:

Terminal window
test ! -r /run/agenix/agenix-test && echo 'Root-only access confirmed'

Run that last command without sudo.

If the checks pass, make the generation persistent and reboot normally:

Terminal window
sudo nixos-rebuild switch --flake .#workstation
sudo systemctl reboot

After login, repeat the dummy content and permission checks. Files under /run are temporary. This fresh boot checks whether agenix can create them again with the installed machine key.

At this point the foundation is working. Keep the dummy around for recovery tests, so you never need to print a real password just to check decryption.

Step 7: add an application token

Now we can add a second file. I will call it api-token.age. Your application might use it for a script or an API call.

Inside the existing attribute set in secrets/agenix-rules.nix, add:

secrets/agenix-rules.nix: add beside the dummy rule
"api-token.age".publicKeys = [ workstation recovery ];

For a safe practice run, encrypt another fake value:

Terminal window
cd ~/nixos-config/secrets
printf '%s\n' 'practice-token-not-a-real-key' | agenix -e api-token.age
agenix -c

When you are ready for an actual key that already exists in a local file, replace the fake value by redirecting that file into agenix:

Terminal window
agenix -e api-token.age < /absolute/path/to/your-existing-api-key
agenix -c

Replace the source path. Do not type the key itself into the command. The < reads the file locally, so its contents do not appear in shell history or terminal output.

If there is no existing file, edit the payload locally instead:

Terminal window
EDITOR=nano agenix -e api-token.age -i ~/.ssh/id_ed25519

Use an installed editor you trust, with plaintext backup and swap files disabled. Keep the secret in the format the application expects. A raw token and a file containing API_KEY=value are different formats.

In configuration.nix, add this beside the dummy declaration. Replace the user and group with your own:

configuration.nix: add the application secret
age.secrets.api-token = {
file = ./secrets/api-token.age;
owner = "alex";
group = "users";
mode = "0400";
};

This makes /run/agenix/api-token readable by alex. For a system service, use the service’s own user if it has one. There is no need to give every desktop process access to a root service’s password.

Step 8: make a consumer use the file

Creating the file does not change an application’s configuration. You must tell the application to read it.

If a NixOS module has an option such as passwordFile, tokenFile, or credentialsFile, check that module’s docs and set the appropriate option to config.age.secrets.api-token.path. The option name and file format depend on the application.

For a small repeatable check, add this manual test service to configuration.nix. It does not read token value and touch outside service:

configuration.nix: optional token-check service
systemd.services.token-check = {
description = "Check access to the agenix API token";
serviceConfig = {
Type = "oneshot";
User = "alex";
Group = "users";
ExecStart = pkgs.writeShellScript "token-check" ''
set -eu
test -r ${config.age.secrets.api-token.path}
test -s ${config.age.secrets.api-token.path}
echo "Token file is readable and not empty."
'';
};
};

For this block, the existing module arguments at the top of configuration.nix must include config and pkgs:

configuration.nix: extend the existing argument list
{ config, pkgs, ... }:

Keep any other arguments already there. The test service has no wantedBy, so it runs only when you start it. The generated script contains the runtime path, not the private token.

The same idea applies to a real script: open the credential file at runtime. Avoid putting the value in command-line arguments or debug logs. Some applications accept only an environment variable. In those cases use their supported runtime environment-file method with the right file format (rather than writing the value into environment.variables).

Do not use builtins.readFile on a decrypted secret, copy plaintext through environment.etc, or put it in a writeText file. Those approaches can put the value in the Nix store.

Step 9: check the application path

Stage your new encrypted file, rules and declaration, build, then test-activate:

Terminal window
cd ~/nixos-config
git add secrets/agenix-rules.nix secrets/api-token.age configuration.nix
git diff --cached --check
nix build .#nixosConfigurations.workstation.config.system.build.toplevel --no-link
sudo nixos-rebuild test --flake .#workstation
stat -Lc '%U:%G %a' /run/agenix/api-token

Expected for this example:

alex:users 400

Start the optional test service and inspect its result:

Terminal window
sudo systemctl start token-check.service
sudo systemctl show token-check.service -p Result -p ExecMainStatus
sudo journalctl -u token-check.service -b --no-pager -n 10

Expect Result=success, ExecMainStatus=0, and the message that the file is readable and not empty. A oneshot service can show as inactive after a successful run. That is normal here.

This proves the example user can access a nonempty file. It does not prove a token is accepted by an API. For a real application, do one ordinary action that uses the key. Whether an upload, a read-only API request, or the service’s own connection test. Keep the output free of credentials.

Then switch and reboot:

Terminal window
sudo nixos-rebuild switch --flake .#workstation
sudo systemctl reboot

After login, check ownership again, start token-check.service if you added it, and repeat the real application action. Do not run cat on the API token.

Once the real application works after reboot, remove its old plaintext copy if nothing else uses it. Keep protected recovery copies of the decryption keys. Leave those outside Git.

Step 10: review and commit the change

From secrets/, check all recipients once more:

Terminal window
cd ~/nixos-config/secrets
agenix -c
cd ..
git status --short
git diff --cached --stat
git diff --cached --check

Review the actual staged changes locally too. Expect Nix configuration, public rules, encrypted .age files, and any script changes. No private key or plaintext token should be staged.

Commit only the files belonging to this change. Keep the flake input, its lock-file pin, public rules and required encrypted files together so another checkout can build it.

If you added token-check just for learning, remove that service after testing and validate the smaller configuration. Keep the harmless dummy if it is useful for recovery checks.

Edit a secret or change its recipients

To edit a value later:

Terminal window
cd ~/nixos-config/secrets
agenix -e api-token.age -i ~/.ssh/id_ed25519
agenix -c

Then stage the changed ciphertext, build, activate, and test the consumer. A running application may need restarting before it reads the new file. Agenix does not know how every application reloads credentials.

Changing the stored value is separate from rotating the key at its provider. If you need a new API key, create it at the provider. Then encrypt it locally, test it, and revoke the old key when the provider’s workflow allows.

When you add or remove a public recipient, update agenix-rules.nix. Then re-encrypt the listed files:

Terminal window
agenix -r -i ~/.ssh/id_ed25519
agenix -c

This is called rekeying.

Commit the updated rules and changed .age files together.

Removing a recipient does not erase old encrypted versions in Git history. Someone holding the old private key may still open those old versions. If a private key was exposed, rotate the actual passwords or API keys too.

How to use in the next desktop restore

The encrypted credentials are restored with the repo but the private machine key does not. You need to put a working identity in place before activating the restored configuration.

There are two ways to do make this functional.

Restore the original machine key

If you have a protected copy of the dedicated machine key, install that copy:

Terminal window
sudo install -d -m 700 /var/lib/agenix
sudo install -m 600 -o root -g root \
/path/to/restored-machine-key /var/lib/agenix/machine-key

The public recipient still matches so the encrypted files does not need change.

Make a new machine key

You can also restore your personal recovery key. Generate a new dedicated machine key using step 2, and replace workstation in secrets/agenix-rules.nix with the new machine’s public key. Keep the recovery entry.

On a fresh installation, build the pinned CLI from step 4 first. Then rekey the existing payloads:

Terminal window
cd ~/nixos-config/secrets
/tmp/agenix-guide-cli/bin/agenix -r -i ~/.ssh/id_ed25519
/tmp/agenix-guide-cli/bin/agenix -c
cd ..
git add secrets/agenix-rules.nix secrets/*.age

The CLI opens the files locally with your personal key and encrypts them for the new recipients without printing their contents. Install the new private machine key under /var/lib/agenix/, as in step 2, before activation.

If the old desktop will remain in use, keep both machines as recipients with separate machine keys. Replacing its only recipient would stop the old machine from opening newly rekeyed files.

After reviewing hardware settings, build, test-activate, check the dummy, switch, reboot, and test the application. Commit the new public rules and rekeyed files after verification.

My current setup passed activation, personal-key dummy decryption, real application use, and full-reboot checks. I have documented the fresh-install path, but have not tested a complete reinstall with it yet. Browser profiles, OAuth databases, and other application state still need their usual backups.

If a check fails

SymptomWhat to check
agenix command not foundUse the pinned CLI link from step 4 until the package is activated.
No rules file foundRun from secrets/ and check the filename is agenix-rules.nix.
Recipient check failsCheck the public keys in the rules, then re-encrypt the affected file.
Build says a .age file is missingCreate it and stage it with Git. Check that its relative path matches the declaration.
No readable identity or no matching recipientCheck /var/lib/agenix/machine-key, root ownership/mode, and whether its public key matches the workstation recipient.
Permission denied in the appCheck the decrypted target with stat -L, then check the user running the application.
API rejects the keyCheck the provider, expiry, scopes and required file format locally. Do not print it into a shared log.
Works until rebootCheck the persistent generation and the identity on disk. A test activation alone is not a persistent switch.

For a missing or unreadable machine key, start with metadata:

Terminal window
sudo stat -c '%U:%G %a' /var/lib/agenix/machine-key
sudo ssh-keygen -y -f /var/lib/agenix/machine-key

The second command prints the public key derived from that private key. Compare it with the workstation public key in the rules. It should not print a private key or API token.

If activation fails, keep the old plaintext application file until you have fixed the setup, or return to your known working configuration. To undo an application migration, restore its old file path and remove the new consumer reference before removing the agenix declaration. Do not delete your last working decryption key while troubleshooting.

On my workstation, I could remove the old API-key file after the application worked again following a reboot. The repo now has the encrypted copy, and my restore notes include the private-key step.

Comments

In this series: Building My NixOS Workstation

9 published posts

View series →
  1. Moving into Niri & DankMaterialShell in NixOS
  2. Install the ChatGPT and Codex Desktop App Declaratively on NixOS
  3. How I Run Affinity V2 on NixOS with affinity-nix
  4. Use Elgato Wave:3 on Linux with Noise Reduction, EQ, Compression and a Virtual Microphone
  5. Install Vicinae on NixOS with a Modular Niri Setup
  6. 5 Best Terminal Tools for NixOS: My Zsh Setup and Wrappers
  7. Moving from Niri and DMS to Noctalia v5 on NixOS
  8. Distrobox on NixOS: Declare an Ubuntu Toolbox and Its Zsh Setup
  9. Keep NixOS Secrets in Git with agenix: A Step-by-Step Guide
← All posts

Type to search the published collection.