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
.agefiles 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:
| 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 (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:
| 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:
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:
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:
{ 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:
cd ~/nixos-confignix flake lockgit diff -- flake.nix flake.lock packages.nixExpect 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:
ls -l ~/.ssh/id_ed25519 ~/.ssh/id_ed25519.pubssh-keygen -lf ~/.ssh/id_ed25519.pubDo not show the private file. If you do not have a key, create a separate one for this purpose (with a passphrase):
install -d -m 700 ~/.sshssh-keygen -t ed25519 -C agenix-recovery -f ~/.ssh/agenix-recoveryFor 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:
install -d -m 700 ~/.local/state/agenixssh-keygen -t ed25519 -N '' -C agenix-workstation \ -f ~/.local/state/agenix/machine-keyStop 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:
sudo install -d -m 700 /var/lib/agenixsudo install -m 600 -o root -g root \ ~/.local/state/agenix/machine-key /var/lib/agenix/machine-keysudo stat -c '%U:%G %a' /var/lib/agenix/machine-keyExpected:
root:root 600This 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:
cat ~/.local/state/agenix/machine-key.pubcat ~/.ssh/id_ed25519.pubCreate secrets/agenix-rules.nix. Replace the placeholder strings with your public keys, starting with ssh-ed25519. The trailing key comment is optional.
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:
secrets/*!secrets/*.age!secrets/agenix-rules.nixThe ! 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:
cd ~/nixos-confignix build --impure --expr \ '(builtins.getFlake (toString ./.)).inputs.agenix.packages.x86_64-linux.default' \ --out-link /tmp/agenix-guide-cliThis example is for x86_64-linux. Use aarch64-linux instead on an ARM Linux machine.
Now create the encrypted dummy file:
cd ~/nixos-config/secretsprintf '%s\n' 'agenix dummy secret - safe test value' \ | /tmp/agenix-guide-cli/bin/agenix -e agenix-test.age/tmp/agenix-guide-cli/bin/agenix -cExpected check output:
✓ agenix-test.ageWith 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:
/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_ed25519Both should print:
agenix dummy secret - safe test valueThe 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:
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:
cd ~/nixos-configgit add flake.nix flake.lock packages.nix configuration.nix .gitignore \ secrets/agenix-rules.nix secrets/agenix-test.agegit diff --cached --checknix build .#nixosConfigurations.workstation.config.system.build.toplevel --no-linkIf 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:
sudo nixos-rebuild test --flake .#workstationThis 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:
sudo cat /run/agenix/agenix-testsudo stat -Lc '%U:%G %a' /run/agenix/agenix-testExpected:
agenix dummy secret - safe test valueroot:root 400An ordinary user should not be able to read this root-owned dummy:
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:
sudo nixos-rebuild switch --flake .#workstationsudo systemctl rebootAfter 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:
"api-token.age".publicKeys = [ workstation recovery ];For a safe practice run, encrypt another fake value:
cd ~/nixos-config/secretsprintf '%s\n' 'practice-token-not-a-real-key' | agenix -e api-token.ageagenix -cWhen you are ready for an actual key that already exists in a local file, replace the fake value by redirecting that file into agenix:
agenix -e api-token.age < /absolute/path/to/your-existing-api-keyagenix -cReplace 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:
EDITOR=nano agenix -e api-token.age -i ~/.ssh/id_ed25519Use 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:
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:
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:
{ 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:
cd ~/nixos-configgit add secrets/agenix-rules.nix secrets/api-token.age configuration.nixgit diff --cached --checknix build .#nixosConfigurations.workstation.config.system.build.toplevel --no-linksudo nixos-rebuild test --flake .#workstationstat -Lc '%U:%G %a' /run/agenix/api-tokenExpected for this example:
alex:users 400Start the optional test service and inspect its result:
sudo systemctl start token-check.servicesudo systemctl show token-check.service -p Result -p ExecMainStatussudo journalctl -u token-check.service -b --no-pager -n 10Expect 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:
sudo nixos-rebuild switch --flake .#workstationsudo systemctl rebootAfter 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:
cd ~/nixos-config/secretsagenix -ccd ..git status --shortgit diff --cached --statgit diff --cached --checkReview 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:
cd ~/nixos-config/secretsagenix -e api-token.age -i ~/.ssh/id_ed25519agenix -cThen 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:
agenix -r -i ~/.ssh/id_ed25519agenix -cThis 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:
sudo install -d -m 700 /var/lib/agenixsudo install -m 600 -o root -g root \ /path/to/restored-machine-key /var/lib/agenix/machine-keyThe 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:
cd ~/nixos-config/secrets/tmp/agenix-guide-cli/bin/agenix -r -i ~/.ssh/id_ed25519/tmp/agenix-guide-cli/bin/agenix -ccd ..git add secrets/agenix-rules.nix secrets/*.ageThe 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:
sudo stat -c '%U:%G %a' /var/lib/agenix/machine-keysudo ssh-keygen -y -f /var/lib/agenix/machine-keyThe 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