Most home labs and small networks eventually need remote access. The common shortcuts, such as forwarding a web admin panel or SSH port to the internet, turn every exposed service into an attack surface. A WireGuard tunnel fixes most of that. It listens on a single UDP port, silently drops packets from peers it doesn’t recognize, and gives you a private path into your network. This guide is for home-lab operators and sysadmins who want that setup done properly: a WireGuard server with key-based peer rotation, firewall rules that allow only the subnets you actually need, and a private recursive DNS resolver that blocks malicious domains and does not hand your lookups to a third party.
Before you start
- A Linux host (Debian or Ubuntu commands are shown) that will act as the VPN server, with root or sudo access.
- A public IPv4 address, or a router that can forward UDP port 51820 to the server. If your ISP uses carrier-grade NAT, a tunnel to a public host is the only reliable option.
- A DNS name for your server (shown as YOUR_DOMAIN) if your public address changes. A static address works too.
- The subnet your internal services live on (shown as 192.168.10.0/24). Replace it everywhere it appears.
- A client device with the WireGuard app installed (available for Windows, macOS, Linux, iOS, and Android).
Step 1: Install WireGuard and nftables
Install the WireGuard tools, the nftables firewall, and Unbound, the resolver we will use later. Then generate the server key pair. Keep the umask restrictive so the private key is never world-readable.
sudo apt update
sudo apt install -y wireguard-tools nftables unbound dns-root-data
umask 077
wg genkey | sudo tee /etc/wireguard/server.key | wg pubkey | sudo tee /etc/wireguard/server.pub >/dev/null
sudo chmod 600 /etc/wireguard/server.key
Enable IP forwarding so the server can route traffic between the tunnel and your LAN. Make it persistent so it survives reboots.
echo 'net.ipv4.ip_forward=1' | sudo tee /etc/sysctl.d/99-wireguard.conf
sudo sysctl --system
Step 2: Configure the WireGuard server
Create /etc/wireguard/wg0.conf. The server takes the first address in the tunnel subnet, 10.8.0.1. Replace SERVER_PRIVATE_KEY with the contents of /etc/wireguard/server.key. Each peer gets a /32 in AllowedIPs, which means the server will only accept traffic from that peer on that exact tunnel address.
[Interface]
Address = 10.8.0.1/24
ListenPort = 51820
PrivateKey = SERVER_PRIVATE_KEY
SaveConfig = false
# Peer: laptop-alice
[Peer]
PublicKey = ALICE_PUBLIC_KEY
AllowedIPs = 10.8.0.2/32
Set the file permissions, then bring the interface up and enable it at boot.
sudo chmod 600 /etc/wireguard/wg0.conf
sudo systemctl enable --now wg-quick@wg0
sudo wg show wg0
Open the WireGuard port on your router or cloud firewall, forwarding UDP 51820 to the server’s LAN address. Do not forward anything else.
Step 3: Restrict access with nftables
WireGuard only handles the tunnel. What peers can reach once connected is decided by the firewall. The policy below drops everything by default, allows the WireGuard port from anywhere, allows SSH and DNS only from the tunnel, and forwards tunnel traffic to the internal subnet only. Peers cannot reach the rest of the internet through this server, and they cannot reach any LAN subnet you did not list.
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
chain input {
type filter hook input priority 0; policy drop;
iif "lo" accept
ct state established,related accept
ct state invalid drop
ip protocol icmp accept
udp dport 51820 accept
iifname "wg0" tcp dport 22 accept
iifname "wg0" ip daddr 10.8.0.1 udp dport 53 accept
iifname "wg0" ip daddr 10.8.0.1 tcp dport 53 accept
}
chain forward {
type filter hook forward priority 0; policy drop;
ct state established,related accept
iifname "wg0" ip daddr 192.168.10.0/24 accept
}
chain output {
type filter hook output priority 0; policy accept;
}
}
Save this as /etc/nftables.conf, load it, and enable the service. The flush ruleset line replaces all existing nftables rules, so check for other nftables or container rules before you apply it.
sudo nft -c -f /etc/nftables.conf
sudo nft -f /etc/nftables.conf
sudo systemctl enable nftables
Before you close your current SSH session, confirm you can still reach the server over SSH from a LAN address or through the tunnel. Because the input policy is drop, SSH from the public internet will stop working on purpose.
Step 4: Add peers with client-generated keys
The safer pattern is for each client to generate its own key pair. The private key never leaves the device, and you only receive the public key. Run this on the client:
umask 077
wg genkey | tee client.key | wg pubkey > client.pub
cat client.pub
On the server, add the public key as a peer using the live interface. Then make the same change in wg0.conf so it survives a reboot.
sudo wg set wg0 peer CLIENT_PUBLIC_KEY allowed-ips 10.8.0.2/32
The client configuration looks like this. The DNS line points at the resolver you will build in step 5. AllowedIPs covers only the tunnel and the internal subnet, so this is split tunneling and normal internet traffic keeps using the local connection.
[Interface]
PrivateKey = CLIENT_PRIVATE_KEY
Address = 10.8.0.2/32
DNS = 10.8.0.1
[Peer]
PublicKey = SERVER_PUBLIC_KEY
Endpoint = YOUR_DOMAIN:51820
AllowedIPs = 10.8.0.0/24, 192.168.10.0/24
PersistentKeepalive = 25
If you want all client traffic to pass through the tunnel, change AllowedIPs to 0.0.0.0/0. That also requires NAT on the server for internet egress, which is a separate configuration. Choose split or full tunneling on purpose rather than by accident.
Step 5: Rotate peer keys
Rotate a peer’s key when a device is lost or repurposed, when someone leaves, or on a regular schedule. Rotation means the client generates a new key pair, and the server swaps the old public key for the new one. The script below does the live swap. Save it as /usr/local/sbin/rotate-peer.sh.
#!/usr/bin/env bash
# Usage: rotate-peer.sh PEER_NAME PEER_TUNNEL_IP OLD_PUBLIC_KEY NEW_PUBLIC_KEY
set -euo pipefail
NAME="${1:?peer name required}"
IP="${2:?tunnel IP required}"
OLD_PUB="${3:?old public key required}"
NEW_PUB="${4:?new public key required}"
wg set wg0 peer "$OLD_PUB" remove
wg set wg0 peer "$NEW_PUB" allowed-ips "${IP}/32"
echo "Peer ${NAME} rotated live. Update its [Peer] block in /etc/wireguard/wg0.conf."
Make it executable and run it with the peer’s old and new public keys:
sudo chmod 700 /usr/local/sbin/rotate-peer.sh
sudo /usr/local/sbin/rotate-peer.sh laptop-alice 10.8.0.2 OLD_PUBLIC_KEY NEW_PUBLIC_KEY
Then update the matching [Peer] block in wg0.conf and reapply the file without dropping existing sessions. The process substitution below strips the wg-quick-only fields before passing the config to wg syncconf.
sudo wg syncconf wg0 <(sudo wg-quick strip wg0)
Finally, have the client load its new private key and drop the old one. Rotating the server key is more disruptive because every client needs the new server public key, so plan that change for a maintenance window.
Step 6: Build a private recursive DNS resolver
Running Unbound on the server gives your clients a resolver that walks the DNS hierarchy itself rather than forwarding every query to a public provider. That removes the single third party that would otherwise see the full stream of your lookups. Recursion does not hide your traffic from the authoritative servers you query or from network observers, so treat it as reducing exposure, not eliminating it. The configuration below binds only to the tunnel address and loopback, refuses everything else so the server is not an open resolver, validates DNSSEC, and hardens against common spoofing and information leaks.
server:
interface: 10.8.0.1
interface: 127.0.0.1
port: 53
do-ip6: no
access-control: 127.0.0.0/8 allow
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
prefetch: yes
root-hints: "/usr/share/dns/root.hints"
auto-trust-anchor-file: "/var/lib/unbound/root.key"
Save this as /etc/unbound/unbound.conf.d/homelab.conf. Because Unbound binds to the tunnel address, it must start after WireGuard. Add a systemd drop-in to enforce that order:
sudo systemctl edit unbound
Paste the following into the editor that opens, then save and exit:
[Unit]
[email protected]
[email protected]
Now add a blocklist. The script below downloads a hosts-format list you trust, converts blocked entries into Unbound local-zone rules that return NXDOMAIN, validates the configuration, and reloads Unbound only if the check passes. Replace YOUR_BLOCKLIST_URL with the list you choose.
#!/usr/bin/env bash
set -euo pipefail
URL="YOUR_BLOCKLIST_URL"
OUT=/etc/unbound/unbound.conf.d/blocklist.conf
TMP="$(mktemp)"
curl -fsSL "$URL" -o "$TMP"
{
echo "server:"
grep -v '^#' "$TMP" | awk '$1=="0.0.0.0" && $2!="0.0.0.0" {print " local-zone: \"" $2 "\" always_nxdomain"}' | sort -u
} > "$OUT"
rm -f "$TMP"
unbound-checkconf
sudo systemctl reload unbound
Run it with sudo and schedule it weekly with a systemd timer or cron job so the list stays current.
Start the resolver and confirm the configuration is valid:
sudo unbound-checkconf
sudo systemctl enable --now unbound
Verify it worked
Run these checks from a connected client and from the server. Each one tests a specific property of the setup.
- Tunnel is up and recently handshaked:
sudo wg show wg0on the server should list each peer with a recentlatest handshaketime and nonzero transfer counters after the client connects. - Internal access works: from the client,
ping -c 3 192.168.10.1(or any host on your internal subnet) should succeed through the tunnel. - Unlisted subnets are blocked: from the client, ping an address on a LAN subnet you did not add to AllowedIPs or the forward rules. It should fail.
- Resolver answers through the tunnel:
dig @10.8.0.1 example.comshould return an answer with theadflag if DNSSEC validation succeeded. - DNSSEC failures are rejected:
dig @10.8.0.1 dnssec-failed.orgshould return SERVFAIL, which shows the resolver is validating signatures. - Blocklist works: query a domain you know is in your list, such as one of the entries from the downloaded file. The answer should be NXDOMAIN.
- Server is not an open resolver: from a machine outside the tunnel, run
dig @YOUR_PUBLIC_IP example.com. It should time out, since UDP 53 is not open on the public interface and Unbound does not listen there. - Firewall matches the policy:
sudo nft list rulesetshould show the input and forward chains with the policies shown above. From the public internet, only UDP 51820 should respond.
On the client, confirm the operating system is actually using the tunnel resolver. On Linux with systemd-resolved, run resolvectl status and look for 10.8.0.1 on the WireGuard interface. On macOS, check scutil --dns. If a lookup on your public connection is still going to an ISP or public resolver, your client has a DNS setting that bypasses the tunnel, and you should fix it before relying on this setup.
Common mistakes
- Forgetting IP forwarding. Clients connect and handshake, but cannot reach the LAN. Check
sysctl net.ipv4.ip_forward, which should return 1. - Exposing Unbound on the public interface. If you bind the resolver to 0.0.0.0 or your public address, you run an open resolver that can be abused for amplification. Bind only to the tunnel and loopback addresses, and keep the refuse rule.
- Locking yourself out. The drop policy on input removes public SSH access. Test tunnel SSH from a second session before you disconnect your current one.
- Using 0.0.0.0/0 by accident. Full-tunnel AllowedIPs without server-side NAT sends internet traffic into a black hole. Decide between split and full tunneling deliberately.
- Losing live changes on reboot.
wg setchanges only the running interface. Always updatewg0.confas well, or usewg syncconfafter editing the file. - Starting Unbound before the tunnel exists. Unbound fails to bind to 10.8.0.1 at boot. The systemd drop-in in step 6 prevents this.
- Leaving secrets in the wrong place. Private keys and configs with keys should be mode 600 or stricter and never committed to version control or shared in chat.
- Trusting a blocklist blindly. A very large or unmaintained list can break legitimate sites. Start with a well-known list, check the logs for false positives, and add exceptions with
local-zonerules set totransparentwhere needed. - Skipping the config check. Always run
unbound-checkconfandnft -c -fbefore reloading. A typo that stops the resolver or firewall at boot is avoidable.
Takeaway
A WireGuard server with a single open UDP port, nftables rules that allow only the tunnel to reach the subnets you specify, client-generated keys with a simple rotation procedure, and a local recursive resolver with DNSSEC validation and a blocklist gives you remote access that exposes very little. Each piece is small and testable, so you can verify each one with the checks above. Revisit the rules when your network changes, rotate keys on a schedule, and keep the blocklist current.
