To have all internet-bound traffic go out through a Wireguard tunnel—and if Wireguard goes down, to not not have any internet-bound traffic leak through other interfaces—the easiest option is to use a container with a private network stack.

There are two options:

  1. You can move the Wireguard interface into the container. This takes some work to set up and prevents the host from using the interface.
  2. If the host needs connectivity over the same Wireguard tunnel, the host can create virtual interfaces and perform network address translation (NAT) to forward traffic to and from the child.

I have most confidence in the first option, especially as the surrounding configuration evolves. You’re less likely to subtly break the configuration and start leaking traffic in the future.

Both options are shown here. NixOS is my preferred system, so I’ve specified the configuration using NixOS here. Translation to a non-NixOS system should be straightforward.

Option 1: move the Wireguard interface into the container

Here we set up Wireguard in a new network namespace and move that namespace into the container. The Wireguard interface must be created in the main (host) network namespace and subsequently moved into the new namespace: this allows Wireguard to communicate with the endpoint over the main network interface.

The following sets up the Wireguard interface in the container as the default route, and allows some traffic between the LAN and the container through a virtual ethernet interface. This configuration assumes the host’s main network interface is called enp3s0.

systemd-networkd does not yet provide the required tools to do this. Instead, this configuration defines systemd units to perform the network setup.

{ lib, pkgs, ... }:
let
  wgIpv4 = "1.2.3.4";
  wgIpv6 = "1:2:3:4::0";

  natIpv4Host = "192.168.10.10";
  natIpv4Ns = "192.168.10.11";
in
{
  networking.useDHCP = false;
  networking.interfaces."enp3s0" = {
    useDHCP = true;
  };

  environment.etc."netns-wgns-wg-ips".text = ''
    IPV4=${wgIpv4}
    IPV6=${wgIpv6}
  '';
  environment.etc."netns-wgns-veth-ips".text = ''
    IPV4_HOST=${natIpv4Host}
    IPV4_NS=${natIpv4Ns}
  '';

  systemd.services = {
    "netns@" = {
      description = "%i network namespace";
      serviceConfig = with pkgs; {
        Type = "oneshot";
        RemainAfterExit = true;
        ExecStart = "${iproute}/bin/ip netns add %i";
        ExecStop = "${iproute}/bin/ip netns del %i";
      };
    };

    "lo@" = {
      description = "loopback in %i network namespace";

      bindsTo = [ "netns@.service" ];
      after = [ "netns@.service" ];

      serviceConfig = {
        Type = "oneshot";
        RemainAfterExit = true;
        ExecStart =
          let
            start = with pkgs; writeShellScript "lo-up" ''
              set -e

              ${iproute}/bin/ip -n $1 addr add 127.0.0.1/8 dev lo
              ${iproute}/bin/ip -n $1 link set lo up
            '';
          in
          "${start} %i";
        ExecStopPost = with pkgs; "${iproute}/bin/ip -n %i link del lo";
      };
    };

    # This unit assumes a file named /etc/netns-<network namespace>-veth-ips
    # exists to set IPV4_HOST and IPV4_NS environment variables to valid
    # IPv4 addresses to be used for the two sides of the veth interface.
    "veth@" = {
      description = "virtual ethernet network interface between the main and %i network namespaces";

      bindsTo = [ "netns@.service" ];
      after = [ "netns@.service" ];

      serviceConfig = {
        Type = "oneshot";
        RemainAfterExit = true;
        ExecStart =
          let
            start = with pkgs; writeShellScript "veth-up" ''
              set -e

              . /etc/netns-$1-veth-ips

              # As this templated unit potentially has multiple units running
              # simultaneously, the interface is created with a unique name in
              # the main network namespace, then moved to the new network
              # namespace and renamed.
              ${iproute}/bin/ip link add ve-$1 type veth peer name eth-lan-$1
              ${iproute}/bin/ip link set eth-lan-$1 netns $1
              ${iproute}/bin/ip -n $1 link set dev eth-lan-$1 name eth-lan
              ${iproute}/bin/ip addr add $IPV4_HOST/32 dev ve-$1
              ${iproute}/bin/ip -n $1 addr add $IPV4_NS/32 dev eth-lan
              ${iproute}/bin/ip link set ve-$1 up
              ${iproute}/bin/ip -n $1 link set eth-lan up
              ${iproute}/bin/ip route add $IPV4_NS/32 dev ve-$1
              ${iproute}/bin/ip -n $1 route add $IPV4_HOST/32 dev eth-lan
            '';
          in
          "${start} %i";
        ExecStopPost =
          let
            stop = with pkgs; writeShellScript "veth-down" ''
              ${iproute}/bin/ip -n $1 link del eth-lan
              ${iproute}/bin/ip link del ve-$1
            '';
          in
          "${stop} %i";
      };
    };

    # This unit assumes a Wireguard configuration file exists at
    # /etc/wg-<network namespace>.conf. Note this is a Wireguard configuration
    # file, not a wg-quick configuration file. See "CONFIGURATION FILE FORMAT
    # EXAMPLE" in wg(8).
    #
    # This further assumes a file exists at /etc/netns-<network
    # namespace>-wg-ips to set IPV4 and IPV6 environment variables to valid IP
    # addresses to be used by the Wireguard interface.
    "wg@" = {
      description = "wg network interface in %i network namespace";

      bindsTo = [ "netns@.service" ];
      wants = [
        "network-online.target"
        "nss-lookup.target"
      ];
      after = [
        "netns@.service"
        "network-online.target"
        "nss-lookup.target"
      ];

      serviceConfig = {
        Type = "oneshot";
        RemainAfterExit = true;
        Restart = "on-failure";
        RestartSec = 3;
        ExecStart =
          let
            start = with pkgs; writeShellScript "wg-up" ''
              set -e

              . /etc/netns-$1-wg-ips

              # As this templated unit potentially has multiple units running
              # simultaneously, the interface is created with a unique name in
              # the main network namespace, then moved to the new network
              # namespace and renamed.
              ${iproute}/bin/ip link add wg-$1 type wireguard
              ${iproute}/bin/ip link set wg-$1 netns $1
              ${iproute}/bin/ip -n $1 link set dev wg-$1 name wg0
              ${iproute}/bin/ip -n $1 addr add $IPV4 dev wg0
              ${iproute}/bin/ip -n $1 -6 addr add $IPV6 dev wg0
              ${iproute}/bin/ip netns exec $1 \
                ${wireguard-tools}/bin/wg setconf wg0 /etc/wg-$1.conf
              ${iproute}/bin/ip -n $1 link set wg0 up
              ${iproute}/bin/ip -n $1 route add default dev wg0
              ${iproute}/bin/ip -n $1 -6 route add default dev wg0
            '';
          in
          "${start} %i";
        ExecStopPost =
          let
            stop = with pkgs; writeShellScript "wg-down" ''
              ${iproute}/bin/ip -n $1 route del default dev wg0
              ${iproute}/bin/ip -n $1 -6 route del default dev wg0
              ${iproute}/bin/ip -n $1 link del wg0
            '';
          in
          "${stop} %i";
      };
    };
  };

  boot.kernel.sysctl = {
    "net.ipv4.ip_forward" = 1;
  };
  networking.nftables = {
    enable = true;
    ruleset = ''
      table inet nat {
        chain postrouting {
          type nat hook postrouting priority 100; policy accept;

          iifname "enp3s0" oifname "ve-wgns" masquerade
        }
        chain prerouting {
          type nat hook prerouting priority -100; policy accept;

          iifname "enp3s0" tcp dport { ssh, http } dnat ip to ${natIpv4Ns}
        }
      }
    '';
  };

  systemd.services."container@wgcontainer" = {
    requires = [
      "lo@wgns.service"
      "veth@wgns.service"
      "wg@wgns.service"
    ];
    after = [
      "lo@wgns.service"
      "veth@wgns.service"
      "wg@wgns.service"
    ];
  };
  containers = {
    wgcontainer = {
      specialArgs = { inherit inputs; };
      autoStart = true;

      extraFlags = [ "--network-namespace-path=/run/netns/wgns" ];

      config = { ... }: {
        networking.useHostResolvConf = lib.mkForce false;
        networking.nameservers = [
          # Alternatively, use a DNS server provided by the VPN
          "1.1.1.1"
          "9.9.9.9"
        ];
        networking.useDHCP = false;
        networking.useNetworkd = true;

        networking.firewall.enable = false;
        networking.nftables = {
          enable = true;
          ruleset = ''
            table inet filter {
              chain output {
                # Drop everything by default
                type filter hook output priority 100; policy drop;

                oif lo accept comment "Accept all packets sent to loopback"
                oifname "wg0" accept comment "Accept all packets that go out over Wireguard"

                ct state established,related accept comment "Accept all packets of established traffic"
              }

              chain input {
                # Drop everything by default
                type filter hook input priority filter; policy drop;

                iif lo accept
                iif != lo ip daddr 127.0.0.1/8 counter drop comment "drop connections to loopback not coming from loopback"

                iifname "eth-lan" accept comment "Accept all packets coming from lan"
                iifname "wg0" tcp dport { 1111, 2222 } accept comment "Accept specific ports coming from Wireguard"

                ct state established,related accept
              }
            }
          '';
        };
      };
    };
  };
}

This configuration defines systemd units to bring up and take down the network parts required for the container’s network. In this configuration the units are templated (i.e., their name ends with the @-suffix) to allow multiple Wireguard network namespaces to be created simultaneously. If you’d start units wg@wgns1.service and wg@wgns2.service, two network namespaces would be created (wgns1 and wgns2), each with their own Wireguard interfaces. This aspect of the configuration might not be necessary for your use-case.

The important details here are:

  1. The container is started using --network-namespace-path=/run/netns/<namespace name> to bind the network namespace to the container. This makes the namespace unavailable to the host and sets the namespace up as the main namespace in the container.
  2. The wg@.service unit sets up the Wireguard interface in the main network namespace and moves it to the namespace to be used by the container. The interface is set up as the default route.
  3. The veth@.service unit sets up a virtual ethernet interface pair between the host’s main network namespace and the namespace to be used by the container. This allows communication between the host and the container. IPv4 forwarding is enabled using sysctl and nftables rules are set up to destination-NAT some incoming traffic from the host to the container.
  4. The lo@.service unit creates a loopback device in the network namespace. This is required to allow processes on the container to communicate locally with other processes on the container using the network stack.
  5. The veth@.service and wg@.service units read some configuration from disk based on the network namespace name. The units are only passed the network namespace name as systemd template argument, but need more configuration information (such as IP addresses and Wireguard configuration files). If you don’t use templated units, the values can be hardcoded instead.
  6. For hardening, the container’s firewall allows outbound traffic to go out of only the Wireguard interface, except for traffic that is related to earlier accepted traffic. Incoming LAN traffic is accepted (but only traffic forwarded by the host can reach the container). The container cannot initiate LAN connections. It also does not have routes set up to reach other systems on the host’s network.

Option 2: NAT to and from the container

This assumes the host’s main network interface is called enp3s0 and a Wireguard interface is already set up named wg0. This further assumes the wg0 interface is set as the default route. This configuration ensures container traffic to the internet only goes out through the Wireguard interface; the traffic is dropped otherwise. See here how you might set up Wireguard on the host.

{ ... }:
{
  boot.kernel.sysctl = {
    "net.ipv4.ip_forward" = 1;
    "net.ipv6.ip_forward" = 1;
  };
  networking.nftables = {
    enable = true;
    ruleset = ''
      table inet wg-wg0 {
        chain forward {
          type filter hook forward priority filter; policy drop;

          # Dynamically rewrite the TCP max segment size to the MTU discovered on this path
          tcp flags syn tcp option maxseg size set rt mtu

          iifname "ve-wgcontainer" oifname "wg0" accept
          ct state established,related accept comment "Accept all packets of established traffic"
          ct status dnat accept
        }
        chain postrouting {
          type nat hook postrouting priority 100; policy accept;

          iifname "ve-wgcontainer" oifname "wg0" masquerade
          iifname "enp3s0" oifname "eth-lan" masquerade
        }
        chain prerouting {
          type nat hook prerouting priority -100; policy accept;

          iifname "wg0" meta nfproto ipv4 tcp dport { 1111, 2222 } dnat to 192.168.100.11
          iifname "wg0" meta nfproto ipv6 tcp dport { 1111, 2222 } dnat to fc00:100::2

          iifname "enp3s0" meta nfproto ipv4 tcp dport { http } dnat to 192.168.105.11
        }
      }
    '';
  };

  containers = {
    wgcontainer = {
      autoStart = true;

      privateNetwork = true;

      hostAddress = "192.168.100.10";
      localAddress = "192.168.100.11";
      hostAddress6 = "fc00:100::1";
      localAddress6 = "fc00:100::2";

      extraVeths."eth-lan" = {
        hostAddress = "192.168.105.10";
        localAddress = "192.168.105.11";
      };

      config = { ... }:
      {
        networking.firewall.enable = false;
        networking.nftables = {
          enable = true;
          tables."nixos-fw".enable = false;
          ruleset = ''
            table inet filter {
              chain output {
                # Drop everything by default
                type filter hook output priority 100; policy drop;

                oif lo accept comment "Accept all packets sent to loopback"
                oifname "eth0" accept comment "Accept all packets that go out over Wireguard"

                ct state established,related accept comment "Accept all packets of established traffic"
              }

              chain input {
                # Drop everything by default
                type filter hook input priority filter; policy drop;

                iif lo accept
                iif != lo ip daddr 127.0.0.1/8 counter drop comment "drop connections to loopback not coming from loopback"

                iifname "eth-lan" accept comment "Accept all packets coming from lan"
                iifname "eth0" tcp dport { 1111, 2222 } accept comment "Accept specific ports coming from Wireguard"

                ct state established,related accept
              }
            }
          '';
        };
      };
    };
  };
}

An interface pair (named eth0 on the container and ve-wgcontainer on the host) is set up by the containers.wgcontainer.{host,local}Address rules. It is automatically set up as the default route in the container and is used exclusively to send traffic through the Wireguard tunnel (i.e., to reach the internet and potentially to reach a network on the other side of the tunnel). The configuration further sets up eth-lan (identically named on the container and host) to forward some LAN traffic to the container. In this configuration the container cannot initiate connections to the LAN; it can only respond to them.

The important details on the host are:

  1. Allow IP forwarding using sysctl. This is required to perform destination-NAT to the container.
  2. For security, don’t forward any traffic by default.
  3. Accept forwarding all traffic coming from the container going out through Wireguard.
  4. Accept forwarding established/related traffic: this allows forwarding all traffic necessary for flows that were initiated and accepted earlier.
  5. Accept forwarding destination-NAT’ed traffic. All traffic that is explicitly “port-forwarded” by destination-NAT rules is accepted.
  6. Masquerade traffic from the container interface ve-wgcontainer to the Wireguard interface wg0—this rewrites the source IP address from 192.168.100.11 or fc00:100::2 to the IP on the wg0 interface, so the other side of the tunnel knows where to send responses (it is unaware of this local network setup). The remote peer will most likely perform its own NAT on this traffic if it is to reach the internet.
  7. Masquerade traffic from the LAN interface enp3s0 to the eth-lan interface. This pretends all LAN traffic comes from the host, effectively hiding the LAN from the container. The container has a route set up to the other side of the eth-lan interface, but only for the host’s IP 192.168.105.10.
  8. As an example, forward some traffic from the Wireguard interface addressing ports 1111 and 2222 to the container, and the HTTP port (80) is forwarded from the LAN to the container. This enables some specific traffic that is not initiated by the container itself.

The container configuration is simpler. Some hardening is applied:

  1. All outgoing traffic is dropped by default. Traffic going to the loopback interface is allowed, traffic that goes out to the interface that the host NATs to Wireguard is allowed, and traffic that is established/related to existing flows is allowed. This means the container cannot initiate any network connection except for those that go out through Wireguard.
  2. All incoming traffic from the LAN interface is allowed, and some traffic to ports from the Wireguard interface is allowed. Again, established/related traffic is allowed.