Your values
Fill these in once. Every command below rewrites itself. They stay in this browser and never leave your machine.
- Phase 0Get an account and provision the VPS
- Phase 1First login, full update, admin user
- Phase 2Lock down SSH, firewall, patching, kernel settings
- Phase 3Tailscale and the exit node
- Phase 4aWireGuard tunnel to your home router (UniFi)
- Phase 4bConnect a Windows PC
- Phase 5Pi-hole and Unbound, tailnet only
- Final checksVerification and lockout recovery
What you are building
The design rule: the only public listeners are the two VPN UDP ports. SSH is reachable only on tailscale0 and wg0; DNS and the Pi-hole web UI are reachable on tailscale0 alone (step 7 of Phase 5 can add wg0). Commands assume Debian 13 or Ubuntu 24.04 LTS; both use apt and systemd, and the few differences are called out inline.
| Item | Value | Exposure |
|---|---|---|
| WireGuard | UDP 51820, tunnel net WG_NET (VPS WG_SRV, router WG_RTR, PC WG_PC) | Public |
| Tailscale direct path | UDP 41641 (optional, improves direct connections) | Public |
| SSH | TCP 22 | Public only until Phase 3 ends, then tailscale0 and wg0 only |
| Tailscale addresses | 100.64.0.0/10, assigned by Tailscale | Private |
| Pi-hole DNS | TCP/UDP 53 | tailscale0 only (optionally wg0), never the public IP |
| Pi-hole web UI | TCP 80 and 443 | tailscale0 only |
| Unbound | 127.0.0.1 port 5335, upstream of Pi-hole | Loopback only |
WAN in the firewall file is your public interface name; the worksheet fills it in for you with ip -o route show default. Keep the AlexHost panel's VNC/console open in another tab for the whole build; it is your way back in if a firewall or SSH change locks you out.
Phase 0: Get an account and provision the VPS
Goal A fresh Debian 13 (or Ubuntu 24.04) KVM VPS in Bulgaria, with its public IPv4 and root access in hand.
Order the server
- Sign up at AlexHost with my link (affiliate: I earn a commission if you sign up, at no extra cost to you), or use any KVM VPS provider. Nothing below depends on the host.
- Order a KVM VPS with at least 2 vCPU, 4 GB RAM and 40 GB disk. The build in the video used the 1 Gbit/s unmetered plan.
- Location: Bulgaria. AlexHost also offers the Netherlands and Sweden, which sit inside the Nine and Fourteen Eyes alliances and defeat the point of the exercise.
- OS image: Debian 13, plain, no control panel, no extras. Ubuntu 24.04 LTS also works with this worksheet.
Note what the panel gives you
From the panel, write down the public IPv4 (put it in the bench above), the root password or key the panel set, and where the VNC/console button is. Keep that console tab open for the whole build; it is your way back in if a firewall or SSH change locks you out.
ssh root@VPS_IP
cat /etc/os-release | head -3ssh root@VPS_IP logs in from your workstation and cat /etc/os-release names the OS you ordered.Phase 1: First login, full update, admin user
Goal A fully patched system with a non-root sudo user you can log into with an SSH key. Nothing here changes how you connect, so it is safe to do over the root session.
Log in and update everything
ssh root@VPS_IP
apt update && apt full-upgrade -y && apt autoremove -yInstall the baseline packages used in later phases
apt install -y sudo curl ca-certificates gnupg chrony nftables \
unattended-upgrades apt-listchanges fail2ban
timedatectl set-timezone UTC
hostnamectl set-hostname HOST
timedatectl status | grep -E 'synchronized|NTP'chrony matters beyond tidiness: TLS, WireGuard handshakes and Tailscale key checks all depend on sane time. On Ubuntu cloud images, cloud-init can revert the hostname at reboot; if it does, set preserve_hostname: true in /etc/cloud/cloud.cfg.
Create the admin user
adduser USER
usermod -aG sudo USERKeep a sudo password on this account even though SSH will be key-only. It is the second factor if your key is ever stolen.
Generate a key on your workstation and install it
Run this on your workstation, not the VPS. On Windows, ssh-copy-id does not exist; use the second block instead.
# macOS / Linux
ssh-keygen -t ed25519 -a 100 -f ~/.ssh/KEY -C "USER@workstation HOST"
ssh-copy-id -i ~/.ssh/KEY.pub USER@VPS_IP# Windows PowerShell
ssh-keygen -t ed25519 -a 100 -f $env:USERPROFILE\.ssh\KEY -C "USER@workstation HOST"
scp $env:USERPROFILE\.ssh\KEY.pub USER@VPS_IP:/tmp/KEY.pub
ssh USER@VPS_IP "mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat /tmp/KEY.pub >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys && rm /tmp/KEY.pub"Reboot if the kernel or libc changed, then verify
systemctl reboot # from the root session; wait ~1 minutessh -i ~/.ssh/KEY USER@VPS_IP 'sudo whoami' # prompts for the sudo password, prints: rootUSER with the key (no root password involved) and sudo whoami prints root. Leave one such session open while you work on SSH in the next phase.Phase 2: Lock down SSH, firewall, patching, kernel settings
Goal Key-only SSH, a default-drop firewall that already contains the rules the VPN phases need, automatic security updates, and sane sysctls. Log in as your admin user and run sudo -i for this phase.
SSH: keys only, no root
The file is named 00- on purpose. sshd uses the first value it sees, and Ubuntu cloud images ship 50-cloud-init.conf with PasswordAuthentication yes, which would silently win if yours sorted later.
cat > /etc/ssh/sshd_config.d/00-hardening.conf <<'EOF'
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey
AllowUsers USER
MaxAuthTries 3
LoginGraceTime 20
X11Forwarding no
AllowAgentForwarding no
ClientAliveInterval 300
ClientAliveCountMax 2
EOF
sshd -t && systemctl reload ssh
sshd -T | grep -Ei '^(permitrootlogin|passwordauthentication|allowusers|authenticationmethods)'The last command must show no, no, USER, publickey. Then open a second new terminal and confirm key login still works before touching anything else. Leave AllowTcpForwarding at its default so ssh -L keeps working.
Firewall: nftables, default drop
This ruleset is complete for Phases 3 and 4; Phase 5 adds one small block for DNS and the Pi-hole web UI. It lives in its own table (inet host_fw) and never runs flush ruleset; that matters because Tailscale and fail2ban add their own tables and a global flush on reload would erase them. iifname matches by name, so rules for tailscale0 and wg0 are valid before those interfaces exist.
cat > /etc/nftables.conf <<'EOF'
#!/usr/sbin/nft -f
define WAN = "ens3" # -- your public interface (fixed by the sed below)
define WG_NET = WG_NET
table inet host_fw
delete table inet host_fw
table inet host_fw {
chain input {
type filter hook input priority filter; policy drop;
ct state invalid drop
ct state established,related accept
iifname "lo" accept
ip protocol icmp icmp type { echo-request, destination-unreachable, time-exceeded } limit rate 20/second accept
ip6 nexthdr icmpv6 icmpv6 type { echo-request, destination-unreachable, packet-too-big, time-exceeded, parameter-problem, nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } accept
udp sport 67 udp dport 68 accept
# Public: the two VPN transports only
udp dport 51820 accept comment "WireGuard"
udp dport 41641 accept comment "Tailscale direct"
# TEMPORARY public SSH: delete this line at the end of Phase 3
tcp dport 22 accept comment "temp public ssh"
# Private-only services
iifname { "tailscale0", "wg0" } tcp dport 22 accept
}
chain forward {
type filter hook forward priority filter; policy drop;
ct state invalid drop
ct state established,related accept
iifname "tailscale0" oifname $WAN accept comment "exit node egress"
iifname "wg0" oifname $WAN accept comment "home tunnel egress"
}
}
table ip wg_nat
delete table ip wg_nat
table ip wg_nat {
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
ip saddr $WG_NET oifname $WAN masquerade
}
}
EOF
sed -i "s/^define WAN .*/define WAN = \"$(ip -o route show default | awk '{print $5; exit}')\"/" /etc/nftables.conf
grep '^define WAN' /etc/nftables.conf
nft -c -f /etc/nftables.conf && echo SYNTAX-OKApply it with a dead-man switch so a mistake undoes itself in five minutes:
systemd-run --on-active=5min --unit=fw-rollback nft delete table inet host_fw
nft -f /etc/nftables.conf
# open a NEW ssh session now. If it connects:
systemctl stop fw-rollback.timer
systemctl enable --now nftables
nft list ruleset | head -40Tailscale accepts forwarded packets in its own table, but a drop in any table wins, which is why the two forward accept rules above are required for the exit node and for the home tunnel to reach the internet. IPv6 is covered by the inet family for the host; the WireGuard NAT table is IPv4-only on purpose (see Phases 4a and 4b).
fail2ban for SSH
Once SSH is tailnet-only this rarely fires, but until then the host is on the public internet. The journal backend works on Debian 13 and Ubuntu, which no longer write /var/log/auth.log by default.
cat > /etc/fail2ban/jail.local <<'EOF'
[DEFAULT]
backend = systemd
banaction = nftables
banaction_allports = nftables[type=allports]
bantime = 1h
findtime = 10m
maxretry = 4
ignoreip = 127.0.0.1/8 ::1 100.64.0.0/10 WG_NET
[sshd]
enabled = true
EOF
systemctl enable --now fail2ban && fail2ban-client status sshdUnattended security updates
cat > /etc/apt/apt.conf.d/20auto-upgrades <<'EOF'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
EOF
cat > /etc/apt/apt.conf.d/52unattended-local <<'EOF'
Unattended-Upgrade::Remove-Unused-Dependencies "true";
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "04:30";
EOF
unattended-upgrade --dry-run -d 2>&1 | tail -5The 04:30 UTC reboot is 06:30 in Bulgaria in winter. Your VPN and exit node drop for about a minute when a kernel update lands. If that bothers you, set Automatic-Reboot "false" and watch for /var/run/reboot-required instead.
Kernel and network sysctls
This file also turns on IP forwarding, so Phase 3 skips Tailscale's separate sysctl step. Two non-obvious lines: rp_filter = 2 (loose) because strict reverse-path filtering drops legitimate asymmetric packets on a VPN gateway, and accept_ra = 2 because enabling IPv6 forwarding otherwise makes Linux ignore router advertisements and you lose your IPv6 default route. BBR helps long, high-latency paths like US to Bulgaria.
cat > /etc/sysctl.d/99-hardening.conf <<'EOF'
net.ipv4.ip_forward = 1
net.ipv6.conf.all.forwarding = 1
net.ipv6.conf.all.accept_ra = 2
net.ipv6.conf.default.accept_ra = 2
net.ipv4.conf.all.rp_filter = 2
net.ipv4.conf.default.rp_filter = 2
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv6.conf.all.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.all.accept_source_route = 0
net.ipv4.tcp_syncookies = 1
net.ipv4.icmp_echo_ignore_broadcasts = 1
kernel.dmesg_restrict = 1
kernel.kptr_restrict = 2
net.core.default_qdisc = fq
net.ipv4.tcp_congestion_control = bbr
EOF
sysctl --system | grep -E 'ip_forward|rp_filter|congestion'A small swap file (optional, recommended on 4 GB)
fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
echo 'vm.swappiness = 10' > /etc/sysctl.d/99-swap.conf && sysctl -p /etc/sysctl.d/99-swap.conf
free -hnft list ruleset shows host_fw; sysctl net.ipv4.ip_forward prints 1; sshd -T shows password auth off.Phase 3: Tailscale and the exit node
Goal The VPS joins your tailnet, advertises itself as an exit node, and becomes your SSH path so public port 22 can be closed. IP forwarding is already on from Phase 2, so Tailscale's own sysctl step is skipped.
Install and authenticate
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale set --advertise-exit-node --hostname=HOST
sudo tailscale upup prints a login URL; open it on any device and approve. The hostname becomes the MagicDNS name (HOST).
Approve the exit node and stop the key from expiring
In the Tailscale admin console, open Machines, find HOST (filter property:exit-node), open its menu, choose Edit route settings, and enable Use as exit node. In the same menu choose Disable key expiry, or the server will fall off your tailnet when its key lapses.
Optional, if you would rather automate this: tag the node and let the policy file auto-approve it. Tagged devices do not have key expiry, so this does both jobs.
"tagOwners": { "tag:vps": ["autogroup:admin"] },
"autoApprovers": { "exitNode": ["tag:vps"] }Then run sudo tailscale up --advertise-tags=tag:vps. If your tailnet uses a restrictive policy rather than the default allow-all, clients also need access to autogroup:internet to use any exit node.
Optional: UDP GRO forwarding tweak for throughput
Tailscale recommends this for exit nodes and subnet routers. A KVM virtio NIC may report the feature as unsupported; that is harmless. The change does not survive reboot, so make it a oneshot service.
sudo apt install -y ethtool
sudo tee /usr/local/sbin/ts-gro.sh >/dev/null <<'EOF'
#!/bin/sh
NETDEV=$(ip -o route get 8.8.8.8 | cut -f 5 -d " ")
ethtool -K "$NETDEV" rx-udp-gro-forwarding on rx-gro-list off || true
EOF
sudo chmod 755 /usr/local/sbin/ts-gro.sh
sudo tee /etc/systemd/system/ts-gro.service >/dev/null <<'EOF'
[Unit]
Description=Tailscale UDP GRO forwarding
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/ts-gro.sh
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload && sudo systemctl enable --now ts-gro.serviceUse it from a client and prove it
# Linux client
tailscale set --exit-node=HOST --exit-node-allow-lan-access=true
curl -s https://ifconfig.me ; echo # should print the VPS public IP
tailscale set --exit-node= # turn it off againOn phones and desktops pick the exit node from the Tailscale app's Exit Node menu. Test from a device that is not on your home network, ideally on cellular, and confirm the public IP is the VPS's and that a speed test lands near your uplink.
While you are on the VPS, run tailscale ip -4 and put the 100.x address in the bench above; later steps use it.
Move SSH onto the tailnet, then close public port 22
First prove the private path works from a tailnet device:
ssh -i ~/.ssh/KEY USER@HOST # MagicDNS name; or USER@TS_IPOnly after that succeeds, remove the temporary rule and reload under a fresh rollback timer:
sudo sed -i '/temp public ssh/d' /etc/nftables.conf
sudo nft -c -f /etc/nftables.conf && echo SYNTAX-OK
sudo systemd-run --on-active=5min --unit=fw-rollback nft delete table inet host_fw
sudo nft -f /etc/nftables.conf
# from a tailnet device, open a NEW ssh session to HOST. If it works:
sudo systemctl stop fw-rollback.timer
# from a non-tailnet network, this should now hang and time out:
ssh -o ConnectTimeout=5 USER@VPS_IPIf you prefer, sudo tailscale set --ssh turns on Tailscale SSH, which authenticates through your tailnet identity instead of key files. It is a reasonable swap later; the OpenSSH setup above keeps working either way.
VPS_IP:22 times out; tailscale status shows the VPS online with key expiry disabled.Phase 4a: WireGuard tunnel to your home router (UniFi)
Goal A point-to-point tunnel between the VPS (WG_SRV) and your home router (WG_RTR). You generate every key on the VPS, build one config file, and import it into UniFi. The firewall and NAT for wg0 already exist from Phase 2, so the server config only describes the tunnel.
Install WireGuard, then open a root shell as its own step
sudo apt install -y wireguard wireguard-toolsAnything pasted on the same lines after sudo -i is not run by the new shell, so paste this on its own:
sudo -iGenerate the keys (in the root shell)
The router's key pair is made here because UniFi imports a finished file; the private half is shredded at the end. Note the two public keys: server.pub belongs in the router's config, and router.pub belongs in the server's peer entry. Swapping them is the classic mistake.
cd /etc/wireguard && umask 077
wg genkey | tee server.key | wg pubkey > server.pub
wg genkey | tee router.key | wg pubkey > router.pub
wg genpsk > router.psk
ls -lTreat every key as a secret. Copy from the terminal straight into the target app, and never paste keys into chat, forums, tickets or screenshots. If one gets out, generate a new one.
Write the server config and start it
HOME_LAN comes from the bench (use "" if the VPS never needs to reach your LAN). It must not overlap any network on your tailnet.
HOME_LAN="HOME_LAN"
cat > /etc/wireguard/wg0.conf <<EOF
[Interface]
Address = WG_SRV/24
ListenPort = 51820
PrivateKey = $(cat /etc/wireguard/server.key)
[Peer]
# home router
PublicKey = $(cat /etc/wireguard/router.pub)
PresharedKey = $(cat /etc/wireguard/router.psk)
AllowedIPs = WG_RTR/32${HOME_LAN:+, $HOME_LAN}
EOF
chmod 600 /etc/wireguard/wg0.conf
systemctl enable --now wg-quick@wg0
wg showwg show should list interface wg0 on port 51820 with one peer. That peer's key must differ from the interface's public key. No handshake yet is normal.
Build the router's config file
UniFi refuses a file with no DNS line ("Invalid DNS in [Interface]") and requires an IP address there. Pi-hole is tailnet-only at this point, so use a public resolver; Phase 5 step 7 shows how to switch the tunnel to Pi-hole later. The file holds secrets and is not printed.
cat > /home/USER/unifi.conf <<EOF
[Interface]
PrivateKey = $(cat /etc/wireguard/router.key)
Address = WG_RTR/32
DNS = 9.9.9.9
[Peer]
PublicKey = $(cat /etc/wireguard/server.pub)
PresharedKey = $(cat /etc/wireguard/router.psk)
Endpoint = VPS_IP:51820
AllowedIPs = WG_NET
PersistentKeepalive = 25
EOF
chown USER:USER /home/USER/unifi.confCopy the file to your PC
Root SSH login is disabled, so pull it as your admin user:
scp -i $env:USERPROFILE\.ssh\KEY USER@HOST:unifi.conf .scp -i ~/.ssh/KEY USER@HOST:unifi.conf .Import it into UniFi
Menu names differ a little between Network app versions.
- Open Settings, then VPN (some versions call it Teleport & VPN), find VPN Client, and choose Create New.
- Type: WireGuard. Name it (for example
Bulgaria VPN). Setup: File, and uploadunifi.conf. - Leave Device Wizard and Content Wizard set to Off for now. They create routing policies that you only need once the tunnel is up.
- Save and apply.
Decide what uses the tunnel
The file above is a split tunnel: only traffic for WG_NET (the VPS) goes through it. To send the whole house, or chosen devices, out through Bulgaria, change AllowedIPs to 0.0.0.0/0 in the file and re-import it, then pick the network or devices in UniFi's Device Wizard. Disable IPv6 on that network, because the server's NAT is IPv4-only and IPv6 would otherwise bypass the tunnel. If large transfers stall while pings work, set a custom MTU of 1380 under Advanced.
Verify, then clean up the secrets
wg show # router peer: latest handshake within ~2 minutes, transfer counters rising
ping -c3 WG_RTROnce the tunnel is up, remove every copy of the private material. The server keeps what it needs inside wg0.conf.
shred -u /home/USER/unifi.conf /etc/wireguard/router.key /etc/wireguard/router.pskRemove-Item .\unifi.confwg show reports a recent handshake for the router peer, ping WG_RTR from the VPS answers, and the Phase 3 exit node still works.Phase 4b: Connect a Windows PC
Goal Your Windows PC joins the same WireGuard network as WG_PC, so it can reach the VPS at WG_SRV (for example over SSH) and, if you choose, send all its internet traffic out through Bulgaria. Phase 4a must be done first. Keep the preshared key out of chat and screenshots.
Install WireGuard and create the tunnel on the PC
Install WireGuard for Windows from wireguard.com/install. In the app, click the arrow next to Add Tunnel, choose Add empty tunnel, and name it HOST. The app generates a key pair and shows the Public key at the top of the editor. Copy that public key; the private key stays in the app.
Register the PC on the VPS
Open a root shell first (sudo -i, on its own), then paste this block. Replace WINDOWS_PUBKEY with the key you copied.
umask 077
PSK=$(wg genpsk)
cat >> /etc/wireguard/wg0.conf <<EOF
[Peer]
# windows pc
PublicKey = WINDOWS_PUBKEY
PresharedKey = $PSK
AllowedIPs = WG_PC/32
EOF
wg syncconf wg0 <(wg-quick strip wg0)
wg show
echo "--- server public key:"; cat /etc/wireguard/server.pub
echo "--- preshared key for this PC:"; echo "$PSK"wg show should now list two peers. syncconf loads the new peer without interrupting the router tunnel. Copy the last two values straight into the PC's config in the next step, then run clear.
Finish the config on the PC (split tunnel)
In the app, click Edit on HOST. Keep the PrivateKey line the app generated, and make the rest read as below.
[Interface]
PrivateKey = (leave the generated line as is)
Address = WG_PC/32
[Peer]
PublicKey = <server public key from step 2>
PresharedKey = <preshared key from step 2>
Endpoint = VPS_IP:51820
AllowedIPs = WG_NET
PersistentKeepalive = 25Click Save, then Activate.
Test
ping WG_SRV
ssh USER@WG_SRVIn the app, Latest handshake should read a few seconds, and the Transfer counters should rise as you use the tunnel. On the VPS, sudo wg show lists the PC with an endpoint and a recent handshake. A handshake with no way to browse the internet is expected here: with AllowedIPs = WG_NET only traffic for the VPN network itself uses the tunnel, and everything else uses your normal connection.
Optional: send all internet traffic through the VPS
In the PC's config, add a DNS line to [Interface] and widen AllowedIPs. Pi-hole answers only over Tailscale (unless you do Phase 5 step 7), so use a public resolver.
[Interface]
DNS = 9.9.9.9
[Peer]
AllowedIPs = 0.0.0.0/0, ::/0Save, deactivate, and activate again. Then check:
curl.exe -s https://ifconfig.me ; ""The answer should be the VPS's public IP. The ::/0 entry stops IPv6 leaking around the tunnel because the server NATs IPv4 only. The Windows app also blocks other traffic if the tunnel drops while a full tunnel is on. If you also run Tailscale on this PC, use either the full tunnel or the Tailscale exit node, not both at once.
If a full tunnel connects but nothing loads, check forwarding and NAT on the VPS:
sysctl net.ipv4.ip_forward # 1
sudo nft list table ip wg_nat # a masquerade rule for WG_NET
sudo nft list chain inet host_fw forward # an accept rule for iifname "wg0" to the WANTroubleshooting
- Tunnel shows Active but no handshake: the preshared key or the server public key does not match exactly, or
Endpointis wrong. - Handshake works but
ping WG_SRVfails: check the PC'sAddressisWG_PC/32and the VPS peer'sAllowedIPsisWG_PC/32. - You re-created the tunnel on the PC: its public key changed. Replace the key on the VPS with
sed -i 's|^PublicKey = OLDKEY$|PublicKey = NEWKEY|' /etc/wireguard/wg0.confand runwg syncconf wg0 <(wg-quick strip wg0). - Adding another device: repeat steps 1 to 3 with the next tunnel address (
.4/32,.5/32, and so on) and its own public key and preshared key.
ping WG_SRV answers, and sudo wg show on the VPS lists both the router and the PC with handshakes.Phase 5: Pi-hole and Unbound, tailnet only
Goal Unbound does recursive DNS on loopback (no upstream provider sees your queries), Pi-hole filters on top of it, and the only network that can talk to either is your tailnet. The public IP answers nothing on port 53, 80 or 443. Three independent layers enforce that: the firewall (the hard gate), Pi-hole answering only on tailscale0, and Unbound bound to 127.0.0.1.
Open DNS and the web UI on tailscale0 only
The firewall step comes first on purpose. An open resolver on an unmetered 1 Gbit port gets found and abused for DNS amplification within hours, so the port must be closed before any DNS software is installed.
These rules attach to the private-services line already in /etc/nftables.conf. wg0 is deliberately left out; step 7 adds it if you want your home tunnel to use this resolver too.
cat > /tmp/dns-rules <<'EOF'
iifname "tailscale0" meta l4proto { tcp, udp } th dport 53 accept comment "Pi-hole DNS (tailnet only)"
iifname "tailscale0" tcp dport { 80, 443 } accept comment "Pi-hole web UI (tailnet only)"
EOF
sudo sed -i '/iifname { "tailscale0", "wg0" } tcp dport/r /tmp/dns-rules' /etc/nftables.conf
sudo nft -c -f /etc/nftables.conf && echo SYNTAX-OK
sudo systemd-run --on-active=5min --unit=fw-rollback nft delete table inet host_fw
sudo nft -f /etc/nftables.conf
# open a NEW ssh session over the tailnet. If it works:
sudo systemctl stop fw-rollback.timer
sudo nft list chain inet host_fw input | grep -E 'dport (53|\{ 80)'Install and test Unbound first
Installing the upstream first means Pi-hole has something to talk to the moment it starts. The package pulls in the root hints and keeps them updated. Set do-ip6: no if the VPS has no working IPv6.
sudo apt install -y unbound dnsutils
sudo tee /etc/unbound/unbound.conf.d/pi-hole.conf >/dev/null <<'EOF'
server:
verbosity: 0
interface: 127.0.0.1
port: 5335
do-ip4: yes
do-udp: yes
do-tcp: yes
do-ip6: yes
prefer-ip6: no
harden-glue: yes
harden-dnssec-stripped: yes
use-caps-for-id: no
edns-buffer-size: 1232
prefetch: yes
num-threads: 1
so-rcvbuf: 1m
private-address: 192.168.0.0/16
private-address: 169.254.0.0/16
private-address: 172.16.0.0/12
private-address: 10.0.0.0/8
private-address: fd00::/8
private-address: fe80::/10
EOF
# Debian: stop unbound-resolvconf from rewriting /etc/resolv.conf with a port-less 127.0.0.1
sudo systemctl disable --now unbound-resolvconf.service 2>/dev/null || true
sudo rm -f /etc/unbound/unbound.conf.d/resolvconf_resolvers.conf
sudo systemctl restart unbound
dig pi-hole.net @127.0.0.1 -p 5335 # NOERROR with an answer
dig +ad dnssec.works @127.0.0.1 -p 5335 # NOERROR and the 'ad' flag
dig fail01.dnssec.works @127.0.0.1 -p 5335 # SERVFAIL, proving DNSSEC validationThis is the Pi-hole project's recommended Unbound configuration, condensed. If Unbound logs that so-rcvbuf was not granted, set net.core.rmem_max = 1048576 in /etc/sysctl.d/99-unbound.conf, apply it, and restart Unbound.
Install Pi-hole
The installer is interactive. Download it and skim it first rather than piping to bash. When it asks which interface to use, choose tailscale0; for the upstream DNS provider pick anything, since step 4 replaces it.
curl -fsSLo /tmp/basic-install.sh https://install.pi-hole.net
less /tmp/basic-install.sh
sudo bash /tmp/basic-install.sh
sudo pihole setpassword # web UI admin passwordOn Ubuntu, if FTL fails with "address already in use" on port 53, the cause is systemd-resolved's stub listener. Disable only the stub:
sudo mkdir -p /etc/systemd/resolved.conf.d
printf '[Resolve]\nDNSStubListener=no\n' | sudo tee /etc/systemd/resolved.conf.d/10-no-stub.conf
sudo systemctl restart systemd-resolved && sudo systemctl restart pihole-FTLPoint Pi-hole at Unbound and restrict where it answers
Set the interface from the shell first; the installer often picks eth0 and the web UI's radio buttons are labelled with whichever interface is current. These are the Pi-hole v6 config keys (confirm with pihole-FTL --config dns):
sudo pihole-FTL --config dns.upstreams '["127.0.0.1#5335"]'
sudo pihole-FTL --config dns.interface tailscale0
sudo pihole-FTL --config dns.listeningMode SINGLE
sudo systemctl restart pihole-FTLThen open http://HOST/admin from a tailnet device (it will not load from anywhere else) and confirm under Settings, DNS: the only upstream is 127.0.0.1#5335, and Interface settings reads "Respond only on interface tailscale0". Never choose the eth0 or "Permit all origins" options here.
Pi-hole's default "allow only local requests" mode will not work on this box. It accepts only subnets directly attached to the server, and a Tailscale interface is a single /32 address, so tailnet peers would be refused. The docs group the interface-based modes under "potentially dangerous options" because a mistake can make an open resolver; that is exactly why the firewall in step 1 is the real gate and this setting is the second layer.
Make your tailnet use it
In the Tailscale admin console, open the DNS page, add a custom global nameserver with the VPS's 100.x address (TS_IP), and then:
- Turn on "Use with exit node" for that nameserver. Without it, a device using an exit node resolves through the exit node itself and bypasses Pi-hole.
- Turn on "Override DNS servers" so devices ignore their local resolvers. First confirm your ACLs allow all devices to reach the VPS on port 53, or DNS will fail everywhere.
- Leave MagicDNS on so
HOSTand other tailnet names keep resolving.
The cost of a single resolver is that if this VPS is down, every tailnet device loses DNS until you turn the override off. Adding a second public nameserver would fix that but lets those queries bypass Pi-hole and Unbound, so it is a deliberate trade.
Verify, including from outside
# from a tailnet device
dig example.com @TS_IP # normal answer
dig doubleclick.net @TS_IP # blocked: 0.0.0.0 or NXDOMAIN
# from a network that is NOT on your tailnet: all three must time out
dig +time=3 +tries=1 example.com @VPS_IP
curl -m 5 http://VPS_IP/admin
curl -m 5 -k https://VPS_IP/adminIn the Pi-hole query log you should see forwards going to 127.0.0.1#5335. Unattended-upgrades updates Unbound with the OS but does not touch Pi-hole itself, so run sudo pihole -up monthly.
Optional: let the home tunnel use Pi-hole too
As built, Pi-hole answers only on tailscale0, so devices at home that reach the VPS over the WireGuard router tunnel cannot use it. If you want it as a second resolver for the house (for example as the sync partner of a home Pi-hole), open port 53 on wg0 as well and let Pi-hole listen on both. Pi-hole v6 has no two-interface mode, so this switches it to listen on all interfaces and leaves the firewall as the only gate; that is why the outside check at the end is not optional.
sudo sed -i 's/iifname "tailscale0" meta l4proto { tcp, udp } th dport 53/iifname { "tailscale0", "wg0" } meta l4proto { tcp, udp } th dport 53/' /etc/nftables.conf
sudo nft -c -f /etc/nftables.conf && echo SYNTAX-OK
sudo systemd-run --on-active=5min --unit=fw-rollback nft delete table inet host_fw
sudo nft -f /etc/nftables.conf
# open a NEW ssh session over the tailnet. If it works:
sudo systemctl stop fw-rollback.timer
sudo pihole-FTL --config dns.listeningMode ALL
sudo systemctl restart pihole-FTL
# from a device at home, with the router tunnel up: a filtered answer
dig doubleclick.net @WG_SRV
# from a network that is NOT your tailnet or your LAN: must time out
dig +time=3 +tries=1 example.com @VPS_IPThen point the house at it: set WG_SRV as the DNS server in the LAN network's DHCP settings in UniFi (the router already routes WG_NET through the tunnel). If you re-import the VPN client file, DNS = WG_SRV can replace the public resolver.
dig +ad against Unbound shows the ad flag; a tailnet device gets filtered answers from TS_IP; every request to VPS_IP on 53, 80 and 443 from outside times out.Final verification and lockout recovery
Run these once everything is up, and again after the first reboot, since a reboot is what exposes ordering mistakes (a service that starts before its interface, a firewall that does not persist).
| Check | Run | Expect |
|---|---|---|
| Fully patched | apt list --upgradable 2>/dev/null | wc -l | 1 (header only) |
| SSH hardened | sudo sshd -T | grep -Ei '^(permitrootlogin|passwordauthentication)' | both no |
| Public listeners | from a non-tailnet network: nmap -Pn -p 22,53,80,443 VPS_IP | all four ports filtered (only the two VPN UDP ports are open to the world) |
| Firewall persists | sudo systemctl is-enabled nftables, then reboot and sudo nft list table inet host_fw | enabled, table present after boot |
| Forwarding on | sysctl net.ipv4.ip_forward net.ipv4.conf.all.rp_filter | 1 and 2 |
| Auto-updates | systemctl list-timers apt-daily-upgrade.timer | a next-run time |
| fail2ban | sudo fail2ban-client status sshd | jail active |
| Tailscale | tailscale status | HOST online, exit node approved |
| Exit node egress | client with exit node on: curl -s https://ifconfig.me | the VPS public IP |
| WireGuard | sudo wg show | handshake under ~2 minutes |
| Services after reboot | systemctl is-active tailscaled wg-quick@wg0 nftables fail2ban unbound pihole-FTL | six times active |
| Unbound and DNSSEC | dig +ad dnssec.works @127.0.0.1 -p 5335 | NOERROR with the ad flag |
| Pi-hole filtering | from a tailnet device: dig doubleclick.net @TS_IP | 0.0.0.0 or NXDOMAIN |
| DNS and web UI not public | from outside: dig +time=3 +tries=1 example.com @VPS_IP and curl -m 5 http://VPS_IP/admin | both time out |
If you lock yourself out
- Firewall mistake with the rollback timer armed: wait five minutes; the
fw-rollbacktimer deletes thehost_fwtable and public SSH works again. - Firewall or SSH mistake with no timer: use the AlexHost panel's VNC/console, log in as
USERor root with the panel-provided access, thennft delete table inet host_fwto open everything, or fix/etc/ssh/sshd_config.d/00-hardening.confandsystemctl reload ssh. - Tailscale offline after a reboot: from the console,
sudo systemctl status tailscaledandtailscale status; a key that expired means you skipped Disable key expiry in Phase 3, fixed withsudo tailscale up --force-reauth. - Total failure: the panel's reinstall option gives you a clean OS in minutes, and everything on this page is scripted enough to replay. Save the contents of
/etc/nftables.confand/etc/wireguard/wg0.conf(secrets redacted) in your password manager's notes first.
Routine upkeep: a monthly glance at df -h, sudo wg show and tailscale status, plus sudo pihole -up, covers almost everything that goes wrong on a box like this.