A couple of years ago I ditched commercial VPNs for a setup I control end‑to‑end. It’s faster, cheaper long‑term, and it does exactly what I need: secure my traffic on hostile networks, give me a stable exit IP I own, and let me reach my homelab from anywhere.
This post is a practical guide to building the same thing with WireGuard, plus how to make it fast and private without sending data to a third‑party. We’ll cover:
- What you actually get from a self‑hosted VPN (and what you don’t)
- A production‑ready WireGuard server on Debian 12 (VPS or at home)
- Client setups for iOS, Android, macOS, Windows, Linux
- Optional: run your own recursive DNS (Unbound) so your queries stay yours
- Performance tuning that actually moves the needle
- Common errors I’ve hit and how to fix them fast
If you’re comfortable with a terminal and basic networking, you’re good. Grab a coffee.
Why self‑host at all?
Control and privacy. With a self‑hosted VPN you decide what’s logged (ideally nothing), where your exit IP lives, and how DNS is handled. For most day‑to‑day privacy use cases, that’s huge. I also use mine to geofence admin panels, enforce MFA prompts from a fixed egress IP, and stream my LAN media when travelling.
Performance. WireGuard is notably lean. On decent hardware you’ll saturate 1 Gbps easily and go higher on modern CPUs. The protocol uses modern crypto (Curve25519 + ChaCha20‑Poly1305) and lives in the kernel on Linux.
Cost. A small EU VPS with 1 vCPU/1–2 GB RAM is plenty and typically costs less than a month of a commercial VPN. Or host at home if you have a public IPv4 and/or IPv6.
Reality check: a self‑hosted VPN does not make you anonymous . Your VPS provider can tie activity to your account, and sites still know who you are when you log in. If you need anonymity, use Tor. A self‑hosted VPN is for privacy, integrity on untrusted networks, and control.
Architecture at a glance
- Server: Debian 12 on a VPS (or a home box) with UDP/51820 open.
- Tunnel: WireGuard
wg0
with an RFC1918 IPv4 subnet and a ULA IPv6 prefix. - NAT & firewall:
nftables
with default‑deny, NAT44/66 enabled. - DNS (optional but recommended): Unbound recursive resolver bound to the WG subnet. Clients use it over the tunnel.
[Phone/Laptop] ⇄ Internet ⇄ [VPS: wg0 + nftables + Unbound] ⇄ Internet
⤷ optional route to homelab over wg peer(s)
Prerequisites
- Debian 12 server with a public IPv4. IPv6 strongly recommended.
- Port 51820/UDP reachable.
- Root or sudo.
Hostnames are optional. I usually point a DNS A/AAAA at the VPS for convenience.
Step 1: Install WireGuard
sudo apt update
sudo apt install -y wireguard wireguard-tools qrencode nftables
Enable nftables
at boot:
sudo systemctl enable --now nftables
Step 2: Create keys and base config
We’ll use 10.66.66.0/24
for IPv4 and a ULA for IPv6. Change to taste.
sudo umask 077
cd /etc/wireguard
wg genkey | tee server.key | wg pubkey > server.pub
Create /etc/wireguard/wg0.conf
:
[Interface]
Address = 10.66.66.1/24, fd86:ea04:1115::1/64
ListenPort = 51820
PrivateKey = <paste contents of /etc/wireguard/server.key>
# Good defaults
SaveConfig = false
MTU = 1420
# We’ll let nftables handle firewalling/NAT; see next step.
Tip: If you’re on PPPoE or see weird “some sites don’t load” symptoms later, drop
MTU
to 1280 and retest. We’ll cover MTU testing below.
Step 3: Firewall and NAT with nftables
Edit /etc/nftables.conf
to something like this (replace eth0
with your public NIC name):
flush ruleset
# Replace with your real interfaces
define pub_if = "eth0"
define wg_if = "wg0"
table inet filter {
chain input {
type filter hook input priority 0; policy drop;
iifname lo accept
ct state established,related accept
meta l4proto { icmp, ipv6-icmp } accept
udp dport 51820 iifname $pub_if accept # WireGuard
# Optional: allow SSH from everywhere or your management IPs only
tcp dport 22 iifname $pub_if accept
}
chain forward {
type filter hook forward priority 0; policy drop;
ct state established,related accept
iifname $wg_if oifname $pub_if accept # WG → Internet
iifname $pub_if oifname $wg_if accept # Internet → WG (return traffic)
}
chain output {
type filter hook output priority 0; policy accept;
}
}
table ip nat {
chain postrouting {
type nat hook postrouting priority 100; policy accept;
oifname $pub_if masquerade
}
}
table ip6 nat {
chain postrouting {
type nat hook postrouting priority 100; policy accept;
oifname $pub_if masquerade
}
}
Apply it:
sudo nft -f /etc/nftables.conf
sudo nft list ruleset | less
Enable IP forwarding:
printf 'net.ipv4.ip_forward=1\nnet.ipv6.conf.all.forwarding=1\n' | sudo tee /etc/sysctl.d/99-wg-forward.conf
sudo sysctl --system
Step 4: Bring up WireGuard
sudo systemctl enable --now wg-quick@wg0
wg show
You should see your interface listening on 51820
.
Step 5: Add your first client (phone/laptop)
Generate client keys on the server or your laptop:
wg genkey | tee alice.key | wg pubkey > alice.pub
Append this peer
to /etc/wireguard/wg0.conf
on the server:
[Peer]
# Alice’s phone
PublicKey = <contents of alice.pub>
AllowedIPs = 10.66.66.2/32, fd86:ea04:1115::2/128
Reload without dropping the interface:
sudo wg setconf wg0 <(wg-quick strip /etc/wireguard/wg0.conf)
Create alice.conf
for the client:
[Interface]
Address = 10.66.66.2/32, fd86:ea04:1115::2/128
PrivateKey = <contents of alice.key>
DNS = 10.66.66.1
[Peer]
PublicKey = <server public key from /etc/wireguard/server.pub>
Endpoint = your.server.name.or.ip:51820
AllowedIPs = 0.0.0.0/0, ::/0
PersistentKeepalive = 25
Split‑tunnel? Instead of
0.0.0.0/0, ::/0
, setAllowedIPs
to the subnets you want routed via the VPN (for example10.66.66.0/24
or your homelab ranges).
Import on mobile via QR
On the server:
qrencode -t ansiutf8 < alice.conf
Open the WireGuard mobile app, add a tunnel, and scan the QR in your terminal.
Optional: your own private DNS with Unbound
Why bother? Because it keeps your DNS queries inside your tunnel instead of sending them to a third‑party. We’ll run Unbound locally and point WireGuard clients at it.
Install:
sudo apt install -y unbound
Create /etc/unbound/unbound.conf.d/wg.conf
:
server:
interface: 10.66.66.1
access-control: 10.66.66.0/24 allow
do-ip4: yes
do-ip6: yes
do-udp: yes
do-tcp: yes
qname-minimisation: yes
hide-identity: yes
hide-version: yes
harden-glue: yes
harden-dnssec-stripped: yes
edns-buffer-size: 1232
prefetch: yes
# Debian ships root hints via dns-root-data; explicit root-hints is optional
# root-hints: "/usr/share/dns/root.hints"
forward-zone:
name: "."
forward-first: no
# If you prefer full recursion, remove this block entirely.
Start it:
sudo systemctl enable --now unbound
Confirm DNS from a client after connecting WG:
# from your laptop/phone shell
nslookup example.com 10.66.66.1
If you want ad/tracker blocking for your devices, drop Pi‑hole in front and set its upstream to Unbound. I run Unbound standalone to keep the stack simple.
Performance tuning that actually helps
WireGuard is already quick. Tune only if you hit limits.
1) MTU sanity
Start with MTU = 1420
. If some sites hang or you see stalls on mobile data, reduce to 1280 . Quick path MTU test from a client:
# IPv4: find largest size that does not fragment
ping -M do -s 1400 your.server.ip
# If it fails, reduce -s in steps of 10 until it succeeds, then add 28 bytes for ICMP/IPv4 overhead
2) Buffers for high‑throughput links
For fast WANs with high RTT, larger UDP buffers can help:
cat <<'EOF' | sudo tee /etc/sysctl.d/99-wg-buffers.conf
net.core.rmem_max = 8388608
net.core.wmem_max = 8388608
EOF
sudo sysctl --system
3) Measure properly
Use iperf3
through the tunnel to isolate issues:
# On a server reachable over WG
iperf3 -s
# On client
iperf3 -c 10.66.66.1 -P 4
Expect high hundreds of Mbps to multi‑Gbps on modern CPUs. If speeds are poor, check CPU single‑core utilisation, NIC offloads, and MTU first.
Don’t over‑tune. Changing
txqueuelen
, random kernel knobs, or exotic qdiscs tends to create bufferbloat or placebo gains. Keep it boring.
Security and privacy notes
- Treat
*.key
like SSH private keys.chmod 600
and never commit them anywhere. - Use a random listen port if you want to be slightly less obvious than 51820.
- If you care about “no logs,” don’t enable verbose logging on the server. I keep
journald
default and avoid WG debug logs in production. - Self‑hosting protects you from ISP snooping on hostile networks and from commercial VPN logging, but your VPS account is still tied to your identity. This is privacy, not anonymity.
Troubleshooting: common gotchas
- Handshake never completes: a firewall is blocking UDP/51820 or your
Endpoint
is wrong. Verify port is open from outside and the server sees packets (sudo tcpdump -ni eth0 udp port 51820
). - Connected but no Internet: NAT or forwarding is missing. Recheck
nftables
NAT postrouting andnet.ipv4.ip_forward=1
. - Some sites hang/time out: almost always MTU/fragmentation. Drop
MTU
to 1280 on client and server and retry. - UFW users: remember UFW needs route rules for forwarded traffic. If you stick with UFW instead of raw
nftables
, addPostUp = ufw route allow in on wg0 out on eth0
and the matchingPreDown
delete. SaveConfig=true
ate my comments:wg-quick down
writes the live config back and can strip comments. I keepSaveConfig=false
and edit files explicitly.- Revoke a device:
wg set wg0 peer <client-public-key> remove
then delete its stanza fromwg0.conf
and reload.
Optional extras I like
- Site‑to‑site: add another
[Peer]
with its own subnet inAllowedIPs
and let the server route between them. For routed IPv6, ideally get a routed /64 or /56 from your provider and assign real IPv6 to peers. - Kill‑switch on clients: use your OS firewall to drop all traffic except via
wg0
if you need to prevent leaks when the tunnel drops. - Backups: I keep
/etc/wireguard/
in a private repo with age/sops. Keys are rotated yearly.
Who shouldn’t self‑host
- If you need many rotating exit IPs across countries for streaming arbitrage, use a provider. Self‑hosting is about control, not cat‑and‑mouse with content farms.
- If you need anonymity against strong adversaries, use Tor. A VPN, self‑hosted or not, won’t cut it.
Wrap‑up
Self‑hosting a VPN is one of those projects that pays back every day. WireGuard keeps the mental overhead low, and running your own DNS closes a huge privacy gap. Build it once, keep it patched, and enjoy not sending your traffic to yet another third‑party.