How To Set Up a WireGuard Server and Client for a Self-Hosted VPN
Tricknowtech Team 10 min read
Goal
In this tutorial, you will install WireGuard on an Ubuntu server, configure it as a VPN gateway with NAT and IP forwarding, generate a client key pair, and connect a client device through an encrypted point-to-point tunnel.
Prerequisites
A server (VPS or dedicated) running Ubuntu 22.04 or 24.04 with root or sudo access — the same steps work on Debian
A second device to use as the VPN client: Linux, macOS, Windows, iOS, or Android
Basic comfort with the command line and editing files over SSH
The server's public IP address or a DNS hostname pointing to it
Let an AI agent do this for you
Copy a ready-made prompt for an AI coding assistant with terminal access to your server (Claude Code, Cursor, or similar) — it can carry out the steps below for you. Review what it plans to run before it executes anything.
WireGuard is a modern VPN protocol built on state-of-the-art cryptography (Curve25519, ChaCha20, Poly1305) with a codebase small enough to audit and, on Linux, implemented as an in-kernel module for near-native throughput. Unlike OpenVPN, it has no negotiation handshake in the traditional sense and no concept of client/server roles at the protocol level — every WireGuard node is a "peer" with a public key, and peers exchange encrypted packets over UDP once each side knows the other's public key and endpoint. That simplicity makes it a good fit for a self-hosted VPN: one machine acts as the always-on gateway (the "server"), and any number of devices connect to it as peers ("clients") to route their traffic through it.
This tutorial sets up one Ubuntu server as a WireGuard gateway and one client that tunnels all of its traffic through it. The same client configuration format works unmodified with the official WireGuard apps for macOS, Windows, iOS, and Android — only the Linux client steps differ (import instead of wg-quick).
Step 1 — Install WireGuard
On Ubuntu 22.04 and 24.04, WireGuard's userspace tools and kernel integration are packaged together in the wireguard package (Debian is identical). Install it on the machine that will act as the server:
bash
sudo apt update && sudo apt install -y wireguard
This gives you two commands you'll use throughout: wg, for key management and inspecting the current state of an interface, and wg-quick, a wrapper that brings a WireGuard interface up or down along with any routing and firewall commands you define for it.
Step 2 — Generate the server key pair
Every WireGuard peer identifies itself with a Curve25519 key pair. Generate the server's pair inside /etc/wireguard, which is root-only by default. Run umask 077 first — it ensures every file wg genkey creates from this point on is written as -rw------- (mode 600), so the private key is never briefly world-readable between the moment it's created and the moment you'd otherwise remember to chmod it:
bash
sudo -i
cd /etc/wireguard
umask 077
wg genkey | tee server_private.key | wg pubkey > server_public.key
“server_private.key must never be shared, pasted into chat, emailed, or committed to version control. Anyone who has it can decrypt and impersonate this server's side of every tunnel. Confirm its permissions with ls -l server_private.key — it should show -rw------- owned by root.”
Step 3 — Create the server configuration
Tricknowtech VPS Hosting
Dedicated KVM resources and full root access — deployed in under 60 seconds, no ticket required.
Create /etc/wireguard/wg0.conf. This defines the server's tunnel interface: its address on the private WireGuard subnet, the UDP port it listens on, and its own private key.
10.8.0.1/24 is the WireGuard interface's own address on a private tunnel subnet — this is a conventional RFC 1918 range for WireGuard setups, not a public-facing address, so it doesn't need to be a documentation range; the server will hand out addresses in this subnet to itself and each client. PostUp/PostDown run automatically when wg-quick brings the interface up or down: the first rule allows forwarding for traffic entering via the tunnel, and the MASQUERADE rule rewrites outgoing packets' source address to the server's own public-facing IP, so replies can find their way back through the tunnel (standard NAT, the same mechanism a home router uses).
“eth0 in both lines must match your server's actual public-facing network interface, not necessarily eth0 — cloud providers commonly name it ens3, enp0s3, or similar. Check yours before continuing:”
bash
ip route
The interface listed after dev on the line starting with default is the one to use in both PostUp and PostDown.
Step 4 — Enable IP forwarding
By default the Linux kernel does not forward packets between network interfaces — it only accepts packets addressed to itself and drops anything it would otherwise need to route onward. That's fine for a normal server, but a VPN gateway needs to take packets arriving on wg0 and forward them out through the public interface (and route the replies back), so this has to be turned on explicitly.
bash
sudo nano /etc/sysctl.conf
Find the line net.ipv4.ip_forward=1 and uncomment it (or add it if it isn't present), then apply it without rebooting:
bash
sudo sysctl -p
Step 5 — Configure the firewall
Open only what this setup needs: the WireGuard UDP port, and SSH so you don't lock yourself out. Don't disable ufw — that would expose every other service on the box, not just WireGuard.
If you've already customized your SSH port or have an existing rule for it, use that rule instead of ufw allow OpenSSH — the point is that SSH access must survive ufw enable, along with 51820/udp and nothing else new.
Step 6 — Generate a client key pair
Client keys are generated the same way as the server's — ideally on the client machine itself, since a private key never needs to leave the device that owns it. If WireGuard is installed there too, run:
bash
umask 077
wg genkey | tee client_private.key | wg pubkey > client_public.key
“Same rule as the server key: client_private.key stays on the client, is never shared or committed anywhere, and should be mode 600.”
Step 7 — Add the client as a peer on the server
Back on the server, append a [Peer] block to /etc/wireguard/wg0.conf for this client, using the public key you just generated:
ini
[Peer]
PublicKey = <paste the contents of client_public.key here>
AllowedIPs = 10.8.0.2/32
AllowedIPs here does double duty: on the server side it's both a routing rule (packets destined for 10.8.0.2 go to this peer) and an access-control rule (this peer is only trusted to send packets claiming to be from 10.8.0.2). The /32 restricts it to that single address — this client isn't a router for anything else.
Step 8 — Create the client configuration
On the client, create its own wg0.conf:
ini
[Interface]
PrivateKey = <paste the contents of client_private.key here>
Address = 10.8.0.2/24
DNS = 1.1.1.1
[Peer]
PublicKey = <paste the contents of server_public.key here>
Endpoint = 203.0.113.10:51820
AllowedIPs = 0.0.0.0/0
PersistentKeepalive = 25
Replace 203.0.113.10 with your server's actual public IP address or hostname — 203.0.113.10 is a documentation-only address (RFC 5737) used here as a placeholder, not a real reachable host. AllowedIPs = 0.0.0.0/0 routes every packet the client sends through the tunnel — a full tunnel, useful when you want all of the client's internet traffic to exit via the server. For a split tunnel that only sends traffic destined for the WireGuard subnet through the tunnel and lets everything else use the client's normal internet connection, set AllowedIPs = 10.8.0.0/24 instead. PersistentKeepalive = 25 sends a keepalive packet every 25 seconds; it's needed whenever the client sits behind NAT (almost always true on home or mobile networks), because otherwise the NAT device's UDP mapping expires from inactivity and the server can no longer reach the client until the client sends new traffic first.
Step 9 — Start the WireGuard server
Bring the interface up and enable it at boot in one command:
bash
sudo systemctl enable --now wg-quick@wg0
Step 10 — Start the WireGuard client
On a Linux client, bring the interface up the same way:
bash
sudo wg-quick up wg0
On macOS, Windows, iOS, or Android, install the official WireGuard app for that platform and import the client's wg0.conf file directly (on mobile, this is usually done by scanning a QR code generated from the file, or importing it from storage) rather than typing the values in by hand. The config format is identical across every official client, so the file you created in Step 8 works as-is. Check the WireGuard project's own documentation for the exact current import steps on each platform, since app UI details change over time.
Step 11 — Verify the connection
On both the server and the client, inspect the interface's current state:
bash
sudo wg show
Look at the latest handshake field for the peer entry. A timestamp of a few seconds or minutes ago means the tunnel is live; (none) means no handshake has ever completed. Then, from the client, confirm routed connectivity by pinging the server's tunnel address:
bash
ping -c 4 10.8.0.1
A successful reply confirms packets are flowing through the tunnel in both directions.
Troubleshooting: no handshake
The most common failure mode is wg show reporting no handshake at all, with an otherwise correct configuration on both ends. This almost always means the UDP port isn't actually reachable from the internet, even though ufw allow 51820/udp succeeded. Most cloud providers enforce a second firewall layer in front of the VPS — a security group, network ACL, or similar — that filters traffic before it ever reaches the instance's own iptables/ufw rules. ufw allowing the port only controls the host-level firewall; if the provider's separate network firewall is still blocking inbound UDP 51820, the symptom looks identical: a syntactically valid config with no handshake. Check that layer's rules in whatever control panel or API your provider exposes, in addition to ufw, before assuming the WireGuard configuration itself is wrong. Also double-check that Endpoint in the client config matches the server's current public IP or hostname exactly, and that both PrivateKey/PublicKey pairs were pasted without truncation or extra whitespace.