Your NAS, your Docker containers, your VMs: none of it needs to touch the internet. Ever. That’s the first reason to run a WireGuard tunnel back into your house. Not privacy, just never opening a port publicly in the first place. WireGuard is the one door in, and once you’re through it, everything behaves like you’re on the LAN, because you are.

The second reason is the one people actually think of when they hear “VPN”: tunneling home while you’re on somebody else’s network, hotel wifi, a coffee shop, your phone’s hotspot. Commercial VPNs solve that by routing you through their own servers, meaning you trust their business model instead of your own network. Point WireGuard at home instead and your traffic exits from your house. Looks like you’re at home, because you basically are.

I use mine for both: pulling files off my home server when I’m not there, and pointing the tunnel home any time I’m on wifi I don’t trust.


WireGuard and PiVPN

WireGuard is the modern VPN protocol. Faster and simpler than OpenVPN, built straight into the Linux kernel, and a working config file is maybe 30 lines.

PiVPN is a script that handles the entire WireGuard install for you: keys, firewall rules, client management, all of it. Despite the name it’s not locked to actual Pi hardware, it works fine on Raspberry Pi OS, Ubuntu, or plain Debian. I’ve done this setup twice now, once on a Debian VM on my home server before I had a spare Pi, once on an actual Raspberry Pi 4 running Raspberry Pi OS more recently. Same script, same steps, no difference.


What You Need

A machine to be the VPN server. It needs to be on 24/7 and sitting on your home network.

  • A Raspberry Pi. I’m running mine on a 4GB Pi 4 right now, which is complete overkill. PiVPN barely touches RAM, a 2GB Pi 4 is plenty. Flash standard Raspberry Pi OS with the official Raspberry Pi Imager, nothing special needed.
  • A VM on a home server or an old laptop. Before I had a Pi to spare, I ran this exact setup on a Debian VM instead. Identical script, identical steps.
  • A cheap VPS, if you’d rather your traffic exit from a datacenter than your house.

Router access. You’ll need to forward UDP port 51820 from your router to your VPN server’s local IP. Every router’s admin panel is laid out differently, look for “Port Forwarding.”

A static local IP for your server. If your server’s local IP changes via DHCP, your port forward breaks. Set a static IP in your router’s DHCP reservations (usually under “DHCP Clients” or “LAN Setup”), binding your server’s MAC address to a fixed IP.

An external IP or hostname. Clients connecting from outside need to know your home’s public IP. If your ISP gives you a static one, you’re set. Mine isn’t static either, it just doesn’t change very often, which is exactly the kind of thing that breaks quietly months later. Dynamic DNS fixes that for good (Step 3).


Step 1: Install PiVPN

SSH into your server and run:

curl -L https://install.pivpn.io | bash

This launches an interactive installer. Walk through each screen:

  1. Static IP warning: confirm you’ve set a static local IP (or set one now via DHCP reservation). If your server’s IP changes later, the VPN stops working.
  2. Choose a user: pick the local user whose home directory stores client configs.
  3. VPN protocol: choose WireGuard.
  4. Port: default is 51820 UDP. Keep it unless you have a reason not to.
  5. DNS provider: what DNS your clients use once connected. Cloudflare (1.1.1.1) or Google (8.8.8.8) are fine defaults. If you run Pi-hole, point to its IP instead for network-wide ad blocking even when you’re remote.
  6. Public IP or DNS: the installer detects your current public IP. If yours changes (see Step 3), enter your dynamic DNS hostname instead.
  7. Unattended upgrades: enable it. One less thing to remember.

Reboot when it asks:

sudo reboot

Step 2: Forward Port 51820 on Your Router

Log into your router’s admin panel (usually 192.168.1.1 or 192.168.0.1) and find Port Forwarding.

Create a rule:

  • Protocol: UDP
  • External port: 51820
  • Internal IP: your VPN server’s static local IP (e.g. 192.168.1.50)
  • Internal port: 51820

Save it. Test from outside your network:

nc -vuz YOUR_PUBLIC_IP 51820

Or use any online UDP port checker, enter your public IP and port 51820. Open means the forwarding worked.


Step 3: Dynamic DNS (Skip If You Have a Static Public IP)

Check your public IP today, check it again in a few days. Different means you’ve got a dynamic IP and need this step. Mine isn’t static either, it just doesn’t change often enough to notice, which is worse in a way: the VPN works fine for months, then your ISP quietly rotates your IP and it just stops, with no obvious reason why.

If you already own a domain and run it through Cloudflare, dynamic DNS is free and takes about ten minutes. I run the oznu/cloudflare-ddns container on my home server. Point it at a Cloudflare API token and a subdomain, and it checks your public IP every 15 minutes or so and updates the DNS record when it’s changed:

docker run -d \
  --name cloudflare-ddns \
  --restart always \
  -e API_KEY=YOUR_CLOUDFLARE_API_TOKEN \
  -e ZONE=yourdomain.com \
  -e SUBDOMAIN=vpn \
  oznu/cloudflare-ddns

Check the image’s Docker Hub page for the current environment variable names before copying this verbatim, the auth options have shuffled a bit over time (API token vs. the older Global API Key plus an EMAIL variable). Once it’s running, vpn.yourdomain.com always resolves to your current home IP, and that hostname is what you hand PiVPN.

No domain on Cloudflare? Free DDNS services do the same job:

  • DuckDNS: free, reliable, easy to automate.
  • No-IP: free tier available, requires a monthly confirmation email.

Whatever hostname you land on, that’s what you give PiVPN when it asks for your public IP or DNS name, and it gets baked into every client config.


Step 4: Add a Client Device

Back on your VPN server, run:

pivpn add

You’ll be prompted for a client name (e.g., phone, laptop-work, tablet). PiVPN generates:

  • A unique key pair for this client
  • A config file at ~/configs/<name>.conf

To view a QR code for mobile devices:

pivpn -qr <client-name>

This prints a QR code in the terminal that you scan directly with the WireGuard app.

To list all clients and their last-seen time:

pivpn -l

To revoke a device (lost phone, old laptop):

pivpn -r <client-name>

Step 5: Connect Your Devices

WireGuard’s apps are good, genuinely good, a lot better than wrestling an old OpenVPN .ovpn file into some clunky client. I run this on Android and Windows day to day and neither has ever given me trouble.

iOS / Android

  1. Install the WireGuard app from the App Store or Play Store.
  2. Tap the + button → Create from QR code.
  3. Scan the QR code from pivpn -qr <client-name>.
  4. Give the tunnel a name, save, and toggle it on.

Windows / macOS

  1. Download the WireGuard client from wireguard.com/install.
  2. On your server, copy the .conf file: ~/configs/<name>.conf
  3. Transfer it securely (SCP, USB, a password manager, not plaintext email or unencrypted cloud storage).
  4. In the WireGuard app: Import tunnel from file → select the .conf file.
  5. Toggle the tunnel on.

Linux

sudo apt install wireguard
sudo cp <name>.conf /etc/wireguard/wg0.conf
sudo wg-quick up wg0

To connect on boot:

sudo systemctl enable wg-quick@wg0

Step 6: Verify the Connection

Once your client is connected, check your IP from a phone browser or laptop:

  • Go to whatismyip.com: it should show your home’s public IP, not your cellular or coffee shop IP.

From the server, see active connections:

sudo wg show

You’ll see each configured peer, their public key, allowed IPs, latest handshake time, and bytes transferred. “Latest handshake: X seconds ago” means it’s actively connected.


Full Tunnel vs. Split Tunnel

By default, PiVPN sets up a full tunnel: all traffic from the client routes through the VPN, not just traffic to your home network. This is the safest option on untrusted networks, and it’s the one I actually use, on purpose. The whole reason I set this up was to stop trusting hotel and airport wifi, so I want every packet going through home, not just the NAS traffic.

Split tunnel routes only your home network’s IP range through the VPN, everything else goes direct. Faster, less bandwidth, but your general internet traffic isn’t protected by the VPN anymore.

To set up split tunnel, edit the client .conf file. Look for:

AllowedIPs = 0.0.0.0/0, ::/0

Replace with just your home network’s subnet:

AllowedIPs = 192.168.1.0/24

Now the client only routes traffic destined for 192.168.1.x through the tunnel (your NAS, Home Assistant, Plex, etc.), while everything else goes direct through your current connection.

Regenerate the QR code or re-import the config after editing:

pivpn -qr <client-name>

Maintenance

Update PiVPN and WireGuard:

pivpn -up

Check server status:

sudo wg show
pivpn -l     # list clients and last-seen

Revoke lost or old devices immediately. A stolen phone with an active VPN config has access to your entire home network. Revoke it with pivpn -r <name> and add a new client on the replacement device.

Back up your server config. The server’s private key and all client configs live in /etc/wireguard/ and ~/configs/. If your server dies without a backup, you’re regenerating everything and updating every client by hand. Copy these somewhere safe.


Troubleshooting

“Latest handshake: never” on the server: the client hasn’t been able to reach the server.

  • Check the port forward is correct (UDP 51820 to the server’s local IP)
  • Check the server’s public IP or DDNS hostname is correct in the client config
  • Check UFW on the server: sudo ufw status, port 51820/udp must be allowed

VPN connects but can’t reach local devices: routing issue.

  • Check the client’s AllowedIPs includes your home subnet
  • Make sure the DNS setting in the client config matches something accessible on your network

WireGuard app shows “Active” but traffic isn’t tunneling: the tunnel is up but something is blocking traffic flow. Check sudo wg show on the server to see if handshakes are occurring.

Dynamic DNS not updating: check the container/cron job is actually running and Cloudflare (or DuckDNS/No-IP) is receiving updates. Most dashboards show the last update time.