Build your own privacy VPS (worksheet)

Build Your Own Privacy VPS: Worksheet | Dad the Engineer

Your values

Fill these in once. Every command below rewrites itself. They stay in this browser and never leave your machine.

orange: still a placeholdergreen: your value
0 steps done
  • 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.

ItemValueExposure
WireGuardUDP 51820, tunnel net WG_NET (VPS WG_SRV, router WG_RTR, PC WG_PC)Public
Tailscale direct pathUDP 41641 (optional, improves direct connections)Public
SSHTCP 22Public only until Phase 3 ends, then tailscale0 and wg0 only
Tailscale addresses100.64.0.0/10, assigned by TailscalePrivate
Pi-hole DNSTCP/UDP 53tailscale0 only (optionally wg0), never the public IP
Pi-hole web UITCP 80 and 443tailscale0 only
Unbound127.0.0.1 port 5335, upstream of Pi-holeLoopback 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.

bash
ssh root@VPS_IP
cat /etc/os-release | head -3
Gate to the next phase ssh 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

bash
ssh root@VPS_IP
apt update && apt full-upgrade -y && apt autoremove -y

Install the baseline packages used in later phases

bash
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

bash
adduser USER
usermod -aG sudo USER

Keep 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.

bash
# 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
powershell
# 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

bash
systemctl reboot          # from the root session; wait ~1 minute
bash
ssh -i ~/.ssh/KEY USER@VPS_IP 'sudo whoami'   # prompts for the sudo password, prints: root
Gate to the next phase A brand-new terminal logs in as USER 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.

bash
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.

bash
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-OK

Apply it with a dead-man switch so a mistake undoes itself in five minutes:

bash
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 -40

Tailscale 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.

bash
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 sshd

Unattended security updates

bash
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 -5

The 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.

bash
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)

bash
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 -h
Gate to the next phase A new SSH session works after the firewall is live and the rollback timer is cancelled; nft 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

bash
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale set --advertise-exit-node --hostname=HOST
sudo tailscale up

up 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.

json
"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.

bash
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.service

Use it from a client and prove it

bash
# 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 again

On 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:

bash
ssh -i ~/.ssh/KEY USER@HOST          # MagicDNS name; or USER@TS_IP

Only after that succeeds, remove the temporary rule and reload under a fresh rollback timer:

bash
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_IP

If 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.

Gate to the next phase A client using the exit node shows the VPS's IP; SSH works over the tailnet name; public 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

bash
sudo apt install -y wireguard wireguard-tools

Anything pasted on the same lines after sudo -i is not run by the new shell, so paste this on its own:

bash
sudo -i

Generate 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.

bash
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 -l

Treat 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.

bash
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 show

wg 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.

bash
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.conf

Copy the file to your PC

Root SSH login is disabled, so pull it as your admin user:

powershell
scp -i $env:USERPROFILE\.ssh\KEY USER@HOST:unifi.conf .
bash
scp -i ~/.ssh/KEY USER@HOST:unifi.conf .

Import it into UniFi

Menu names differ a little between Network app versions.

  1. Open Settings, then VPN (some versions call it Teleport & VPN), find VPN Client, and choose Create New.
  2. Type: WireGuard. Name it (for example Bulgaria VPN). Setup: File, and upload unifi.conf.
  3. Leave Device Wizard and Content Wizard set to Off for now. They create routing policies that you only need once the tunnel is up.
  4. 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

bash
wg show                  # router peer: latest handshake within ~2 minutes, transfer counters rising
ping -c3 WG_RTR

Once the tunnel is up, remove every copy of the private material. The server keeps what it needs inside wg0.conf.

bash
shred -u /home/USER/unifi.conf /etc/wireguard/router.key /etc/wireguard/router.psk
powershell
Remove-Item .\unifi.conf
Gate to the next phase wg 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.

bash
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.

ini
[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 = 25

Click Save, then Activate.

Test

powershell
ping WG_SRV
ssh USER@WG_SRV

In 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.

ini
[Interface]
DNS = 9.9.9.9

[Peer]
AllowedIPs = 0.0.0.0/0, ::/0

Save, deactivate, and activate again. Then check:

powershell
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:

bash
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 WAN

Troubleshooting

  • Tunnel shows Active but no handshake: the preshared key or the server public key does not match exactly, or Endpoint is wrong.
  • Handshake works but ping WG_SRV fails: check the PC's Address is WG_PC/32 and the VPS peer's AllowedIPs is WG_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.conf and run wg 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.
Gate to the next phase The PC shows a recent handshake, 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.

bash
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.

bash
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 validation

This 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.

bash
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 password

On Ubuntu, if FTL fails with "address already in use" on port 53, the cause is systemd-resolved's stub listener. Disable only the stub:

bash
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-FTL

Point 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):

bash
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-FTL

Then 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 HOST and 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

bash
# 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/admin

In 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.

bash
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_IP

Then 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.

Gate to the next phase 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).

CheckRunExpect
Fully patchedapt list --upgradable 2>/dev/null | wc -l1 (header only)
SSH hardenedsudo sshd -T | grep -Ei '^(permitrootlogin|passwordauthentication)'both no
Public listenersfrom a non-tailnet network: nmap -Pn -p 22,53,80,443 VPS_IPall four ports filtered (only the two VPN UDP ports are open to the world)
Firewall persistssudo systemctl is-enabled nftables, then reboot and sudo nft list table inet host_fwenabled, table present after boot
Forwarding onsysctl net.ipv4.ip_forward net.ipv4.conf.all.rp_filter1 and 2
Auto-updatessystemctl list-timers apt-daily-upgrade.timera next-run time
fail2bansudo fail2ban-client status sshdjail active
Tailscaletailscale statusHOST online, exit node approved
Exit node egressclient with exit node on: curl -s https://ifconfig.methe VPS public IP
WireGuardsudo wg showhandshake under ~2 minutes
Services after rebootsystemctl is-active tailscaled wg-quick@wg0 nftables fail2ban unbound pihole-FTLsix times active
Unbound and DNSSECdig +ad dnssec.works @127.0.0.1 -p 5335NOERROR with the ad flag
Pi-hole filteringfrom a tailnet device: dig doubleclick.net @TS_IP0.0.0.0 or NXDOMAIN
DNS and web UI not publicfrom outside: dig +time=3 +tries=1 example.com @VPS_IP and curl -m 5 http://VPS_IP/adminboth time out

If you lock yourself out

  • Firewall mistake with the rollback timer armed: wait five minutes; the fw-rollback timer deletes the host_fw table and public SSH works again.
  • Firewall or SSH mistake with no timer: use the AlexHost panel's VNC/console, log in as USER or root with the panel-provided access, then nft delete table inet host_fw to open everything, or fix /etc/ssh/sshd_config.d/00-hardening.conf and systemctl reload ssh.
  • Tailscale offline after a reboot: from the console, sudo systemctl status tailscaled and tailscale status; a key that expired means you skipped Disable key expiry in Phase 3, fixed with sudo 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.conf and /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.

Next
Next

BTS of a small YouTube channel