Homelab Security: WireGuard VPN & Firewall Setup Guide

{"time":1776378046122,"version":"2.28.2","blocks":[{"type":"header","data":{"text":"Goals Achieved","level":1}},{"type":"paragraph","data":{"text":"This runbook documents the complete setup procedure for a secure, split-tunnel WireGuard homelab. By the end of this guide, the following will be in place:"}},{"type":"list","data":{"style":"unordered","items":["The main server runs a WireGuard server on the wg0 interface, acting as the VPN hub for the network.","Pi 1 and Pi 2 each run a WireGuard client, connecting back to the server as authenticated tunnel peers.","A split tunnel is configured so that only VPN subnet traffic traverses WireGuard. All other internet traffic continues to use each device’s local gateway at full speed.","AdGuard Home runs locally on a Pi and retains full DNS control. WireGuard does not override system DNS, preventing common resolution failures.","Optional: Docker hosts on each Pi are monitored by Uptime Kuma over the WireGuard tunnel, using a read-only socket proxy.","Optional: IPv6 is permanently disabled at the kernel and firewall level to eliminate dual-stack complexity.","UFW enforces a default-deny firewall policy, with only explicitly required ports open to the public internet."]}},{"type":"header","data":{"text":"1. WireGuard on the Main Server (Server Side)","level":1}},{"type":"header","data":{"text":"1.1 Install WireGuard","level":2}},{"type":"code","data":{"code":"sudo apt update\nsudo apt install -y wireguard"}},{"type":"header","data":{"text":"1.2 Generate the Server Keypair","level":2}},{"type":"paragraph","data":{"text":"WireGuard uses public-key cryptography. Each peer has a private key (kept secret) and a derived public key (shared with other peers). The following command generates both in a single pipeline and saves them to the standard WireGuard directory:"}},{"type":"code","data":{"code":"wg genkey | sudo tee /etc/wireguard/server.key | wg pubkey | sudo tee /etc/wireguard/server.pub >/dev/null\nsudo chmod 600 /etc/wireguard/server.key"}},{"type":"paragraph","data":{"text":"The chmod 600 command restricts the private key file to root-only read access. WireGuard will refuse to start if the private key file has overly permissive permissions."}},{"type":"header","data":{"text":"1.3 Create the Server Configuration","level":2}},{"type":"code","data":{"code":"sudo nano /etc/wireguard/wg0.conf"}},{"type":"paragraph","data":{"text":"Paste the following and replace <SERVER_PRIVATE_KEY> with the contents of /etc/wireguard/server.key:"}},{"type":"code","data":{"code":"[Interface]\nAddress = 10.8.0.1/24\nListenPort = 51820\nPrivateKey = "}},{"type":"paragraph","data":{"text":"To retrieve the private key value for pasting:"}},{"type":"code","data":{"code":"sudo cat /etc/wireguard/server.key"}},{"type":"paragraph","data":{"text":"The server is assigned 10.8.0.1 — the first address in the VPN subnet — and will act as the gateway for all tunnel peers. Peer entries ([Peer] blocks for each Raspberry Pi) will be added in Step 3 after the Pi keys are generated."}},{"type":"header","data":{"text":"1.4 Enable and Start the WireGuard Interface","level":2}},{"type":"code","data":{"code":"sudo systemctl enable wg-quick@wg0\nsudo systemctl start wg-quick@wg0\nsudo wg"}},{"type":"paragraph","data":{"text":"The systemctl enable command ensures the WireGuard interface starts automatically on every boot. The sudo wg command displays the current tunnel status. At this point, the server will show a listening interface but no connected peers — that is expected until the Pi clients are configured and their public keys are added."}},{"type":"header","data":{"text":"2. WireGuard on Raspberry Pi 1 and Pi 2 (Client Side)","level":1}},{"type":"paragraph","data":{"text":"Perform the following steps on each Raspberry Pi, substituting the correct IP address for each device. Pi 1 uses 10.8.0.2 and Pi 2 uses 10.8.0.3."}},{"type":"header","data":{"text":"2.1 Install WireGuard","level":2}},{"type":"code","data":{"code":"sudo apt update\nsudo apt install -y wireguard"}},{"type":"header","data":{"text":"2.2 Generate the Client Keypair","level":2}},{"type":"code","data":{"code":"wg genkey | tee ~/client.key | wg pubkey > ~/client.pub\nchmod 600 ~/client.key"}},{"type":"paragraph","data":{"text":"The public key (~/client.pub) must be copied to the server and added to the server’s wg0.conf in Step 3. The private key (~/client.key) stays on this Pi and is referenced in the client configuration below."}},{"type":"header","data":{"text":"2.3 Create the Client Configuration","level":2}},{"type":"header","data":{"text":"Pi 1 Configuration (10.8.0.2)","level":3}},{"type":"code","data":{"code":"sudo nano /etc/wireguard/wg0.conf"}},{"type":"code","data":{"code":"[Interface]\nAddress = 10.8.0.2/24\nPrivateKey = \n# No DNS directive — AdGuard Home manages DNS locally on this device\n\n[Peer]\nPublicKey = \nEndpoint = :51820\nAllowedIPs = 10.8.0.0/24\nPersistentKeepalive = 25"}},{"type":"header","data":{"text":"Pi 2 Configuration (10.8.0.3)","level":3}},{"type":"paragraph","data":{"text":"Use the same configuration structure, with the following values changed:"}},{"type":"code","data":{"code":"Address = 10.8.0.3/24\nPrivateKey = "}},{"type":"paragraph","data":{"text":"All other fields — PublicKey, Endpoint, AllowedIPs, and PersistentKeepalive — remain identical."}},{"type":"paragraph","data":{"text":"Critical: the split-tunnel rule."}},{"type":"code","data":{"code":"AllowedIPs = 10.8.0.0/24 ✅ Correct — routes only VPN subnet traffic through the tunnel"}},{"type":"code","data":{"code":"AllowedIPs = 0.0.0.0/0 ❌ Wrong — creates a full-tunnel VPN, routing all internet traffic through the server"}},{"type":"paragraph","data":{"text":"The AllowedIPs directive controls both which traffic is sent into the tunnel and which source addresses are accepted from the peer. Using the /24 VPN subnet ensures that only tunnel traffic is affected. Using 0.0.0.0/0 would redirect all internet traffic through the WireGuard server, breaking NAT, degrading performance, and requiring IP forwarding to be enabled on the server — none of which is intended in this design."}},{"type":"paragraph","data":{"text":"PersistentKeepalive = 25 sends a keepalive packet every 25 seconds. This is required because the Raspberry Pis sit behind NAT routers, and NAT state tables will expire idle UDP sessions within one to two minutes of inactivity. Without keepalives, the tunnel silently drops after a period of no traffic."}},{"type":"header","data":{"text":"2.4 Enable and Start on Each Pi","level":2}},{"type":"code","data":{"code":"sudo systemctl enable wg-quick@wg0\nsudo systemctl start wg-quick@wg0\nsudo wg"}},{"type":"paragraph","data":{"text":"The interface will start, but the tunnel handshake will not complete until the server’s wg0.conf is updated with this Pi’s public key in the next step."}},{"type":"header","data":{"text":"3. Add Pi Peers to the Main Server","level":1}},{"type":"paragraph","data":{"text":"On the main server, open the WireGuard configuration file and append a [Peer] block for each Raspberry Pi:"}},{"type":"code","data":{"code":"sudo nano /etc/wireguard/wg0.conf"}},{"type":"paragraph","data":{"text":"Add the following blocks at the end of the file, replacing the placeholder values with the actual public keys generated on each Pi in Step 2.2:"}},{"type":"code","data":{"code":"[Peer]\nPublicKey = \nAllowedIPs = 10.8.0.2/32\n\n[Peer]\nPublicKey = \nAllowedIPs = 10.8.0.3/32"}},{"type":"paragraph","data":{"text":"Each peer uses a /32 host mask in AllowedIPs. This tells the server to accept packets from each peer only when they originate from that specific IP address, preventing one peer from injecting traffic that claims to originate from another peer’s tunnel address."}},{"type":"paragraph","data":{"text":"Restart the server interface to apply the changes:"}},{"type":"code","data":{"code":"sudo systemctl restart wg-quick@wg0\nsudo wg"}},{"type":"paragraph","data":{"text":"After restarting, sudo wg should show both peers listed. Once the Pi clients send their first keepalive or data packet, the “latest handshake” timestamp for each peer will update, confirming the tunnel is active."}},{"type":"header","data":{"text":"4. Verify the Split Tunnel Is Correct","level":1}},{"type":"header","data":{"text":"4.1 Tunnel Ping Tests","level":2}},{"type":"paragraph","data":{"text":"From Pi 1 or Pi 2, verify that the server is reachable over the tunnel:"}},{"type":"code","data":{"code":"ping 10.8.0.1"}},{"type":"paragraph","data":{"text":"From the server, verify that both Pis are reachable:"}},{"type":"code","data":{"code":"ping 10.8.0.2\nping 10.8.0.3"}},{"type":"paragraph","data":{"text":"A successful ping in both directions confirms that the tunnel handshake completed and traffic is flowing correctly through the wg0 interface."}},{"type":"header","data":{"text":"4.2 Confirm Internet Traffic Bypasses the Tunnel","level":2}},{"type":"paragraph","data":{"text":"On each Pi, inspect the routing table:"}},{"type":"code","data":{"code":"ip route"}},{"type":"paragraph","data":{"text":"The output must show exactly two relevant entries:"}},{"type":"list","data":{"style":"unordered","items":["Default route via LAN gateway — for example, default via 192.168.1.1 dev eth0. This confirms that all internet-bound traffic exits through the local router, not the VPN tunnel.","10.8.0.0/24 dev wg0 — confirming that VPN subnet traffic is correctly directed into the WireGuard interface."]}},{"type":"paragraph","data":{"text":"If the default route points to wg0 instead of the LAN gateway, the tunnel has been accidentally configured as a full tunnel. Bring the interface down immediately with sudo wg-quick down wg0, correct AllowedIPs in the configuration file, then restart the interface. Do not proceed until this is resolved."}},{"type":"header","data":{"text":"5. DNS Configuration (AdGuard Home on Pi)","level":1}},{"type":"paragraph","data":{"text":"Because AdGuard Home runs locally on the Raspberry Pi, no DNS = directive should be present in the WireGuard client configuration — and none has been added."}},{"type":"paragraph","data":{"text":"Adding a DNS = line to the WireGuard config would instruct the wg-quick tool to modify /etc/resolv.conf when the tunnel comes up, redirecting all DNS queries through WireGuard. When AdGuard Home is already running locally, this creates a circular dependency: DNS resolution requires the tunnel, and the tunnel endpoint may require DNS resolution to establish. It also means that if the tunnel is temporarily down, all name resolution on the Pi fails — even for entirely local queries."}},{"type":"paragraph","data":{"text":"Because AdGuard Home is co-located on the same device, DNS resolution is already local and requires no tunnel involvement. The correct configuration is to omit the DNS = directive entirely and allow AdGuard Home to manage resolution directly."}},{"type":"header","data":{"text":"6. Optional: Disable IPv6 Permanently (Ubuntu / Raspberry Pi OS)","level":1}},{"type":"paragraph","data":{"text":"Disabling IPv6 removes an entire class of dual-stack routing and firewall edge cases. In small homelab environments, the operational complexity of managing rules across two protocol families rarely provides any benefit. The following procedure disables IPv6 at both the kernel level and the firewall level."}},{"type":"header","data":{"text":"6.1 Disable at the Kernel Level (Recommended Method)","level":2}},{"type":"code","data":{"code":"sudo nano /etc/sysctl.d/99-disable-ipv6.conf"}},{"type":"paragraph","data":{"text":"Paste the following:"}},{"type":"code","data":{"code":"net.ipv6.conf.all.disable_ipv6 = 1\nnet.ipv6.conf.default.disable_ipv6 = 1\nnet.ipv6.conf.lo.disable_ipv6 = 1"}},{"type":"paragraph","data":{"text":"Apply the settings immediately without rebooting:"}},{"type":"code","data":{"code":"sudo sysctl --system"}},{"type":"paragraph","data":{"text":"Verify that no IPv6 addresses are assigned to any interface:"}},{"type":"code","data":{"code":"ip a | grep inet6"}},{"type":"paragraph","data":{"text":"If this command produces no output, IPv6 has been successfully disabled at the kernel level. The settings will persist across reboots because the configuration file resides in /etc/sysctl.d/, which is read during every boot sequence."}},{"type":"header","data":{"text":"7. Firewall Configuration (UFW)","level":1}},{"type":"header","data":{"text":"7.1 Reset to a Clean State","level":2}},{"type":"paragraph","data":{"text":"Before applying the intended ruleset, reset UFW to eliminate any previously configured rules that might conflict or leave unexpected ports open:"}},{"type":"code","data":{"code":"sudo ufw disable\nsudo ufw reset\nsudo ufw default deny incoming\nsudo ufw default allow outgoing"}},{"type":"paragraph","data":{"text":"The default-deny incoming policy means every inbound packet is dropped unless a specific rule explicitly permits it. This is the foundation of the entire firewall strategy — every open port in this system is a deliberate decision, not an oversight or default."}},{"type":"header","data":{"text":"7.2 Allow Required Ports","level":2}},{"type":"header","data":{"text":"SSH (Choose One)","level":3}},{"type":"paragraph","data":{"text":"Recommended — WireGuard-only access (most secure):"}},{"type":"code","data":{"code":"sudo ufw allow from 10.8.0.0/24 to any port 22 proto tcp comment \"SSH via WireGuard only\""}},{"type":"paragraph","data":{"text":"This rule permits SSH connections only from addresses within the WireGuard VPN subnet. Because 10.8.0.0/24 is a private address range not routable on the public internet, an attacker cannot reach port 22 without first authenticating through WireGuard. External port scans will show port 22 as filtered — not open, not closed."}},{"type":"paragraph","data":{"text":"Alternative — public SSH with rate limiting (if WireGuard-only is not viable):"}},{"type":"code","data":{"code":"sudo ufw limit 22/tcp comment \"SSH public with rate limiting\""}},{"type":"paragraph","data":{"text":"UFW’s limit action blocks source IPs that attempt more than six connections within 30 seconds. This reduces brute-force exposure but leaves the SSH service reachable from any source IP. Use this only if VPN-gated access is not possible."}},{"type":"header","data":{"text":"HTTP and HTTPS","level":3}},{"type":"code","data":{"code":"sudo ufw allow 80/tcp comment \"HTTP\"\nsudo ufw allow 443/tcp comment \"HTTPS\""}},{"type":"paragraph","data":{"text":"Port 80 is required for Let’s Encrypt ACME HTTP-01 challenge responses during TLS certificate issuance and renewal. Port 443 is the primary public-facing endpoint for any HTTPS services hosted on the server."}},{"type":"header","data":{"text":"WireGuard","level":3}},{"type":"code","data":{"code":"sudo ufw allow 51820/udp comment \"WireGuard handshake and keepalive\"\nsudo ufw allow in on wg0 comment \"WireGuard tunnel — all internal traffic\""}},{"type":"paragraph","data":{"text":"Port 51820 must be open on the public interface so that Raspberry Pi clients can initiate and maintain the WireGuard handshake from behind their home routers. The allow in on wg0 rule permits all traffic arriving on the WireGuard interface. Because WireGuard cryptographically authenticates every packet using public-key cryptography, only peers with a valid keypair can inject traffic into wg0, making blanket allowance on this interface appropriate."}},{"type":"header","data":{"text":"7.3 Enable UFW and Verify","level":2}},{"type":"code","data":{"code":"sudo ufw enable\nsudo ufw status numbered\nsudo ufw status verbose"}},{"type":"paragraph","data":{"text":"Review the output of ufw status verbose carefully. Every rule listed should correspond to a service you have intentionally chosen to expose. If any unexpected rules appear, investigate before proceeding."}},{"type":"header","data":{"text":"8. Optional: Docker Monitoring over WireGuard (Uptime Kuma)","level":1}},{"type":"header","data":{"text":"8.1 Deploy a Read-Only Docker Socket Proxy on Each Pi","level":2}},{"type":"paragraph","data":{"text":"Rather than exposing the raw Docker socket or the full unfiltered Docker API — both of which would grant complete control over every container on the host — a socket proxy is deployed in front of it. The proxy exposes only the read-only API endpoints required by Uptime Kuma (container status, version, ping, and metadata) and blocks everything else."}},{"type":"paragraph","data":{"text":"Create a docker-compose.yml file in a directory of your choice on each Pi:"}},{"type":"code","data":{"code":"version: \"3.8\"\nservices:\n docker-socket-proxy:\n image: tecnativa/docker-socket-proxy\n container_name: docker-socket-proxy\n ports:\n - \"2375:2375\"\n environment:\n CONTAINERS: 1 # Allow container list and inspect endpoints\n INFO: 1 # Allow /info endpoint\n PING: 1 # Allow /_ping health check endpoint\n VERSION: 1 # Allow /version endpoint\n volumes:\n - /var/run/docker.sock:/var/run/docker.sock:ro # Mount socket read-only\n restart: unless-stopped"}},{"type":"paragraph","data":{"text":"Start the proxy:"}},{"type":"code","data":{"code":"docker compose up -d"}},{"type":"paragraph","data":{"text":"With this configuration, Uptime Kuma can query container health and status, but cannot start, stop, delete, or modify any container. Monitoring capability and control capability are intentionally separated at the API layer."}},{"type":"header","data":{"text":"8.2 Restrict Docker Proxy Access to WireGuard Only","level":2}},{"type":"paragraph","data":{"text":"On each Pi, add a UFW rule that permits connections to port 2375 only from addresses within the WireGuard VPN subnet:"}},{"type":"code","data":{"code":"sudo ufw allow from 10.8.0.0/24 to any port 2375 comment \"Docker socket proxy — WireGuard only\""}},{"type":"paragraph","data":{"text":"Port 2375 must never be reachable from the public internet. The combination of the VPN-only UFW rule and the read-only socket proxy provides two independent layers of protection: network-level access control and API-level capability restriction."}},{"type":"header","data":{"text":"8.3 Configure Uptime Kuma","level":2}},{"type":"paragraph","data":{"text":"In the Uptime Kuma interface, add a new monitor for each Raspberry Pi with the following settings:"}},{"type":"list","data":{"style":"unordered","items":["Monitor Type: Docker Host","Docker Host URL for Pi 1: http://10.8.0.2:2375","Docker Host URL for Pi 2: http://10.8.0.3:2375"]}},{"type":"paragraph","data":{"text":"Because Uptime Kuma itself runs on a device within the WireGuard subnet, it can reach these addresses directly over the tunnel. No public internet exposure is involved."}},{"type":"header","data":{"text":"9. Common Pitfalls to Avoid","level":1}},{"type":"paragraph","data":{"text":"The following mistakes are frequently made in homelab WireGuard deployments. Each was explicitly considered and avoided in this build:"}},{"type":"list","data":{"style":"unordered","items":["❌ Never expose Docker port 2375 to the public internet. The Docker API grants full control over all containers and images on the host. Exposure to any untrusted network is equivalent to granting root access. Always gate it behind the VPN.","❌ Never use AllowedIPs = 0.0.0.0/0 on clients. This creates a full-tunnel VPN. All internet traffic is routed through the server, introducing NAT requirements, degrading Pi performance, and breaking the split-tunnel design. Always use the specific VPN subnet: 10.8.0.0/24.","❌ Never set DNS = 10.8.0.1 (or any address) unless the WireGuard server is also running a DNS resolver. If AdGuard Home runs on the Pi itself, adding a DNS directive causes WireGuard to override local DNS with a tunnel-dependent resolver, breaking resolution whenever the tunnel is down.","❌ Never rely on a Certbot or Caddy HTTPS reverse proxy to secure the Docker API. An HTTPS proxy in front of the Docker socket may restrict which endpoints are reachable over HTTP, but it does not eliminate API access vulnerabilities, does not protect against misconfiguration, and does not provide network-layer isolation. WireGuard plus UFW is the correct security model.","✅ Always use WireGuard plus UFW together. WireGuard provides authenticated tunnel access. UFW enforces which tunnel-internal services are accessible and from which addresses. Neither alone is sufficient — both are necessary."]}},{"type":"header","data":{"text":"System Health Checklist","level":1}},{"type":"paragraph","data":{"text":"Run the following commands on any node after a configuration change, reboot, or any time you want to confirm the system is in the expected state:"}},{"type":"code","data":{"code":"sudo wg # Confirm tunnel is active, peers are listed, handshakes are recent\nip route # Confirm default route → LAN gateway, not wg0\nsudo ufw status verbose # Confirm expected rules are in place, no unexpected ports are open\nss -tulpen | head -n 30 # Confirm which processes are listening and on which interfaces"}},{"type":"paragraph","data":{"text":"For a complete validation, also verify the following behavioral properties:"}},{"type":"list","data":{"style":"unordered","items":["Pinging 10.8.0.1 from a Pi succeeds — the VPN tunnel is up.","Pinging 8.8.8.8 from a Pi succeeds — internet traffic is bypassing the tunnel correctly.","Pinging google.com from a Pi succeeds — DNS resolution is functioning independently of the tunnel.","An external port scan of the server’s public IP shows port 22 as filtered — SSH is not reachable from outside the VPN.","SSH from a WireGuard peer using the correct key succeeds — VPN-gated access is working as intended."]}}]}