WireGuard Split-Tunnel VPN: Secure Setup Guide

{"time":1776378046119,"version":"2.28.2","blocks":[{"type":"paragraph","data":{"text":"This document is the final, stable Standard Operating Procedure (SOP) for a small production homelab built on WireGuard, AdGuard Home, Docker monitoring, and UFW. It reflects all configuration decisions, corrections, and lessons learned through implementation and testing. It is intended to serve as both a day-to-day operational reference and an audit record of every intentional security choice made in this environment."}},{"type":"header","data":{"text":"1. Design Goals","level":2}},{"type":"paragraph","data":{"text":"The following principles govern every configuration decision in this homelab. When a future change is proposed, it should be evaluated against these goals before implementation:"}},{"type":"list","data":{"style":"unordered","items":["Secure all private management traffic. Administrative access to servers, containers, and APIs must never be reachable from the public internet without VPN authentication.","No performance impact on internet traffic. A split-tunnel design ensures that only VPN-destined traffic traverses WireGuard. All other traffic uses each device’s local gateway at full speed.","Public DNS via AdGuard Home is intentional. AdGuard Home is deliberately exposed as a public DNS resolver. This is a feature, not a misconfiguration, and is reflected explicitly in the firewall rules.","No public exposure of administrative or Docker APIs. Management interfaces are accessible only to authenticated WireGuard peers. They are invisible to the public internet.","Simple, predictable routing and firewall rules. Every rule has a documented purpose. Nothing is open by default or by accident. The configuration must be auditable by anyone reading this document."]}},{"type":"header","data":{"text":"2. Final Network Model","level":2}},{"type":"paragraph","data":{"text":"The following table describes the final, stable topology of this homelab:"}},{"type":"list","data":{"style":"unordered","items":["Main Server — Acts as the WireGuard server and central management hub. Holds the authoritative wg0.conf with peer entries for all connected Raspberry Pis.","Raspberry Pi 1 and Pi 2 — WireGuard clients. Each connects to the server as an authenticated peer and hosts internal services accessible only over the VPN tunnel.","VPN Subnet — 10.8.0.0/24. All WireGuard tunnel addresses are drawn from this private RFC 1918 range.","Tunnel Mode — Split tunnel. Only traffic destined for 10.8.0.0/24 is routed through WireGuard. All other traffic continues to use the local LAN gateway.","DNS — Managed locally by AdGuard Home running on a Raspberry Pi. WireGuard does not override system DNS on any device.","IPv6 — Permanently disabled at both the kernel level (via sysctl) and the firewall level (via UFW configuration).","Firewall — UFW operating in IPv4-only mode with a default-deny incoming policy."]}},{"type":"header","data":{"text":"3. WireGuard Configuration (Final)","level":2}},{"type":"header","data":{"text":"Server — /etc/wireguard/wg0.conf","level":3}},{"type":"code","data":{"code":"[Interface]\nAddress = 10.8.0.1/24\nListenPort = 51820\nPrivateKey = PLEASE_PUT_YOUR_SERVER_PRIVATE_KEY\n\n[Peer]\nPublicKey = PLEASE_PUT_YOUR_PI1_PUBLIC_KEY\nAllowedIPs = 10.8.0.2/32\n\n[Peer]\nPublicKey = PLEASE_PUT_YOUR_PI2_PUBLIC_KEY\nAllowedIPs = 10.8.0.3/32"}},{"type":"paragraph","data":{"text":"Each peer entry uses a /32 host mask. This instructs the server to accept packets from each peer only when they originate from that specific IP address, preventing one authenticated peer from spoofing another peer’s tunnel address."}},{"type":"header","data":{"text":"Clients — Pi 1 and Pi 2 (/etc/wireguard/wg0.conf)","level":3}},{"type":"code","data":{"code":"[Interface]\nAddress = PLEASE_PUT_YOUR_PI_WG_IP\nPrivateKey = PLEASE_PUT_YOUR_PI_PRIVATE_KEY\n# No DNS directive — AdGuard Home manages DNS locally on this device\n\n[Peer]\nPublicKey = PLEASE_PUT_YOUR_SERVER_PUBLIC_KEY\nEndpoint = PLEASE_PUT_YOUR_SERVER_PUBLIC_IP_OR_DNS:51820\nAllowedIPs = 10.8.0.0/24\nPersistentKeepalive = 25"}},{"type":"paragraph","data":{"text":"✅ Critical rule: AllowedIPs = 10.8.0.0/24"}},{"type":"paragraph","data":{"text":"This is the most consequential setting in the client configuration. It limits WireGuard’s routing influence to the VPN subnet only, preserving the split-tunnel design. Using 0.0.0.0/0 instead would redirect all internet traffic through the server, introducing NAT dependencies and breaking the performance goal of this build."}},{"type":"paragraph","data":{"text":"❌ Never use /0 unless the explicit, documented intent is to build a full-tunnel VPN that routes all client traffic through the server."}},{"type":"header","data":{"text":"4. Routing Verification (Mandatory)","level":2}},{"type":"paragraph","data":{"text":"After bringing up the WireGuard interface on each client, verify the routing table before treating the configuration as correct. This step is not optional — an incorrect default route will silently degrade performance and create NAT failures that are difficult to diagnose later."}},{"type":"paragraph","data":{"text":"Run on each client:"}},{"type":"code","data":{"code":"ip route"}},{"type":"paragraph","data":{"text":"Expected output must include exactly the following two relevant entries:"}},{"type":"list","data":{"style":"unordered","items":["Default route → LAN gateway (e.g., default via 192.168.1.1 dev eth0) — confirms that internet-bound traffic exits through the local router, not the tunnel.","10.8.0.0/24 dev wg0 — confirms that VPN subnet traffic is correctly directed into the WireGuard interface."]}},{"type":"paragraph","data":{"text":"If the default route points to wg0, stop immediately. Bring the interface down with sudo wg-quick down wg0, correct AllowedIPs in the configuration file, and restart before proceeding. Do not continue with an incorrect default route in place."}},{"type":"header","data":{"text":"5. DNS Model (Final)","level":2}},{"type":"list","data":{"style":"unordered","items":["AdGuard Home runs locally on a Raspberry Pi and serves as the DNS resolver for the homelab.","WireGuard must not override system DNS. Adding a DNS = directive to any WireGuard configuration file would redirect all DNS queries through the tunnel and create a dependency on the VPN being active for basic name resolution — which would break DNS whenever the tunnel is momentarily down. Because AdGuard Home is co-located on the device, no tunnel involvement in DNS is required or desirable.","No DNS = entry exists in any WireGuard configuration file in this environment. This is intentional and must not be changed."]}},{"type":"paragraph","data":{"text":"The following DNS ports are intentionally exposed as public services, allowing external clients to use AdGuard Home as a resolver:"}},{"type":"list","data":{"style":"unordered","items":["53/udp — Standard DNS (UDP, used by the majority of DNS clients)","53/tcp — Standard DNS (TCP, used for large responses and zone transfers)","853/tcp — DNS-over-TLS (DoT), providing encrypted resolution for clients that support it"]}},{"type":"paragraph","data":{"text":"DNS access over the WireGuard tunnel is also permitted for internal VPN clients, as documented in the firewall rules in Section 7."}},{"type":"header","data":{"text":"6. IPv6 Policy (Final)","level":2}},{"type":"paragraph","data":{"text":"IPv6 is permanently disabled across all nodes in this environment. Managing dual-stack routing requires maintaining firewall rules for two separate protocol families. A single misconfigured IPv6 rule can expose services that the UFW IPv4 rules correctly block, creating a false sense of security. In a small homelab where IPv6 provides no functional benefit, disabling it entirely is the correct trade-off."}},{"type":"paragraph","data":{"text":"IPv6 is disabled at the kernel level via /etc/sysctl.d/99-disable-ipv6.conf:"}},{"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":"IPv6 rule generation is also disabled in UFW via /etc/default/ufw:"}},{"type":"code","data":{"code":"IPV6=no"}},{"type":"paragraph","data":{"text":"These two layers are independent. If IPv6 were somehow re-enabled at the kernel level, UFW would still not be generating rules for it, making the discrepancy immediately visible during a firewall audit."}},{"type":"header","data":{"text":"7. Firewall Policy (UFW — Final)","level":2}},{"type":"header","data":{"text":"Default Policy","level":3}},{"type":"code","data":{"code":"ufw default deny incoming\nufw default allow outgoing"}},{"type":"paragraph","data":{"text":"Every inbound packet is dropped unless a specific rule explicitly permits it. This means every open port in this environment is a documented, deliberate decision — not a default or an oversight."}},{"type":"header","data":{"text":"Publicly Exposed Ports (Intentional)","level":3}},{"type":"code","data":{"code":"ufw allow 22/tcp # SSH — WireGuard-only or rate-limited (see SSH section below)\nufw allow 80/tcp # HTTP — required for Let's Encrypt ACME certificate renewal\nufw allow 443/tcp # HTTPS — primary public-facing service endpoint\nufw allow 51820/udp # WireGuard — must be reachable for clients to establish the tunnel\nufw allow 53/udp # DNS — AdGuard Home public resolver (UDP)\nufw allow 53/tcp # DNS — AdGuard Home public resolver (TCP)\nufw allow 853/tcp # DNS-over-TLS — encrypted DNS for supporting clients"}},{"type":"header","data":{"text":"WireGuard Internal Traffic","level":3}},{"type":"code","data":{"code":"ufw allow in on wg0\nufw allow in on wg0 to any port 53\nufw allow in on wg0 to any port 853"}},{"type":"paragraph","data":{"text":"The first rule permits all traffic arriving on the wg0 interface. Because WireGuard cryptographically authenticates every packet using public-key cryptography, only peers holding a valid private key can inject traffic into this interface. Blanket allowance on wg0 is therefore appropriate and safe. The additional DNS rules are explicit documentation that VPN peers are permitted to query AdGuard Home on the standard DNS ports."}},{"type":"header","data":{"text":"SSH Access Model (Choose One)","level":3}},{"type":"paragraph","data":{"text":"Recommended — WireGuard-only access:"}},{"type":"code","data":{"code":"ufw allow from 10.8.0.0/24 to any port 22 proto tcp"}},{"type":"paragraph","data":{"text":"SSH is reachable only from addresses within the WireGuard VPN subnet. Because this subnet is not routable on the public internet, WireGuard authentication is a mandatory prerequisite for SSH access. Port 22 appears as filtered to any external scanner."}},{"type":"paragraph","data":{"text":"Alternative — public SSH with rate limiting (use only if VPN-gated access is not viable):"}},{"type":"code","data":{"code":"ufw limit 22/tcp"}},{"type":"paragraph","data":{"text":"UFW’s limit action blocks source IPs that exceed six connection attempts within 30 seconds. This reduces brute-force exposure but leaves port 22 reachable from any source IP. This option should be considered a fallback, not a preference."}},{"type":"header","data":{"text":"8. Docker Monitoring Policy (Final)","level":2}},{"type":"list","data":{"style":"unordered","items":["The Docker API is never exposed publicly. Port 2375 is not open on any public interface. Exposing the Docker API to the internet grants full control over all containers on the host and is equivalent to remote root access.","Access is permitted only over WireGuard. The UFW rule restricts port 2375 to the VPN subnet, ensuring that only authenticated tunnel peers can reach the Docker API.","A read-only docker-socket-proxy is strongly preferred. Rather than exposing the full Docker API, the socket proxy limits Uptime Kuma to read-only endpoints (container status, health, metadata). This separates monitoring capability from control capability at the API layer."]}},{"type":"paragraph","data":{"text":"Firewall rule:"}},{"type":"paragraph","data":{"text":"