← Back

Managing secrets with agenix


Dealing with sensitive information in Nix is tricky. The primary concern is that Nix is insecure by default – anything Nix touches ends up in the Nix store, and the store is readable to every process and user on your computer. The second concern is that putting secrets in your configuration means that you now have to be careful about never leaking your configuration by, say, putting it in a public repo. Both of these issues become larger the larger and more distributed your infrastructure is.

So, how do you effectively manage your secrets? There are many proposed solutions to this issue, but I have generally found them to not be worth the hassle, and instead just manually deployed secrets to my machines. That is, until I discovered agenix.

I can’t overstate how right agenix managed to get this. It requires almost no setup or overhead, and works seamlessly across multiple machines and users. We use it at $work, I use it in my personal config, and I have not looked back.

Unfortunately, I find that the manual makes agenix seem more complicated than it is, and doesn’t really highlight what makes it so nice to use. So, here is more evangelism for niche technologies a brief, opinionated guide to agenix.

1 Example

First, I want to give a little taste of how agenix tackles this problem. As an example, let’s say we’re setting up an OpenVPN configuration:

  # Option 1
  services.openvpn.myServer.config = ''
    auth-user-pass path/to/credentials.txt
  # Option 2
  services.openvpn.myServer.config = ''
    auth-user-pass ${./path/to/credentials.txt}

Option 1 avoids the Nix store, but it is not declarative; we need to manually make sure that the path/to/credentials.txt actually exists.

With option 2, we have Nix manage the file for us, but now it ends up in the Nix store, making it world-readable.

With agenix, we get the best of both worlds. Our configuration looks like this:

  age.secrets.myServerCredentials.file = path/to/credentials.age;
  services.openvpn.myServer.config = ''
    auth-user-pass ${config.age.secrets.myServerCredentials.path}

The only thing Nix ever sees is the encrypted .age file, so no unencrypted secrets will end up in the nix store. During early boot, agenix will take the encrypted file out of the nix store, decrypt it, and put it in config.age.secrets.myServerCredentials.path (defaults to /run/secrets/myServerCredentials).

2 age and agenix

agenix is powered by age. As you want from a cryptographic application, age is extremely light on features (cough PGP cough), but it does have two that make it ideal for this use case: encryption using SSH keys, and multiple recipients. Multiple recipients just means that we encrypt with multiple keys, allowing anyone that has just one of them to decrypt.

You, the user, have an SSH key, and your machines have SSH keys12. Yours is the one typically found in ~/.ssh/, and the one for your system is in /etc/ssh/ (or reported by ssh-keyscan localhost). agenix’s big insight is that this is all we need to leverage age for robust secret management infrastructure.

When we put a secret in our Nix configuration, we use the agenix command-line tool to encrypt it with both the user’s and the system’s public keys as recipients. This way, the user can still view and edit the keys, and the agenix NixOS module can now decrypt the secrets at boot using the system key, and put them in their desired place.

3 Usage

Configuration consists of two parts; configuring the NixOS module and configuring the agenix command-line tool, or decryption and encryption, respectively.

3.1 Decryption: NixOS module configuration

Start by importing the agenix module into your system configuration, using your Nix dependency-management tool of choice (flakes, channels, niv, builtins.fetchTarball, etc.). We actually already saw the NixOS module’s configuration in its entirety in the OpenVPN example above; set age.secrets.myServerCredentials.file, and read age.secrets.myServerCredentials.path. There’s some additional fields that allow you to control mode flags etc., but the defaults usually work fine. As long as the configured secrets can actually be decrypted with the system’s SSH key, that’s all the setup needed.

3.2 Encryption: The agenix CLI

Let’s see how we actually encrypt a file.

  1. Start by making a folder in your Nix config called secrets.

  2. In the secrets folder, create a file called secrets.nix. secrets.nix contains all relevant public keys, and determines what files are encrypted with what keys. While secrets.nix is a Nix file, it is not in any way actually part of the rest of your NixOS configuration; you never import it or otherwise refer to it in your NixOS configuration. This is the example secrets.nix file from the tutorial:

  user1 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIL0idNvgGiucWgup/mP78zyC23uFjYq0evcWdjGQUaBH";
  user2 = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCoQ9S7V+CufAgwoehnf2TqsJ9LTsu8pUA3FgpS2mdVwcMcTs++8P5sQcXHLtDmNLpWN4k7NQgxaY1oXy5e25x/4VhXaJXWEt3luSw+Phv/PB2+aGLvqCUirsLTAD2r7ieMhd/pcVf/HlhNUQgnO1mupdbDyqZoGD/uCcJiYav8i/V7nJWJouHA8yq31XS2yqXp9m3VC7UZZHzUsVJA9Us5YqF0hKYeaGruIHR2bwoDF9ZFMss5t6/pzxMljU/ccYwvvRDdI7WX4o4+zLuZ6RWvsU6LGbbb0pQdB72tlV41fSefwFsk4JRdKbyV3Xjf25pV4IXOTcqhy+4JTB/jXxrF";
  users = [ user1 user2 ];

  system1 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPJDyIr/FSz1cJdcoW69R+NrWzwGK/+3gJpqD1t8L2zE";
  system2 = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKzxQgondgEYcLpcPdJLrTdNgZ2gznOHCAxMdaceTUT1";
  systems = [ system1 system2 ];
  "secret1.age".publicKeys = [ user1 system1 ];
  "secret2.age".publicKeys = users ++ systems;
  1. From the secrets directory, use agenix -e <filename> to edit files. One thing to note is that the system module doesn’t install the agenix executable so you still need to get that somewhere. In my flake-based config I do this:
{ environment.systemPackages = [ inputs.agenix.packages.${system}.agenix ]; }

That’s it! You now have secure declarative management of secrets. The only other thing to be aware of is that you need to rekey your secrets if you change what files have access to them, which is a matter of running agenix --rekey.

4 Caveats

4.1 Configuration

One thing to watch out for is that incorporating your secrets into your config is not always as easy as it is in the OpenVPN example above. You might want to use secrets for options that were not designed to be kept secret, or use agenix for things that aren’t configured through Nix at all.

Two examples from my own config:

  1. I want to add secret binary caches, but the nix.binaryCaches option only takes strings, it does not take file paths. So, I sidestep the usual cache declaration mechanism and go through nix.extraOptions and the new !include directive.

  2. AWS credentials aren’t configured through Nix, it relies on a file being present in a designated location in your root/user folder. So, we just override the target path so it writes to /root/.aws/credentials directly.

The takeaway is that you might encounter situations that require some creativity.

4.2 Some thoughts on security

I am not a security expert, the author of agenix is not a security expert, so you may be hesitant to trust agenix with your secrets. I’m not here to convince you of anything, but here are some considerations that convinced me.

First of all, the author of age itself is a security expert. I found some discussion of potential attack vectors against age, but based on my understanding of the underlying reasoning, this would not apply to agenix since we’re not dealing with not high-volume network traffic, all encrypted with the same key.

Second, you can very easily audit agenix yourself, it’s just 150 lines of pretty understandable bash in the encryption code, and another 100 in the NixOS module.

For what it’s worth, I trust it enough to make my encrypted secrets public and then brag about it in a blog post, so at least if something breaks we’re both screwed.

  1. Provided you have ssh, i.e. services.openssh, enabled. If not, you manually have to specify age.sshKeyPaths.↩︎

  2. More info on the distinction between user and system keys here.↩︎

← Back