# 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.

Author: Ruhani Rabin
Canonical: https://rabin.blog/encrypt-nixos-secrets-in-git-with-agenix/
Published: 2026-10-27T16:55:00.000Z

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](https://github.com/ryantm/agenix). 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](https://age-encryption.org/) 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:

| Job                                              | What does it                         |
| ------------------------------------------------ | ------------------------------------ |
| Create and edit encrypted files                  | The `agenix` command you run locally |
| Decrypt and set file ownership during activation | The 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](https://ryantm.github.io/agenix/) 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

**Note**: You need a working NixOS flake in Git. Access to `sudo`, and an ordinary user account.

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

These are the example names used below:

| Name                | Meaning                                            |
| ------------------- | -------------------------------------------------- |
| `~/nixos-config`    | Your Git checkout                                  |
| `workstation`       | The name under `nixosConfigurations` in your flake |
| `alex`              | Your existing Linux user                           |
| `users`             | That user’s group in this example                  |
| `~/.ssh/id_ed25519` | Your 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

```nix
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

```nix
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

```nix
{ 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:

```sh
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:

```sh
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):

```sh
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.

**Note**: Keep a protected copy of the personal private key somewhere you can access if the desktop fails. Also keep a way to recover its passphrase. I used my existing protected copies.

**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:

```sh
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:

```sh
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:

```text
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:

```sh
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

```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

```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:

```sh
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:

```sh
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:

```text
✓ 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:

```sh
/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:

```text
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

```nix
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:

```sh
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:

```sh
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:

```sh
sudo cat /run/agenix/agenix-test
sudo stat -Lc '%U:%G %a' /run/agenix/agenix-test
```

Expected:

```text
agenix dummy secret - safe test value
root:root 400
```

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

```sh
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:

```sh
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

```nix
"api-token.age".publicKeys = [ workstation recovery ];
```

For a safe practice run, encrypt another fake value:

```sh
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:

```sh
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:

```sh
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

```nix
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

```nix
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

```nix
{ 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:

```sh
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:

```text
alex:users 400
```

Start the optional test service and inspect its result:

```sh
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:

```sh
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:

```sh
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:

```sh
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:

```sh
agenix -r -i ~/.ssh/id_ed25519
agenix -c
```

This is called *rekeying*.

**Note**: Your personal private key must be able to open every file listed in those rules.

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:

```sh
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:

```sh
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

| Symptom                                       | What to check                                                                                                           |
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `agenix` command not found                    | Use the pinned CLI link from step 4 until the package is activated.                                                     |
| No rules file found                           | Run from `secrets/` and check the filename is `agenix-rules.nix`.                                                       |
| Recipient check fails                         | Check the public keys in the rules, then re-encrypt the affected file.                                                  |
| Build says a `.age` file is missing           | Create it and stage it with Git. Check that its relative path matches the declaration.                                  |
| No readable identity or no matching recipient | Check `/var/lib/agenix/machine-key`, root ownership/mode, and whether its public key matches the workstation recipient. |
| Permission denied in the app                  | Check the decrypted target with `stat -L`, then check the user running the application.                                 |
| API rejects the key                           | Check the provider, expiry, scopes and required file format locally. Do not print it into a shared log.                 |
| Works until reboot                            | Check 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:

```sh
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.
