My new setup with wireguard on nixos and with systemd-networkd took a little bit time, but here is how I did it:

TLDR:

Server config

{
  networking.firewall.interfaces."wg0" = {
    allowedTCPPorts = [ 53 ];
    allowedUDPPorts = [ 53 ];
  };
  networking.firewall = {
    allowedUDPPorts = [ 51820 ];
    extraCommands = ''
      iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
      ip46tables -A FORWARD -i eth0 -o wg0 -j ACCEPT
      ip46tables -A FORWARD -i wg0 -j ACCEPT
    '';
    #flush the chain then remove it
    extraStopCommands = ''
      iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE
      ip46tables -D FORWARD -i eth0 -o wg0 -j ACCEPT
      ip46tables -D FORWARD -i wg0 -j ACCEPT
    '';
  };
  services.resolved.extraConfig = ''
    DNSStubListener=no
  '';
  services.dnsmasq = {
    enable = true;
    settings.interface = "wg0";
  };
  boot.kernel.sysctl = {
    # if you use ipv4, this is all you need
    "net.ipv4.conf.all.forwarding" = true;

    # If you want to use it for ipv6
    "net.ipv6.conf.all.forwarding" = true;
  };

  systemd.network = {
    netdevs = {
      "90-wg0" = {
        netdevConfig = { Kind = "wireguard"; Name = "wg0"; };
        wireguardConfig = {
          PrivateKeyFile = config.age.secrets.wg-pk-server.path;
          ListenPort = 51820;
        };
        wireguardPeers = [
          # client 1
          {
            wireguardPeerConfig = {
              PublicKey = "6uh1CPUJscxuTfrhiKj76741tsmp/cJYp7VqaIDzigo=";
              PresharedKeyFile = config.age.secrets.wg-psk-client1.path;
              AllowedIPs = [ "10.66.66.2" "2001:db8:aaaa:bbbb:cccc:dddd::2" ];
              PersistentKeepalive = 15;
            };
          }
          # client 2
          {
            wireguardPeerConfig = {
              PublicKey = "juQP6cIW/eAgZomARSEZl3MIhLWhIsy8TYnd432chTE=";
              PresharedKeyFile = config.age.secrets.wg-psk-client2.path;
              AllowedIPs = [ "10.66.66.3" "2001:db8:aaaa:bbbb:cccc:dddd::3" ];
              PersistentKeepalive = 15;
            };
          }
        ];
      };
    };
    networks = {
      "90-wg0" = {
        matchConfig = { Name = "wg0"; };
        address = [ "10.66.66.1/24" "fdd6:b156:90e9::1/64" "2001:db8:aaaa:bbbb:cccc:dddd::1/80" ];
        networkConfig = {
          IPForward = true;
        };
      };
    };
  };
}

client 1

networking.wg-quick.interfaces = {
wg0 =
    {
    autostart = false;
    address = [
        "10.66.66.2"
        "2001:db8:aaaa:bbbb:cccc:dddd::2"
    ];
    dns = [
        "10.66.66.1"
        "fdd6:b156:90e9::1"
    ];
    peers = [
        {
        allowedIPs = ["0.0.0.0/0 "::/0" ];
        endpoint = "my-server.com:51820";
        publicKey = "Q5U2mYdt1f9AtclL7ndq1/Vvne7/fwtrxJRGbBE4Ths=";
        presharedKeyFile = config.age.secrets.wg-psk-client1.path;
        }
    ];
    privateKeyFile = config.age.secrets.wg-pk-client1.path;
    };
};

The details:

First, here are the important IPs:

server/client IPv4 IPv6 IPv6 ULA
server 10.66.66.1 2001:db8:aaaa:bbbb:cccc:dddd::1 fdd6:b156:90e9::1
client 1 10.66.66.2 2001:db8:aaaa:bbbb:cccc:dddd::2
client 2 10.66.66.3 2001:db8:aaaa:bbbb:cccc:dddd::3

The IPv6 must lie within the subnet of the server. The server has a 2001:db8:aaaa:bbbb::/64 IP (e.g. 2001:db8:aaaa:bbbb::1). The IPs of the clients (or rather, peers) are chosen in the same subnet, but distinct of the server IP (for my own convenience).

The server needs to open the firewall on the external interface as well as port 53 for internal DNS on the wireguard interface wg0:

networking.firewall.interfaces."wg0" = {
    allowedTCPPorts = [ 53 ];
    allowedUDPPorts = [ 53 ];
  };
  networking.firewall = {
    allowedUDPPorts = [ 51820 ];
    };

Since working with systemd-networkd, we’ll need to deactivate the stub DNS Resolver which blocks port 53 and add a new DNS resolver:

services.resolved.extraConfig = ''
    DNSStubListener=no
  '';
services.dnsmasq = {
enable = true;
settings.interface = "wg0";
};

We’ll use this setup primarily as a VPN, so port forwarding needs to be enabled1. Adapt your config to the WAN interface of your server (here it is eth0):

networking.firewall = {
    extraCommands = ''
      iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
      ip46tables -A FORWARD -i eth0 -o wg0 -j ACCEPT
      ip46tables -A FORWARD -i wg0 -j ACCEPT
    '';
    #flush the chain then remove it
    extraStopCommands = ''
      iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE
      ip46tables -D FORWARD -i eth0 -o wg0 -j ACCEPT
      ip46tables -D FORWARD -i wg0 -j ACCEPT
    '';
};
boot.kernel.sysctl = {
    # if you use ipv4, this is all you need
    "net.ipv4.conf.all.forwarding" = true;

    # If you want to use it for ipv6
    "net.ipv6.conf.all.forwarding" = true;
};

Lastly, we’ll use systemd for wireguard, first creating the netdevs:

systemd.network = {
    netdevs = {
      "90-wg0" = {
        netdevConfig = { Kind = "wireguard"; Name = "wg0"; };
        wireguardConfig = {
          PrivateKeyFile = config.age.secrets.wg-pk-server.path;
          ListenPort = 51820;
        };
        wireguardPeers = [
          # client 1
          {
            wireguardPeerConfig = {
              PublicKey = "6uh1CPUJscxuTfrhiKj76741tsmp/cJYp7VqaIDzigo=";
              PresharedKeyFile = config.age.secrets.wg-psk-client1.path;
              AllowedIPs = [ "10.66.66.2" "2001:db8:aaaa:bbbb:cccc:dddd::2" ];
              PersistentKeepalive = 15;
            };
          }
          # client 2
          {
            wireguardPeerConfig = {
              PublicKey = "juQP6cIW/eAgZomARSEZl3MIhLWhIsy8TYnd432chTE=";
              PresharedKeyFile = config.age.secrets.wg-psk-client2.path;
              AllowedIPs = [ "10.66.66.3" "2001:db8:aaaa:bbbb:cccc:dddd::3" ];
              PersistentKeepalive = 15;
            };
          }
        ];
      };
    };

Here we use agenix2 for secret management of the preshared key file and private key file. The public/private keypairs are generated by wg genkey | tee privatekey | wg pubkey > publickey. The preshared key with wg genpsk. The AllowedIPs represent the client IP address. The first one is an arbitrary private IP address. The second one is an IPv6 IP within the subnet of the server. It needs to be a public routable address to be able to access the internet. (Or use NPt which isn’t covered here). If you just use a ULA (Unique local address) the wireguard server can still be reached, but cannot route IPv6 traffic to the internet.

Next, we’ll create the corresponding network:

networks = {
    "90-wg0" = {
    matchConfig = { Name = "wg0"; };
    address = [ "10.66.66.1/24" "fdd6:b156:90e9::1" "2001:db8:aaaa:bbbb:cccc:dddd::1/80" ];
    networkConfig = {
        IPForward = true;
    };
    };
};

We’ll use the subnet /24 for the IPv4 and /80 for the IPv6 address, so the server has no issues addressing these adresses. Also, we add a ULA for the server here, to use for the IPv6 address of the DNS server. The IPForward = true; is needed, since the kernel forwarding parameter is ignored by systemd3.

Thats it for the server.

For the client, only the wireguard config is needed, as wg-quick takes care of all the rest. The address is the address of the client. As already stated above, the IPv6 address needs to publicly routable.

networking.wg-quick.interfaces = {
wg0 =
    {
    autostart = false;
    address = [
        "10.66.66.2"
        "2001:db8:aaaa:bbbb:cccc:dddd::2"
    ];

We’ll use the DNS server of the server to prevent DNS leaking:

    dns = [
        "10.66.66.1"
        "fdd6:b156:90e9::1"
    ];

Next, the server is added as an peer. The allowedIPs parameter states, which address should be reachable through that peer. By adding the catch-all IPv4 and IPv6 addresses here, wg-quick will add a default route:

peers = [
        {
        allowedIPs = ["0.0.0.0/0" ::/0" ];
        endpoint = "my-server.com:51820";
        publicKey = "Q5U2mYdt1f9AtclL7ndq1/Vvne7/fwtrxJRGbBE4Ths=";
        presharedKeyFile = config.age.secrets.wg-psk-client1.path;
        }
    ];

Lastly, the private key file is needed:

    privateKeyFile = config.age.secrets.wg-pk-client1.path;
    };
};

And thats it. The tunnel is created automatically by systemd-networkd on the server and by sudo systemctl start wg-quick-wg0.service.

Some more reading material:

systemd-netdevs wireguard section
Extensive arch wiki about wireguard
IPv6 with wireguard
Systemd wireguard config
NixOS wireguard wiki page
Wireguard with IPv6

References: