Secure WireGuard Homelab: Complete Setup Guide

{"time":1776378046128,"version":"2.28.2","blocks":[{"type":"paragraph","data":{"text":"This page documents the complete, final configuration of a secure homelab environment built around WireGuard, Raspberry Pis, UFW, AdGuard Home, Fail2Ban, n8n workflow automation, and Uptime Kuma. It is structured as a top-to-bottom technical reference, covering design intent, implementation details, validation procedures, and the final security posture of the system."}},{"type":"paragraph","data":{"text":"The goal of this build is not maximum complexity — it is clear, intentional security that is straightforward to operate, reason about, and maintain over time. Every decision documented here was made deliberately, and the rationale for each choice is explained alongside the configuration itself."}},{"type":"header","data":{"text":"Design Goals","level":2}},{"type":"paragraph","data":{"text":"Before examining the implementation, it is worth stating the principles that shaped every configuration decision in this build:"}},{"type":"list","data":{"style":"unordered","items":["Secure all management access using WireGuard — No administrative interface is reachable from the public internet without first authenticating through the VPN tunnel.","Preserve full internet performance — A split-tunnel design ensures that only VPN-destined traffic traverses WireGuard. Normal internet traffic continues to use each device’s local gateway without any performance penalty.","Expose only explicitly intended public services — Every open port is a deliberate choice. Nothing is open by accident or by default.","Eliminate accidental routing, NAT, and DNS side effects — Misconfigured AllowedIPs ranges and unintended DNS overrides are among the most common homelab failure modes. This design prevents both.","Provide monitoring, alerting, and automated response — Passive security is insufficient. The system actively detects anomalies, generates alerts, and can respond automatically.","Keep the system auditable and maintainable — Every component and rule is documented with intent. A future administrator — or your future self — should be able to understand what is running and why."]}},{"type":"header","data":{"text":"Final Architecture Overview","level":2}},{"type":"list","data":{"style":"unordered","items":["Main Server: Acts as the WireGuard server and central management node for the homelab.","Raspberry Pi 1 & 2: WireGuard clients that also serve as hosts for internal services.","VPN Subnet: 10.8.0.0/24 — a private RFC 1918 address space dedicated to WireGuard tunnel traffic.","Tunnel Mode: Split tunnel — only traffic destined for the VPN subnet is routed through WireGuard.","DNS: Handled locally by AdGuard Home running on a Raspberry Pi. WireGuard does not override system DNS.","Firewall: UFW managing all inbound traffic rules, operating in IPv4-only mode.","IPv6: Intentionally and permanently disabled at both the kernel and firewall level."]}},{"type":"paragraph","data":{"text":"Only traffic destined for the 10.8.0.0/24 VPN subnet is routed through the WireGuard interface. All other outbound traffic — web browsing, software updates, external API calls — continues to flow through each device’s local LAN gateway. This split-tunnel design eliminates the performance overhead and NAT complexity of a full-tunnel VPN while still protecting all administrative access paths."}},{"type":"header","data":{"text":"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 in AllowedIPs, which tells the WireGuard server to accept packets from that peer only if they originate from that specific IP address. This prevents one peer from spoofing another’s tunnel address and keeps the routing table clean and unambiguous."}},{"type":"header","data":{"text":"Raspberry Pi Clients — /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 line — AdGuard Home runs 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 single setting is the most consequential value in the client configuration, and it is worth understanding precisely why. The AllowedIPs directive serves a dual purpose in WireGuard: it defines both the routing table entries added to the host when the tunnel is active and the cryptographic filter used to validate incoming packets from this peer."}},{"type":"paragraph","data":{"text":"Using AllowedIPs = 10.8.0.0/24 tells the client to route only the VPN subnet through the wg0 interface. Using 0.0.0.0/0 instead would create a full-tunnel VPN, routing all traffic — including normal internet requests — through the WireGuard server. This would introduce NAT dependencies on the server, degrade performance on the Pis, and break the split-tunnel design entirely. The /24 subnet mask is non-negotiable for this architecture."}},{"type":"paragraph","data":{"text":"PersistentKeepalive = 25 sends a keepalive packet to the server every 25 seconds. This is necessary because the Raspberry Pis sit behind NAT (most home routers perform NAT), and NAT state tables expire idle UDP sessions after a period of inactivity. Without keepalives, the tunnel would silently fail whenever no traffic was exchanged for more than a minute or two."}},{"type":"header","data":{"text":"Routing Validation (Required)","level":2}},{"type":"paragraph","data":{"text":"After bringing the tunnel up on each Raspberry Pi, you must validate the routing table before considering the configuration correct. Run the following on each Pi:"}},{"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 → LAN gateway — All non-VPN traffic exits through the local router. This confirms the split-tunnel is functioning as intended.","10.8.0.0/24 → wg0 — All VPN subnet traffic is correctly directed into the WireGuard interface."]}},{"type":"paragraph","data":{"text":"If the default route points to wg0 rather than the LAN gateway, the configuration has created a full-tunnel VPN. Stop immediately, bring the interface down with sudo wg-quick down wg0, correct the AllowedIPs value in the configuration file, and restart the tunnel before continuing. Proceeding with an incorrect default route will cause all internet traffic to be routed through the VPN server, breaking NAT and potentially causing a connectivity loss that requires physical access to recover."}},{"type":"header","data":{"text":"DNS Design (AdGuard Home)","level":2}},{"type":"paragraph","data":{"text":"DNS resolution in this homelab is handled entirely by AdGuard Home, which runs locally on one of the Raspberry Pis. This approach provides ad blocking, query logging, and the ability to detect DNS-based abuse patterns — all without relying on any external resolver for internal queries."}},{"type":"list","data":{"style":"unordered","items":["AdGuard Home is deployed locally on a Raspberry Pi and listens on the standard DNS ports.","WireGuard does not override DNS — there is no DNS = directive in any WireGuard configuration file. This is intentional.","Adding a DNS = line to the WireGuard client config would redirect all system DNS queries through the tunnel, creating an unintended dependency on the VPN being active for basic name resolution. Because AdGuard Home is already local, this redirection is unnecessary and counterproductive."]}},{"type":"paragraph","data":{"text":"The following DNS ports are intentionally exposed as public services to allow external clients to use AdGuard Home as a resolver:"}},{"type":"list","data":{"style":"unordered","items":["53/udp and 53/tcp — Standard DNS (plain-text queries)","853/tcp — DNS-over-TLS (DoT), providing encrypted resolution for clients that support it"]}},{"type":"paragraph","data":{"text":"DNS query logs generated by AdGuard Home feed directly into the automation pipeline. Unusual query volumes, known malicious domains, or patterns indicative of DNS abuse trigger automated detection and response workflows in n8n."}},{"type":"header","data":{"text":"IPv6 Policy","level":2}},{"type":"paragraph","data":{"text":"IPv6 is intentionally and permanently disabled across all nodes in this homelab. While IPv6 is the future of internet addressing, it introduces meaningful complexity in small environments: dual-stack routing requires firewalls to manage rules for two separate protocol families, and many homelab tools and guides implicitly assume IPv4-only operation. A single misconfigured IPv6 rule can expose services that the UFW IPv4 rules correctly block."}},{"type":"paragraph","data":{"text":"Rather than manage this complexity, IPv6 is disabled at two independent layers to prevent any possibility of accidental re-enablement."}},{"type":"header","data":{"text":"Kernel-Level Disable — /etc/sysctl.d/99-disable-ipv6.conf","level":3}},{"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":"These sysctl parameters disable IPv6 at the network stack level, preventing the kernel from assigning IPv6 addresses to any interface. Apply the settings immediately with sudo sysctl --system without requiring a reboot."}},{"type":"header","data":{"text":"Firewall-Level Disable — /etc/default/ufw","level":3}},{"type":"code","data":{"code":"IPV6=no"}},{"type":"paragraph","data":{"text":"Setting IPV6=no in UFW’s configuration file prevents UFW from generating or applying any IPv6 ip6tables rules. This provides a second layer of defense: even if IPv6 were somehow re-enabled at the kernel level, UFW would not be managing rules for it, making the discrepancy immediately visible during audits."}},{"type":"paragraph","data":{"text":"The entire environment operates exclusively over IPv4."}},{"type":"header","data":{"text":"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":"The default-deny incoming policy is the foundation of the entire firewall strategy. Every inbound packet is dropped unless a specific rule explicitly permits it. This means every open port in this system is a conscious decision — not an oversight. The default-allow outgoing policy permits services on the machine to initiate connections externally without restriction, which is appropriate for a homelab host that needs to reach package repositories, APIs, and update servers."}},{"type":"header","data":{"text":"Publicly Exposed Services (Intentional)","level":3}},{"type":"paragraph","data":{"text":"The following ports are intentionally reachable from the public internet. Each has a documented reason for being open:"}},{"type":"list","data":{"style":"unordered","items":["22/tcp — SSH. Restricted to WireGuard IPs only (see SSH Access Model below). On the public interface, this port is blocked.","80/tcp — HTTP. Used for Let’s Encrypt ACME challenge responses during TLS certificate issuance and renewal.","443/tcp — HTTPS. The primary public-facing service endpoint for any web services hosted on the server.","51820/udp — WireGuard. Must be reachable on the public interface so that Raspberry Pi clients can establish and maintain the VPN tunnel from behind their home routers.","53/udp and 53/tcp — DNS. AdGuard Home accepts external queries on these ports.","853/tcp — DNS-over-TLS. Encrypted DNS resolution for clients that support the DoT standard."]}},{"type":"header","data":{"text":"WireGuard Internal Access","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 with a valid keypair can inject traffic into wg0. Traffic arriving on this interface is therefore already trusted at the transport layer, and blanket allowance is appropriate."}},{"type":"paragraph","data":{"text":"The additional DNS rules on wg0 are belt-and-suspenders entries that explicitly permit VPN peers to query AdGuard Home on ports 53 and 853. They are technically redundant given the first rule but serve as explicit documentation that DNS over the VPN is an intended access path."}},{"type":"header","data":{"text":"SSH Access Model","level":3}},{"type":"paragraph","data":{"text":"SSH is restricted to authenticated WireGuard peers only. The firewall rule is:"}},{"type":"code","data":{"code":"ufw allow from 10.8.0.0/24 to any port 22 proto tcp"}},{"type":"paragraph","data":{"text":"This rule permits SSH connections only when the source IP falls within the WireGuard VPN subnet. Because 10.8.0.0/24 is a private address range that is not routable on the public internet, the only way to originate a connection from this subnet is to already be authenticated as a WireGuard peer. This effectively makes WireGuard a mandatory pre-authentication step before SSH is even reachable."}},{"type":"paragraph","data":{"text":"External port scans targeting the public IP address will correctly show port 22 as blocked. LAN-originating SSH attempts (from the local home network, for example) are also blocked because LAN addresses do not fall within the 10.8.0.0/24 range. Access requires VPN authentication first — no exceptions."}},{"type":"header","data":{"text":"SSH Hardening","level":2}},{"type":"paragraph","data":{"text":"Network-layer access control is only part of the SSH security posture. The SSH daemon itself is configured with the following hardening measures in /etc/ssh/sshd_config:"}},{"type":"list","data":{"style":"unordered","items":["Key-only authentication — Public key authentication is the sole permitted login method. Clients without a corresponding private key cannot authenticate regardless of any other factor.","Password authentication disabled — PasswordAuthentication no is set explicitly, eliminating the entire class of brute-force and credential-stuffing attacks against SSH.","Root login disabled — PermitRootLogin no prevents direct root access over SSH. Administrative tasks requiring root are performed via sudo from a non-privileged account.","Reachable only via WireGuard IPs — As enforced by the UFW rule above, SSH is not reachable from any address outside the VPN subnet."]}},{"type":"paragraph","data":{"text":"To verify reachability before testing SSH, use ping 10.8.0.X to confirm that the VPN tunnel is up and the target host is responding at the network layer. A successful ping confirms connectivity; a failed SSH connection after a successful ping indicates a key placement or permission issue on the target host rather than a network or firewall problem."}},{"type":"header","data":{"text":"Docker Monitoring (Uptime Kuma)","level":2}},{"type":"paragraph","data":{"text":"Uptime Kuma monitors the health of Docker containers running on the Raspberry Pis. To enable this, it requires read access to the Docker API. The following design decisions keep this monitoring capability secure:"}},{"type":"list","data":{"style":"unordered","items":["The Docker API is never exposed publicly. Port 2375 (the default unencrypted Docker API port) is not open on any public interface.","Access is restricted to WireGuard peers only. The UFW rule limits access to the VPN subnet, ensuring the API is reachable only by authenticated tunnel participants.","A read-only Docker socket proxy mediates all access. Rather than exposing the raw Docker socket or the full API — which would grant complete control over all containers — a socket proxy is placed in front of it. The proxy exposes only the read-only endpoints required by Uptime Kuma (container status, health, metadata), and nothing else."]}},{"type":"paragraph","data":{"text":"The UFW rule permitting this access is:"}},{"type":"code","data":{"code":"ufw allow from 10.8.0.0/24 to any port 2375"}},{"type":"paragraph","data":{"text":"This combination — network restriction plus a read-only proxy — means Uptime Kuma can monitor container health without ever having the ability to start, stop, delete, or modify containers. Monitoring capability and control capability are intentionally separated."}},{"type":"header","data":{"text":"Detection, Alerting, and Automation","level":2}},{"type":"paragraph","data":{"text":"Static firewall rules block known bad patterns at the network layer, but they cannot adapt to novel threats or respond in real time. This homelab adds an active detection and response layer built on three tools:"}},{"type":"list","data":{"style":"unordered","items":["Fail2Ban monitors authentication logs (SSH, web server, and others) and automatically blocks source IP addresses that exceed configured failure thresholds. It translates log events into firewall rules dynamically, without manual intervention.","AdGuard Home logs every DNS query passing through the resolver. These logs serve as a signal source for detecting DNS-based abuse, including domain generation algorithm (DGA) traffic, known command-and-control domains, and unusual query volumes that may indicate a compromised device on the network.","n8n acts as the automation and orchestration layer. Running on a scheduled trigger every minute, it ingests events from Fail2Ban, AdGuard Home, and other log sources, applies logic to determine whether a response is warranted, executes automated IP blocking when thresholds are exceeded, and delivers real-time alert notifications to a configured Slack channel.","Uptime Kuma provides continuous availability monitoring for hosts, services, and Docker containers. It alerts immediately when a monitored target becomes unreachable, providing early warning of hardware failures, service crashes, or network disruptions."]}},{"type":"paragraph","data":{"text":"Together, these tools form a complete detect → alert → respond pipeline. A threat event does not require a human to be watching a dashboard to trigger a response — the system acts autonomously and notifies operators of what it has done."}},{"type":"header","data":{"text":"Validation Checklist","level":2}},{"type":"paragraph","data":{"text":"Run the following commands after any configuration change to verify that the system is in the expected state. Do not skip this step — confirming correct behavior is as important as configuring it."}},{"type":"code","data":{"code":"wg # Confirm tunnel is up, peers are listed, and handshakes are recent\nip route # Confirm default route → LAN gateway, not wg0\nping 10.8.0.1 # Confirm VPN tunnel reachability to the server\nping 8.8.8.8 # Confirm internet reachability bypasses the tunnel\nping google.com # Confirm DNS resolution is functioning correctly\nufw status verbose # Confirm all expected rules are in place and nothing unexpected is open\nss -tulpen | head -n 30 # Confirm which services are actually listening and on which interfaces"}},{"type":"paragraph","data":{"text":"In addition to these local checks, perform an external port scan using a tool such as nmap from a machine outside the VPN to confirm that port 22 appears as filtered (not open, not closed) from the public internet. SSH access must work correctly only from WireGuard peer addresses — never from any external or LAN source."}},{"type":"header","data":{"text":"Common Pitfalls (Avoided in This Design)","level":2}},{"type":"paragraph","data":{"text":"The following mistakes are common in homelab WireGuard deployments. Each was explicitly considered and avoided in this build:"}},{"type":"list","data":{"style":"unordered","items":["AllowedIPs = 0.0.0.0/0 on clients — Creates an unintended full-tunnel VPN. All internet traffic is routed through the server, introducing NAT dependencies, degrading performance, and requiring IP forwarding to be enabled on the server. Always use the specific VPN subnet (10.8.0.0/24) for split-tunnel operation.","Adding a DNS = directive when AdGuard Home runs locally — Overriding DNS in WireGuard when the resolver is already local creates circular dependencies and breaks name resolution when the tunnel is down. Remove the directive entirely; local DNS requires no tunnel involvement.","Exposing the Docker API publicly — The Docker API grants full control over all containers and images on the host. Exposing port 2375 (or even the TLS-protected 2376) to the public internet is equivalent to granting root access to anyone who can connect. Always gate Docker API access behind the VPN.","Using an HTTPS reverse proxy as the sole Docker security layer — A proxy in front of the Docker API may restrict which endpoints are accessible over HTTP, but it does not protect against misconfiguration, proxy bypass vulnerabilities, or incorrect TLS certificate validation. Network-layer access control via UFW is a necessary additional layer.","Relying on NAT as a security mechanism — NAT hides internal hosts from external initiators as a side effect, but it is not a firewall. NAT state can be bypassed, and an explicit firewall policy should never be substituted with “it’s behind NAT.” This build treats NAT as a connectivity mechanism, not a security control."]}},{"type":"header","data":{"text":"Final Summary","level":2}},{"type":"paragraph","data":{"text":"This homelab build delivers a secure, high-performance, split-tunnel WireGuard network connecting a central server and multiple Raspberry Pis, without sacrificing internet speed or introducing unnecessary operational complexity."}},{"type":"paragraph","data":{"text":"The main server acts as the WireGuard hub. Each Raspberry Pi connects as a spoke client, receiving a static address within the private 10.8.0.0/24 VPN subnet. Only traffic destined for that subnet is routed through WireGuard — all other internet traffic continues to use each device’s local gateway directly. This eliminates NAT dependencies on the server side and ensures no performance degradation for normal internet activity on the Pis."}},{"type":"paragraph","data":{"text":"DNS is handled entirely by AdGuard Home running locally on a Raspberry Pi. WireGuard does not override system DNS settings on any device, preventing common resolution failures and keeping DNS behavior predictable and independently functional — even if the VPN tunnel is momentarily down."}},{"type":"paragraph","data":{"text":"IPv6 is disabled at both the kernel (via sysctl) and firewall (via UFW configuration) levels, removing an entire class of dual-stack edge cases from the threat model without any practical impact on functionality."}},{"type":"paragraph","data":{"text":"The UFW firewall operates under a default-deny incoming policy. Every open port is explicitly documented and intentional. SSH access is restricted to WireGuard-authenticated peers and enforced with key-only authentication, making brute-force attacks structurally impossible — an attacker cannot even reach the SSH service without first authenticating through WireGuard."}},{"type":"paragraph","data":{"text":"Monitoring, automated alerting, and real-time response are provided by Uptime Kuma, Fail2Ban, AdGuard Home log analysis, and n8n automation — forming an active defense layer that operates continuously without manual intervention."}},{"type":"paragraph","data":{"text":"The result is a fast, secure, low-maintenance homelab with a clearly defined and deliberately minimized attack surface, documented access paths, and operational procedures that can be understood, audited, and reproduced by anyone reading this reference — including you, six months from now."}}]}