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:

WireGuard mental model - peers, keys, and encrypted tunnel

1. Interface

WireGuard creates a virtual network interface (like wg0 - the name comes from the config filename, which we’ll see later). 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.2 through 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.

Endpoint = 203.0.113.5:51820

Here’s the key insight: you only need to specify an Endpoint for the peer you’re connecting TO, not for peers connecting to you. If your machine initiates the connection, you need their Endpoint. If they initiate, WireGuard learns their address from incoming packets - no Endpoint needed in your config.

This means at least one side must have a known, reachable address. If both peers are behind NAT, one must have port forwarding set up to be reachable.

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

This is the most common real-world scenario:

  • Server: A VPS or cloud instance with a static public IP address (like 203.0.113.5). Anyone on the internet can reach it directly.
  • Client: Your laptop or home machine behind a router. It has a private IP (like 192.168.1.50) that the outside world can’t reach directly.

The key insight is that the client can reach the server, but the server can’t initiate contact with the client. This asymmetry shapes everything about how we configure WireGuard.

Point-to-point WireGuard tunnel between client and server

Step 1: Generate Keys (Both Machines)

On both the server and client:

cd /etc/wireguard
umask 077
wg genkey | tee privatekey | wg pubkey > publickey

Let’s break down what each part does:

cd /etc/wireguard

This changes the current directory to /etc/wireguard.

  • This is the standard system directory where WireGuard configuration files and keys are stored on Linux.
  • You need appropriate permissions (usually root) to write here.

umask 077

This sets the file creation permission mask for the current shell.

  • 077 means:
    • Owner: full permissions
    • Group & others: no permissions
  • Result: any files created afterward will default to permissions like 600 (read/write only by the owner).

This is important because private keys must not be readable by other users.

wg genkey | tee privatekey | wg pubkey > publickey

This line does most of the work:

  • wg genkey - Generates a WireGuard private key and outputs it to stdout.
  • | tee privatekey - tee writes the private key to a file called privatekey while also passing it through the pipe. Because of the earlier umask 077, this file will have secure permissions.
  • | wg pubkey - Takes the private key from stdin and derives the corresponding public key.
  • > publickey - Redirects the public key output into a file named publickey.

After running this, you end up with:

  • /etc/wireguard/privatekey - Your private WireGuard key (owner-only permissions)
  • /etc/wireguard/publickey - The corresponding public key (safe to share with peers)

The private key is never printed to the screen, file permissions are locked down automatically, and both keys are generated in a single clean pipeline.

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’s privatekey file
  • <CLIENT_PUBLIC_KEY> - the client’s public key

Notice there’s no Endpoint for the client. The server doesn’t know (or need to know) the client’s IP address ahead of time. Why? Because:

  1. The client is behind NAT - its public IP might change, and the server can’t reach it directly anyway
  2. The client will initiate the connection - when it does, the server learns where to send responses
  3. WireGuard remembers the source address of incoming packets and uses that as the endpoint

This is like a phone call: the server is sitting by a known phone number (public IP + port), waiting. The client dials in. Once connected, both sides can talk - but the server didn’t need to know the client’s number in advance.

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’s privatekey file
  • <SERVER_PUBLIC_KEY> - the server’s public key
  • <SERVER_PUBLIC_IP> - the server’s public IP (e.g., 203.0.113.5)

The client config has two things the server config didn’t:

Endpoint - The client must know where to find the server. Since the server has a public IP, we specify it here. Without this, the client has no idea where to send packets. The server didn’t need an Endpoint for the client because the client initiates the connection - the server learns the client’s address from incoming packets.

PersistentKeepalive = 25 - This solves a NAT problem. When the client sends a packet through its router, the router creates a temporary mapping: “packets from the internet on this port go to the client.” But routers forget these mappings after a period of inactivity (often 30-60 seconds). If the tunnel goes quiet, the mapping expires, and the server’s responses can no longer reach the client.

PersistentKeepalive = 25 tells WireGuard to send a keepalive packet every 25 seconds, even when there’s no real traffic. This keeps the NAT mapping alive. The server doesn’t need this setting because it’s not behind NAT - it’s directly reachable.

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

wg-quick is a helper script that comes with WireGuard. The core wg command only manages keys and peers - it doesn’t create network interfaces, assign IP addresses, or set up routing. wg-quick handles all of that for you. It reads your config file and does everything needed to bring up a working tunnel: creates the interface, assigns the Address, configures routes based on AllowedIPs, and runs any PostUp/PostDown scripts.

Where does wg0 come from? It’s derived from the config filename. We created /etc/wireguard/wg0.conf, so the interface name is wg0 (the filename without .conf). You could name it anything - wg-home.conf would create interface wg-home, vpn.conf would create interface vpn.

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. Run this on both machines:

sudo systemctl enable wg-quick@wg0

Now wg0 will come up after every reboot.

Server: You almost always want this. The server should be ready to accept connections as soon as it boots - clients expect it to be available.

Client: Optional. Enable it if you want the VPN tunnel to connect automatically on startup. Skip it if you prefer to manually bring the tunnel up only when needed (with sudo wg-quick up wg0).

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 alive
  • transfer - bytes sent/received
  • endpoint - where the peer is connecting from

No Handshake?

Possible causes:

  1. Firewall blocking UDP 51820 - check both sides
  2. Wrong endpoint - verify the public IP
  3. Key mismatch - double-check public keys are exchanged correctly
  4. NAT without keepalive - add PersistentKeepalive = 25

Handshake Works, But No Ping?

  1. AllowedIPs misconfigured - must include the peer’s VPN IP
  2. Firewall blocking ICMP - try curl instead of ping
  3. Routing issue - check ip route on 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:

  1. Generate new key pair
  2. Update configs on both sides
  3. 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

ActionCommand
Start interfacesudo wg-quick up wg0
Stop interfacesudo wg-quick down wg0
Show statussudo wg show
Show configsudo wg showconf wg0
Generate private keywg genkey
Derive public keywg pubkey < privatekey
Generate preshared keywg genpsk
Add peer dynamicallysudo wg set wg0 peer <PUB> allowed-ips <IP>
Remove peersudo wg set wg0 peer <PUB> remove
Enable on bootsudo 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

PatternMeaning
10.0.0.2/32Only this specific IP
10.0.0.0/24Entire VPN subnet
192.168.1.0/24Remote LAN (site-to-site)
0.0.0.0/0, ::/0All 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:

  1. Generate keys - wg genkey | tee privatekey | wg pubkey > publickey
  2. Exchange public keys - never share private keys
  3. Write configs - ~10 lines per machine
  4. Start it - sudo wg-quick up wg0
  5. Enable on boot - sudo 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.

About the Author

Ashish Anand

Ashish Anand

Founder & Lead Developer

Full-stack developer with 10+ years experience in Python, JavaScript, and DevOps. Creator of DevGuide.dev. Previously worked at Microsoft. Specializes in developer tools and automation.