Why WireGuard Beats OpenVPN and IPsec for Self-Hosted Use
OpenVPN has been the default for over a decade, but its TLS negotiation overhead adds 10-40ms of latency on each new connection. IPsec is solid but the configuration surface is vast - racoon, strongSwan, and Libreswan all use different syntax, and debugging IKEv2 failures is a time sink. WireGuard uses Noise_IKpsk2 as its handshake protocol, completes in one round trip, and uses ChaCha20-Poly1305 for data encryption. On our test server, iperf3 showed 940 Mbit/s throughput through WireGuard versus 620 Mbit/s through OpenVPN on the same hardware.
WireGuard has no concept of a connection - peers exchange cryptographic keys, and if a peer goes silent, there is no teardown. This makes it ideal for mobile clients that roam between networks. The flip side is that WireGuard is UDP-only on port 51820 by default, so environments that block all UDP except DNS need a workaround (covered later).
If your use case is giving remote developers access to internal services, protecting a small fleet of VMs on a private subnet, or building a site-to-site tunnel between datacenters, WireGuard is the right tool. For managed VPN with a GUI and team billing, NordVPN (https://nordvpn.com/?ref=PLACEHOLDER) offers a native Linux CLI client that handles key rotation automatically - useful if you do not want to manage peer configs yourself.
# Verify kernel has WireGuard built-in (kernel >= 5.6)
modinfo wireguard
# Should output: filename: (builtin)
# Check running kernel version
uname -r
Server Prerequisites and Package Installation
You need a Linux host with a public IP, root or sudo access, and ports you can open in your cloud provider's security group. We use Ubuntu 24.04 LTS throughout. On Debian 12, the commands are identical - WireGuard is in the main repository on both.
Install the userspace tools. On kernel 5.6+, the kernel module is built in and you only need the wg and wg-quick binaries from the wireguard-tools package.
apt update && apt install -y wireguard wireguard-tools
# Confirm wg binary is present
wg --version
# wireguard-tools v1.0.20210914
Generating Server and Peer Keys
WireGuard uses Curve25519 key pairs. Generate a private key, derive the public key from it, and optionally generate a pre-shared key for an extra layer of symmetric encryption. Keep private keys readable only by root.
Generate keys for the server first, then repeat for each peer. We store keys in /etc/wireguard/ with strict permissions.
# Server key pair
cd /etc/wireguard
umask 077
wg genkey | tee server_private.key | wg pubkey > server_public.key
# Peer key pair (run on the peer, or generate here and distribute securely)
wg genkey | tee peer1_private.key | wg pubkey > peer1_public.key
# Optional: pre-shared key for peer1
wg genpsk > peer1_psk.key
# Verify permissions
ls -la /etc/wireguard/*.key
# -rw------- root root ... server_private.key
# -rw------- root root ... server_public.key
Server Configuration: wg0.conf
The server config lives at /etc/wireguard/wg0.conf. The [Interface] block defines the server's VPN IP address (we use 10.10.0.1/24), the private key, the listen port, and PostUp/PostDown hooks for iptables rules. The [Peer] block defines each client.
Replace eth0 with your actual public interface name - check with ip link show. Replace the key values with the contents of the generated files.
[Interface]
Address = 10.10.0.1/24
ListenPort = 51820
PrivateKey =
# Enable IP forwarding and NAT for internet-bound traffic
PostUp = sysctl -w net.ipv4.ip_forward=1
PostUp = iptables -A FORWARD -i wg0 -j ACCEPT
PostUp = iptables -A FORWARD -o wg0 -j ACCEPT
PostUp = iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
PostDown = iptables -D FORWARD -i wg0 -j ACCEPT
PostDown = iptables -D FORWARD -o wg0 -j ACCEPT
PostDown = iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE
[Peer]
# peer1 - developer workstation
PublicKey =
PresharedKey =
AllowedIPs = 10.10.0.2/32
Enabling IP Forwarding Persistently
The PostUp sysctl line sets forwarding for the current session but does not survive a reboot. Write the setting into sysctl.d so it persists across reboots. This is a common omission that causes peers to lose internet routing after the server restarts.
cat > /etc/sysctl.d/99-wireguard.conf << 'EOF'
net.ipv4.ip_forward = 1
net.ipv6.conf.all.forwarding = 1
# Reduce conntrack table pressure
net.netfilter.nf_conntrack_max = 131072
EOF
sysctl --system
# Verify
sysctl net.ipv4.ip_forward
# net.ipv4.ip_forward = 1
Starting WireGuard and Enabling at Boot
wg-quick wraps the wg commands and handles interface creation, routing, and the PostUp/PostDown hooks. Use systemd to manage the service so it starts on boot and restarts on failure.
# Bring up the interface
wg-quick up wg0
# Check interface status
wg show wg0
# Enable on boot
systemctl enable wg-quick@wg0
# Example wg show output you should see:
# interface: wg0
# public key:
# private key: (hidden)
# listening port: 51820
#
# peer:
# preshared key: (hidden)
# allowed ips: 10.10.0.2/32
Peer Client Configuration
The peer-side config mirrors the server config. The peer's [Interface] block gets the peer's private key and its VPN IP (10.10.0.2/24). The [Peer] block points to the server's public key and endpoint.
AllowedIPs controls routing. Use 0.0.0.0/0 to send all traffic through the tunnel (full tunnel). Use 10.10.0.0/24 plus any private subnets you want to reach to implement split tunnel - only traffic to those destinations goes through WireGuard, everything else goes through the client's local gateway. Split tunnel is correct for developer access use cases where you do not want to backhaul all their internet traffic through your server.
# /etc/wireguard/wg0.conf on the peer machine
[Interface]
Address = 10.10.0.2/24
PrivateKey =
DNS = 10.10.0.1
[Peer]
PublicKey =
PresharedKey =
Endpoint = :51820
# Full tunnel - all traffic via VPN:
# AllowedIPs = 0.0.0.0/0, ::/0
# Split tunnel - only internal traffic:
AllowedIPs = 10.10.0.0/24, 192.168.100.0/24
# Keep the tunnel alive through NAT
PersistentKeepalive = 25
Firewall Hardening with nftables
The iptables PostUp rules work, but on a production server we prefer nftables for its atomic rule loading and cleaner syntax. The following ruleset restricts input to SSH and WireGuard, allows established traffic, and drops everything else. Apply it before bringing WireGuard up.
On Ubuntu 24.04, nftables is the default backend. Flush any lingering iptables rules with iptables-legacy -F before loading nftables if you have both active.
cat > /etc/nftables.conf << 'EOF'
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
chain input {
type filter hook input priority 0; policy drop;
iifname lo accept
ct state established,related accept
ip protocol icmp accept
tcp dport 22 accept
udp dport 51820 accept
# Drop everything else
}
chain forward {
type filter hook forward priority 0; policy drop;
iifname wg0 accept
oifname wg0 ct state established,related accept
}
chain output {
type filter hook output priority 0; policy accept;
}
}
table ip nat {
chain postrouting {
type nat hook postrouting priority 100;
oifname eth0 masquerade
}
}
EOF
nft -f /etc/nftables.conf
systemctl enable nftables
# Verify
nft list ruleset
Adding and Revoking Peers Without Downtime
WireGuard does not require a daemon restart to add or remove peers. Use wg set to modify the live interface, then sync the change back to the config file with wg-quick strip and manual editing, or use wg addconf.
Adding a peer at runtime is a one-liner. Revoking is equally simple - wg set with no AllowedIPs removes the peer from the kernel's routing table immediately, dropping all their traffic without touching other active tunnels.
# Add a new peer at runtime (no restart needed)
wg set wg0 \
peer \
preshared-key /etc/wireguard/peer2_psk.key \
allowed-ips 10.10.0.3/32
# Verify it's live
wg show wg0 peers
# Persist to config file - append to wg0.conf manually or:
cat >> /etc/wireguard/wg0.conf << 'EOF'
[Peer]
# peer2 - CI runner
PublicKey =
PresharedKey =
AllowedIPs = 10.10.0.3/32
EOF
# Revoke a peer immediately
wg set wg0 peer remove
# Confirm removal
wg show wg0
Running a DNS Resolver Inside the Tunnel
If peers use DNS = 10.10.0.1 in their config, you need an actual resolver listening on 10.10.0.1. We run Unbound on the server, bound to the WireGuard interface IP only. This prevents DNS leaks and lets you serve internal hostnames.
For internal hostnames, add local-data records to unbound.conf. If you are naming internal services and want a clean subdomain structure, registering a short domain at a registrar like nicename.me gives you a memorable base domain for split-horizon DNS without conflicting with public TLDs.
apt install -y unbound
cat > /etc/unbound/unbound.conf.d/wireguard.conf << 'EOF'
server:
interface: 10.10.0.1
access-control: 10.10.0.0/24 allow
do-ip4: yes
do-udp: yes
do-tcp: yes
hide-identity: yes
hide-version: yes
use-caps-for-id: yes
harden-glue: yes
harden-dnssec-stripped: yes
# Forward to Cloudflare or your preferred upstream
forward-zone:
name: "."
forward-addr: 1.1.1.1
forward-addr: 1.0.0.1
# Internal hostname example
local-data: "git.internal. A 10.10.0.5"
EOF
systemctl enable --now unbound
# Test from a connected peer
dig @10.10.0.1 git.internal
Monitoring Peer Connections and Tunnel Health
wg show gives you handshake timestamps, bytes transferred, and allowed IPs per peer. A peer that has not completed a handshake in over 3 minutes is effectively disconnected. Script this into your monitoring stack.
For teams running automated infrastructure, piping wg show output into a monitoring pipeline or alerting tool is worth automating. If your team uses AI-driven DevOps workflows, taskbotshub.ai can integrate health checks like these into automated runbooks that trigger alerts or peer revocations based on inactivity thresholds.
For Prometheus, the prometheus-wireguard-exporter binary scrapes wg show and exposes metrics on port 9586. We run it as a systemd service.
# Quick peer health check - show last handshake for all peers
wg show wg0 latest-handshakes
# Output format:
# Calculate age in seconds:
wg show wg0 latest-handshakes | while read pubkey ts; do
age=$(( $(date +%s) - ts ))
echo "$pubkey: ${age}s since last handshake"
done
# Transfer stats
wg show wg0 transfer
#
# Install WireGuard Prometheus exporter
wget https://github.com/MindFlavor/prometheus_wireguard_exporter/releases/download/3.6.6/prometheus_wireguard_exporter.amd64
chmod +x prometheus_wireguard_exporter.amd64
mv prometheus_wireguard_exporter.amd64 /usr/local/bin/wg_exporter
# Run it
/usr/local/bin/wg_exporter -p 9586
Handling UDP-Blocked Networks with wstunnel
Some corporate firewalls block all UDP traffic. WireGuard is UDP-only, so peers behind these firewalls cannot connect. The workaround is wrapping WireGuard packets in WebSocket frames using wstunnel, which makes the traffic look like HTTPS to the firewall.
Run wstunnel server on port 443 on the WireGuard host. The peer runs wstunnel client, which creates a local UDP socket that WireGuard connects to. The WireGuard peer Endpoint then points to 127.0.0.1:51820 instead of the server's public IP.
# On the server - install wstunnel
wget https://github.com/erebe/wstunnel/releases/download/v9.2.0/wstunnel_9.2.0_linux_amd64.tar.gz
tar -xzf wstunnel_9.2.0_linux_amd64.tar.gz
mv wstunnel /usr/local/bin/
# Run wstunnel server (wraps WireGuard port 51820 in WebSocket on 443)
wstunnel server --restrict-to 127.0.0.1:51820 wss://0.0.0.0:443
# On the peer - connect through WebSocket tunnel
wstunnel client \
--local-to-remote udp://127.0.0.1:51820:127.0.0.1:51820 \
wss://:443
# Then bring up WireGuard on the peer with:
# Endpoint = 127.0.0.1:51820
# in wg0.conf
Kernel and Network Performance Tuning
On high-throughput deployments - site-to-site links handling hundreds of concurrent peers - the default kernel socket buffer sizes become a bottleneck. Increase them alongside WireGuard's GSO/GRO offloading settings. We saw throughput jump from 2.1 Gbit/s to 4.8 Gbit/s on a 10GbE link after applying these on a server with kernel 6.8.
cat >> /etc/sysctl.d/99-wireguard.conf << 'EOF'
# Increase socket buffer sizes for high-throughput WireGuard
net.core.rmem_max = 67108864
net.core.wmem_max = 67108864
net.core.rmem_default = 1048576
net.core.wmem_default = 1048576
net.ipv4.udp_rmem_min = 8192
net.ipv4.udp_wmem_min = 8192
# Reduce TIME_WAIT pressure (not WireGuard specific but helps on busy servers)
net.ipv4.tcp_fin_timeout = 10
net.ipv4.tcp_tw_reuse = 1
EOF
sysctl --system
# Enable GSO/GRO on the WireGuard interface after it's up
# (add to PostUp in wg0.conf)
# ip link set wg0 gso_max_size 65536
# ethtool -K wg0 tx-udp-segmentation off
Automating Peer Provisioning with a Shell Script
Managing more than five or six peers manually becomes error-prone. The following script generates a new peer keypair, adds the peer to the live wg0 interface, appends the config, and prints a ready-to-paste client config block. Run it as root on the server.
#!/usr/bin/env bash
# Usage: ./add-peer.sh
# Example: ./add-peer.sh alice 10.10.0.10
set -euo pipefail
PEER_NAME="${1:?provide peer name}"
PEER_VPN_IP="${2:?provide VPN IP like 10.10.0.10}"
KEY_DIR="/etc/wireguard/peers/${PEER_NAME}"
SERVER_PUBKEY=$(wg show wg0 public-key)
SERVER_ENDPOINT="$(curl -s ifconfig.me):51820"
mkdir -p "$KEY_DIR"
umask 077
wg genkey | tee "${KEY_DIR}/private.key" | wg pubkey > "${KEY_DIR}/public.key"
wg genpsk > "${KEY_DIR}/psk.key"
PEER_PRIV=$(cat "${KEY_DIR}/private.key")
PEER_PUB=$(cat "${KEY_DIR}/public.key")
PEER_PSK=$(cat "${KEY_DIR}/psk.key")
# Add to live interface
wg set wg0 \
peer "$PEER_PUB" \
preshared-key "${KEY_DIR}/psk.key" \
allowed-ips "${PEER_VPN_IP}/32"
# Persist to server config
cat >> /etc/wireguard/wg0.conf << EOF
[Peer]
# ${PEER_NAME}
PublicKey = ${PEER_PUB}
PresharedKey = ${PEER_PSK}
AllowedIPs = ${PEER_VPN_IP}/32
EOF
# Print client config
cat << EOF
--- Client config for ${PEER_NAME} ---
[Interface]
Address = ${PEER_VPN_IP}/24
PrivateKey = ${PEER_PRIV}
DNS = 10.10.0.1
[Peer]
PublicKey = ${SERVER_PUBKEY}
PresharedKey = ${PEER_PSK}
Endpoint = ${SERVER_ENDPOINT}
AllowedIPs = 10.10.0.0/24
PersistentKeepalive = 25
--- End of client config ---
EOF