nix wireguard key gen
Mar 20, 2026 - ⧖ 17 minPublished as part of 'Wireguard Nix Flake' series.
Key Generation
Okay, so now we have our basic options setup, we can build a handy utility for the user which will generate all their key pairs for each machine. I hate writing bash and I hate writing Nix code so you can imagine my delight in writing Nix code to generate a Bash script which generates Nix code.
So now that we have our machines, we need to create Wireguard keys for each of them. Each machine needs a public key, and we need the private key of the machine which is building this config.
Public Keys
You can bring your own Wireguard keys and simply set each machine's public key to it's corresponding machines.
Private Key
The machine building this config needs to use it's private key (which matches it's public key) to secure and authenticate its end of the Wireguard interfaces. Unlike public keys we do not want to store this value directly in the Nix config because this will make it world readable by anyone. Instead, we want a path to the key, which is located outside the Nix Store. To further increase our security we will make this a path to file encrypted by Agenix. When you build your system, it will use your Agenix keys to decrypt the file holding the Wireguard private key, and place it where the interface can find it.
To do this let's add another option to our options.nix which takes in a path to file which has been Age encrypted.
It will then decrypt the file, and deploy it so it's only readable by root.
By default it will look for a key of the same name, inside a 'private_keys' directory.
# Private key which will be used to secure all the interfaces
# Should be encrypted by Agenix as it will be copied into the /nix/store
# Agenix will then decrypt this to its final location
#
# Defaults to the private key of the same name generated by this package
encrypted_private_key_file = mkOption {
type = types.path;
description = "Path to a Wireguard private key which matches this machines Wireguard public key, encrypted via Agenix.";
default = ./private_keys + ("/" + "${cfg.machine}.key");
example = ./private_keys + ("/" + "${cfg.machine}.key");
};
A Package of Convenience
So generating Wireguard keys for each machine is a pain. If you have N machines, you need to generate N private/public key pairs, and then encrypt them. If you want post-quantum resistant encryptiong, you also need to generate a key for each pair of machines. So in total, 2N + (N^2 - N)/2) keys which for even a modestly sized network of a dozen machines, is 90 keys (and then you have to encrypt them with your Agenix keys appropriately).
To amerliorate this, let's create a package which will take a your list of machines and generate all the necessary keys.
- Private/Public Keys
- Pair-Wise Preshared Keys
We will call this glorifeid shell script of a program wgqt-setup.
Setup
How much I hate
So let's make the package directory where we will store our package related files.
Then let's create a new nix file which will generate the package.
We will need pkgs because we are deriving a new package. config because we want to import our attrset of machines. We need agenix because we will be using it to encrypt the keys we create. pkg_name because we use this package name in our option warnings.
{ pkgs
, config
, agenix
}:
let
lib = pkgs.lib;
cfg = config.networking.wgqt;
# Format peers as a bash array literal: ("peer1" "peer2")
peers = (builtins.attrNames config.networking.wgqt.machines);
peers_array = lib.concatStringsSep " " (map (p: ''"${p}"'') peers);
# Only get the keys of the peers we're going to connect
peer_key_pairs = lib.filterAttrs (name: _: builtins.elem name peers) cfg.agenix_public_keys;
toBash = attrs:
let
escape = s: builtins.replaceStrings [ "\\" "\"" " " ] [ "\\\\" "\\\"" "\\ " ] s;
toAssign = name: value: ''
PUB_KEYS["\"${escape name}\""]="\"${escape (toString value)}\""
'';
in
builtins.concatStringsSep "\n" (builtins.attrValues (builtins.mapAttrs toAssign attrs));
pub_keys = toBash peer_key_pairs;
in
pkgs.stdenv.mkDerivation rec {
pname = "wgqt-setup";
version = "0.1.0";
src = ./.;
buildInputs = [ pkgs.makeWrapper ];
buildCommand = ''
mkdir -p $out/bin
# Substitute placeholders in the script template
substitute ${./generate-secrets-nix.sh} $out/bin/${pname} \
--subst-var-by PEERS "${peers_array}" \
--subst-var-by PUB_KEYS "${pub_keys}"
chmod +x $out/bin/${pname}
# Wrap the script to add required tools to PATH
wrapProgram $out/bin/${pname} \
--prefix PATH : ${lib.makeBinPath [ pkgs.wireguard-tools pkgs.age agenix ]}
'';
meta = with lib; {
description = "Generate the file 'secrets.nix' in the current working directory which will be used by Agenix to encrypt interface private keys, pre-shared keys, and the gossip protocol key.";
license = licenses.mit;
maintainers = [ ];
platforms = platforms.all;
};
}
Options
To fully automate this, we need a way to access the Agenix public keys so that we can encrypt everything properly, such that only the machine that is building this config can decrypt its private key and only members of a presharedkey pair can decrypt their preshared keys.
If these keys are not set, we won't build the package because it doesn't matter
NixOS Module
In nixosModule.nix we will include our package, so long as it is being built
So each machine needs:
- A public/private key pair
- A way to associate
The public keys should be added to our machines config, since they can be viewed by anyone.
The private keys should be encrypted and stored in ./private_keys so they cannot be compromised.
To encrypt our Wireguard private keys we will use Agenix. This means that we will need another pair or public/private keys for Agenix.
Do not confuse the two. Agenix keys are used to ecrypt Wireguard private keys. Wireguard keys are used to secure the network interface. Agenix keys are used to encrypt these keys so they don't end up in the world readable /nix/store
This guide will not cover using Agenix. It assumes you know how to generate Agenix public/private pair keys.
So what we actually need to do is:
-
Generate a public/private key pair for each machine
-
Set the public key to each machine's corresponding 'public_key' entry we created
-
Set a path to the encrypted private key
-
Get the Agenix public keys for each machine
-
Create a secrets.nix which tells Agenix which public keys to use to encrypt which Wireguard private keys
-
Generate the Wireguard keys pairs for each machine
-
Added the public key to each machines public_key
-
Encrypt the Wireguard private key with the corresponding Agenix public key
-
Tell our configuration where to find the decrypted Wireguard private key
Updating our options
We need to add two more options to our options set, one for where our Wireguard private key will be and one with all our machine's agenix keys.
We know where
# Private key which will be used to secure all the interfaces
# Should be encrypted by Agenix as it will be copied into the /nix/store
# Agenix will then decrypt this to its final location
#
# Defaults to the private key of the same name generated by this package
private_key_file = mkOption {
type = types.path;
default = ./private_keys + ("/" + "${cfg.machine}.key");
};
{ lib, config, ... }:
with lib; with builtins;
let
cfg = config.networking.wgqt;
machineNames = attrNames cfg.machines;
# Config Checks
cfgCheck = cfg.enable;
atLeastTwoMachines = (length machineNames) >= 2;
nameExtantInMachines = hasAttr cfg.machine cfg.machines;
in
{
# Ensure that our options are set correctly
config = {
assertions = [
# Check that there are at least two machines
{
assertion = cfgCheck && atLeastTwoMachines;
# Maybe check if there is also at least one proxy or network
message = ''You must have at least two machines in `networking.wgqt.machines` to form a Wireguard interface.'';
}
# Check that 'machine' exists in 'machines'
{
assertion = cfgCheck && nameExtantInMachines;
# Maybe check if there is also at least one proxy or network
message = ''The machine listed in `networking.wgqt.machine` must exist in `networking.wgqt.machines`.'';
}
];
};
The Complete Options
You can also view this file here:
{lib, config, ...}:
with lib;
let
# Short hand so we don't need to type this out each time
cfg = config.networking.wgqt;
# Check if the module is enabled at all
cfgCheck = cfg.enable;
machineNames = attrNames cfg.machines;
# The option submodule type for an individual machine
machine_type = types.attrsOf (
types.submodule {
options = {
public_key = mkOption {
type = type.str;
description = "This machine's Wireguard public key";
};
public_address = mkOption {
type = types.NullOr types.str;
description = "The address this machine can be reached by all other machines.";
};
};
});
in
options = {
networking = {
wgqt = {
enable = mkOption {
description = "Enable being a Wireguard cutie.";
type = types.bool;
default = true;
};
# Private key which will be used to secure all the interfaces
# Should be encrypted by Agenix as it will be copied into the /nix/store
# Agenix will then decrypt this to its final location
#
# Defaults to the private key of the same name generated by this package
private_key_file = mkOption {
type = types.path;
default = ./private_keys + ("/" + "${cfg.machine}.key");
};
# The public Agenix keys that correspond to each of your machines so
# that they can decrypt their corresponding private_key_file
agenix_public_keys = mkOption {
type = types.attrsOf types.str;
description = "The public keys to be used for creating the secrets file, if you're using the included packages to create it.";
default = config.secrets.agenix.public_keys.machines;
};
# This Machine's name
# Must match one of the keys in the machines attribute set
machine = mkOption {
description = "The name of the machine listed in networking.wgqt.machines which is the machine for which the interface will be built (i.e. this machine).";
type = types.str;
};
# List of machines that can be part of this net
machines = mkOption {
description = "An attribute set of your machines, containing their public key, and their reachable addresses.";
type = types.attrsOf machine_type;
default = defaults.machines;
};
};
};
};
}
Before we begin, we can create some general options and tooling to make our lives easier going forward. Here's the abbreviated option setup we're going to be building under:
# options.nix
options = {
networking = {
wgqt = {
enable = mkOption {
description = "Enable being a Wireguard cutie.";
type = types.bool;
default = true;
};
# Eliding PKS, DERS, DERRS
# This Machine
machine = mkOption {
description = "The name of the machine listed in networking.wgqt.machines which is the machine for which the interface will be built (i.e. this machine).";
type = types.str;
# default = "";
};
# List of machines
machines = mkOption {
description = "An attribute set of your machines, containing their public key, and their reachable addresses.";
type = types.attrsOf machine_type;
default = defaults.machines;
};
};
};
};
We have an enable to turn on and off all functionality. Some Wireguard interface general parameters with sensible defaults (which I've elided because you probably won't mess with them, but they're there is you need them). Then a list of machines, and a specific machine which represents this host. It's useful to have all your machines in a single attrest because it makes creating new interfaces easier, and some of the math later easier as well, if you know all the machines ahead of time. We also add some checks to ensure that you have at least two machines and that your machine is listed among those two (otherwise you can't have network).
config = {
assertions = [
# Check that there are at least two machines
{
assertion = cfgCheck && atLeastTwoMachines;
# Maybe check if there is also at least one proxy or network
message = ''You must have at least two machines in `networking.wgqt.machines` to form a Wireguard interface.'';
}
# Check that 'machine' exists in 'machines'
{
assertion = cfgCheck && nameExtantInMachines;
# Maybe check if there is also at least one proxy or network
message = ''The machine listed in `networking.wgqt.machine` must exist in `networking.wgqt.machines`.'';
}
];
};
So what is a 'machine' ? For our purposes at them moment, all we need is a public key, which Wireguard will use to identify and encrypt packets with, and the public endpoint where this machine can be reached by all machines. This is optional, as part of the purpose of this flake is to auto configure routes for machines which have no static addresses at all. Later we will extend this but those attributes will make more sense if introduced in the context in which they are needed. If none of the nodes can find each other, then Wireguard can't setup a network.
machine_type = types.attrsOf (
types.submodule {
options = {
public_key = mkOption {
type = type.str;
description = "This machine's Wireguard public key";
};
public_address = mkOption {
type = types.NullOr types.str;
description = "The address this machine can be reached by all other machines.";
};
};
});
Public and Private Keys
Okay so how do we get these public keys (and their private counterparts) ? For our purposes we will be using Agenix to store these keys secretly in the ./private_keys directory, and their public keys will of course be stored in the networking.wgqt.machines.<machine>.public_key as well in the secrets.nix which tells Agenix how to decrypt/encrypt the secrets.
So Wireguard needs this machine's private key to instantiate an interface. We can add that to the options, so if you BYOK you can just epcify it. It defaults to where the programs which will automatically generate the keys for you would place it in the flake.
options = {
# Private key which will be used to secure all the interfaces
# Should be encrypted by Agenix as it will be copied into the /nix/store
# Agenix will then decrypt this
private_key_file = mkOption {
type = types.str;
# By default we use the key of the same machine in the private_keys directory in the flake
default = ./private_keys + ("/" + "${cfg.machine}.key");
};
};
So if you've enabled wgqt and added in your machines to the machines attrset, then STOP. Rebuilding your system will add the package "wgqt-key-gen" to your PATH. You will need to either run this, or generate your own keys before trying to enable any proxies. This guide does not cover using Agenix, and hopefully you won't need to know how if you use these scripts.
To make things easier, you can enable wgqt and add your machines and it will create a package for you which if run will create the public/private key pairs for each machine, and create a secrets.nix which Agenix can use to deploy your encrypted secrets without exposing them to the
Reverse Proxy
This is a very common use case for Wireguard, where many computers connect to a 'gateway' computer which then proxies all their network requests. This is also the most simple to setup, as each machine only has one connection, and doesn't need to
I will be updating this blog post as I update my flake and module to be more ergonomic to use, stay tuned.
Example Machines Config
# Example Machines
netowrking.wgqt.machines = {
server = {
public_key = "CZc/OcuvBGUGDSll32yIidvRZr4WWRpKhS/a/ccPuwA="
public_endpoint = "123.456.789.012";
};
laptop = {
public_key = "MvnDMnuK8iN+pED7rjhqhQ/Mq46Cui/LRYurhFvHi2U=";
};
}