---
id: 2026-08-26-set-up-a-wireguard-vpn-server-on-ubuntu
slug: set-up-a-wireguard-vpn-server-on-ubuntu
title: "Set up a WireGuard VPN server on Ubuntu"
date: "2026-08-26T12:00:00+02:00"
updated: "2026-09-07T18:02:00+02:00"
author:
  name: "Sebastian Palencsár"
  handle: "spalencsar"
category: "server-environments"
tags: ["ubuntu", "ubuntu2404", "ubuntu2604", "wireguard", "vpn", "netzwerk", "security", "linux"]
reading_time: 25
excerpt: "Set up WireGuard VPN on Ubuntu: dual-stack IPv4/IPv6, Unbound DNS, UFW/nftables routing and automated client setup via QR code."
toc: true
---

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](https://en.wikipedia.org/wiki/Curve25519){.badge-link-text}, [ChaCha20-Poly1305](https://en.wikipedia.org/wiki/ChaCha20){.badge-link-text}, [BLAKE2s](https://en.wikipedia.org/wiki/BLAKE2){.badge-link-text}) 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)](/en/tag/ubuntu2404){.badge-link-text} and [Ubuntu 26.04 LTS (Resolute Raccoon)](/en/tag/ubuntu2604){.badge-link-text}, 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](/en/category/server-environments){.badge-link-text} and follow standards of exams such as the [LPIC-1 series](/en/category/lpic-1-serie){.badge-link-text}.

<blockquote class="infobox infobox--info">
💡 **Note on kernel compatibility:** WireGuard has been a fixed part of the official upstream kernel since Linux kernel 5.6. On [Ubuntu 24.04](/en/tag/ubuntu2404){.badge-link-text} and [Ubuntu 26.04](/en/tag/ubuntu2604){.badge-link-text} no external repositories (PPAs) or third-party kernel modules are required any longer.
</blockquote>

## 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:

```markdown
┌─────────────────────────────────────────────────────────────┐
│             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 |

<blockquote class="infobox infobox--info">
💡 **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).
</blockquote>

## 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:

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

<blockquote class="infobox infobox--info">
💡 **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.
</blockquote>

Check whether the WireGuard kernel module is loaded:

```bash
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:

```bash
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:

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

**Verification:**

Check that both parameters are actively set to `1`:

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

**Expected output:**

```bash
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`):

```bash
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`):

```bash
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):

```bash
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`):

```bash
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.

<blockquote class="infobox infobox--info">
💡 **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.
</blockquote>

### 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:

```bash
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:

```bash
sudo chmod 600 /etc/wireguard/wg0.conf
```

### Variant A (recommended): consistent firewall and NAT with UFW

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.

<span class="nb-accent">A clean UFW architecture separates responsibilities clearly:</span>

* **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`:

```bash
sudo nano /etc/ufw/before.rules
```

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

```ini
# 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:

```bash
/etc/ufw/before6.rules
```

the analogous block before `*filter`:

```ini
*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:

```bash
# 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:

```bash
sudo ufw reload
```

**Verification of UFW and netfilter rules:**

```bash
# 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:

```bash
# 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`):

```ini
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`:

```bash
sudo systemctl enable --now wg-quick@wg0
```

### Technical verification

Check the status of the core components systematically:

**Check service status:**

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

**Verify network interface and IP addresses:**

```bash
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:**

```bash
sudo ss -ulnp | grep 51820
```

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

**Query WireGuard kernel status:**

```bash
sudo wg show
```

**Expected output:**

```bash
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:

```bash
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`:

```bash
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`:

```bash
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):

```bash
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`:

```ini
# 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.

<blockquote class="infobox infobox--info">
💡 **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).
</blockquote>

### 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:

```bash
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`:

```bash
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:

```bash
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:

```markdown
┌─────────────────────────────────────────────────────────────┐
│           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:

```ini
[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:

```ini
[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:

```bash
# 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:

```bash
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`:

```bash
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:

```bash
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).

<blockquote class="infobox infobox--info">
💡 **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.
</blockquote>

## 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.

<blockquote class="infobox infobox--warn">
⚠️ **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.
</blockquote>

### 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:

```bash
# 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:

```bash
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:

```bash
sudo wg show
```

Sample output:

```bash
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
```

<blockquote class="infobox infobox--warn">
⚠️ **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.
</blockquote>

### 2. Routing check

If there is a current handshake, test routing:

```bash
# 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}$$

```bash
# 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.

<blockquote class="infobox infobox--practice">
❗ **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]`.
</blockquote>

## 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:

```bash
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:

```bash
# 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 `@`):

```bash
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.

<blockquote class="infobox infobox--warn">
⚠️ **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.
</blockquote>

## 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:

```bash
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`:

```bash
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:**

```bash
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):

```bash
# 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:

```bash
# 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:

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

### 2. Analyse systemd logs and interfaces

```bash
# 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 \| tee priv.key \| 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" \| 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](https://www.wireguard.com/papers/wireguard.pdf){.badge-link-text} | Official protocol design, Noise framework and crypto analysis |
| **WireGuard tools and quick** | [WireGuard manpages](https://manpages.ubuntu.com/manpages/noble/man8/wg.8.html){.badge-link-text} | Official reference for `wg` and `wg-quick` parameters |
| **Ubuntu Server Guide** | [Ubuntu Server Documentation](https://ubuntu.com/server/docs){.badge-link-text} | Reference for network configuration, kernel routing and UFW |
| **Linux server hardening** | [Linux server hardening: SSH and CrowdSec](/en/server-environments/linux-server-hardening-fido2-crowdsec){.badge-link-text} | Security guidelines, SSH with FIDO2 and firewall hardening |
| **Ubuntu upgrade** | [Ubuntu 24.04 to 26.04 LTS upgrade](/en/server-environments/ubuntu-upgrade-from-24-04-lts-to-26-04-lts){.badge-link-text} | System upgrade of kernel and package sources to the next LTS version |
| **LPIC-1 course series** | [LPIC-1 series: Linux administration](/en/category/lpic-1-serie){.badge-link-text} | 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.

<span class="nb-accent">The main operational takeaways at a glance:</span>

**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.
