WireGuard Tutorial: A Practical Guide to Modern VPN Setup
Learn WireGuard from scratch - concepts, configuration, common topologies, and practical examples for setting up secure VPN tunnels between Linux machines.
The OpenVPN config file was 87 lines. Cipher suites, TLS auth keys, certificate paths, compression settings, route-nopull directives. I copy-pasted it from a tutorial, changed three values, and prayed.
It didn’t work.
Four hours later—after debugging certificate chains, realizing the server wanted tls-auth but the client config had tls-crypt, and discovering that my firewall was blocking UDP 1194—I finally got a connection. The tunnel dropped every 20 minutes because of a keep-alive mismatch.
Then I discovered WireGuard. My first working config was 10 lines. It took 5 minutes. It hasn’t dropped in two years.
This is what VPN configuration should feel like.
Why WireGuard?
Simplicity: A WireGuard config file is ~10 lines. An OpenVPN config is often 50+.
Performance: WireGuard runs in the kernel, not userspace. Benchmarks show 3-4x throughput vs OpenVPN.
Security: Modern cryptography only (Curve25519, ChaCha20, Poly1305). No cipher negotiation, no legacy options.
Stealth: Silent when not in use. Doesn’t respond to unauthenticated packets.
Core Concepts
Before configuring anything, understand these five concepts:

1. Interface
WireGuard creates a virtual network interface (like wg0). This interface has:
- A private key
- A listening port (optional)
- An IP address within your VPN network
wg0: 10.0.0.1/24 ← This machine's VPN IP
2. Peers
A peer is any other machine in your WireGuard network. Each peer has:
- A public key (how you identify them)
- Allowed IPs (what traffic to send to them)
- An endpoint (optional - their public IP:port)
Peer A ←──────────────→ Peer B
encrypted tunnel
3. Key Pairs
Each machine has a public/private key pair:
- Private key: Secret, never shared, stays on the machine
- Public key: Derived from private key, shared with peers
You give your public key to peers. They give you theirs. That’s how you authenticate each other.
4. AllowedIPs
This is the trickiest concept. AllowedIPs serves two purposes:
Outbound: “What destination IPs should be routed through this peer?” Inbound: “What source IPs is this peer allowed to claim?”
Example:
[Peer]
AllowedIPs = 10.0.0.2/32
This means:
- Send traffic destined for
10.0.0.2through this peer - Accept traffic from this peer only if it claims to be from
10.0.0.2
5. Endpoint
The public IP:port where a peer can be reached. Only needed for one side if the other is behind NAT.
Endpoint = 203.0.113.5:51820
If both peers are behind NAT, one must have a publicly reachable endpoint.
Installation
Debian/Ubuntu
sudo apt update
sudo apt install -y wireguard
Fedora/RHEL
sudo dnf install -y wireguard-tools
Arch
sudo pacman -S wireguard-tools
macOS
brew install wireguard-tools
Verify Installation
which wg
# /usr/bin/wg
wg --version
# wireguard-tools v1.0.20210914
Your First WireGuard Tunnel
Let’s connect two machines: Server (has public IP) and Client (behind NAT).

Step 1: Generate Keys (Both Machines)
On both the server and client:
cd /etc/wireguard
umask 077
wg genkey | tee privatekey | wg pubkey > publickey
View the keys:
cat privatekey
# aB3dE5fG7hI9jK1lM3nO5pQ7rS9tU1vW3xY5zA7bC9=
cat publickey
# XyZ9aB7cD5eF3gH1iJ9kL7mN5oP3qR1sT9uV7wX5yZ=
Write down both public keys — you’ll exchange them.
Step 2: Configure Server
Create /etc/wireguard/wg0.conf on the server:
[Interface]
# Server's private key
PrivateKey = <SERVER_PRIVATE_KEY>
# Server's VPN IP address
Address = 10.0.0.1/24
# Port to listen on
ListenPort = 51820
[Peer]
# Client's public key
PublicKey = <CLIENT_PUBLIC_KEY>
# Client's VPN IP (what IPs can this peer use?)
AllowedIPs = 10.0.0.2/32
Replace:
<SERVER_PRIVATE_KEY>— contents of server’sprivatekeyfile<CLIENT_PUBLIC_KEY>— the client’s public key
Step 3: Configure Client
Create /etc/wireguard/wg0.conf on the client:
[Interface]
# Client's private key
PrivateKey = <CLIENT_PRIVATE_KEY>
# Client's VPN IP address
Address = 10.0.0.2/24
[Peer]
# Server's public key
PublicKey = <SERVER_PUBLIC_KEY>
# Server's public IP and port
Endpoint = <SERVER_PUBLIC_IP>:51820
# Server's VPN IP (route this through the tunnel)
AllowedIPs = 10.0.0.1/32
# Keep connection alive (needed when behind NAT)
PersistentKeepalive = 25
Replace:
<CLIENT_PRIVATE_KEY>— contents of client’sprivatekeyfile<SERVER_PUBLIC_KEY>— the server’s public key<SERVER_PUBLIC_IP>— the server’s public IP (e.g.,203.0.113.5)
Step 4: Open Firewall on Server
Allow incoming UDP on port 51820:
# UFW
sudo ufw allow 51820/udp
# firewalld
sudo firewall-cmd --permanent --add-port=51820/udp
sudo firewall-cmd --reload
# iptables
sudo iptables -A INPUT -p udp --dport 51820 -j ACCEPT
Step 5: Start WireGuard
On the server first:
sudo wg-quick up wg0
Then on the client:
sudo wg-quick up wg0
Step 6: Verify Connection
Check status on either machine:
sudo wg show
Expected output (on client):
interface: wg0
public key: XyZ9aB7cD5eF3gH1iJ9kL7mN5oP3qR1sT9uV7wX5yZ=
private key: (hidden)
listening port: 43210
peer: AbC1dE2fG3hI4jK5lM6nO7pQ8rS9tU0vW1xY2zA3bC=
endpoint: 203.0.113.5:51820
allowed ips: 10.0.0.1/32
latest handshake: 5 seconds ago ← This confirms it's working
transfer: 1.24 KiB received, 928 B sent
persistent keepalive: every 25 seconds
“latest handshake” confirms the tunnel is established.
Test connectivity:
# From client
ping -c 4 10.0.0.1
# From server
ping -c 4 10.0.0.2
Enable on Boot
Make WireGuard start automatically:
sudo systemctl enable wg-quick@wg0
Now wg0 will come up after every reboot.
Common Topologies
Point-to-Point (Two Machines)
What we just set up. Two machines connected directly.
Machine A ←────────→ Machine B
Use case: Accessing a home server from a VPS.
Hub and Spoke (Star Topology)
One central server, multiple clients. Clients connect to the hub, not to each other.
┌──── Client 1
│
Hub ────┼──── Client 2
│
└──── Client 3
Hub config has multiple [Peer] sections:
[Interface]
PrivateKey = <HUB_PRIVATE>
Address = 10.0.0.1/24
ListenPort = 51820
[Peer]
PublicKey = <CLIENT1_PUBLIC>
AllowedIPs = 10.0.0.2/32
[Peer]
PublicKey = <CLIENT2_PUBLIC>
AllowedIPs = 10.0.0.3/32
[Peer]
PublicKey = <CLIENT3_PUBLIC>
AllowedIPs = 10.0.0.4/32
Each client only needs the hub as a peer.
Use case: Remote access for a team to a central server.
Full Mesh
Every machine connects to every other machine. Complex but allows direct communication between any pair.
A ──── B
│╲ ╱│
│ ╲ ╱ │
│ ╲╱ │
│ ╱╲ │
│ ╱ ╲ │
│╱ ╲│
C ──── D
Each machine has N-1 peer sections (where N is total machines).
Use case: Small team needing peer-to-peer access.
Site-to-Site
Connect two networks (not just two machines). Requires IP forwarding and routing.
Network A Network B
192.168.1.0/24 192.168.2.0/24
│ │
│ │
Gateway A ◄───── WireGuard ─────► Gateway B
10.0.0.1 10.0.0.2
Gateway A config:
[Interface]
PrivateKey = <A_PRIVATE>
Address = 10.0.0.1/24
ListenPort = 51820
# Enable IP forwarding
PostUp = sysctl -w net.ipv4.ip_forward=1
PostUp = iptables -A FORWARD -i wg0 -j ACCEPT
PostUp = iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
PostDown = iptables -D FORWARD -i wg0 -j ACCEPT
PostDown = iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE
[Peer]
PublicKey = <B_PUBLIC>
Endpoint = <B_PUBLIC_IP>:51820
AllowedIPs = 10.0.0.2/32, 192.168.2.0/24 # Include remote network
The key is AllowedIPs including the remote network’s CIDR.
Routing All Traffic Through WireGuard
To route all internet traffic through the tunnel (like a traditional VPN):
Client config:
[Peer]
PublicKey = <SERVER_PUBLIC>
Endpoint = <SERVER_IP>:51820
AllowedIPs = 0.0.0.0/0, ::/0 # Route everything
PersistentKeepalive = 25
Server needs NAT enabled:
[Interface]
PrivateKey = <SERVER_PRIVATE>
Address = 10.0.0.1/24
ListenPort = 51820
PostUp = iptables -A FORWARD -i wg0 -j ACCEPT
PostUp = iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
PostDown = iptables -D FORWARD -i wg0 -j ACCEPT
PostDown = iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE
[Peer]
PublicKey = <CLIENT_PUBLIC>
AllowedIPs = 10.0.0.2/32
Note: Replace eth0 with your server’s actual internet-facing interface.
DNS Configuration
When routing all traffic, you also want DNS to go through the tunnel:
[Interface]
PrivateKey = <PRIVATE_KEY>
Address = 10.0.0.2/24
DNS = 1.1.1.1, 8.8.8.8 # Or your preferred DNS
wg-quick will configure the system resolver automatically.
Adding and Removing Peers
Add a Peer Without Restarting
# Generate keys for new peer
wg genkey | tee newpeer_private | wg pubkey > newpeer_public
# Add peer to running interface
sudo wg set wg0 peer $(cat newpeer_public) allowed-ips 10.0.0.5/32
Remove a Peer
sudo wg set wg0 peer <PEER_PUBLIC_KEY> remove
Save Running Config to File
sudo wg-quick save wg0
This writes the current running config (including dynamically added peers) to /etc/wireguard/wg0.conf.
Debugging
Check Interface Status
sudo wg show
Look for:
latest handshake— recent timestamp means connection is alivetransfer— bytes sent/receivedendpoint— where the peer is connecting from
No Handshake?
Possible causes:
- Firewall blocking UDP 51820 — check both sides
- Wrong endpoint — verify the public IP
- Key mismatch — double-check public keys are exchanged correctly
- NAT without keepalive — add
PersistentKeepalive = 25
Handshake Works, But No Ping?
- AllowedIPs misconfigured — must include the peer’s VPN IP
- Firewall blocking ICMP — try
curlinstead ofping - Routing issue — check
ip routeon both sides
View Interface Details
ip addr show wg0
ip route | grep wg0
Debug Logging
For kernel-level debugging:
echo module wireguard +p > /sys/kernel/debug/dynamic_debug/control
dmesg -w | grep wireguard
Turn off when done:
echo module wireguard -p > /sys/kernel/debug/dynamic_debug/control
Security Best Practices
Protect Private Keys
chmod 600 /etc/wireguard/wg0.conf
chmod 600 /etc/wireguard/privatekey
Only root should read these files.
Use Unique Keys Per Peer
Don’t reuse keys across machines. Generate fresh key pairs for each device.
Restrict AllowedIPs
Only allow the IPs the peer actually needs:
# Good: specific IP
AllowedIPs = 10.0.0.2/32
# Risky: entire subnet
AllowedIPs = 10.0.0.0/24
# Dangerous: route everything
AllowedIPs = 0.0.0.0/0
Rotate Keys Periodically
WireGuard keys don’t expire. Rotate them annually or when a device is compromised:
- Generate new key pair
- Update configs on both sides
- Restart WireGuard
Preshared Keys (Extra Security)
For post-quantum resistance, add a preshared key:
wg genpsk > preshared.key
Add to both peer configs:
[Peer]
PublicKey = <PEER_PUBLIC>
PresharedKey = <PRESHARED_KEY>
AllowedIPs = 10.0.0.2/32
Both sides must have the same preshared key.
Quick Reference
Commands
| Action | Command |
|---|---|
| Start interface | sudo wg-quick up wg0 |
| Stop interface | sudo wg-quick down wg0 |
| Show status | sudo wg show |
| Show config | sudo wg showconf wg0 |
| Generate private key | wg genkey |
| Derive public key | wg pubkey < privatekey |
| Generate preshared key | wg genpsk |
| Add peer dynamically | sudo wg set wg0 peer <PUB> allowed-ips <IP> |
| Remove peer | sudo wg set wg0 peer <PUB> remove |
| Enable on boot | sudo systemctl enable wg-quick@wg0 |
Config File Structure
[Interface]
PrivateKey = <this machine's private key>
Address = <this machine's VPN IP>/24
ListenPort = 51820 # optional, for servers
DNS = 1.1.1.1 # optional, for clients
PostUp = <command to run after up> # optional
PostDown = <command to run after down> # optional
[Peer]
PublicKey = <peer's public key>
AllowedIPs = <IPs to route to this peer>
Endpoint = <peer's public IP:port> # optional if peer connects to us
PersistentKeepalive = 25 # optional, for NAT traversal
PresharedKey = <shared secret> # optional, extra security
Common AllowedIPs Patterns
| Pattern | Meaning |
|---|---|
10.0.0.2/32 | Only this specific IP |
10.0.0.0/24 | Entire VPN subnet |
192.168.1.0/24 | Remote LAN (site-to-site) |
0.0.0.0/0, ::/0 | All traffic (full tunnel) |
When WireGuard Doesn’t Fit
WireGuard isn’t the answer to every VPN problem. Here’s when you might need something else:
No built-in user authentication. WireGuard authenticates by public key, not username/password. If you need Active Directory integration or RADIUS authentication, you’ll need to layer something on top (like a management tool) or use OpenVPN.
Static IP assignment. Each peer has a fixed IP in the config. There’s no DHCP-style dynamic assignment. For large organizations with hundreds of users, this means more config management.
No TCP fallback. WireGuard is UDP-only. If you’re behind a restrictive corporate firewall that blocks all UDP, WireGuard won’t work. OpenVPN can fall back to TCP 443, masquerading as HTTPS traffic.
Limited obfuscation. WireGuard’s packet format is distinctive. In countries that actively block VPN protocols, it can be detected and blocked. Tools like Shadowsocks or obfsproxy are designed for this use case.
No session persistence. Rebooting a peer requires the other side to re-establish the connection. For most setups with PersistentKeepalive, this happens automatically. But if you need guaranteed sub-second failover, you might need more infrastructure.
WireGuard works best when:
- You control both endpoints
- You have a small-to-medium number of peers (tens, not thousands)
- You value simplicity and performance over enterprise features
- UDP can reach your server
For personal VPNs, homelab connectivity, and small team infrastructure—WireGuard is almost always the right choice.
Summary
WireGuard’s power is in its simplicity:
- Generate keys —
wg genkey | tee priv | wg pubkey > pub - Exchange public keys — never share private keys
- Write configs — ~10 lines per machine
- Start it —
wg-quick up wg0 - Enable on boot —
systemctl enable wg-quick@wg0
The mental model is straightforward: each machine has an interface with a private key, and a list of peers identified by their public keys. AllowedIPs determines both routing and authentication.
Start with a simple two-machine setup, verify it works, then expand to more complex topologies as needed.
Remember that 87-line OpenVPN config? Here’s the WireGuard equivalent:
[Interface]
PrivateKey = <your-key>
Address = 10.0.0.2/24
[Peer]
PublicKey = <server-key>
Endpoint = server.example.com:51820
AllowedIPs = 0.0.0.0/0
PersistentKeepalive = 25
Seven lines. Works on the first try. Doesn’t drop.