Set Up a WireGuard VPN Server
Configure a WireGuard VPN server from scratch: key generation, server and client configs, NAT with nftables, IP forwarding, firewall rules, and MTU tuning.
Before you start
- ▸A Linux server with a public IP address and root or sudo access
- ▸Linux kernel 5.6 or newer (or the wireguard-dkms backport on older kernels)
- ▸Basic familiarity with systemd service management
- ▸UDP port 51820 unblocked at the cloud provider or hardware firewall level
WireGuard is a modern VPN protocol built into the Linux kernel since 5.6. It is faster to configure than OpenVPN or IPsec, uses proven cryptography (Curve25519, ChaCha20, Poly1305), and its codebase is small enough to audit. This guide walks through a full server and client setup: key generation, server config, firewall rules with NAT, routing, and MTU tuning.
Prerequisites and Network Assumptions
The server is a VPS or dedicated machine with a public IPv4 address. The examples use 10.0.0.1/24 as the WireGuard tunnel subnet and eth0 as the public-facing interface — replace both to match your environment. All server commands run as root or with sudo.
Install WireGuard
Debian / Ubuntu
apt update && apt install -y wireguard
Fedora / RHEL 9 / Rocky Linux
dnf install -y wireguard-tools
On RHEL/Rocky the kernel module ships with the standard kernel since 5.6; wireguard-tools provides wg and wg-quick.
Arch Linux
pacman -S wireguard-tools
Generate Server Keys
WireGuard uses static Curve25519 key pairs. Keep the private key readable only by root.
cd /etc/wireguard
umask 077
wg genkey | tee server_private.key | wg pubkey > server_public.key
Store the public key somewhere handy — every client config needs it.
Generate Client Keys
Generate one key pair per client. You can do this on the server for convenience, or on the client itself (preferred for zero-knowledge setups).
wg genkey | tee client1_private.key | wg pubkey > client1_public.key
Optionally generate a pre-shared key for an extra layer of symmetric encryption:
wg genpsk > client1_psk.key
Create the Server Configuration
Create /etc/wireguard/wg0.conf. Substitute the actual key values from the files generated above.
cat /etc/wireguard/server_private.key # copy this value
cat /etc/wireguard/client1_public.key # copy this value
cat > /etc/wireguard/wg0.conf << 'EOF'
[Interface]
Address = 10.0.0.1/24
ListenPort = 51820
PrivateKey = <SERVER_PRIVATE_KEY>
# NAT and routing — PostUp/PreDown manage firewall rules automatically
PostUp = nft add table ip wg-nat; \
nft add chain ip wg-nat postrouting { type nat hook postrouting priority 100 \; }; \
nft add rule ip wg-nat postrouting oifname "eth0" masquerade
PreDown = nft delete table ip wg-nat
[Peer]
# client1
PublicKey = <CLIENT1_PUBLIC_KEY>
PresharedKey = <CLIENT1_PSK>
AllowedIPs = 10.0.0.2/32
EOF
AllowedIPs on the server side acts as an IP whitelist: only packets sourced from 10.0.0.2 are accepted from this peer. Add more [Peer] blocks for additional clients, incrementing the tunnel IP each time.
If you prefer firewalld (common on Fedora/RHEL), replace the PostUp/PreDown lines:
# firewalld alternative — add to [Interface] in place of nft lines
PostUp = firewall-cmd --add-interface=wg0 --zone=internal; \
firewall-cmd --add-masquerade --zone=public
PreDown = firewall-cmd --remove-interface=wg0 --zone=internal; \
firewall-cmd --remove-masquerade --zone=public
Enable IP Forwarding
The kernel must forward packets between the tunnel and the public interface. This setting persists across reboots.
echo 'net.ipv4.ip_forward = 1' > /etc/sysctl.d/99-wireguard.conf
sysctl -p /etc/sysctl.d/99-wireguard.conf
For dual-stack deployments also add net.ipv6.conf.all.forwarding = 1 to the same file.
Open the Firewall Port
ufw (Debian / Ubuntu)
ufw allow 51820/udp
ufw reload
firewalld (Fedora / RHEL / Rocky)
firewall-cmd --permanent --add-port=51820/udp
firewall-cmd --reload
nftables directly
nft add rule inet filter input udp dport 51820 accept
Make this persistent by adding it to your /etc/nftables.conf ruleset.
Start the WireGuard Interface
systemctl enable --now wg-quick@wg0
The wg-quick@ systemd template reads /etc/wireguard/wg0.conf automatically. Verify the interface is up:
wg show wg0
Expected output (will vary):
interface: wg0
public key: (your server public key)
private key: (hidden)
listening port: 51820
peer: (client1 public key)
preshared key: (hidden)
allowed ips: 10.0.0.2/32
Configure the Client
On the client machine, create /etc/wireguard/wg0.conf (Linux) or import into the WireGuard app (Android/iOS/macOS/Windows).
[Interface]
Address = 10.0.0.2/32
PrivateKey = <CLIENT1_PRIVATE_KEY>
DNS = 1.1.1.1
MTU = 1420
[Peer]
PublicKey = <SERVER_PUBLIC_KEY>
PresharedKey = <CLIENT1_PSK>
Endpoint = <SERVER_PUBLIC_IP>:51820
# Route all traffic through the VPN:
AllowedIPs = 0.0.0.0/0, ::/0
# Keep NAT alive on home routers:
PersistentKeepalive = 25
Bring the client tunnel up:
systemctl enable --now wg-quick@wg0
MTU Tuning
WireGuard adds a 60-byte overhead per packet (IPv4: 20 IP + 8 UDP + 32 WireGuard). Standard Ethernet MTU is 1500, so the safe starting value is 1420. If your ISP uses PPPoE (adds 8 bytes), drop it to 1412. Jumbo-frame links can go higher. Set the MTU in the client's [Interface] section and optionally on the server too:
# In [Interface] on either side:
MTU = 1420
Confirm no fragmentation with a ping test from the client after connecting:
ping -M do -s 1380 10.0.0.1
Increase the size until packets start dropping, then back off 20 bytes — that is your practical ceiling.
Verify End-to-End Connectivity
# From the client, ping the server tunnel IP:
ping 10.0.0.1
# Confirm your exit IP is the server's public IP:
curl -s https://ifconfig.me
# Check the handshake timestamp on the server:
wg show wg0 latest-handshakes
A successful handshake timestamp within the last few minutes confirms both sides are talking. If latest-handshakes shows 0 or no output, the tunnel has not connected — see troubleshooting below.
Adding More Clients
For each new client: generate a new key pair, assign a unique tunnel IP (10.0.0.3/32, etc.), and add a [Peer] block to the server config. Reload without dropping existing connections:
wg addconf wg0 <(wg-quick strip /etc/wireguard/wg0.conf)
Or simply restart the service if a brief interruption is acceptable:
systemctl restart wg-quick@wg0
Troubleshooting
- No handshake: Confirm UDP 51820 is reachable from the client with
nc -u -z <SERVER_IP> 51820or check cloud provider security groups — they are a common blocker. - Tunnel up but no internet: IP forwarding is most likely disabled. Re-run
sysctl net.ipv4.ip_forwardand confirm it returns1. Also verify the NAT rule fired withnft list table ip wg-nat. - DNS leaks: Set
DNS =in the client[Interface]and, on systemd-resolved clients, check thatresolvectl status wg0shows the expected DNS server. - Packet loss / high latency: Lower the client MTU in 8-byte increments until loss stops. On PPPoE or VPN-over-VPN paths this is the most common performance issue.
- Permission denied on key files: Key files must be mode 600 and owned by root. Run
chmod 600 /etc/wireguard/*.keyand retry.
Frequently asked questions
- Can I run WireGuard without a public static IP on the server?
- Yes, but clients need a way to reach the server. Use a dynamic DNS hostname (e.g. from DuckDNS) in the client Endpoint field. The server itself does not need to know its own public IP.
- What is PersistentKeepalive and do I always need it?
- It sends a keepalive UDP packet every N seconds so NAT mappings on home routers stay alive. Set it to 25 on clients behind NAT. Servers or peers with direct public IPs do not need it.
- How do I route only specific traffic through the VPN instead of all traffic?
- Replace AllowedIPs = 0.0.0.0/0 in the client config with specific subnets, for example 10.0.0.0/24 for LAN-only access. WireGuard uses AllowedIPs as both a routing and ACL table.
- Is the pre-shared key mandatory?
- No. It is optional but recommended — it adds a layer of symmetric encryption on top of the Curve25519 handshake, providing some protection if Curve25519 were ever broken by a future quantum algorithm.
- Why does my DNS revert to the ISP DNS after connecting?
- On systemd-resolved systems, the DNS line in wg-quick's config sets a per-link DNS server, but split-DNS behavior depends on the DNSSEC and routing settings. Check resolvectl status wg0 and consider setting Domains = ~. in the Interface section to force all DNS over the tunnel.
Related guides
Configure Prometheus Alertmanager
Configure Prometheus Alertmanager with routing trees, receivers, inhibition rules, grouping, Go templates, and PagerDuty/Slack on-call integrations.
Build an Intranet Server on Linux
Set up a complete small-office intranet on one Linux box: Nginx web server, dnsmasq local DNS, Samba file sharing, and a Wiki.js team wiki.
Build an nftables Firewall Script
Build a complete nftables firewall from scratch: tables, chains, sets, default-deny input policy, service allowlisting, and persistent systemd configuration.
Caddy as a Reverse Proxy
Set up Caddy as a reverse proxy with automatic HTTPS, load balancing, WebSocket passthrough, reusable snippets, and header control — no certbot required.