A remote-access VPN to your home lab is one of the most useful things you can run, and one of the easiest to get wrong. A default install often leaves DNS leaking to the coffee shop’s resolver, exposes management ports to the internet, or quietly turns your server into an open resolver. This guide walks through a WireGuard deployment that avoids those problems. The server runs a default-deny firewall, the tunnel uses public-key authentication only, and clients resolve names through a local Unbound instance that blocks known-malicious and advertising domains. Split DNS for your internal names is included too.

This guide is for home-lab operators and self-hosters comfortable with a Linux shell. Commands target Debian or Ubuntu, and the concepts map to other distributions with small changes. Plan on an afternoon, most of which goes to testing.

Before you start

  • A Linux server (a small mini PC, VM, or Raspberry Pi class device) running Debian 12 or Ubuntu 22.04 or later, with root or sudo access.
  • A public IPv4 address or a dynamic DNS name (YOUR_DOMAIN) pointing at your network, and the ability to forward one UDP port (51820) from your router to the server.
  • A workstation or phone to act as the client, with the WireGuard app or the wireguard-tools package installed.
  • Your administrative source IP address (YOUR_ADMIN_IP), so SSH can be restricted to it.
  • Optional: an internal host at 192.168.10.5 running a service you want to reach by name, such as a NAS or a wiki.

Step 1: Install the packages

Install WireGuard, the nftables firewall, and Unbound. Then keep the system patched automatically so the exposed surface stays current.

sudo apt update
sudo apt install -y wireguard wireguard-tools nftables unbound curl unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades

Step 2: Generate keys with safe permissions

WireGuard authenticates peers by public key only. There are no passwords to brute force. Generate the server key pair, one client key pair, and a pre-shared key for an extra symmetric layer against future cryptographic surprises. Set a restrictive umask first so private keys are never world-readable, even for a moment.

sudo -i
umask 077
cd /etc/wireguard
wg genkey | tee server.key | wg pubkey > server.pub
wg genkey | tee client1.key | wg pubkey > client1.pub
wg genpsk > client1.psk
ls -l /etc/wireguard

The .key and .psk files should show mode -rw-------. If they do not, fix that before continuing.

Step 3: Enable forwarding and configure the server interface

The server must forward packets between the tunnel and the internet. Enable IPv4 forwarding persistently, then define the interface. The server takes 10.8.0.1 inside the tunnel, and the first client takes 10.8.0.2.

echo 'net.ipv4.ip_forward = 1' | sudo tee /etc/sysctl.d/99-wireguard.conf
sudo sysctl --system

Create /etc/wireguard/wg0.conf. Replace the placeholders with the contents of the key files.

[Interface]
Address = 10.8.0.1/24
ListenPort = 51820
PrivateKey = SERVER_PRIVATE_KEY

[Peer]
# client1
PublicKey = CLIENT1_PUBLIC_KEY
PresharedKey = CLIENT1_PRESHARED_KEY
AllowedIPs = 10.8.0.2/32

Note that AllowedIPs on the server side is a routing and source filter. Each peer can only send packets from its own tunnel address, which stops a compromised client from spoofing another.

Step 4: Write a default-deny firewall

Load a firewall that drops everything not explicitly allowed. It accepts WireGuard on UDP 51820, SSH only from your admin address, DNS only from the tunnel, and forwarding only from the tunnel out to the internet. Replace eth0 with your real WAN interface name (check with ip route).

#!/usr/sbin/nft -f
flush ruleset

table inet filter {
  chain input {
    type filter hook input priority 0; policy drop;
    ct state established,related accept
    iif lo accept
    ip protocol icmp accept
    icmpv6 type { echo-request, nd-neighbor-solicit, nd-neighbor-advert, nd-router-advert } accept
    ip saddr YOUR_ADMIN_IP tcp dport 22 accept
    udp dport 51820 accept
    iifname wg0 udp dport 53 accept
    iifname wg0 tcp dport 53 accept
    limit rate 5/minute log prefix "nft-input-drop: "
    drop
  }

  chain forward {
    type filter hook forward priority 0; policy drop;
    ct state established,related accept
    iifname wg0 oifname eth0 accept
  }

  chain output {
    type filter hook output priority 0; policy accept;
  }
}

table ip nat {
  chain postrouting {
    type nat hook postrouting priority 100; policy accept;
    ip saddr 10.8.0.0/24 oifname eth0 masquerade
  }
}

Save this as /etc/nftables.conf, validate it, and enable it at boot.

sudo nft -c -f /etc/nftables.conf
sudo nft -f /etc/nftables.conf
sudo systemctl enable --now nftables

The rule order matters. Established traffic is accepted first so replies work, and the rate-limited log line records only the drops that reach the end of the chain.

Step 5: Bring up WireGuard and harden SSH

Start the tunnel and enable it at boot. Then remove password logins from SSH, so the only way into the box is a key, and only from your admin address via the firewall.

sudo systemctl enable --now wg-quick@wg0
sudo wg show

Edit /etc/ssh/sshd_config (or a drop-in in /etc/ssh/sshd_config.d/) so it contains these lines, then reload the service:

PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
PubkeyAuthentication yes
sudo sshd -t && sudo systemctl reload ssh

Keep your current SSH session open while you test a second login from another terminal. If the new login fails, you can still fix the configuration from the open session.

Step 6: Create the client configuration

On the client, create a tunnel configuration. Setting AllowedIPs to 0.0.0.0/0 sends all IPv4 traffic through the tunnel, and DNS points the client at your resolver inside the tunnel.

[Interface]
PrivateKey = CLIENT1_PRIVATE_KEY
Address = 10.8.0.2/32
DNS = 10.8.0.1

[Peer]
PublicKey = SERVER_PUBLIC_KEY
PresharedKey = CLIENT1_PRESHARED_KEY
Endpoint = YOUR_DOMAIN:51820
AllowedIPs = 0.0.0.0/0
PersistentKeepalive = 25

The tunnel is IPv4 only in this design. The server does not route IPv6, so IPv6 traffic from the client must not bypass the tunnel. On a Linux client for testing, you can disable IPv6 with sudo sysctl -w net.ipv6.conf.all.disable_ipv6=1. For production clients, use the operating system’s own IPv6 setting or a firewall rule that drops IPv6 on the physical interface. Use PersistentKeepalive when the client sits behind NAT, such as on a mobile network.

Step 7: Configure Unbound for the tunnel only

Unbound will be your resolver. It listens only on the tunnel address, answers only tunnel clients, refuses everyone else, and validates DNSSEC. Create /etc/unbound/unbound.conf.d/homelab.conf.

server:
    interface: 10.8.0.1
    port: 53
    do-ip6: no
    access-control: 10.8.0.0/24 allow
    access-control: 0.0.0.0/0 refuse
    hide-identity: yes
    hide-version: yes
    qname-minimisation: yes
    harden-glue: yes
    harden-dnssec-stripped: yes
    use-caps-for-id: yes
    auto-trust-anchor-file: "/var/lib/unbound/root.key"
    include: "/etc/unbound/blocklist.conf"
    include: "/etc/unbound/custom.conf"

# Split DNS: internal names go to the internal resolver
server:
    domain-insecure: "lab.YOUR_DOMAIN"

forward-zone:
    name: "lab.YOUR_DOMAIN"
    forward-addr: 192.168.10.5

Two details matter here. The access-control rules are evaluated by most-specific match, so the tunnel subnet is allowed and everything else refused. Also, lab.YOUR_DOMAIN is internal and not signed in public DNS, so it is marked domain-insecure to stop validation failures, and it is forwarded to the internal authoritative host. Public names still go to the root servers with full DNSSEC validation.

Create empty placeholder files so Unbound starts cleanly, then make Unbound wait for the tunnel to come up, since it must bind to 10.8.0.1.

sudo touch /etc/unbound/blocklist.conf /etc/unbound/custom.conf
sudo mkdir -p /etc/systemd/system/unbound.service.d
printf '[Unit]\[email protected]\[email protected]\n' | sudo tee /etc/systemd/system/unbound.service.d/wait-for-wg.conf
sudo systemctl daemon-reload
sudo unbound-checkconf
sudo systemctl enable --now unbound

Step 8: Generate the blocklist automatically

Use a well-maintained hosts-format blocklist from a source you trust (set YOUR_BLOCKLIST_URL). The script below converts it into Unbound local-zone entries that return NXDOMAIN. It validates the output before replacing the live file, so a bad download never takes DNS down.

#!/bin/sh
set -eu

SRC="YOUR_BLOCKLIST_URL"
OUT=/etc/unbound/blocklist.conf
TMP=$(mktemp)

curl -fsSL "$SRC" -o "$TMP"

awk '
  /^[[:space:]]*(#|$)/ { next }
  $1 == "0.0.0.0" || $1 == "127.0.0.1" {
    d = tolower($2)
    if (d ~ /^[a-z0-9.-]+$/ && d != "localhost")
      print "local-zone: " d " always_nxdomain"
  }
' "$TMP" | sort -u > "$OUT.new"

rm -f "$TMP"
[ -s "$OUT.new" ] || { echo "empty blocklist, aborting"; exit 1; }

mv "$OUT.new" "$OUT"
unbound-checkconf
unbound-control reload

Save it as /usr/local/sbin/update-blocklist.sh, make it executable with sudo chmod 755, run it once, and schedule it with a daily systemd timer or cron entry (for example, 17 4 * * * root /usr/local/sbin/update-blocklist.sh in /etc/cron.d/blocklist).

For a test entry you control, add a line to /etc/unbound/custom.conf:

local-zone: "blocked-test.YOUR_DOMAIN" always_nxdomain

Then run sudo unbound-checkconf && sudo unbound-control reload.

Step 9: Set up basic monitoring

You do not need a full observability stack to know the tunnel is healthy. Check handshakes and traffic counters with WireGuard, query Unbound’s built-in statistics, and review rate-limited drop logs. A quick helper script keeps this routine:

#!/bin/sh
echo '== WireGuard peers =='
sudo wg show wg0 latest-handshakes
sudo wg show wg0 transfer
echo '== Unbound query totals =='
sudo unbound-control stats_noreset | grep -E '^total\.num\.queries(_cachehits)?='
echo '== Firewall drops (last hour) =='
sudo journalctl -k --since '1 hour ago' | grep -c 'nft-input-drop'

A peer with a timestamp of zero has never completed a handshake. A steadily growing drop count from random internet addresses is normal background noise, but a sudden spike from one source warrants a look.

Verify it worked

Test each layer separately. Start from the client with the tunnel active.

  • Tunnel up: sudo wg show on the server shows a recent handshake for the client, and the client shows the server endpoint.
  • Traffic goes through the server: run curl -4 https://ifconfig.co on the client. It should return the server’s public IP, not your local network’s.
  • No DNS leak: run dig +short myip.opendns.com @resolver1.opendns.com. The answer should be your server’s public address. If you see your local ISP’s address, DNS is leaking around the tunnel. Also run dig +short txt whoami.ds.akamai.net and confirm the resolver is the server’s, not your ISP’s.
  • Your resolver answers: dig @10.8.0.1 example.com returns an answer with the ad flag set when DNSSEC validates.
  • Blocking works: dig @10.8.0.1 blocked-test.YOUR_DOMAIN returns status: NXDOMAIN. Then test a domain from your blocklist and confirm it also returns NXDOMAIN.
  • Split DNS works: dig @10.8.0.1 nas.lab.YOUR_DOMAIN returns the internal address from 192.168.10.5.
  • No IPv6 leak: curl -6 --max-time 5 https://ifconfig.co should fail or time out. If it returns your real IPv6 address, the client is bypassing the tunnel.
  • Exposure is minimal: from a network outside your home, run nmap -Pn -p 22,53,51820 YOUR_DOMAIN and a UDP scan with nmap -sU -p 51820 YOUR_DOMAIN. Only 51820 (UDP) should appear open or open|filtered, and SSH should show filtered. Do this from a phone hotspot or a cloud shell, not your home network.

Common mistakes

  • Forgetting IP forwarding. The tunnel handshakes but no internet traffic flows. Confirm sysctl net.ipv4.ip_forward returns 1.
  • Running Unbound on all interfaces. An resolver reachable from the internet becomes an open resolver used for amplification attacks. Keep interface bound to the tunnel address and keep the refuse rule.
  • Locking yourself out. Test SSH from a second session before closing the first. Remember that your admin IP may change if your ISP reassigns addresses, so keep a console or out-of-band path available.
  • Leaking DNS through the host’s own resolver. On desktops running systemd-resolved or NetworkManager, the DNS line in wg-quick may be overridden. Confirm with the leak test, and disable other DNS paths or set the tunnel as the default route for DNS.
  • Browser DNS-over-HTTPS bypassing your resolver. Browsers can use their own encrypted DNS. Turn off secure DNS in the browser settings if you want all lookups to pass through Unbound.
  • Oversized or unmaintained blocklists. Very large lists slow startup and can break legitimate sites. Start with one reputable list, review false positives in the query logs, and add exceptions in custom.conf with a local-zone of type transparent.
  • Stale keys. Rotate client keys when a device is lost or retired by removing its peer block from wg0.conf and running sudo wg syncconf wg0 <(sudo wg-quick strip wg0). Remove the peer, do not just stop using it.
  • Skipping time synchronization. DNSSEC validation depends on accurate clocks. Make sure the server uses a time sync service (timedatectl status should show the clock as synchronized).

Takeaway

A home-lab VPN is only as trustworthy as its weakest default. This build uses public-key authentication, a default-deny firewall, a resolver that answers only tunnel clients, and automated blocklist updates, then proves each claim with a leak test. Keep the system patched, rotate keys when devices change, and rerun the verification steps after any firewall or resolver change. A tunnel you have tested is one you can trust.