Set up a WireGuard VPN server on Ubuntu

Set up WireGuard VPN on Ubuntu: dual-stack IPv4/IPv6, Unbound DNS, UFW/nftables routing and automated client setup via QR code.

Reading time: 25 min

WireGuard marks a fundamental architecture shift in virtual private networks (VPN). Unlike traditional heavyweights such as OpenVPN or complex IPsec implementations, WireGuard runs directly as a lean Linux kernel module. With fewer than 4,000 lines of source code the protocol offers a minimal attack surface, modern cryptography (Curve25519, ChaCha20-Poly1305, BLAKE2s) and high throughput at minimal latency.

The essential difference from protocols such as OpenVPN or IPsec/IKEv2 is the protocol architecture: WireGuard does without complex userspace daemons and multi-stage certificate chains. Instead it runs a cryptographic 1-RTT handshake (1 round trip time) based on the proven Noise protocol framework (Noise_IKpsk2) directly in the Linux kernel. Sessions are negotiated silently and continue without a drop when the IP changes (for example from Wi-Fi to mobile) (roaming).

The following setup builds a production-ready WireGuard gateway on Ubuntu 24.04 LTS (Noble Numbat) and Ubuntu 26.04 LTS (Resolute Raccoon), including a cleanly split IPv4/IPv6 architecture, consistent UFW routing, pre-shared keys, Unbound DNS and hardened client management via QR code.

The network and crypto fundamentals taught here are an essential part of modern server environments and follow standards of exams such as the LPIC-1 series.

πŸ’‘ Note on kernel compatibility: WireGuard has been a fixed part of the official upstream kernel since Linux kernel 5.6. On Ubuntu 24.04 and Ubuntu 26.04 no external repositories (PPAs) or third-party kernel modules are required any longer.

WireGuard architecture and tunnel concept

In the WireGuard model every participant is an equal peer. In server practice the Ubuntu host acts as the central gateway that accepts client traffic encrypted and forwards it over the physical network interface into the local network or the internet:


β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚             WireGuard VPN architecture and tunnel           β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                                             β”‚
β”‚   Remote clients (roadwarrior)      WireGuard gateway       β”‚
β”‚   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
β”‚   β”‚ Laptop / smartphone       β”‚     β”‚ Ubuntu Server     β”‚   β”‚
β”‚   β”‚ IP: 10.8.0.2 / GUA/ULA    β”‚     β”‚ IP: 10.8.0.1      β”‚   β”‚
β”‚   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
β”‚                 β”‚                             β”‚             β”‚
β”‚                 β–Ό                             β–Ό             β”‚
β”‚   ═══════════════════════════════════════════════════════   β”‚
β”‚       Encrypted UDP tunnel (port 51820 / ChaCha20)          β”‚
β”‚   ═══════════════════════════════════════════════════════   β”‚
β”‚                                               β”‚             β”‚
β”‚                                               β–Ό             β”‚
β”‚                                 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
β”‚                                 β”‚ Internet and LAN      β”‚   β”‚
β”‚                                 β”‚ (routing / masq)      β”‚   β”‚
β”‚                                 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
β”‚                                                             β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

IPv4 vs. IPv6 architecture: routed prefix vs. NAT66

Addressing and routing in the VPN tunnel differ fundamentally in the recommended architecture for IPv4 and IPv6:

IPv4 (RFC 1918 and NAT/masquerading):

Because public IPv4 addresses are scarce, VPN clients receive private addresses (for example from 10.8.0.0/24). The server masquerades outbound traffic through its public IP address using source NAT (masquerading).

IPv6 β€” preferred architecture (routed GUA prefix):

In IPv6, direct end-to-end reachability without NAT is the default. If your hosting provider gives the server a routed IPv6 subnet (for example its own /64 prefix from a /56 or /48 network), WireGuard clients receive real global unicast addresses (GUA). The server acts as a pure IPv6 router with no NAT at all.

IPv6 β€” alternative architecture (ULA with NAT66):

If the provider assigns the host only a single IPv6 address or a /64 network attached directly to the interface, without routing a separate subnet for clients, native routing without proxy NDP is not possible. In that case a unique local address prefix (ULA per RFC 4193, for example fd42:42:42::/64) combined with NAT66 (IPv6 masquerading) can be used. NAT66 is a deliberate transition solution and not a full equivalent of true IPv6 end-to-end connectivity.

The key facts of the setup:

Parameter Value / configuration Purpose
VPN subnet (IPv4) 10.8.0.0/24 Private RFC1918 address space for clients (server: 10.8.0.1) with NAT
VPN subnet (IPv6) Routed GUA (preferred) or ULA fd42:42:42::/64 IPv6 client addresses (server: ...::1); ULA requires NAT66
VPN port 51820 / UDP Default port for inbound WireGuard tunnel traffic
Encryption ChaCha20-Poly1305 (AEAD) Authenticated symmetric encryption of data packets
Key exchange Curve25519 (ECDH) + optional pre-shared key (PSK) 1-RTT Noise IK handshake; PSK adds symmetric key material

πŸ’‘ Note on post-quantum and pre-shared keys (PSK): WireGuard is not fully post-quantum safe by itself. The asymmetric key exchange is based on Curve25519, which future quantum computers could theoretically attack with Shor's algorithm ("harvest now, decrypt later"). An additional pre-shared key (wg genpsk) mixes 256 bits of symmetric key material into the Noise protocol key derivation.

That is a valuable extra line of defence against later decryption, but it does not replace a full post-quantum-safe asymmetric KEM (such as ML-KEM).

Preparation and installation

1. Update the system and install packages

Install the wireguard package (kernel module and control tools), qrencode for mobile QR codes and firewall tools:


sudo apt update && sudo apt upgrade -y
sudo apt install wireguard wireguard-tools qrencode iptables -y

πŸ’‘ iptables and nftables on Ubuntu: On Ubuntu 24.04 and 26.04 the system uses iptables-nft by default. An abstraction layer translates classic iptables commands directly into the modern nftables kernel subsystem. Tools such as UFW and existing scripts therefore continue to work reliably and performantly.

Check whether the WireGuard kernel module is loaded:


sudo modprobe wireguard
lsmod | grep wireguard

2. Enable IP forwarding in the Linux kernel

For the Linux host to forward packets between the VPN interface and the physical network interface, IP forwarding must be active for IPv4 and IPv6. Create a dedicated configuration file:


sudo tee /etc/sysctl.d/99-wireguard.conf << 'EOF'
# Enable IPv4 packet forwarding
net.ipv4.ip_forward = 1

# Enable IPv6 packet forwarding
net.ipv6.conf.all.forwarding = 1
EOF

Apply the parameters immediately:


sudo sysctl -p /etc/sysctl.d/99-wireguard.conf

Verification:

Check that both parameters are actively set to 1:


sysctl net.ipv4.ip_forward net.ipv6.conf.all.forwarding

Expected output:


net.ipv4.ip_forward = 1
net.ipv6.conf.all.forwarding = 1

Key generation (server and peer)

WireGuard uses asymmetric cryptography: every participant has a private key (secret) and a derived public key. In addition we generate a symmetric pre-shared key (PSK) for every client.

Lock down the configuration directory with strict access rights (0700):


sudo mkdir -p /etc/wireguard
sudo chmod 700 /etc/wireguard
cd /etc/wireguard

Generate the key pair for the server with a safe file mask (umask 077):


umask 077
wg genkey | tee server_private.key | wg pubkey > server_public.key

Read the public key (this is later entered in the client profiles):


echo "Server Public Key: $(cat server_public.key)"

Server configuration and firewall architecture

First determine the name of your primary network interface with internet access (for example eth0, enp1s0 or ens3):


ip -o -4 route show to default | awk '{print $5}'

In the rest of the text we use the interface eth0 as a stand-in. Adjust the identifier to the actual network interface of your server if needed.

To avoid security risks and rule collisions, host firewall and packet forwarding must be aligned cleanly. We distinguish two operating modes:

Variant A (recommended with UFW active):

UFW manages firewall, forwarding and NAT centrally. The file wg0.conf stays free of PostUp/PostDown netfilter commands.

Variant B (alternative without UFW):

Manages only the WireGuard-specific forwarding and NAT rules directly through PostUp and PostDown in wg-quick. Host INPUT rules (such as port 51820 UDP or port 53 for Unbound) must be maintained separately in the host firewall on restrictive systems.

πŸ’‘ Note on IPv6 in the examples: The following configuration examples use a ULA prefix (fd42:42:42::/64) for IPv6 so the setup stays reproducible regardless of the hosting provider's prefix delegation. If you have a GUA prefix routed for the WireGuard network, replace the ULA addresses with addresses from your routed prefix and omit NAT66 from the firewall entirely.

Server configuration file (/etc/wireguard/wg0.conf)

Create the file /etc/wireguard/wg0.conf. If you use UFW (variant A), you do not need PostUp and PostDown lines:


SERVER_PRIV_KEY=$(cat /etc/wireguard/server_private.key)

sudo tee /etc/wireguard/wg0.conf << EOF
[Interface]
# Server tunnel IPs (dual-stack)
Address = 10.8.0.1/24, fd42:42:42::1/64
ListenPort = 51820
PrivateKey = ${SERVER_PRIV_KEY}

# With variant A (UFW), PostUp/PostDown are omitted here entirely.
# Peers are registered below.
EOF

Protect the file against unauthorised access:


sudo chmod 600 /etc/wireguard/wg0.conf

The blanket setting DEFAULT_FORWARD_POLICY="ACCEPT" in /etc/default/ufw is a common but problematic practice, because it disables filtering for every interface of the host.

A clean UFW architecture separates responsibilities clearly:

  • UFW CLI (ufw route): Controls filtering and forwarding targeted at interface and subnet level.
  • /etc/ufw/before.rules: Manages IPv4 source NAT (masquerading), because UFW has no native CLI syntax for it.
  • DEFAULT_FORWARD_POLICY="DROP": Stays unchanged in /etc/default/ufw to protect the host.

Step 1: Configure IPv4 NAT in /etc/ufw/before.rules

Open /etc/ufw/before.rules:


sudo nano /etc/ufw/before.rules

Insert the following nat block at the very start of the file (still before the first filter block):


# NAT rules for WireGuard VPN
*nat
:POSTROUTING ACCEPT [0:0]
-A POSTROUTING -s 10.8.0.0/24 -o eth0 -j MASQUERADE
COMMIT

If you use the ULA setup with NAT66 for IPv6, put in:


/etc/ufw/before6.rules

the analogous block before *filter:


*nat :POSTROUTING ... -A POSTROUTING -s fd42:42:42::/64 -o eth0 -j MASQUERADE COMMIT

With a routed GUA prefix this step is omitted entirely.

Step 2: Set up UFW routing rules and port openings

Configure forwarding directly through the dedicated UFW routing syntax:


# Allow the WireGuard VPN port for inbound tunnel traffic
sudo ufw allow 51820/udp comment 'WireGuard VPN Port'

# Allow local DNS access (Unbound) for WireGuard clients on the host (UDP and TCP)
sudo ufw allow in on wg0 to any port 53 proto udp comment 'DNS from WireGuard'
sudo ufw allow in on wg0 to any port 53 proto tcp comment 'DNS from WireGuard'

# Targeted forwarding: allow traffic from wg0 over the WAN interface (eth0)
sudo ufw route allow in on wg0 out on eth0 from 10.8.0.0/24

# For IPv6 (ULA or routed GUA):
sudo ufw route allow in on wg0 out on eth0 from fd42:42:42::/64

Return traffic for existing connections is handled automatically by UFW's default ruleset (-m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT in before.rules).

Then reload UFW:


sudo ufw reload

Verification of UFW and netfilter rules:


# Shows status, port openings and UFW routing rules
sudo ufw status verbose

Note: ufw status lists only the filter and routing rules managed through the UFW CLI. The low-level netfilter rules defined directly in /etc/ufw/before.rules and /etc/ufw/before6.rules (such as masquerading) are not listed there. You can inspect the full raw table state with sudo ufw show raw or specifically through the kernel tables:


# Check IPv4 masquerading
sudo iptables -t nat -S POSTROUTING

# With a ULA setup and NAT66: check IPv6 masquerading
sudo ip6tables -t nat -S POSTROUTING

Variant B (alternative): netfilter management of tunnel rules through wg-quick

Variant B manages only the forwarding and NAT rules required for WireGuard through wg-quick. It does not replace a complete host firewall. With an existing restrictive INPUT policy, the WireGuard port UDP/51820 on the WAN interface and β€” when using Unbound β€” UDP/TCP 53 on wg0 must be opened separately in the host firewall.

To keep forwarding restrictive, outbound traffic is allowed only from wg0 to eth0, while inbound traffic on eth0 -> wg0 is accepted only for already existing connections (RELATED,ESTABLISHED):


PostUp = iptables -A FORWARD -i wg0 -o eth0 -j ACCEPT; iptables -A FORWARD -i eth0 -o wg0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT; iptables -t nat -A POSTROUTING -s 10.8.0.0/24 -o eth0 -j MASQUERADE
PostDown = iptables -D FORWARD -i wg0 -o eth0 -j ACCEPT; iptables -D FORWARD -i eth0 -o wg0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT; iptables -t nat -D POSTROUTING -s 10.8.0.0/24 -o eth0 -j MASQUERADE

For the ULA setup an analogous line with ip6tables for fd42:42:42::/64 can be added. With a routed GUA prefix the -t nat masquerading rule is omitted and only the FORWARD filter rules are needed.

Start and enable the WireGuard service

Start the wg0 interface through the systemd service wg-quick@wg0:


sudo systemctl enable --now wg-quick@wg0

Technical verification

Check the status of the core components systematically:

Check service status:


sudo systemctl status wg-quick@wg0 --no-pager

Verify network interface and IP addresses:


ip addr show wg0

Expectation: the interface exists, has status UP and carries the addresses 10.8.0.1/24 and fd42:42:42::1/64.

Check the UDP socket:


sudo ss -ulnp | grep 51820

Expectation: the kernel is listening on 0.0.0.0:51820 and [::]:51820.

Query WireGuard kernel status:


sudo wg show

Expected output:


interface: wg0
  public key: <SERVER_PUBLIC_KEY>
  private key: (hidden)
  listening port: 51820

Create and connect a client (peer)

We now configure the first client as an example (for example a smartphone or notebook).

1. Generate client keys and pre-shared key

Create a protected directory for client profiles:


sudo mkdir -p /etc/wireguard/clients
sudo chmod 700 /etc/wireguard/clients
cd /etc/wireguard/clients
umask 077

# Generate key pair and symmetric PSK
wg genkey | tee client1_private.key | wg pubkey > client1_public.key
wg genpsk > client1_preshared.key

2. Register the peer on the server

Add the new peer to /etc/wireguard/wg0.conf:


CLIENT1_PUB=$(cat client1_public.key)
CLIENT1_PSK=$(cat client1_preshared.key)

sudo tee -a /etc/wireguard/wg0.conf << EOF

# Client 1: smartphone / laptop
[Peer]
PublicKey = ${CLIENT1_PUB}
PresharedKey = ${CLIENT1_PSK}
AllowedIPs = 10.8.0.2/32, fd42:42:42::2/128
EOF

3. Reload the configuration without dropping connections (wg syncconf)

To apply new peers, do not use systemctl restart, but the command wg syncconf:


sudo wg syncconf wg0 <(wg-quick strip wg0)

Why wg syncconf?

  • The helper wg-quick strip wg0 filters out helper directives (such as Address, DNS or PostUp) so a pure WireGuard ruleset remains for the kernel command wg.
  • wg syncconf compares the desired configuration with the WireGuard configuration currently active in the kernel and applies only the necessary changes (delta).
  • Existing peer sessions and connections are therefore not disturbed unnecessarily.
  • Pure peer changes do not require a full interface restart, and transfer statistics plus handshake timers stay intact.

4. Create the client configuration file

Enter the reachable server endpoint (public IPv4 address or FQDN):


SERVER_ENDPOINT="vpn.example.com" # Or the public IP of the server
SERVER_PUB=$(cat /etc/wireguard/server_public.key)
CLIENT1_PRIV=$(cat client1_private.key)

tee /etc/wireguard/clients/client1.conf << EOF
[Interface]
PrivateKey = ${CLIENT1_PRIV}
Address = 10.8.0.2/24, fd42:42:42::2/64
# DNS resolver of the gateway (Unbound in step 7) or external resolvers first (e.g. 1.1.1.1)
DNS = 10.8.0.1, fd42:42:42::1

[Peer]
PublicKey = ${SERVER_PUB}
PresharedKey = ${CLIENT1_PSK}
Endpoint = ${SERVER_ENDPOINT}:51820
# 0.0.0.0/0, ::/0 sends all traffic through the VPN (full tunnel)
AllowedIPs = 0.0.0.0/0, ::/0
# Keeps the connection stable behind stateful firewalls / NAT routers
PersistentKeepalive = 25
EOF

Full tunnel vs. split tunneling:

By default AllowedIPs = 0.0.0.0/0, ::/0 sends all client traffic through the VPN (full tunnel). If instead only access to the VPN subnet or internal company networks should go through the tunnel, while regular internet browsing stays on the client's local line (split tunneling), adjust the directive in the client profile accordingly.

Keep consistency with the configured DNS server: if besides the IPv4 subnets the IPv6 resolver (fd42:42:42::1) or internal IPv6 services should be reachable through the tunnel, the matching IPv6 VPN prefix must also be listed in AllowedIPs:


# Dual-stack split tunnel (including IPv6 DNS resolver)
AllowedIPs = 10.8.0.0/24, fd42:42:42::/64, 192.168.10.0/24

If instead a pure IPv4 split tunnel (AllowedIPs = 10.8.0.0/24, 192.168.10.0/24) is set up, the client profile under DNS = may list only the IPv4 address of the resolver (DNS = 10.8.0.1), because queries to fd42:42:42::1 would otherwise be dropped by the client for lack of a routing entry.

πŸ’‘ DNS entry vs. transport encryption: The DNS = ... parameter in the client configuration only assigns the operating system the IP address of the resolver to use. It does not enable DNS-over-TLS (DoT) or DNS-over-HTTPS (DoH). In a full tunnel, DNS packets are encrypted to the server inside the VPN tunnel, but leave it toward the upstream resolver unencrypted over port 53 by default (unless a local DoT resolver such as Unbound is running on the server).

5. Generate a QR code for smartphones

For mobile devices with the official WireGuard app, the configuration can be shown directly in the terminal as an ANSI QR code:


qrencode -t ansiutf8 < /etc/wireguard/clients/client1.conf

Open the app on the smartphone, choose + β†’ Scan from QR code, point the camera at the terminal and activate the tunnel.

Automation: hardened client-management script

To create new clients safely and reproducibly with a one-liner, a hardened management script is recommended. The following script avoids external web lookups, validates input strictly, prevents address collisions, converts IPv6 suffixes to hexadecimal correctly and works fault-tolerantly through temporary files, file locking and a controlled rollback mechanism.

Create the file /usr/local/bin/add-wireguard-client:


sudo tee /usr/local/bin/add-wireguard-client << 'EOF'
#!/bin/bash
set -euo pipefail

# ==============================================================================
# Configuration
# ==============================================================================
WG_DIR="/etc/wireguard"
WG_CONF="$WG_DIR/wg0.conf"
CLIENT_DIR="$WG_DIR/clients"
LOCK_FILE="/var/lock/wireguard-client.lock"

# Enter the fixed FQDN or public IP of your server here
SERVER_ENDPOINT="vpn.example.com"
SERVER_PORT=51820
SERVER_PUB_KEY_FILE="$WG_DIR/server_public.key"

# Address spaces
VPN_NET_PREFIX="10.8.0"
VPN_IPV6_PREFIX="fd42:42:42::"
DNS_SERVERS="10.8.0.1, fd42:42:42::1"

# ==============================================================================
# Validation and concurrency locking
# ==============================================================================
if [ "$EUID" -ne 0 ]; then
    echo "Error: this script must be run with administrative rights (sudo)." >&2
    exit 1
fi

if [ -z "${1:-}" ]; then
    echo "Usage: sudo $0 <client-name> [ip-suffix]" >&2
    echo "Example:   sudo $0 notebook 3" >&2
    exit 1
fi

CLIENT_NAME="$1"
IP_SUFFIX="${2:-}"

# Validate client names (2-32 characters, alphanumeric plus '-' and '_')
if ! [[ "$CLIENT_NAME" =~ ^[a-zA-Z0-9_-]{2,32}$ ]]; then
    echo "Error: invalid client name '$CLIENT_NAME'. Allowed are 2-32 characters [a-zA-Z0-9_-]." >&2
    exit 1
fi

# Concurrency locking via flock against race conditions
exec 200>"$LOCK_FILE"
if ! flock -n 200; then
    echo "Error: another instance of this script is already running." >&2
    exit 1
fi

if [ ! -f "$WG_CONF" ] || [ ! -f "$SERVER_PUB_KEY_FILE" ]; then
    echo "Error: $WG_CONF or $SERVER_PUB_KEY_FILE does not exist." >&2
    exit 1
fi

CONF_FILE="$CLIENT_DIR/${CLIENT_NAME}.conf"
if [ -f "$CONF_FILE" ]; then
    echo "Error: configuration file '$CONF_FILE' already exists." >&2
    exit 1
fi

if grep -qE "^\s*#\s*Peer:\s*${CLIENT_NAME}\s*$" "$WG_CONF"; then
    echo "Error: a peer named '$CLIENT_NAME' is already present in $WG_CONF." >&2
    exit 1
fi

# ==============================================================================
# IP assignment and collision check (IPv4 decimal, IPv6 hexadecimal)
# ==============================================================================
if [ -n "$IP_SUFFIX" ]; then
    if ! [[ "$IP_SUFFIX" =~ ^[0-9]+$ ]] || [ "$IP_SUFFIX" -lt 2 ] || [ "$IP_SUFFIX" -gt 254 ]; then
        echo "Error: the IP suffix must be an integer between 2 and 254." >&2
        exit 1
    fi
    printf -v IPV6_HEX '%x' "$IP_SUFFIX"
    CANDIDATE_IPV4="${VPN_NET_PREFIX}.${IP_SUFFIX}"
    CANDIDATE_IPV6="${VPN_IPV6_PREFIX}${IPV6_HEX}"

    if grep -qF "$CANDIDATE_IPV4" "$WG_CONF" || grep -qF "$CANDIDATE_IPV6" "$WG_CONF"; then
        echo "Error: IP address ($CANDIDATE_IPV4 or $CANDIDATE_IPV6) is already assigned in $WG_CONF." >&2
        exit 1
    fi
else
    # Automatic assignment of the next free IP
    IP_SUFFIX=0
    for i in $(seq 2 254); do
        TEST_IPV4="${VPN_NET_PREFIX}.${i}"
        printf -v TEST_HEX '%x' "$i"
        TEST_IPV6="${VPN_IPV6_PREFIX}${TEST_HEX}"
        if ! grep -qF "$TEST_IPV4" "$WG_CONF" && ! grep -qF "$TEST_IPV6" "$WG_CONF"; then
            IP_SUFFIX="$i"
            break
        fi
    done
    if [ "$IP_SUFFIX" -eq 0 ]; then
        echo "Error: no free addresses in the range ${VPN_NET_PREFIX}.2-254." >&2
        exit 1
    fi
fi

CLIENT_IPV4="${VPN_NET_PREFIX}.${IP_SUFFIX}"
printf -v IPV6_HEX '%x' "$IP_SUFFIX"
CLIENT_IPV6="${VPN_IPV6_PREFIX}${IPV6_HEX}"

# ==============================================================================
# Safe creation via temporary files with rollback
# ==============================================================================
mkdir -p "$CLIENT_DIR"
umask 077

TMP_CLIENT_CONF=$(mktemp "$CLIENT_DIR/.client.XXXXXX")
TMP_WG_CONF=$(mktemp --suffix=.conf "$WG_DIR/wgtmp.XXXXXX")
chmod 600 "$TMP_CLIENT_CONF" "$TMP_WG_CONF"

cleanup() {
    rm -f "$TMP_CLIENT_CONF" "$TMP_WG_CONF" "${WG_CONF}.bak"
}
trap cleanup EXIT INT TERM

echo "==> Generating key material for peer: $CLIENT_NAME ($CLIENT_IPV4 / $CLIENT_IPV6)..."
PRIV_KEY=$(wg genkey)
PUB_KEY=$(echo "$PRIV_KEY" | wg pubkey)
PSK=$(wg genpsk)
SERVER_PUB=$(cat "$SERVER_PUB_KEY_FILE")

# Write the client profile temporarily
cat > "$TMP_CLIENT_CONF" << CLIENT_CONF
[Interface]
PrivateKey = ${PRIV_KEY}
Address = ${CLIENT_IPV4}/24, ${CLIENT_IPV6}/64
DNS = ${DNS_SERVERS}

[Peer]
PublicKey = ${SERVER_PUB}
PresharedKey = ${PSK}
Endpoint = ${SERVER_ENDPOINT}:${SERVER_PORT}
AllowedIPs = 0.0.0.0/0, ::/0
PersistentKeepalive = 25
CLIENT_CONF

# Prepare the new server configuration
cp -p "$WG_CONF" "$TMP_WG_CONF"
cat >> "$TMP_WG_CONF" << PEER_CONF

# Peer: ${CLIENT_NAME}
[Peer]
PublicKey = ${PUB_KEY}
PresharedKey = ${PSK}
AllowedIPs = ${CLIENT_IPV4}/32, ${CLIENT_IPV6}/128
PEER_CONF

# Syntax check of the new configuration before activation
if ! wg-quick strip "$TMP_WG_CONF" > /dev/null 2>&1; then
    echo "Error: the generated server configuration is syntactically invalid. Aborting." >&2
    exit 1
fi

# Update the server configuration with a backup
cp -p "$WG_CONF" "${WG_CONF}.bak"
cp "$TMP_WG_CONF" "$WG_CONF"

# Live sync with the kernel
if ! wg syncconf wg0 <(wg-quick strip "$WG_CONF"); then
    echo "Error: kernel sync via wg syncconf failed. Rolling back..." >&2
    cp -p "${WG_CONF}.bak" "$WG_CONF"
    if ! wg syncconf wg0 <(wg-quick strip "$WG_CONF"); then
        echo "CRITICAL: rollback of the running WireGuard configuration failed." >&2
    fi
    exit 1
fi

# Move the client profile atomically into place
mv "$TMP_CLIENT_CONF" "$CONF_FILE"
rm -f "${WG_CONF}.bak"

echo "==> Profile saved successfully: $CONF_FILE"
echo ""
echo "=== QR CODE FOR MOBILE DEVICES ==="
qrencode -t ansiutf8 < "$CONF_FILE"
EOF

sudo chmod +x /usr/local/bin/add-wireguard-client

New clients can now be created safely:


sudo add-wireguard-client tablet
sudo add-wireguard-client macbook 5

Site-to-site VPN: linking two networks

While the roadwarrior setup attaches individual endpoints, a site-to-site VPN couples two complete networks transparently. Devices at site A (192.168.10.0/24) can reach servers at site B (192.168.20.0/24) directly without client software:


β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚           Site-to-site VPN: linking locations               β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                                             β”‚
β”‚   Site A (office / cloud)           Site B (home lab)       β”‚
β”‚   LAN: 192.168.10.0/24              LAN: 192.168.20.0/24    β”‚
β”‚   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”             β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
β”‚   β”‚ Gateway A (Ubuntu)β”‚             β”‚ Gateway B (Ubuntu)β”‚   β”‚
β”‚   β”‚ IP: 10.8.0.1      β”‚             β”‚ IP: 10.8.0.2      β”‚   β”‚
β”‚   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜             β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
β”‚             β”‚                                 β”‚             β”‚
β”‚             β–Ό                                 β–Ό             β”‚
β”‚   ═══════════════════════════════════════════════════════   β”‚
β”‚         Persistent WireGuard tunnel (UDP port 51820)        β”‚
β”‚   ═══════════════════════════════════════════════════════   β”‚
β”‚                                                             β”‚
β”‚   Transparent routing (no NAT):                             β”‚
β”‚   - Gateway A routes 192.168.20.0/24 -> 10.8.0.2            β”‚
β”‚   - Gateway B routes 192.168.10.0/24 -> 10.8.0.1            β”‚
β”‚                                                             β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Important prerequisites for site-to-site

No overlapping IP subnets:

The two sites must use different IP ranges (for example 192.168.10.0/24 and 192.168.20.0/24). If both sides use the same address range (such as the common 192.168.1.0/24), direct routing is impossible.

IP forwarding on both gateways:

On gateway A and gateway B, net.ipv4.ip_forward = 1 must be active.

No NAT between the sites: Tunnel traffic between the networks typically uses no masquerading, so source IPs stay unchanged for monitoring, logging and firewall rules.

Return routes on the LAN router: Clients at site A send packets to their default gateway (for example FRITZ!Box or pfSense). The main router must know how the remote network is reached. Therefore a static route must be set on the main router at site A:

Destination network:

192.168.20.0/24

Gateway / next hop:

  • IP address of gateway A on the local network (for example 192.168.10.254).
  • Analogously at site B: 192.168.10.0/24 via 192.168.20.254.

1. Configuration of gateway A (/etc/wireguard/wg0.conf)

Gateway A acts as responder with a publicly reachable address or DynDNS:


[Interface]
Address = 10.8.0.1/24
ListenPort = 51820
PrivateKey = <GATEWAY_A_PRIVATE_KEY>

# Allow forwarding between local LAN (eth0) and VPN (wg0) (no NAT)
PostUp = iptables -A FORWARD -i wg0 -o eth0 -j ACCEPT; iptables -A FORWARD -i eth0 -o wg0 -j ACCEPT
PostDown = iptables -D FORWARD -i wg0 -o eth0 -j ACCEPT; iptables -D FORWARD -i eth0 -o wg0 -j ACCEPT

# Peer: Gateway B
[Peer]
PublicKey = <GATEWAY_B_PUBLIC_KEY>
PresharedKey = <SITE_TO_SITE_PSK>
# Allow both the tunnel IP and the entire remote LAN
AllowedIPs = 10.8.0.2/32, 192.168.20.0/24

2. Configuration of gateway B (/etc/wireguard/wg0.conf)

Gateway B actively builds the connection to gateway A:


[Interface]
Address = 10.8.0.2/24
PrivateKey = <GATEWAY_B_PRIVATE_KEY>

PostUp = iptables -A FORWARD -i wg0 -o eth0 -j ACCEPT; iptables -A FORWARD -i eth0 -o wg0 -j ACCEPT
PostDown = iptables -D FORWARD -i wg0 -o eth0 -j ACCEPT; iptables -D FORWARD -i eth0 -o wg0 -j ACCEPT

# Peer: Gateway A
[Peer]
PublicKey = <GATEWAY_A_PUBLIC_KEY>
PresharedKey = <SITE_TO_SITE_PSK>
Endpoint = gateway-a.example.com:51820
AllowedIPs = 10.8.0.1/32, 192.168.10.0/24
PersistentKeepalive = 25

3. Bidirectional verification

Check the connection in both directions:


# 1. Check the tunnel handshake (on both gateways)
sudo wg show

# 2. Ping the remote tunnel IP (from gateway A)
ping -c 3 10.8.0.2

# 3. Ping a host on the remote LAN (from gateway A)
ping -c 3 192.168.20.50

# 4. Traceroute from a regular client on LAN A
traceroute 192.168.20.50

With unfiltered traceroute/ICMP traffic, the local WireGuard gateway and then the remote gateway should typically appear first. Missing intermediate hops (for example asterisks *) do not automatically mean a routing error; they often appear when firewalls or routers between the sites drop ICMP time-exceeded messages.

Own DNS server and internal name resolution (Unbound)

If VPN clients should resolve internal hostnames (for example nas.intern or wiki.corp) without forwarding DNS queries unencrypted to public resolvers, a local Unbound DNS resolver on the server is a good fit.

Install Unbound:


sudo apt install unbound -y

Unbound configuration with DNS-over-TLS (DoT)

Here we cleanly separate local name resolution from the type of forwarding:

  • Variant 1 (recommended): forwarding over DNS-over-TLS (DoT): Encrypts external DNS queries between your server and upstream resolvers (for example Quad9) over port 853 with certificate validation.
  • Variant 2: standard forwarding: Forwards queries unencrypted over the traditional UDP/TCP port 53.

Create the configuration file /etc/unbound/unbound.conf.d/wireguard.conf:


sudo tee /etc/unbound/unbound.conf.d/wireguard.conf << 'EOF'
server:
    interface: 10.8.0.1
    interface: fd42:42:42::1
    interface: 127.0.0.1
    interface: ::1
    access-control: 10.8.0.0/24 allow
    access-control: fd42:42:42::/64 allow
    access-control: 127.0.0.0/8 allow
    access-control: ::1 allow
    
    # Security and hardening settings
    hide-identity: yes
    hide-version: yes
    harden-glue: yes
    harden-dnssec-stripped: yes
    tls-cert-bundle: "/etc/ssl/certs/ca-certificates.crt"
    
    # Define a local infrastructure zone
    local-zone: "intern." static
    local-data: "nas.intern. IN A 192.168.10.50"
    local-data: "wiki.intern. IN A 192.168.10.51"
    local-data: "router.intern. IN A 10.8.0.1"

# Variant 1: encrypted upstream forwarding via DNS-over-TLS (port 853)
forward-zone:
    name: "."
    forward-tls-upstream: yes
    forward-addr: 9.9.9.9@853#dns.quad9.net
    forward-addr: 149.112.112.112@853#dns.quad9.net

# Variant 2 (alternative for standard DNS without TLS on port 53):
# forward-zone:
#     name: "."
#     forward-tls-upstream: no
#     forward-addr: 9.9.9.9
#     forward-addr: 149.112.112.112
EOF

Start Unbound and check the service:


sudo systemctl enable --now unbound
sudo systemctl status unbound --no-pager

In the client profiles under [Interface], now enter DNS = 10.8.0.1, fd42:42:42::1, intern. The client sends all DNS queries through the VPN tunnel to Unbound. When using a routed GUA prefix, replace the ULA addresses with your GUA server addresses. Make sure the host firewall (UFW or nftables) allows queries on port 53 over the wg0 interface (as set up in the firewall sections), because DNS traffic to the gateway is filtered as local host traffic (INPUT).

πŸ’‘ DNS integration of wg-quick on Linux: wg-quick processes the DNS = directive through the resolvconf command. On Ubuntu this interface can be provided by the resolvconf compatibility of systemd-resolved. If a WireGuard profile is instead managed or imported through NetworkManager, NetworkManager takes over DNS integration directly.

After the connection is up, check on the client with resolvectl status wg0 whether the assigned resolvers and β€” depending on the resolver integration in use β€” the configured search/routing domain for intern are in effect.

Strict VPN kill switch for mobile clients

On mobile laptops (for example on public networks or in hotels) a VPN drop must not let unencrypted traffic flow over the local interface.

⚠️ Before enabling the kill switch: The command sudo ufw default deny outgoing immediately blocks all outbound network traffic of the machine. If you run this step carelessly over an existing SSH connection, or if required exceptions for DHCP and LAN are missing, you cut yourself off from the system immediately. Set the exceptions exactly in the given order.

Architecture and a deliberate design choice: IPv4 bootstrap and strict tunnel

A mobile Linux client (laptop) often moves between changing guest Wi-Fi, hotels or mobile hotspots. A fully universal dual-stack kill switch for arbitrary foreign networks is complex at operating-system level, because local IPv6 prefixes, router advertisements and DNS behaviour change dynamically.

We therefore use a deliberate, hardened reference architecture:

Fixed IPv4 server address for the handshake:

The client builds the WireGuard tunnel specifically over the public IPv4 address of the server (Endpoint = <SERVER_IPV4>:51820). That removes any unencrypted DNS bootstrap communication to the outside before the tunnel is up.

Minimal IPv4 bootstrap over the physical interface:

For connection setup, loopback, DHCPv4 (UDP ports 67/68) and the WireGuard handshake (UDP port 51820) are opened on the physical interface. Besides loopback, DHCPv4 and the WireGuard handshake, access to the immediately attached local network can optionally be allowed. That exception means local LAN traffic deliberately does not go through the VPN tunnel; on foreign hotel or guest Wi-Fi this step should be omitted for maximum isolation.

All payload traffic (IPv4 and IPv6) exclusively over wg0:

Once the tunnel is up, all regular traffic β€” IPv4 as well as IPv6 β€” flows encrypted through the wg0 interface.

IPv6 payload outside wg0 blocked:

Regular outbound IPv6 payload over the physical interface is fully blocked by default deny outgoing (no IPv6 leak past the tunnel).

Local link communication:

The ICMPv6 packets required for basic local interface initialisation (neighbor discovery, router advertisements) are processed automatically in UFW's system-wide default rules (/etc/ufw/before6.rules) at link-local level (fe80::/10), so the physical interface stays stable on the local network without letting internet traffic through.

UFW rules for the kill switch (on the Linux client)

Apply the configuration on the client step by step:


# 1. Fully allow the local loopback interface
sudo ufw allow out on lo to any

# 2. Allow DHCPv4 for local IP negotiation on the physical interface
sudo ufw allow out 67:68/udp comment 'DHCP Client'

# 3. Optional: allow the local subnet (only if needed, e.g. home LAN; omit on foreign nets)
sudo ufw allow out to 192.168.1.0/24 comment 'Local LAN (optional)'

# 4. Allow connection setup to the WireGuard server over IPv4 (fixed server IP!)
sudo ufw allow out to <SERVER_IPV4> proto udp port 51820 comment 'WireGuard Handshake'

# 5. Allow all payload traffic (IPv4 and IPv6) over the tunnel interface
sudo ufw allow out on wg0 to any

# 6. Block inbound and outbound traffic on all interfaces by default
sudo ufw default deny incoming
sudo ufw default deny outgoing

# 7. Enable the firewall
sudo ufw enable

Disable in an emergency:

If you want to lift the kill switch again:


sudo ufw default allow outgoing
sudo ufw reload

Monitoring and troubleshooting

Successful troubleshooting strictly separates three layers: cryptographic handshake, IP routing and DNS resolution.

1. Handshake check (wg show)

Run the following command on the server or client:


sudo wg show

Sample output:


interface: wg0
  public key: 4HkL...8Xg=
  private key: (hidden)
  listening port: 51820

peer: 4HkL...8Xg=
  preshared key: (hidden)
  endpoint: 203.0.113.42:54120
  allowed ips: 10.8.0.2/32, fd42:42:42::2/128
  latest handshake: 14 seconds ago
  transfer: 2.14 MiB received, 18.76 MiB sent

⚠️ Handshake missing on connection attempts? WireGuard generates handshakes on demand: without outbound or inbound traffic there is no rekeying; an older latest handshake in idle state is therefore completely normal. If, however, actively generated traffic (for example a running ping) produces no current handshake, or latest handshake does not update despite repeated connection attempts, the usual causes are:

  • UDP port 51820 is blocked in a firewall (for example a cloud security group or router).
  • The server endpoint (domain or IPv4) is not reachable for the client (for example DS-Lite / CGNAT).
  • PublicKey or PresharedKey do not match exactly between server and client.
  • On clients behind NAT, a missing PersistentKeepalive = 25 can make a previously working connection sleep after long idle periods and no longer be immediately reachable from the outside.

2. Routing check

If there is a current handshake, test routing:


# Ping the gateway
ping -c 3 10.8.0.1

# Ping an external IP (checks forwarding and masquerading)
ping -c 3 1.1.1.1

# Query the routing table for a destination IP
ip route get 1.1.1.1

If 10.8.0.1 is reached but 1.1.1.1 is not, kernel forwarding (sysctl net.ipv4.ip_forward) or the UFW/NAT rule on the server is faulty.

3. Understand MTU and path MTU discovery

wg-quick determines the WireGuard tunnel MTU automatically by default from the routing to the endpoint or the MTU of the underlying interface.

A tunnel MTU of 1420 bytes is common on classic setups with a standard 1500-byte Ethernet underlay, but it is not a universal WireGuard value:

Divergent underlays:

Links with PPPoE (typically MTU 1492), extra encapsulations (DS-Lite, GRE, VXLAN), mobile (LTE/5G) or nested VPNs reduce the actual path MTU (PMTU) of the connection.

Manual MTU configuration:

A manual MTU = setting in the client or server configuration should only be used if automatic detection fails or concrete PMTU problems have been diagnosed (for example stalls on large websites or SSH transfers).

PMTU diagnosis with ping:

With the ping command and the don't-fragment flag set (-M do) you can test up to which size unfragmented IP packets can be transported over the path. Note what is being measured: on IPv4 the total IP packet size is the ICMP payload plus 28 bytes of headers (20 bytes IPv4 header + 8 bytes ICMP header):

$$\text{IP packet size} = \text{payload} + 28\text{ bytes}$$


# Tests an IP packet of 1420 bytes (1392 bytes payload + 28 bytes header)
ping -M do -s 1392 -c 3 <SERVER_IP>

If this test is rejected with Frag needed and DF set, reduce the payload in steps of 8 (for example to 1364 or 1332) to find the maximum unfragmented packet size on this path.

❗ Important note on MTU discovery: The measurement reflects the MTU of the transport path and cannot be taken 1:1 as a universal WireGuard MTU. On transfer problems it does, however, give the decisive hint for a targeted adjustment of the MTU = value in the client profile under [Interface].

DNS leak test and encryption

When the VPN tunnel is active, all DNS queries must be sent through the VPN. Otherwise DNS queries flow past the tunnel unencrypted to the local internet service provider (DNS leak).

Test for DNS leaks

Step 1: Establish the tunnel connection

Activate the WireGuard tunnel on the client:


sudo wg-quick up wg0

Step 2: Check the local system resolver

Verify in the terminal which DNS servers and routing domains the system resolver actually uses for the tunnel interface:


# Check status and assigned resolvers of the tunnel interface
resolvectl status wg0

# Run a test query through the configured resolver
resolvectl query example.com

Step 3: Run external leak detection

Test externally through which resolver infrastructure your queries arrive on the web (without forcing a resolver with @):


dig +short TXT whoami.ds.akahelp.net

Alternatively open test pages such as https://dnsleaktest.com or https://ipleak.net in the browser.

Step 4: Assess the test result

A DNS leak is not defined by resolver ownership alone, but by DNS queries using an unexpected resolver path outside the intended VPN/DNS architecture. With the configuration described here, DNS queries should not go through the resolver of the local internet provider (ISP). If that resolver is still used although Unbound or the configured VPN resolver is intended, that points to a DNS leak or faulty resolver prioritisation on the client.

If public anycast resolvers (such as Quad9 or Cloudflare) are used, they do not have to answer geographically at the same site as your VPN gateway, but they must not be queried unencrypted over the physical connection of the local internet access.

⚠️ Tip on DNS leaks: If local DNS servers of the home router are shown, check whether systemd-resolved or the local connection manager has correctly prioritised the WireGuard interface.

nftables alternative for advanced operators

For administrators who want to maintain a purely native nftables ruleset instead of UFW, nftables offers atomic updates and a common syntax for IPv4 and IPv6.

Complete host firewall with WireGuard (/etc/nftables.d/wireguard.nft)

The following ruleset acts as a complete, restrictive host firewall (policy drop). It allows loopback, existing connections, network diagnostics via ICMP/ICMPv6, SSH administration and WireGuard tunnel traffic. If the administration architecture allows it, SSH should additionally be restricted to known management networks or trusted source addresses:


sudo mkdir -p /etc/nftables.d

sudo tee /etc/nftables.d/wireguard.nft << 'EOF'
#!/usr/sbin/nft -f

# Complete host firewall including WireGuard VPN and NAT
table inet filter {
    chain input {
        type filter hook input priority filter; policy drop;

        # Allow the local loopback interface
        iif "lo" accept comment "Allow loopback"

        # Allow existing and related connections
        ct state established,related accept comment "Allow established/related"

        # Drop invalid packets
        ct state invalid drop comment "Drop invalid packets"

        # Allow ICMP and ICMPv6 for network diagnostics and path MTU discovery
        ip protocol icmp accept comment "Allow IPv4 ICMP (ping, PMTU)"
        ip6 nexthdr icmpv6 accept comment "Allow IPv6 ICMPv6 (NDP, ping, PMTU)"

        # SSH access for administration (adjust the port if different)
        tcp dport 22 accept comment "Allow SSH management"

        # Allow inbound WireGuard VPN port
        udp dport 51820 accept comment "Allow WireGuard VPN port"

        # Allow local DNS access (Unbound) for WireGuard clients (UDP and TCP)
        iifname "wg0" udp dport 53 accept comment "DNS from WireGuard"
        iifname "wg0" tcp dport 53 accept comment "DNS from WireGuard"
    }

    chain forward {
        type filter hook forward priority filter; policy drop;

        # Allow existing connections in forwarding
        ct state established,related accept comment "Forward established/related"

        # Forward traffic from the VPN to the internet (eth0)
        iifname "wg0" oifname "eth0" accept comment "WireGuard client forwarding"
    }
}

table inet nat {
    chain postrouting {
        type nat hook postrouting priority srcnat; policy accept;

        # IPv4 masquerading (NAT) for the WireGuard subnet over WAN
        ip saddr 10.8.0.0/24 oifname "eth0" masquerade comment "WireGuard IPv4 NAT"

        # Optional IPv6 NAT66 (only required for ULA configuration)
        # ip6 saddr fd42:42:42::/64 oifname "eth0" masquerade comment "WireGuard IPv6 NAT66"
    }
}
EOF

sudo chmod 600 /etc/nftables.d/wireguard.nft

Include in /etc/nftables.conf

So that the ruleset is loaded at system start, include the directory in /etc/nftables.conf:


if ! grep -q 'include "/etc/nftables.d/\*.nft"' /etc/nftables.conf; then
    echo 'include "/etc/nftables.d/*.nft"' | sudo tee -a /etc/nftables.conf
fi

# Check and load the configuration
sudo nft -f /etc/nftables.conf
sudo systemctl enable --now nftables

Verification:


sudo nft list ruleset

Firewall sets for granular access control (ACLs)

Sets in nftables are useful when you do not want to give every peer the same rights, but maintain access control lists (ACLs) (for example only certain admin peers may reach internal SSH services):


# Define a set for privileged peers in the 'filter' table
sudo nft add set inet filter admin_peers '{ type ipv4_addr; }'
sudo nft add element inet filter admin_peers '{ 10.8.0.2, 10.8.0.5 }'

# Allow only members of the set access to SSH on the internal management network
sudo nft add rule inet filter forward ip saddr @admin_peers ip daddr 192.168.10.10 tcp dport 22 accept

Logging and fault diagnosis

1. Temporarily enable dynamic kernel debugging

WireGuard is silent in normal operation and produces no kernel-log messages by default. On difficult connection drops you can enable kernel debugging:


# Turn on debug logging for the kernel module
echo "module wireguard +p" | sudo tee /sys/kernel/debug/dynamic_debug/control

# Follow messages live in the system log
sudo dmesg -wT | grep -i wireguard

After the analysis you should disable logging again to avoid log overhead:


echo "module wireguard -p" | sudo tee /sys/kernel/debug/dynamic_debug/control

2. Analyse systemd logs and interfaces


# Read systemd events from wg-quick
journalctl -u wg-quick@wg0 --since "1 hour ago"

# Machine-readable summary of all peers
sudo wg show wg0 dump

# Check socket status and queues
sudo ss -ulnp 'sport = :51820'

Command Reference (Cheatsheet)

Command Category Purpose and operational effect
sudo systemctl start wg-quick@wg0 Service control Starts the wg0 interface and sets up tunnel routes
sudo systemctl stop wg-quick@wg0 Service control Stops the interface and tears down tunnel routes
sudo systemctl enable --now wg-quick@wg0 Autostart Enables automatic system start of the VPN tunnel
sudo wg show Monitoring Shows active peers, handshake times, endpoints and transfer volume
sudo wg show wg0 dump Scripting / API Outputs tabular, machine-readable peer and tunnel data
sudo wg syncconf wg0 <(wg-quick strip wg0) Live reload Syncs peer changes in the running kernel without dropping active tunnels
wg genkey &#124; tee priv.key &#124; wg pubkey > pub.key Cryptography Generates an asymmetric Curve25519 key pair
wg genpsk > preshared.key Cryptography Generates a symmetric 256-bit pre-shared key (extra protection)
qrencode -t ansiutf8 < client.conf Deployment Renders a configuration file as a scannable terminal QR code
sudo add-wireguard-client <name> [suffix] Automation Creates a new profile fully automatically, fault-tolerant with file locking and rollback
sudo ufw route allow in on wg0 out on eth0 Firewall Allows targeted forwarding of WireGuard clients over the WAN interface
sudo ufw status verbose Firewall Shows UFW status and port openings in detail
sudo nft -f /etc/nftables.conf Firewall Loads the native nftables ruleset atomically
echo "module wireguard +p" &#124; sudo tee ... Troubleshooting Enables dynamic kernel debugging for detailed diagnosis
resolvectl status wg0 DNS Shows DNS resolver assignment and DNSSEC on the interface

Further Resources

Resource Link Purpose and content
WireGuard official WireGuard Technical Whitepaper Official protocol design, Noise framework and crypto analysis
WireGuard tools and quick WireGuard manpages Official reference for wg and wg-quick parameters
Ubuntu Server Guide Ubuntu Server Documentation Reference for network configuration, kernel routing and UFW
Linux server hardening Linux server hardening: SSH and CrowdSec Security guidelines, SSH with FIDO2 and firewall hardening
Ubuntu upgrade Ubuntu 24.04 to 26.04 LTS upgrade System upgrade of kernel and package sources to the next LTS version
LPIC-1 course series LPIC-1 series: Linux administration Solid course fundamentals on subnets, routing and permissions

Conclusion

WireGuard provides a modern, high-performance VPN architecture for Linux servers: lean in code, implemented directly as a kernel module and cryptographically precise on modern primitives (Curve25519, ChaCha20-Poly1305, BLAKE2s). Direct support in current Ubuntu LTS releases gives a stable, low-maintenance base for gateways and site couplings.

The main operational takeaways at a glance:

Protocol design and handshake:

WireGuard does without heavy userspace connection daemons. The 1-RTT handshake is based on the Noise protocol framework and is handled directly in the kernel, which speeds connection setup and enables seamless roaming.

Cryptographic classification: Symmetric pre-shared keys (PresharedKey) give valuable protection against retroactive decryption ("harvest now, decrypt later"), but they do not make the setup a complete post-quantum-safe KEM.

Architecture for IPv4 and IPv6: IPv4 works by default with a private subnet and NAT. For IPv6 a routed GUA prefix is the preferred standard; ULA with NAT66 is a practical alternative when the provider does not route a client prefix.

Consistent firewall control: Avoid uncontrolled global openings (DEFAULT_FORWARD_POLICY="ACCEPT"). Maintain forwarding and NAT in a structured way in the UFW rule files or through a standalone nftables ruleset.

Uninterrupted operation: Updating peers through wg syncconf protects active VPN sessions from drops and enables clean automation in production.

On new setups start with a single client and verify handshake (wg show), IP routing (ping) and DNS resolution (resolvectl status) systematically as separate layers before rolling out further peers.

Share & export

Export as Markdown

Related posts