How to Set Up Your Own WireGuard VPN Server on a VPS
A commercial VPN asks you to trust a company you cannot audit. Running your own on a VPS replaces that with a different trade: you trust yourself, and you do the work. This guide is the work. Every command below was run on a fresh Ubuntu 24.04 server, in this order, as an ordinary user […]
A commercial VPN asks you to trust a company you cannot audit. Running your own on a VPS replaces that with a different trade: you trust yourself, and you do the work. This guide is the work.
Every command below was run on a fresh Ubuntu 24.04 server, in this order, as an ordinary user with sudo — not as root. Every block of output you see is what the machine actually printed. Where something broke or behaved differently from what the popular guides claim, I have said so rather than quietly fixing it off-camera.
Be honest about what this gets you
A VPN does two things well. It encrypts your traffic between your device and your server, so the coffee-shop Wi-Fi, the hotel network, or your ISP sees only that you are talking to one IP address, not what you are doing. And it makes your traffic appear to come from your server’s IP address instead of your own.
That is the whole of it. Some things it specifically does not do:
- It does not make you anonymous. Your VPS is rented in your name and paid with your money. It is a very short line from that IP address back to you — shorter than with a commercial VPN, not longer.
- It does not hide you from the sites you log into. If you sign into an account, the account knows it is you. No amount of tunnelling changes that.
- It does not spread you across many locations. You get one IP, in one place. Commercial VPNs sell a rotating pool; you are building the opposite of that.
- It does not stop your VPS provider from seeing your traffic leave their network, any more than an ISP could.
What it is genuinely good for: securing your own traffic on untrusted networks, reaching your own servers privately, having a stable IP you control, and not paying a subscription for either. If you want anonymity, this is the wrong tool and no VPN is the right one.
We are using WireGuard. It lives inside the Linux kernel, its whole configuration is a dozen lines, and its code is small enough to actually read. That last part matters more than any speed benchmark: the less code sits between you and the internet, the fewer places for it to be wrong.
Who this guide assumes you are
You can open a terminal and paste a command. That is all I am assuming. Every navigation step, file creation, and key generation is written out. If a line needs a value only you have, I say so at that line and show you where to get it.
Jargon gets defined the first time it appears. If you already administer Linux daily, you will find this slow — skim the command blocks and the troubleshooting section.
What you need before you start
A VPS running Ubuntu 24.04 with a public IP address. A VPS (“virtual private server”) is a rented Linux machine on the internet. WireGuard is so light that the smallest plan anywhere will run it — the constraint is bandwidth, not CPU, which I cover at the end. If you have not got one yet, Getting Started with Lineserve Cloud: Launch Your First VPS walks through launching one.
A normal user account with sudo, not root. sudo means “run this one command with administrator powers”. Logging in as root — the all-powerful account — is a habit worth not forming, and several guides for this topic quietly assume it. If your provider only gave you root, stop here and do Initial Ubuntu 24.04 Server Setup: Users, SSH, Firewall first; it creates the user and takes a few minutes. This guide’s server has a user called ubuntu.
SSH access to that server. SSH is how you get a terminal on a machine that is somewhere else. If you are still logging in with a password, How to Secure SSH: Keys, Ports, Fail2ban, and 2FA on Ubuntu 24.04 is worth doing before you expose anything else to the internet.
A device to connect from. The server half of this guide (Steps 1–9) is the same whatever you connect with. For the device half, I verified two: a Linux laptop (Step 10) and a phone, iPhone or Android (Step 12). If your laptop runs Windows or macOS, read the note at the top of Step 10 before you start — the server you build here works fine with those, but this guide does not yet walk through their desktop app, and I would rather tell you that now than at Step 10.
You do not need a domain name. You do not need to be in the same country as the server, though the closer it is, the faster it feels.
Conventions used in this guide
Every placeholder below has one concrete value that I actually used, and that you will see again in the real output further down. Substitute your own wherever the table says so.
| Thing | This guide’s value | Yours |
|---|---|---|
| Server’s public IP | 52.207.232.75 |
Different — take it from your host’s control panel now; Step 3 confirms it from the server itself |
| Server’s internet interface | ens5 |
Possibly different — Step 3 finds it |
| Your login username | ubuntu |
Whatever your sudo user is called |
| Server’s public key | Xgo2vIEHk/VOGsWdGwVSL6qtvBmsrTZALlZJND80hl8= |
Different — Step 4 prints yours |
| Laptop’s public key | iTqlS6p8Je+5Z60vFHSoNLwl9JuojL0X2PTe7Yx6Xnw= |
Different — Step 10a prints yours |
| Phone’s public key | kxe2HmT9MkQR/fYv58HfEi1cC/yaZknRyp1KofEyvxA= |
Different — Step 12 prints yours |
| VPN network | 10.8.0.0/24 |
Keep it unless it clashes with your home network |
| Server’s address on the VPN | 10.8.0.1 |
Keep as-is |
| Your laptop’s address on the VPN | 10.8.0.2 |
Keep as-is |
| Your phone’s address on the VPN | 10.8.0.3 |
Keep as-is |
| WireGuard port | 51820 (UDP) |
Keep as-is |
Every key in that table is mine, from servers that no longer exist. They appear in the command blocks below so you can see the shape of a real config. Each one must be replaced with the key your machine prints. A key you did not generate is not yours and will not work.
This guide involves three machines — a server, a laptop and a phone. Steps 1 to 9 all happen on the server. From Step 10 onward the guide moves between machines, so every heading from there names its machine and every command block starts with a # on the ... comment telling you where to paste it. If you ever lose track, whoami and pwd will tell you where you are. Running a server command on your laptop is the easiest way to break this setup, and it usually fails silently.
Two words you will need. A peer is any device on the VPN — your server is a peer, your laptop is a peer, your phone is a peer. WireGuard genuinely does not have a “server” and “client” concept; we just call the always-on one the server. A key pair is two matched strings of text: a private key, which never leaves the machine that generated it, and a public key, which you hand out freely. Each peer has one pair, and peers identify each other by public key alone. There are no usernames or passwords anywhere in WireGuard.
The 10.8.0.0/24 above is a private network that will exist only inside your tunnel. /24 means the addresses run from 10.8.0.1 to 10.8.0.254. These are not internet addresses; nobody outside the tunnel can reach them.
Step 1: Connect to your server
From your own computer’s terminal, replacing ubuntu with your username and 52.207.232.75 with your server’s IP:
ssh [email protected]
On macOS and Linux the ssh command is already there. On Windows, open PowerShell and use the same command.
The very first time you connect to a new server, SSH will stop and ask you to confirm the server’s fingerprint before it lets you in — a question ending in (yes/no/[fingerprint])?. That is not an error; it is SSH telling you it has never seen this machine before. Type yes and press Enter. You will not be asked again for this server.
Steps 1 through 9 all happen on the server, in this SSH session. Confirm you are where you think you are, and as whom:
whoami
pwd
Real output from the lab server:
ubuntu
/home/ubuntu
If whoami prints root, you are logged in as the wrong account — go back and read the prerequisites. If it prints your username and pwd prints your home directory, carry on. I will tell you every time a command needs you to be in a particular directory; Step 4 is the first one that does.
Step 2: Install WireGuard
Refresh the list of available packages, then install WireGuard along with qrencode, which we use in Step 12 to get the config onto a phone:
sudo apt update
sudo apt install -y wireguard qrencode
The first time you use sudo in a session it may ask for your password. Type it — nothing appears on screen as you type, not even dots. That is normal.
Check what actually landed:
dpkg-query -W -f='${Package} ${Version}\n' wireguard wireguard-tools qrencode
Real output:
qrencode 4.1.1-1build2
wireguard 1.0.20210914-1ubuntu4
wireguard-tools 1.0.20210914-1ubuntu4
Those version numbers are what this guide was tested against. If yours differ slightly, that is fine — WireGuard’s user-facing commands have been stable for years. If any package is missing from that list, the install did not complete and the rest of the guide will fail confusingly; re-run the install and read the errors before continuing.
One more tool this guide leans on is curl, which fetches a web page from the command line. Ubuntu 24.04 ships with it — I never had to install it on either lab machine. If Step 3 ever answers curl: command not found, you are on a slimmer image than mine: run sudo apt install -y curl and carry on.
There is no kernel module to compile and no service running yet. WireGuard is already in the kernel; wireguard-tools just gave you the commands to drive it.
Step 3: Find your server’s public IP and its internet interface
Two values, both of which you need in later steps, and both of which you must read off your machine rather than copying from me.
The public IP is the address your phone and laptop will connect to:
curl -4 -s https://ifconfig.me; echo
Real output from the lab server:
52.207.232.75
Write yours down. The -4 forces IPv4, which is what we are configuring.
The internet interface is the name Linux gives the network card that faces the internet. You need it because the server has to rewrite VPN traffic to look like it came from that card:
ip route get 1.1.1.1
Real output:
1.1.1.1 via 172.31.16.1 dev ens5 src 172.31.26.69 uid 1000
cache
The name after dev is what you want — here, ens5. Or get it on its own:
ip -o -4 route show to default | awk '{print $5}'
Real output:
ens5
This is the single most common way this setup fails. Nearly every WireGuard guide on the internet hardcodes eth0. On this Ubuntu 24.04 server the interface is ens5, and eth0 does not exist. If you paste a config containing eth0 onto a machine whose interface is ens5, the tunnel will come up, the handshake will succeed, and no traffic will reach the internet — a failure that looks like everything is working. Use whatever your command just printed.
Step 4: Create the server’s key pair
Make a directory in your home folder to hold keys, and move into it:
mkdir -p ~/wg-keys
cd ~/wg-keys
~ means your home directory (/home/ubuntu here), so this creates /home/ubuntu/wg-keys. mkdir -p creates it without complaining if it already exists.
Now set the file permissions that new files will get, and generate the pair:
umask 077
wg genkey > server.key
wg pubkey < server.key > server.pub
umask 077 tells the shell that files it creates should be readable by you and nobody else. It matters here: wg genkey writes a private key, and a private key readable by other accounts on the box is not a private key. Confirm it worked:
ls -l ~/wg-keys
Real output:
total 8
-rw------- 1 ubuntu ubuntu 45 Jul 17 06:49 server.key
-rw------- 1 ubuntu ubuntu 45 Jul 17 06:49 server.pub
-rw------- is what you want: only ubuntu can read server.key. If you see -rw-r--r-- instead, the umask 077 did not apply — run chmod 600 ~/wg-keys/server.key and check again before going on.
Look at the public key, since you will see it again shortly:
cat ~/wg-keys/server.pub
Real output:
Xgo2vIEHk/VOGsWdGwVSL6qtvBmsrTZALlZJND80hl8=
Yours will be a different 44-character string. Do not copy mine — that key belongs to a server that no longer exists, and a key pair you did not generate is not yours.
Copy your server.pub string somewhere you can reach from your laptop — a notes app, a scratch file, anywhere. You need it in Step 10a, by which point you will have closed this SSH session. It is a public key; it is meant to be copied around, and there is no risk in having it sitting in a note.
A trap worth knowing about
You will find guides that generate the keys straight into /etc/wireguard like this:
umask 077
wg genkey | sudo tee /etc/wireguard/keys/server.key | wg pubkey | sudo tee /etc/wireguard/keys/server.pub
That command is in a lot of tutorials and it is quietly wrong. I ran exactly it in the lab and checked:
total 8
-rw-r--r-- 1 root root 45 Jul 17 06:48 server.key
-rw-r--r-- 1 root root 45 Jul 17 06:48 server.pub
-rw-r--r-- — the private key is world-readable, despite the umask 077 right above it. The reason: sudo resets the umask for the command it runs, so the umask you set applied to your shell and not to the tee that actually created the file. Nothing warns you. That is why this guide generates keys in your home directory with no sudo involved, where umask 077 does what it says.
Step 5: Write the server’s configuration
WireGuard reads a single file. We are creating /etc/wireguard/wg0.conf — wg0 is just the name of the tunnel, and the file name is where the name comes from.
Paste this whole block, first replacing ens5 with the interface from Step 3 if yours differs. ens5 appears twice — once on the PostUp line and once on the PostDown line — and you must change both. Changing only one gives you a tunnel that works until the first restart and then quietly stops routing.
Change nothing else in this block. %i, 10.8.0.1/24 and 51820 are literal values, not placeholders, and the PrivateKey line fills itself in:
sudo tee /etc/wireguard/wg0.conf > /dev/null <<EOF
[Interface]
Address = 10.8.0.1/24
ListenPort = 51820
PrivateKey = $(cat ~/wg-keys/server.key)
PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o ens5 -j MASQUERADE
PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o ens5 -j MASQUERADE
EOF
That is a “heredoc”: everything between <<EOF and the closing EOF gets written into the file. The $(cat ~/wg-keys/server.key) part is the useful bit — the shell replaces it with your actual private key as the file is written, so you never copy-paste a 44-character string and never make a typo in it. sudo tee is needed because /etc/wireguard belongs to root; > /dev/null just stops it echoing the file back at you.
Reading the file top to bottom:
Address = 10.8.0.1/24— the server’s own address inside the tunnel.ListenPort = 51820— the UDP port it waits on. UDP, not TCP; this matters in Step 8.PrivateKey— the server’s half of the pair from Step 4. This is why the file is a secret.PostUp/PostDown— commands run when the tunnel starts and stops.%iis replaced withwg0automatically. The twoFORWARDrules let traffic pass through the server.MASQUERADEis the important one: it rewrites your VPN traffic so it appears to come from the server’s own IP, and sends the replies back to you. This is the line that makes it a VPN rather than a private network — and the-o ens5in it is exactly why Step 3 mattered.
Notice there are no peers yet. We add your laptop in Step 10b and your phone in Step 12.
Check the file landed as intended:
sudo cat /etc/wireguard/wg0.conf
You should see your own private key on the PrivateKey line and your interface name on the PostUp/PostDown lines. If PrivateKey is empty or says PrivateKey =, the $(cat ...) did not expand — most likely you quoted the EOF as <<'EOF'. Re-run the block exactly as written.
Step 6: Lock the config file down
This file now contains your server’s private key. Look at how it was created:
sudo ls -l /etc/wireguard/wg0.conf
Real output:
-rw-r--r-- 1 root root 476 Jul 17 06:49 /etc/wireguard/wg0.conf
World-readable — every account on the box can read your server’s private key. Fix it:
sudo chmod 600 /etc/wireguard/wg0.conf
sudo ls -l /etc/wireguard/wg0.conf
Real output:
-rw------- 1 root root 476 Jul 17 06:49 /etc/wireguard/wg0.conf
-rw------- is correct. Do not skip this step on the grounds that you are the only user on the server: “the only user” stops being true the moment anything you install has a service account, and this costs one command.
I want to be clear about why this gets its own step. I brought the tunnel up in the lab with the file still at -rw-r--r--, and WireGuard started it without a single word of complaint. There is no warning. Nothing in the output will ever tell you.
Step 7: Turn on IP forwarding
By default, Linux will not pass traffic from one network interface to another — it will happily receive your VPN traffic and then drop it on the floor. Turn that on permanently by creating a config file for it:
echo 'net.ipv4.ip_forward = 1' | sudo tee /etc/sysctl.d/99-wireguard.conf
Real output (tee echoes what it wrote):
net.ipv4.ip_forward = 1
Apply it now, without rebooting:
sudo sysctl --system
That prints a long list of every config file it read. The line that matters:
* Applying /etc/sysctl.d/99-wireguard.conf ...
net.ipv4.ip_forward = 1
Verify it is actually live:
sudo sysctl net.ipv4.ip_forward
Real output:
net.ipv4.ip_forward = 1
It must say = 1. If it says = 0, forwarding is off, and the symptom later will be a tunnel that connects perfectly and carries no traffic. Because we wrote it into /etc/sysctl.d/, it survives reboots — I confirmed that below in Step 9.
Use sudo sysctl rather than plain sysctl here. On some systems /usr/sbin is not in a normal user’s path and plain sysctl returns command not found — which I hit in the lab, and which means nothing about whether forwarding is on.
Step 8: Open the firewall
ufw is Ubuntu’s firewall. Order matters in this step and getting it wrong locks you out of your own server.
Allow SSH first:
sudo ufw allow OpenSSH
Real output:
Rules updated
Rules updated (v6)
Then WireGuard’s port. Note udp — WireGuard does not use TCP, and sudo ufw allow 51820 without it opens the wrong thing:
sudo ufw allow 51820/udp
Real output, the same two lines:
Rules updated
Rules updated (v6)
Both commands must say Rules updated before you go anywhere near the next one. Only now turn the firewall on:
sudo ufw enable
It will warn that this may disrupt existing SSH connections and ask you to confirm; that warning is precisely why we allowed OpenSSH first. Answer y. It then prints:
Firewall is active and enabled on system startup
Confirm:
sudo ufw status verbose
Real output:
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), deny (routed)
New profiles: skip
To Action From
-- ------ ----
22/tcp (OpenSSH) ALLOW IN Anywhere
51820/udp ALLOW IN Anywhere
22/tcp (OpenSSH (v6)) ALLOW IN Anywhere (v6)
51820/udp (v6) ALLOW IN Anywhere (v6)
Both 22/tcp and 51820/udp must appear. If 51820/udp is missing, your phone and laptop will send packets into a void — Step 11 shows exactly what that looks like.
If you want to understand the rest of what ufw can do, How to Set Up a Firewall with UFW on Ubuntu 24.04 covers it properly.
One thing that is not in this output and that I cannot check for you: many hosts put a second firewall in front of your VPS, in their control panel, independent of ufw. If your provider has one, UDP 51820 has to be open there too. If everything in this guide is correct and Step 11 still shows no handshake, that panel is the first place to look.
Step 9: Start the tunnel
One command starts it and sets it to come back automatically after a reboot:
sudo systemctl enable --now wg-quick@wg0
The @wg0 part points systemd at /etc/wireguard/wg0.conf. On success this prints nothing at all, which is normal and disconcerting. Check it properly:
sudo systemctl status wg-quick@wg0
Real output (first lines):
● [email protected] - WireGuard via wg-quick(8) for wg0
Loaded: loaded (/usr/lib/systemd/system/[email protected]; enabled; preset: enabled)
Active: active (exited) since Fri 2026-07-17 07:03:14 UTC; 18ms ago
Two words to check. enabled means it will start on boot. active (exited) is success — it looks alarming but it is correct: wg-quick configures the kernel and exits, because the tunnel itself lives in the kernel and not in a process. There is no daemon to stay running. Press q to get out of that display.
Now ask WireGuard itself:
sudo wg show
Real output:
interface: wg0
public key: Xgo2vIEHk/VOGsWdGwVSL6qtvBmsrTZALlZJND80hl8=
private key: (hidden)
listening port: 51820
The server is up and listening. No peers are listed because we have not added any — that is the next step.
Use systemctl enable --now, not wg-quick up. Most guides tell you to run sudo wg-quick up wg0 and then sudo systemctl enable wg-quick@wg0. That does work, but it leaves systemd unaware of the tunnel you started by hand, and systemctl status then reports Active: inactive (dead) while the tunnel is plainly running. I hit this in the lab and it is a genuinely confusing state to debug later. One command avoids it.
Confirming it really survives a reboot
An “it starts on boot” claim is worth verifying rather than trusting. I rebooted the lab server and checked:
uptime -p
sudo systemctl is-enabled wg-quick@wg0
sudo systemctl is-active wg-quick@wg0
Real output after the reboot:
up 0 minutes
enabled
active
And forwarding from Step 7 came back too:
net.ipv4.ip_forward = 1
You can run the same three commands after rebooting your own server. If is-active says inactive, run sudo systemctl status wg-quick@wg0 and read the error.
Step 10a: On your Linux laptop — install and generate keys
Now we do the other end. This step is written for a Linux laptop, which is what I verified in the lab. A phone — iPhone or Android — is Step 12, and that route is verified too.
On Windows or macOS? Your server, which is everything up to here, is finished and correct — the server does not care what kind of device connects to it. What this guide does not yet give you is the desktop client half. The official WireGuard app for Windows and macOS uses the same config format as the
[Interface]/[Peer]file in this step, but it has no QR scanner, so the Step 12 route is genuinely phone-only and I am not going to send you there and pretend otherwise. I did not test either desktop app in this lab, and I would rather leave a gap than invent a click path that does not survive contact with the real app. Set up your phone via Step 12 today; for the desktop, use the official install and import instructions at wireguard.com.
These commands run on your laptop, not on the server. If you are still in the SSH session from Step 1, type exit to come back to your own machine:
# on the server — leaves the SSH session
exit
Confirm you are home:
# on the laptop
whoami
pwd
You should see your laptop’s username and your laptop’s home directory. (Both of my lab machines happened to use the username ubuntu, so the outputs below look identical to the server’s — yours will show your laptop’s own username, and that is fine.)
Install WireGuard on the laptop:
# on the laptop
sudo apt update
sudo apt install -y wireguard openresolv
openresolv handles the DNS setting we configure below. Ubuntu 24.04 already has what it needs and will simply tell you so; on slimmer Debian-based systems it is the package that prevents a resolvconf: command not found error partway through connecting.
Before connecting, record what your IP address looks like now, so you have something to compare against in Step 11:
# on the laptop
curl -4 -s https://ifconfig.me; echo
Real output from the lab client:
98.88.251.102
That is the address the internet currently sees you as. Write it down — it is the “before” half of the only test that really matters.
Generate the laptop’s own key pair — on the laptop, exactly as you did on the server. A private key that has been emailed or pasted between machines is not private:
# on the laptop
mkdir -p ~/wg-keys
cd ~/wg-keys
umask 077
wg genkey > client.key
wg pubkey < client.key > client.pub
Check the permissions and read the public key:
# on the laptop
ls -l ~/wg-keys
cat ~/wg-keys/client.pub
Real output:
total 8
-rw------- 1 ubuntu ubuntu 45 Jul 17 06:49 client.key
-rw------- 1 ubuntu ubuntu 45 Jul 17 06:49 client.pub
iTqlS6p8Je+5Z60vFHSoNLwl9JuojL0X2PTe7Yx6Xnw=
Copy your own client.pub value — the whole 44-character string, not mine. It goes on the server in Step 10b. This one is safe to copy around; that is what public keys are for.
Write the laptop’s config. Replace two things: Xgo2vIEHk/... with your server’s public key that you saved in Step 4, and 52.207.232.75 with your server’s public IP from Step 3. Everything else — including 10.8.0.2/24, 1.1.1.1, 51820 and the self-filling PrivateKey line — stays exactly as written:
# on the laptop
sudo tee /etc/wireguard/wg0.conf > /dev/null <<EOF
[Interface]
PrivateKey = $(cat ~/wg-keys/client.key)
Address = 10.8.0.2/24
DNS = 1.1.1.1
[Peer]
PublicKey = Xgo2vIEHk/VOGsWdGwVSL6qtvBmsrTZALlZJND80hl8=
Endpoint = 52.207.232.75:51820
AllowedIPs = 0.0.0.0/0
PersistentKeepalive = 25
EOF
sudo chmod 600 /etc/wireguard/wg0.conf
What these mean:
Address = 10.8.0.2/24— this laptop’s address inside the tunnel.DNS = 1.1.1.1— which server answers “what IP is example.com?” while connected. Without this your laptop keeps using your local network’s DNS, which tells that network every site you visit even though the traffic itself is encrypted. Read the honest note below.PublicKey— the server’s public key. Peers identify each other by public key; this is how your laptop knows it is talking to your server and not something pretending to be it.Endpoint— where to find the server: its public IP and the port from Step 5.AllowedIPs = 0.0.0.0/0— “send everything through the tunnel”. This is what makes it a full VPN. Set to10.8.0.0/24instead and only VPN-internal traffic goes through, which is what you want if you only need to reach your own servers privately.PersistentKeepalive = 25— sends a tiny packet every 25 seconds so home routers and mobile networks do not silently forget the connection. Leave it on.
About DNS = 1.1.1.1: that is Cloudflare’s public resolver. It stops your local network seeing your lookups, but it means Cloudflare sees them instead. You have moved the trust, not removed it. Use any resolver you prefer, or run your own on the VPS — that is a bigger job and out of scope here, but it is the only option that keeps DNS entirely yours.
Step 10b: Back on the server — add the laptop as a peer
Stop. This step runs on the server, not the laptop. You left the SSH session at the start of Step 10a, so log back in — replacing the username and IP with yours:
# on the laptop — reconnects you to the server
ssh [email protected]
Check you actually arrived before touching anything:
# on the server
whoami
pwd
sudo wg show wg0 listening-port
Real output on the server:
ubuntu
/home/ubuntu
51820
That last command is the one that settles it: 51820 only prints on the machine running the tunnel. If it errors instead, you are still on your laptop — do not run the next command until it prints the port.
This matters more than any other checkpoint in the guide. The command below appends to /etc/wireguard/wg0.conf, and both machines now have a file at that exact path. Run it on the laptop by mistake and you will corrupt your laptop’s config, get no error at all, and spend the next hour debugging a tunnel that was fine.
Now add the laptop as a peer. Paste your own laptop’s client.pub from Step 10a in place of iTqlS6p8Je+5... — that string is my lab laptop’s key, and leaving it in place gives you a peer that can never connect:
# on the server
sudo tee -a /etc/wireguard/wg0.conf > /dev/null <<EOF
[Peer]
PublicKey = iTqlS6p8Je+5Z60vFHSoNLwl9JuojL0X2PTe7Yx6Xnw=
AllowedIPs = 10.8.0.2/32
EOF
tee -a appends rather than overwriting — the -a is not optional, and without it you erase your server config. AllowedIPs = 10.8.0.2/32 here means the opposite of what it meant on the laptop: on the server it is a restriction, saying “this peer is allowed to be 10.8.0.2 and nothing else”. The /32 means exactly that one address.
Load the change:
# on the server
sudo systemctl restart wg-quick@wg0
sudo wg show wg0 allowed-ips
Real output:
iTqlS6p8Je+5Z60vFHSoNLwl9JuojL0X2PTe7Yx6Xnw= 10.8.0.2/32
Read the key it printed and compare it, character for character, against your laptop’s client.pub. They must match. This check exists because the failure it catches is invisible: if you left my key in the block, this command prints a perfectly healthy-looking line — my key, 10.8.0.2/32, exactly like the output above — and the tunnel then never handshakes with no clue as to why. The output looking right is not the same as it being right.
Step 10c: Back on your laptop — connect
Leave the SSH window alone — do not type exit this time. Step 11 needs that server session. Open a second terminal window on your laptop instead, so you have one window on each machine for the rest of the guide.
In the new window, confirm it is your laptop and not the server:
# on the laptop
whoami
pwd
You should see your laptop’s username and home directory — and, unlike on the server, sudo wg show wg0 listening-port would error here because no tunnel exists yet. Now connect:
# on the laptop
sudo wg-quick up wg0
If this errors with something about wg0 already existing, you are in the server window by mistake. Move to the other one.
Real output:
[#] ip link add wg0 type wireguard
[#] wg setconf wg0 /dev/fd/63
[#] ip -4 address add 10.8.0.2/24 dev wg0
[#] ip link set mtu 1420 up dev wg0
[#] resolvconf -a wg0 -m 0 -x
[#] wg set wg0 fwmark 51820
[#] ip -4 rule add not fwmark 51820 table 51820
[#] ip -4 rule add table main suppress_prefixlength 0
[#] ip -4 route add 0.0.0.0/0 dev wg0 table 51820
[#] sysctl -q net.ipv4.conf.all.src_valid_mark=1
[#] nft -f /dev/fd/63
Every line starting [#] is a command it ran for you, not an error. This is what success looks like. To disconnect later, sudo wg-quick down wg0.
If instead it stopped with resolvconf: command not found, the openresolv package from Step 10a is missing. Run sudo apt install -y openresolv and try sudo wg-quick up wg0 again.
Step 11: Prove it actually works — four checks on your laptop, one on the server
Do not take “it connected” for an answer. The first four commands run in your laptop window; the last one in your server window. Running these on the wrong machine does not just waste time — the ping below will happily report success on the server, because the server is pinging itself.
Is there a handshake?
# on the laptop
sudo wg show
Real output:
interface: wg0
public key: iTqlS6p8Je+5Z60vFHSoNLwl9JuojL0X2PTe7Yx6Xnw=
private key: (hidden)
listening port: 49815
fwmark: 0xca6c
peer: Xgo2vIEHk/VOGsWdGwVSL6qtvBmsrTZALlZJND80hl8=
endpoint: 52.207.232.75:51820
allowed ips: 0.0.0.0/0
latest handshake: Now
transfer: 92 B received, 180 B sent
persistent keepalive: every 25 seconds
The line that matters is latest handshake. If it is there, the two ends have proved their identities to each other. If there is no latest handshake line at all, you are not connected, whatever else the output says.
Has your IP address changed? This is the real test:
# on the laptop
curl -4 -s https://ifconfig.me; echo
Real output with the tunnel up:
52.207.232.75
That is the server’s public IP, not the laptop’s 98.88.251.102 from Step 10a. The internet now sees your server where it used to see you. If this still shows your own IP, the tunnel is up but traffic is not using it — check AllowedIPs = 0.0.0.0/0 on the laptop, and IP forwarding on the server.
Does the VPN network itself work?
# on the laptop
ping -c 3 10.8.0.1
Real output:
PING 10.8.0.1 (10.8.0.1) 56(84) bytes of data.
64 bytes from 10.8.0.1: icmp_seq=1 ttl=64 time=0.516 ms
64 bytes from 10.8.0.1: icmp_seq=2 ttl=64 time=0.559 ms
64 bytes from 10.8.0.1: icmp_seq=3 ttl=64 time=0.489 ms
--- 10.8.0.1 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2078ms
rtt min/avg/max/mdev = 0.489/0.521/0.559/0.028 ms
0% packet loss is the thing to look for. (Those sub-millisecond times are an artifact of my lab having both machines in one data centre — yours will show the real distance to your server, and that number is the honest floor on how much the VPN slows you down.)
And DNS:
# on the laptop
getent hosts example.com
Real output:
2606:4700:10::ac42:93f3 example.com
2606:4700:10::6814:179a example.com
Any address printed means names are resolving through the tunnel — that is the pass condition. Do not be thrown that these are IPv6 addresses, long ones with colons, when the whole guide has been forcing IPv4 with curl -4. That flag only controls how curl connects; getent reports every address it finds, and example.com happens to answer with IPv6 first. It is not a sign that anything is misconfigured.
Failure looks like nothing at all: getent prints no output and exits quietly. If that happens, the tunnel is up but name resolution is not going through it — see the DNS entry in the troubleshooting section.
Finally, switch to your server window and watch it from the other side:
# on the server
sudo wg show
Real output from the lab server, with the laptop connected and moving data:
interface: wg0
public key: Xgo2vIEHk/VOGsWdGwVSL6qtvBmsrTZALlZJND80hl8=
private key: (hidden)
listening port: 51820
peer: iTqlS6p8Je+5Z60vFHSoNLwl9JuojL0X2PTe7Yx6Xnw=
endpoint: 98.88.251.102:46724
allowed ips: 10.8.0.2/32
latest handshake: 7 seconds ago
transfer: 19.30 KiB received, 20.60 KiB sent
One peer — your laptop — and the server’s view of it. The endpoint is the laptop’s real public address as the server sees it (98.88.251.102, the same address the laptop reported in Step 10a before connecting). The latest handshake and the climbing transfer counters are the server confirming, independently of anything the laptop claims, that this tunnel is real and carrying data.
This is your at-a-glance view of who is using the VPN, and it is worth learning to read now: a peer with an endpoint, a handshake and rising byte counts is connected; a peer with none of those is configured but not currently connected.
Step 12: Add your phone — on the server, then the phone
The phone is the same job with a nicer delivery mechanism. A phone cannot easily run wg genkey, so this is the one case where generating the key pair on the server is the practical choice — the private key does travel, as a QR code, over your own screen.
Everything in this step runs on the server until the phone appears. If you do not have an SSH session open, log back in and confirm you arrived:
# on the laptop — reconnects you to the server
ssh [email protected]
# on the server
sudo wg show wg0 listening-port
Real output on the server:
51820
If that errors, you are on the wrong machine. Take this seriously here: cd ~/wg-keys in the next block will happily succeed on your laptop too — you created that directory in Step 10a — so a mis-boxed reader generates phone keys on their laptop, appends a peer to the laptop’s config, and gets no error anywhere while nothing works.
Generate the phone’s pair:
# on the server
cd ~/wg-keys
umask 077
wg genkey > phone.key
wg pubkey < phone.key > phone.pub
cat phone.pub
Real output:
kxe2HmT9MkQR/fYv58HfEi1cC/yaZknRyp1KofEyvxA=
Add the phone as a peer, on 10.8.0.3 this time so it does not collide with the laptop. Nothing here needs substituting — $(cat ~/wg-keys/phone.pub) inserts the key you just generated, so there is no chance of pasting mine by mistake:
# on the server
sudo tee -a /etc/wireguard/wg0.conf > /dev/null <<EOF
[Peer]
PublicKey = $(cat ~/wg-keys/phone.pub)
AllowedIPs = 10.8.0.3/32
EOF
sudo systemctl restart wg-quick@wg0
sudo wg show wg0 allowed-ips
Real output — both devices now known to the server:
iTqlS6p8Je+5Z60vFHSoNLwl9JuojL0X2PTe7Yx6Xnw= 10.8.0.2/32
kxe2HmT9MkQR/fYv58HfEi1cC/yaZknRyp1KofEyvxA= 10.8.0.3/32
Build the config the phone will import. Replace 52.207.232.75 with your server’s IP — that is the only substitution; the two key lines fill themselves in from the files on this machine:
# on the server
umask 077
cat > ~/wg-keys/phone.conf <<EOF
[Interface]
PrivateKey = $(cat ~/wg-keys/phone.key)
Address = 10.8.0.3/24
DNS = 1.1.1.1
[Peer]
PublicKey = $(cat ~/wg-keys/server.pub)
Endpoint = 52.207.232.75:51820
AllowedIPs = 0.0.0.0/0
PersistentKeepalive = 25
EOF
ls -l ~/wg-keys/phone.conf
Real output:
-rw------- 1 ubuntu ubuntu 250 Jul 17 07:04 /home/ubuntu/wg-keys/phone.conf
Turn it into a QR code printed directly in your terminal:
# on the server
qrencode -t ansiutf8 < ~/wg-keys/phone.conf
This fills your terminal with a 33-line block of black and white squares. If it comes out as unreadable mush, make the terminal window bigger or the font smaller until the square has clean edges — the phone needs to actually focus on it.
Now pick up the phone. Leave the QR code on screen — you are about to scan it. Five screens, in order:
- App store. Install WireGuard, published by WireGuard Development Team, from the App Store (iPhone) or Play Store (Android). There are look-alikes; check the publisher.
- Empty tunnel list. Open the app. It has no tunnels yet. Tap the + button — bottom-right on Android, top-right on iPhone.
- Add-tunnel menu. A short menu appears. Choose Scan from QR code (Android) or Create from QR code (iPhone). Allow camera access if asked.
- Camera. Point it at the QR block in your terminal. It scans as soon as it focuses — no button to press. If nothing happens after a few seconds, enlarge the terminal window or shrink its font until the square has clean, sharp edges, and try again.
- Name and activate. It asks for a name for the tunnel. Type anything —
homeor the server’s name. Save, and you are back at the tunnel list with your tunnel on it and a toggle beside it. Tap the toggle on. On the first connection your phone will ask permission to add a VPN configuration; approve it. iPhone may also ask for Face ID or your passcode.
Connected looks like this: the toggle is on, and tapping the tunnel’s name shows a Status: Active line with a latest handshake time and transfer counters that climb as you use the phone.
Every one of those screens needs a real image before this page ships — the lab has no physical phone, so a human must capture each from a real device:
Do not take the phone’s word for it. Verify from the server:
# on the server
sudo wg show
You should now see two peers. Your phone’s public key — the kxe2HmT9... equivalent that this step printed for you — should have an endpoint, a recent latest handshake and rising transfer numbers, exactly as your laptop’s did in Step 11.
Real output from the lab at this point. One thing to know before you read it: I had switched my laptop’s tunnel off when I captured this, so its peer shows bare. If your laptop is still connected from Step 11 — and it should be, since nothing told you to disconnect it — you will see an endpoint and a handshake on both peers. That is also correct, and is what a healthy two-device VPN looks like. Peers are listed in no particular order, so yours may appear the other way round:
interface: wg0
public key: Xgo2vIEHk/VOGsWdGwVSL6qtvBmsrTZALlZJND80hl8=
private key: (hidden)
listening port: 51820
peer: kxe2HmT9MkQR/fYv58HfEi1cC/yaZknRyp1KofEyvxA=
endpoint: 98.88.251.102:35187
allowed ips: 10.8.0.3/32
latest handshake: 2 minutes, 23 seconds ago
transfer: 2.29 KiB received, 11.28 KiB sent
peer: iTqlS6p8Je+5Z60vFHSoNLwl9JuojL0X2PTe7Yx6Xnw=
allowed ips: 10.8.0.2/32
The phone (10.8.0.3) is connected — endpoint, handshake, bytes. The laptop (10.8.0.2) shows no endpoint and no handshake line, which is exactly what a peer with its tunnel switched off looks like. Nothing is wrong with it; it is configured and waiting.
Finally, load any “what is my IP” page in the phone’s browser. It should show your server’s IP, not your mobile network’s.
I verified this config in the lab by running the very file the QR encodes on a real machine: it handshook on the first attempt and the egress IP came back as 52.207.232.75, the server’s. The template above is known-good.
Once the phone has scanned it, delete the config from the server — it contains the phone’s private key and it has done its job:
# on the server
shred -u ~/wg-keys/phone.conf
shred -u overwrites the file before removing it. Keep phone.pub if you want to remember which peer is which; the phone keeps its own private key from here on.
Every extra device is this same loop: generate a pair, add a [Peer] block with the next free address (10.8.0.4, 10.8.0.5, and so on), restart, import. Give each device its own key pair. Sharing one config across devices means both fight over the same tunnel address and neither works reliably, and revoking one device then means revoking all of them.
When it does not work
No latest handshake line, and transfer shows bytes sent but 0 received. This is the most common failure and the output is unmistakable. I reproduced it in the lab by pointing a client at the wrong port — 51821 instead of 51820 — which is indistinguishable, from the client’s side, from a port that a firewall is blocking. Note the 51821 in the output below: that is the deliberate fault, and your endpoint should read 51820.
peer: Xgo2vIEHk/VOGsWdGwVSL6qtvBmsrTZALlZJND80hl8=
endpoint: 52.207.232.75:51821
allowed ips: 0.0.0.0/0
transfer: 0 B received, 444 B sent
persistent keepalive: every 25 seconds
No handshake line at all, and 0 B received while 444 B sent climbs. Your packets are leaving and nothing is coming back. In order of likelihood: the port is not open (sudo ufw status verbose on the server, look for 51820/udp); your host has a separate cloud firewall that also needs UDP 51820 open; the Endpoint IP or port in the client config is wrong; the server tunnel is not running (sudo systemctl is-active wg-quick@wg0).
Handshake works, but no internet. The tunnel is fine and the routing is not. Check IP forwarding on the server (sudo sysctl net.ipv4.ip_forward must be 1), and check that the interface name in the PostUp/PostDown lines matches what ip -o -4 route show to default | awk '{print $5}' prints. An eth0 in that file on a machine that uses ens5 produces exactly this symptom.
Handshake works, websites load, but names do not resolve. DNS. Check DNS = 1.1.1.1 is in the client’s [Interface] block, and if wg-quick printed resolvconf: command not found, install openresolv.
Some sites load, others hang forever. Classic MTU problem. MTU is the largest packet a link will carry; the tunnel’s encryption headers make packets bigger, so WireGuard uses a smaller MTU to leave room. wg-quick works this out for you — you can see it choose 1420 in the Step 10c output, which is the normal value on a standard 1500-MTU connection. If your connection is unusual and large pages hang while small ones work, add MTU = 1380 under [Interface] in the client config and bring the tunnel down and up again. Lower it further only if that does not help.
Locked out of SSH after ufw enable. You enabled the firewall before allowing SSH. Use your provider’s web console to get in, run sudo ufw allow OpenSSH, and treat the ordering in Step 8 as the rule it is.
sysctl: command not found. /usr/sbin is not in your path. Use sudo sysctl ..., which I hit in the lab and which says nothing about your actual configuration.
The cost that will actually bite you
Not CPU. WireGuard runs in the kernel and a VPN tunnel is close to free in compute terms — the lab ran comfortably on a 2 GB box that was mostly idle. Bandwidth is the real constraint, and it catches people out for a reason worth stating plainly: with AllowedIPs = 0.0.0.0/0, every byte your devices pull from the internet is a byte your VPS also pulls. Stream a film through the VPN and your server streams that film too. Your normal monthly usage lands on the VPS’s transfer allowance, roughly doubled in the sense that it flows in and back out again.
So size on transfer, not specs. Lineserve’s smallest Linux VPS, S1 — 1 vCPU, 2 GB RAM, 20 GB SSD, 1 TB transfer, one dedicated IPv4 — is more machine than WireGuard needs at $13.51/month (KES 1,746 · TZS 33,775 · NGN 21,616). That 1 TB is the number to think about. Light browsing and email on a couple of devices will not come near it. Video is a different story, and if that is your plan, look at S2 (2 TB transfer, $18.35/month) or M1 (3 TB, $34.18/month) instead. The dedicated IPv4 matters more than it sounds: a shared or changing IP makes for a VPN endpoint your devices cannot reliably find.
Where the server sits decides how the VPN feels, because every packet detours through it. A VPS in Nairobi, Dar es Salaam or Lagos keeps that detour short for anyone in the region — routing your traffic via Europe to read a local site is a real and avoidable cost. Lineserve runs regions in all three, billed in local currency, payable with M-Pesa and mobile money in Kenya and Tanzania and by card or bank transfer in Naira in Nigeria — no foreign card and no forex conversion.
Launch a Linux VPS in Nairobi, Dar es Salaam or Lagos →
Before you call it done
You have a working VPN. The server it runs on is now a permanently-online Linux box with your name on it, and it deserves the same basic hygiene as anything else you expose: key-based SSH, no root login, automatic security updates. 8 Essential Security Steps for Every Production VPS is the short version, and none of it takes long.
Two habits worth forming. Removing a device is the reverse of adding one: delete its [Peer] block from /etc/wireguard/wg0.conf with sudo nano /etc/wireguard/wg0.conf, save with Ctrl+O then Enter, exit with Ctrl+X, and run sudo systemctl restart wg-quick@wg0. Its key stops working immediately — that is the entire revocation story, and it is why per-device key pairs are worth the extra minute. Checking who is connected is sudo wg show on the server, any time.
And keep the honest framing from the top of this page. You have built something that protects your traffic on networks you do not trust and gives you an IP address you control. That is genuinely useful and worth the hour. It is not anonymity, and the moment you start treating it as anonymity is the moment it starts lying to you.