Why I built my own VPN (and you should too)

July 29, 2024 / 9 min read / - views​

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, set AllowedIPs to the subnets you want routed via the VPN (for example 10.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

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 and net.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, add PostUp = ufw route allow in on wg0 out on eth0 and the matching PreDown delete.
  • SaveConfig=true ate my comments: wg-quick down writes the live config back and can strip comments. I keep SaveConfig=false and edit files explicitly.
  • Revoke a device: wg set wg0 peer <client-public-key> remove then delete its stanza from wg0.conf and reload.

Optional extras I like

  • Site‑to‑site: add another [Peer] with its own subnet in AllowedIPs 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.