WireGuard on Linux: a working tunnel in eight steps
You will end up with an encrypted link between a server and a client, a config that survives a reboot, and a `wg show` output that tells you whether traffic is actually flowing. No daemon, no certificate authority, no negotiation phase.
Requirements
Two Linux machines with root or sudo access. Kernel 5.6 or newer on both — check with `uname -r`. On anything older you load the out-of-tree module, which means DKMS and a rebuild on every kernel upgrade. Not worth it on a modern distro.Install the userspace tools. On Debian 12 and Ubuntu 24.04:
```bash sudo apt update sudo apt install -y wireguard wireguard-tools ```
On RHEL 9, Rocky 9 or AlmaLinux 9:
```bash sudo dnf install -y wireguard-tools ```
On Arch:
```bash sudo pacman -S --needed wireguard-tools ```
You also need a way for the two hosts to reach each other. At least one side needs a publicly reachable IP address or a forwarded UDP port. If both machines sit behind NAT with no port forwarding, you need a third party — a VPS with a public IP — to act as the rendezvous point. That costs money, and it is the part most tutorials skip.
Decide on a UDP port. The default is 51820. Pick something else if 51820 is already in use, and open it in the firewall on the listening side.
Step 1 — Generate the key pairs
WireGuard authenticates peers by public key, not by username or certificate. Each host gets a private key and derives its public key from it.Run this on both machines:
```bash umask 077 wg genkey | tee privatekey | wg pubkey > publickey ```
The `umask 077` matters. Without it, the private key lands on disk world-readable, and on a multi-user system that is the whole game. The key is a 32-byte Curve25519 value encoded in base64.
Print both files so you can copy them:
```bash cat privatekey cat publickey ```
Write down the public key of each host. You need the other side's public key in the config. You never copy a private key across the wire.
Step 2 — Choose the tunnel addressing
Pick a private subnet for the tunnel itself, separate from anything on either LAN. `10.200.0.0/24` is a reasonable choice if you have no idea what else is in use.Assign one address per host. Say the server gets `10.200.0.1` and the client gets `10.200.0.2`. These addresses exist only inside the tunnel. They do not need to match anything on your physical network, and they should not.
If you later connect a third peer, it gets `10.200.0.3`. The subnet has room for 254 peers, which is more than any hand-maintained config file wants to hold.
Step 3 — Write the server config
Create `/etc/wireguard/wg0.conf` on the server:```ini
[Interface]
Address = 10.200.0.1/24
ListenPort = 51820
PrivateKey =
[Peer]
PublicKey =
The `AllowedIPs` line does two things at once, and this trips people up. On inbound traffic it acts as an access control list: packets from this peer must have a source address inside `10.200.0.2/32`, or they are dropped. On outbound traffic it acts as a routing table: packets destined for that range go to this peer.
If you set `AllowedIPs = 0.0.0.0/0` on the server side, you are telling the kernel that this one peer owns the entire internet. Don't.
Step 4 — Write the client config
Create `/etc/wireguard/wg0.conf` on the client:```ini
[Interface]
Address = 10.200.0.2/24
PrivateKey =
[Peer]
PublicKey =
Replace `203.0.113.10` with the server's real public IP or hostname.
`PersistentKeepalive = 25` sends an empty packet every 25 seconds. You need this when the client is behind NAT, because otherwise the NAT mapping expires and the server can no longer reach the client. It is not free — it keeps a mobile radio awake and drains battery. On a laptop that moves between networks, leave it. On a server with a static address, drop it.
Step 5 — Open the firewall
On the server, allow the UDP port. With `ufw`:```bash sudo ufw allow 51820/udp ```
With `firewalld`:
```bash sudo firewall-cmd --permanent --add-port=51820/udp sudo firewall-cmd --reload ```
With plain `nftables` or `iptables`, add the rule yourself. WireGuard does not open ports for you.
If the server also routes traffic between the tunnel and the LAN — a site-to-site setup — you need forwarding enabled and a NAT rule. That is a separate step and it changes the security model: the tunnel stops being a point-to-point link and becomes a bridge between two networks.
Step 6 — Bring the interface up
On both machines:```bash sudo wg-quick up wg0 ```
`wg-quick` reads `/etc/wireguard/wg0.conf`, creates the interface, assigns the address, adds the routes, and hands the keys to the kernel. It does not run as a service. When the command returns, the tunnel is up.
Tear it down with:
```bash sudo wg-quick down wg0 ```
That removes the interface and every route and rule it added. Nothing is left behind.
Step 7 — Enable it at boot
`wg-quick` ships a systemd template unit called `wg-quick@.service`. The part after the `@` is the config file name without the `.conf` extension.```bash sudo systemctl enable wg-quick@wg0 sudo systemctl start wg-quick@wg0 ```
Do this on the server if it must always be reachable. Do it on the client only if the client should always be on the VPN — on a laptop, a failed boot-time tunnel can leave you without network until you notice.
Check the unit state:
```bash systemctl status wg-quick@wg0 ```
The service is a oneshot wrapper around the same script you ran by hand. If it fails, the error is in `journalctl -u wg-quick@wg0`, not in a WireGuard log, because WireGuard has no log.
Step 8 — Route what you actually want to route
By default, only traffic to the tunnel subnet goes through the tunnel. Everything else uses the normal path. That is usually what you want for site-to-site and peer-to-site.For a full-tunnel client — all traffic through the VPN — change `AllowedIPs` on the client to:
```ini AllowedIPs = 0.0.0.0/0, ::/0 ```
`wg-quick` then installs a default route via the tunnel and adds a policy rule so the encrypted packets themselves still leave via the physical interface. This is the part that breaks DNS: your resolver still points at the local network's DNS server, which is now unreachable or, worse, still answering. Set `DNS = 10.200.0.1` in the `[Interface]` section and run a resolver on the server, or accept that DNS leaks.
Full tunnel costs throughput and adds latency on every request. On a server, that is rarely worth it. On a laptop on hotel wifi, it usually is.
Verification
On either host, ask the kernel what it knows:```bash sudo wg show ```
You want to see the interface, the peer's public key, and — after traffic has flowed — a `latest handshake` timestamp and non-zero `transfer` counters. A handshake that never happens means the endpoint or port is wrong, or a firewall is dropping UDP.
Then test reachability:
```bash ping -c 3 10.200.0.1 ```
From the client, that should reach the server's tunnel address. If ping works but nothing else does, your `AllowedIPs` on the receiving side is too narrow.
Confirm the interface exists and carries the address:
```bash ip addr show wg0 ```
And check the routes:
```bash ip route show | grep wg0 ```
Common problems
Handshake never completes. The endpoint address or port is wrong, or the UDP port is blocked upstream. Test with `nc -u -l 51820` on the server and `nc -uTunnel comes up but no traffic passes. Almost always `AllowedIPs`. The source address of the packets must fall inside the range the receiving peer allows. If the client sends from `10.200.0.2` and the server's peer entry says `AllowedIPs = 10.200.0.3/32`, the kernel drops every packet silently. There is no log line for this.
Works until reboot, then stops. The systemd unit is not enabled, or the config file has a syntax error that `wg-quick up` tolerated interactively. Run `wg-quick strip wg0` — it parses the config and prints what the kernel will receive. If it errors, you found it.
WireGuard gives you a tunnel and nothing else: no address assignment, no routing policy, no authentication beyond the key pair. That is the trade. You get a protocol small enough to audit in an afternoon, and you get to configure every piece around it yourself.