logo

Håvards page

2026 02 17 Secure and Isolated Application Networks Using Wireguard

Also posted on medium

Wireguard has become well known as an excellent VPN option and used in many products and solutions. While it excels at this, there are a feature build in that can be overlooked, one that allows you to create secure and isolated network connectivity between your application instances.

The official documentation does a good job of describing how Wireguard works and there are many other guides and descriptions out there, so I’ll leave the basic understanding of Wireguard to those. In the official documentation, there is a section about routing & network namespace integration, that shows some intriguing possibilities that I will show in this article.

The way Wireguard works is that you create a Wireguard interfaces which actually consists of two parts. When the Wireguard interface is configured, it creates a udp listener and sender that receives the encrypted Wireguard traffic, validates and decrypts it and outputs the decrypted traffic on the Wireguard interface. Traffic sent to the wireguard interface and that matches the Wireguard configuration, is encrypted and sent out through the udp listener and sender. This is how you normally configure and use Wireguard as a VPN.

However, what is possible, is to move the Wireguard interface into another network namespace. When you do that, the udp listener and sender remains in the original network namespace, while the Wireguard interface, the one that outputs decrypted traffic and receives traffic to be encrypted and sent to one of the peers, is now inside this other network namespace. In other words, you can receive the Wireguard udp packets in the original host namespace, that is the one with connectivity and routing to other machines and possible the Internet, while the tunneled traffic goes to and from another, possible disconnected and isolated, network namespace. This means you can have an application running with only a localhost and a Wireguard interface and can only communicate with what other network nodes you have configured through Wireguard.

To configure such a network, we will use IPv6 and will start by creating a unique local address prefix using the algorithm described in RFC 4193 by running this python script:

#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2026 Håvard Moen
# SPDX-License-Identifier: GPL-3.0-or-later

from datetime import UTC, datetime
from hashlib import sha1
from time import gmtime, time
from uuid import getnode

SYSTEM_EPOCH = datetime(*gmtime(0)[:3], tzinfo=UTC)
NTP_EPOCH = datetime(1900, 1, 1, tzinfo=UTC)
NTP_DELTA = (SYSTEM_EPOCH - NTP_EPOCH).total_seconds()


def generate_unique_local_prefix():
    """
    Generate a unique local IPv6 prefix
    using the algorithm in RFC 4193.
    """

    # Current time in NTP timestamp format (seconds since 1900-01-01)
    current_time = time() + NTP_DELTA

    # Get the MAC address of the machine
    mac_address = f"{getnode():x}"
    mac_address_string = ":".join(
        [mac_address[i : i + 2] for i in range(0, len(mac_address), 2)]
    )
    hash = sha1(f"{current_time}{mac_address_string}".encode("ascii")).hexdigest()[-10:]
    prefix = f"fd{hash[:2]}:{hash[2:6]}:{hash[6:10]}::/48"
    return prefix


if __name__ == "__main__":
    prefix = generate_unique_local_prefix()
    print(prefix)

For the rest of this article, I will use the prefix fd69:2d8e:610a::/48, for your own setup, run the script to get a unique local address prefix.

What we need are a way to create a network namespace, create a Wireguard interface, move the Wireguard interface into this network namespace, configure the Wireguard interface and then run the application within this network namespace. This we will do using systemd and some support scripts.

First we need a systemd service to create a new network namespace. Create /etc/systemd/system/netns@.service with the following content

[Unit]
Description=%i network namespace
After=network.target
StopWhenUnneeded=true

[Service]
Type=oneshot
RemainAfterExit=true
PrivateNetwork=true
PrivateMounts=false
ExecStart=echo Starting netns %i
ExecStart=ip netns add %i
ExecStart=umount /run/netns/%i
ExecStart=mount --bind /proc/self/ns/net /var/run/netns/%i
ExecStart=ip netns exec %i sysctl net.ipv4.ip_unprivileged_port_start=0
ExecStop=echo Stopping netns %i
ExecStop=ip netns del %i

We then need a script to create, move and configure the wireguard interface. The following script will do this using

  • Wireguard configuration in /etc/wireguard/<appname>/wg.conf
  • Extra features configuration in /etc/wireguard/<appname>/config (more on those later in this article)
  • Network and Wireguard key provided using systemd credentials
  • Optional nftables config to be loaded in /etc/wireguard/<appname>/nftables.conf

Place the following script in /usr/local/sbin/wireguard-service.sh

#!/bin/bash
# SPDX-FileCopyrightText: 2026 Håvard Moen
# SPDX-License-Identifier: GPL-3.0-or-later

function check_config() {
  if ! [ -f "/etc/wireguard/${netns}/wg.conf" ]
  then
    echo "Missing wireguard configuration"
    exit 1
  fi
}

function get_config() {
  local netns="$1"
  if [ -e "${CREDENTIALS_DIRECTORY}/private_key" ] && [ -e "${CREDENTIALS_DIRECTORY}/psk" ]
  then
   sed \
     -e "s|PrivateKey =.*|PrivateKey = $(< ${CREDENTIALS_DIRECTORY}/private_key)|"\
     -e "s|PresharedKey =.*|PresharedKey = $(< ${CREDENTIALS_DIRECTORY}/psk)|"\
     "/etc/wireguard/${netns}/wg.conf"
 elif [ -e "${CREDENTIALS_DIRECTORY}/private_key" ]
 then
   sed \
     -e "s|PrivateKey =.*|PrivateKey = $(< ${CREDENTIALS_DIRECTORY}/private_key)|"\
     "/etc/wireguard/${netns}/wg.conf"
 else
  echo "Missing private key"
  exit 1
 fi
}

function get_device() {
  local type="$1"
  local device="$2"
  local max_len=$(( 15 - $(wc -c <<<"${type}-") ))
  if [ $(wc -c <<<$device) -gt $max_len ]
  then
    echo "${type}-$(sha256sum <<<$device | cut -c 1-$(( max_len - 1)) )"
  else
    echo "${type}-${device}"
  fi

}

function start() {
  local netns="$1"
  local device="$(get_device wg "${netns}")"
  local network="$(< ${CREDENTIALS_DIRECTORY}/network)"
  local ip_address="$(< ${CREDENTIALS_DIRECTORY}/ipv6_address)"
  check_config "$1"

  if [ -f "/etc/wireguard/${netns}/config" ]
  then
    source "/etc/wireguard/${netns}/config"
  fi

  if ! ip link show "${device}" >/dev/null 2>&1
  then
    ip link add "${device}" type wireguard || exit 2
  fi
  wg setconf "${device}" <( get_config "${netns}" ) || exit 2
  ip link set "${device}" netns "${netns}" up || exit 2
  ip -6 -n "${netns}" addr add "${ip_address}/64" dev "${device}" || exit 2
  ip -6 -n "${netns}" route add "${ip_address}/48" dev "${device}" || exit 2

  if [ "${ADD_VETH}" = "1" ]
  then
    local veth1="$(get_device veth1 "${netns}")"
    local veth2="$(get_device veth2 "${netns}")"
    local ip_address1="$(< ${CREDENTIALS_DIRECTORY}/ipv6_veth_address1)"
    local ip_address2="$(< ${CREDENTIALS_DIRECTORY}/ipv6_veth_address2)"
    ip link add "${veth1}" type veth peer "${veth2}" netns "${netns}" || exit 2
    ip link set "${veth1}" up || exit 2
    ip -6 addr add "${ip_address1}/64" dev "${veth1}" || exit 2
    ip -n "${netns}" link set "${veth2}" up || exit 2
    ip -6 -n "${netns}" addr add "${ip_address2}/64" dev "${veth2}" || exit 2
    ip -6 route add "${network}" via "${ip_address2}"  || exit 2
    if [ -n "${WG_SERVER_NETWORK}" ]
    then
      ip -6 -n "${netns}" route add "${WG_SERVER_NETWORK}" via "${ip_address1}"  || exit 2
    fi
    ip netns exec "${netns}" sysctl net.ipv6.conf.all.forwarding=1

    if [ "${ADD_VETH_IP4}" = "1" ]
    then
      local net_id="$(< ${CREDENTIALS_DIRECTORY}/id)"
      local ip4_address1="172.25.$(( net_id % 255 )).$(( net_id / 255 * 4 + 1 ))"
      local ip4_address2="172.25.$(( net_id % 255 )).$(( net_id / 255 * 4 + 2 ))"
      ip addr add "${ip4_address1}/30" dev "${veth1}" || exit 2
      ip -n "${netns}" addr add "${ip4_address2}/30" dev "${veth2}" || exit 2
      ip netns exec "${netns}" sysctl net.ipv4.conf.all.forwarding=1
      sysctl "net.ipv4.conf.all.forwarding=1"
    fi

    if [ -n "${ADD_ROUTES}" ]
    then
      for route in ${ADD_ROUTES}
      do
        if [ "${route}" = "default" ]
        then
          ip -6 -n "${netns}" route add default via "${ip_address1}"
        elif [ "${route}" = "default4" ] && [ "${ADD_VETH_IP4}" = "1" ]
        then
          ip -n "${netns}" route add default via "${ip4_address1}"
        else
          if grep -q : <<<${route}
          then
            ip -6 -n "${netns}" route add "${route}" via "${ip_address1}"
          elif  [ "${ADD_VETH_IP4}" = "1" ]
          then
            ip -n "${netns}" route add "${route}" via "${ip4_address1}"
          fi
        fi
      done
    fi
  fi

  if [ -f "/etc/wireguard/${netns}/nftables.conf" ]
  then
    ip netns exec "${netns}" nft -f "/etc/wireguard/${netns}/nftables.conf"
  fi
}

function stop() {
  local netns="$1"
  local device="$(get_device wg "${netns}")"
  check_config "$1"

  if [ -f "/etc/wireguard/${netns}/config" ]
  then
    source "/etc/wireguard/${netns}/config"
  fi

  if [ "${ADD_VETH}" = "1" ]
  then
    local veth1="$(get_device veth1 "${netns}")"
    ip link del "${veth1}"
  fi

  ip -n "${netns}" link del "${device}" || exit 2
}

function reload() {
  local netns="$1"
  local device="$(get_device wg "${netns}")"
  check_config "$1"

  ip netns exec "${netns}" wg syncconf "${device}" <( get_config "${netns}" ) || exit 2

  if [ -f "/etc/wireguard/${netns}/nftables.conf" ]
  then
    ip netns exec "${netns}" nft -f "/etc/wireguard/${netns}/nftables.conf"
  fi

  if [ "x${ADD_VETH}" = "1" ] && [ -n "${ADD_ROUTES}" ]
  then
    local ip_address1="$(< ${CREDENTIALS_DIRECTORY}/ipv6_veth_address1)"
    for route in $(ip -6 -n "${netns}" route list |grep "via ${ip_address1}")
    do
      ip -6 -n "${netns}" route del ${route}
    done
    for route in ${ADD_ROUTES}
    do
      ip -6 -n "${netns}" route add "${route}" via "${ip_address1}"
    done
  fi
}

case "$1" in
  start)
    echo "Starting wireguard for $2"
    start "$2"
    ;;
  stop)
    echo "Stopping wireguard for $2"
    stop "$2"
    ;;
  reload)
    echo "Reloading wireguard for $2"
    reload "$2"
    ;;
  *)
    echo "Usage $0 [start|stop|reload] service"
    exit 3
    ;;
esac

The last piece is the wireguard systemd service. For these examples we are going to inject encrypted systemd credentials using SetCredential, but you can use credentials from Hashicorp Vault or some other mechanism to retrieve these. Create the file /etc/systemd/system/wg@.service

[Unit]
Description=wireguard network interface
# Keep the netns service running as long as this is running
BindsTo=netns@%i.service
After=netns@%i.service
After=network.target
# Uncomment the next two lines to use Hashicorp Vault credentials socket server
# Requires=network-online.target
# Requires=vault-credential-server.socket
StopWhenUnneeded=yes

[Service]
Type=oneshot
RemainAfterExit=true
# Uncomment to use Hashicorp Vault credentials socket server
#LoadCredential=id:/run/vault-credentials.socket
#LoadCredential=private_key:/run/vault-credentials.socket
#LoadCredential=psk:/run/vault-credentials.socket
#LoadCredential=network:/run/vault-credentials.socket
#LoadCredential=ipv6_address:/run/vault-credentials.socket
#LoadCredential=ipv6_veth_address1:/run/vault-credentials.socket
#LoadCredential=ipv6_veth_address2:/run/vault-credentials.socket
ExecStart=/usr/local/sbin/wireguard-service.sh start %i
ExecStop=/usr/local/sbin/wireguard-service.sh stop %i
ExecReload=/usr/local/sbin/wireguard-service.sh reload %i

To demonstrate this, we will start two simple applications, one receiver and one sender. They will both use the subnet fd69:2d8e:610a:1::/64 (replace this with an appropiate subnet from your local prefix) with the receiver having the Wireguard ip address fd69:2d8e:610a:1::1 and the sender fd69:2d8e:610a:1::2.

We will now create two Wireguard keys, one for the receiver application and one for the sender application

key_receiver=$(wg genkey)
key_receiver_pub=$(wg pubkey <<<$key_receiver)
echo "Receiver private key=${key_receiver} public key=${key_receiver_pub}"
key_sender=$(wg genkey)
key_sender_pub=$(wg pubkey <<<$key_sender)
echo "Sender private key=${key_sender} public key=${key_sender_pub}"

Run this and save the information.

Create /etc/wireguard/test-receiver/wg.conf with

[Interface]
PrivateKey =
ListenPort = 51820

[Peer]
PublicKey = <receiver public key>
EndPoint = [::1]:51821
# AllowedIPs is the IP of the sender
AllowedIPs = fd69:2d8e:610a:1::2

and /etc/wireguard/test-sender/wg.conf with

[Interface]
PrivateKey =
ListenPort = 51821

[Peer]
PublicKey = <sender public key>
EndPoint = [::1]:51820
# AllowedIPs is the IP of the receiver
AllowedIPs = fd69:2d8e:610a:1::1

Since we are running both the receiver and the sender on the same host, we will use localhost in the EndPoint, but this could also be the public IP of the machine to allow the applications to run on different machines. The endpoint IP is only used by Wireguard for initial traffic, Wireguard will dynamically update the endpoint IP if it receives valid traffic from another IP, thus allowing seamless functioning of peers that change IPs, for instance mobile phones.

Since we are manually creating systemd credential configuration in this example, the next step is to create Wireguard service overrides. Create /etc/systemd/system/wg@test-receiver.service.d/credentials.conf with the following content (run the systemd-creds command in the same shell you ran the wg genkey commands)

[Service]
SetCredential=id:1
# Replace with your IPs
SetCredential=network:fd69:2d8e:610a:1::
SetCredential=ipv6_address:fd69:2d8e:610a:1::1
# Run run0 systemd-creds encrypt --name=private_key -p - - <<<$key_receiver

and in /etc/systemd/system/wg@test-sender.service.d/credentials.conf

[Service]
SetCredential=id:2
# Replace with your IPs
SetCredential=network:fd69:2d8e:610a:1::
SetCredential=ipv6_address:fd69:2d8e:610a:1::2
# Run run0 systemd-creds encrypt --name=private_key -p - - <<<$key_sender

Then we create the application services, first /etc/systemd/system/test-receiver.service

[Unit]
Description=wireguard test receiver service
# This keeps the wireguard service running while the application is running
BindsTo=wg@test-receiver.service
# This connects the application to the network namespace
JoinsNamespaceOf=netns@test-receiver.service

[Service]
# Needed for JoinsNamespaceOf
PrivateNetwork=true
ProtectSystem=full
DynamicUser=true
ExecStart=nc -k -l :: 1234

and /etc/systemd/system/test-sender.service

[Unit]
Description=wireguard test sender service
# This keeps the wireguard service running while the application is running
BindsTo=wg@test-sender.service
# This connects the application to the network namespace
JoinsNamespaceOf=netns@test-sender.service

[Service]
# Needed for JoinsNamespaceOf
PrivateNetwork=true
ProtectSystem=full
DynamicUser=true
# Needed to allow ping
CapabilityBoundingSet=CAP_NET_RAW
AmbientCapabilities=CAP_NET_RAW
# Replace with your IPs
ExecStart=/bin/bash -c 'while true; do ping -c 1 fd69:2d8e:610a:1::1 ; echo hello | nc  fd69:2d8e:610a:1::1 1234; sleep 5; done'

You should now be able to run

systemctl daemon-reload
systemctl start test-receiver
systemctl start test-sender

and journalctl -u test-receiver should give you a hello every 5 seconds, while journalctl -u test-sender should give you a ping response every 5 seconds. You can also inspect the network namespaces using the ip netns command, for instance

run0 ip netns list
run0 ip netns exec test-receiver ip a
run0 ip netns exec test-receiver wg

You might have noticed in the wireguard script, that there are support for veth interface and routing. Sometimes an application can not function without some external access. This can be done by adding a virtual ethernet interface to the network namespace and routing.

  • Set ADD_VETH=1 in /etc/wireguard/<application>/config, optionally ADD_VETH_IP4=1 for IPv4
  • Set ADD_ROUTES either to default for default route or to a comma separated list of routes to add via the veth interface
  • You will also need to add firewall rules on the host with outgoing NAT to reach the Internet

While this works, it does break the isolation part. If the application supports using a proxy, another possibility is to configure a http proxy application with veth access and the use Wireguard routing to the proxy from other applications that need Internet http access. The same can be done for DNS to allow DNS for the isolated applications.

For getting traffic from the outside into the isolated application, systemd has a neat trick for us to use. You can configure a systemd socket to listen on a given port and the let the service it spawns join the network namespace of the isolated application.

As an example for our test receiver, create /etc/systemd/system/test.socket with

[Unit]
Description=test receiver forwarding socket

[Socket]
ListenStream=[::]:1234

and /etc/systemd/system/test.service

[Unit]
Description=test receiver forwarding service1
After=wg@test-receiver.service
BindsTo=wg@test-receiver.service
JoinsNamespaceOf=netns@test-receiver.service

[Service]
PrivateNetwork=true
DynamicUser=true
ProtectSystem=full
ExecStart=/usr/lib/systemd/systemd-socket-proxyd ::1:1234

If you are on Fedora or Redhat with SELinux enabled, you also need to run

run0 semanage boolean -m systemd_socket_proxyd_connect_any --on

Running

systemctl daemon-reload
systemctl start test.socket

You should then be able to run

echo "test from outside" | nc ::1 1234

and see “test from outside” in the logs of the test receiver application.

If you have another machine that can reach the one you are running this on, replace ::1 with the IP or DNS name of the machine running the test receiver and you can see that this also works from across the network.

Another use case for this is to setup Traefik to route traffic the different isolated applications across your network. You will need three socket files, one each for http, https and http3 that all point to the traefik service. This is because systemd socket with FileDescriptionName can not be used for multiple ports and names in one socket. FileDescriptionName is used by Traefik to match the passed socket to it’s configured listener

[Unit]
Description=Traefik web server http

[Socket]
ListenStream=[::]:80
FileDescriptorName=web
Service=traefik.service

[Install]
WantedBy=sockets.target

[Unit]
Description=Traefik web server https

[Socket]
ListenStream=[::]:443
FileDescriptorName=websecure
Service=traefik.service

[Install]
WantedBy=sockets.target

[Unit]
Description=Traefik web server http3

[Socket]
ListenDatagram=[::]:443
FileDescriptorName=websecure
Service=traefik.service

[Install]
WantedBy=sockets.target

and in your traefik.toml file

[entrypoints.web]
address = ":80"
asDefault = true

[entrypoints.web.http.redirections.entryPoint]
to = "websecure"
scheme = "https"

[entrypoints.websecure]
address = ":443"
asDefault = true
http3 = {}

Lastly I want to point to how you can combine this with containers running in podman using podman kube play. By using the podman systemd generator, you can create a kube configuration in /etc/containers/systemd/<app>.kube with

[Unit]
Description=<app>
After=wg@<app>.service
Requires=wg@<app>.service

[Kube]
Network=ns:/run/netns/<app>
Yaml=path/to/your/pod/yaml/file
... other options ...