Test the Complete Homelab from Outside
A successful browser load is not a complete security test. Test this homelab from networks that are genuinely outside it: Starlink’s default IPv4 CGNAT remains between the home router and the internet, the Netgate 2100 isolates LAN4 (192.168.60.0/24), the Pi 5 is 192.168.60.10, Nginx is only 127.0.0.1:8080, and cloudflared publishes the intended web hostname. Tailscale is the separate private administration path.
Record the expected design
Public hostname: www.example.net
Public path: Cloudflare DNS/proxy -> named tunnel -> 127.0.0.1:8080
DMZ host: 192.168.60.10
Private administration: Tailscale only
WAN port forwards: none
Public SSH: none
IPv4 expectation: Public web works through the tunnel, not the Starlink address
IPv6 expectation: No public path unless an intentional, secured path is documented
Capture the baseline Cloudflare DNS records, certificate name, Netgate rule counters, tunnel status, Tailscale device status, and synchronized test time. Use a test account that is not an administrator and do not place real credentials in command history or screenshots. For every run, resolve both records and force both address families; a resolver preference or an application fallback must not hide a broken path:
dig +short A www.example.net
dig +short AAAA www.example.net
curl -4 -vI --fail https://www.example.net/
curl -6 -vI --fail https://www.example.net/
Record the selected address, TLS certificate subject/SAN, TLS verification result, HTTP status, redirect location, and response headers for both curl runs. Repeat with a full-body request when the application has meaningful content; -I alone does not prove the response body path.
External test matrix
| Test source and action | Expected result | Evidence to collect |
|---|---|---|
| IPv4-only cellular: resolve and request the public HTTPS hostname | Expected certificate and application response through Cloudflare | DNS answer/chain, HTTP status, TLS name, Cloudflare and Nginx logs |
IPv6-capable external network: query A and AAAA, then force curl -4 and curl -6 | Each family matches the documented design; AAAA is absent when IPv6 is disabled, or reaches only the intentionally secured path | Both DNS answers, selected addresses, certificates, HTTP statuses, and Netgate IPv6 logs |
| Independent resolver: query authoritative and public recursive DNS | Only the intended record and tunnel target appear; no stale residential A/AAAA or wildcard surprise | Answers, TTL, CNAME chain, DNS change audit |
| HTTPS with wrong Host/SNI and an unrecognized hostname | Reject, default catch-all error, or intended Cloudflare error; never the application | Response status and tunnel/Nginx ingress evidence |
| Direct Starlink-observed/old WAN address, TCP 80/443/7844/8080/22 | No service is reachable by direct address; no WAN forward exists | Scanner results from an authorized test source and Netgate counters |
| Private administration from outside with Tailscale up | Only the named Pi ports for the administrator policy work | Tailscale path, policy decision, SSH/system log |
| Private administration without Tailscale or as an unauthorized identity | SSH and administration fail; public website behavior is unchanged | Client error and denied tailnet/host evidence |
| Attempt to reach Netgate WebGUI, DMZ origin, trusted LAN, and other DMZ hosts | All are unreachable from the public test network | Timeout/refusal and any expected firewall log |
| Authorized IPv6 test to each published Netgate and Pi IPv6 address: TCP 22, 80, 443, and 8080 as applicable | Only the explicitly documented public service works; Netgate management, SSH, the origin, and unused ports are closed | IPv6 connection results, WAN rule counters/logs, Pi nftables/service logs, and the tested address inventory |
| Access-protected hostname with allowed and denied identities | Allowed identity passes the policy; denied and unauthenticated users do not | Access event, application response, and no trusted-header bypass |
Interpret timeouts carefully: they may indicate a filtered port, a missing route, or a test-network limitation. Repeat from at least two independent external networks and record the protocol and address family. For explicit owned-address exposure tests, use a narrow command such as nc -6 -vz <netgate-ipv6> 22 80 443 and the equivalent Pi address/ports, or an approved IPv6 scanner; never scan networks or hosts you do not own or have permission to test.
Prove the disabled-IPv6 posture
If IPv6 is disabled for LAN4, the acceptance state is stronger than “the browser did not use it.” On the Pi, verify that ip -6 addr shows no global (scope global) or ULA (fc00::/7) address, ip -6 route has no default route, and router advertisements have not supplied a route or prefix. Check the Netgate LAN4 status and IPv6 neighbor/router-advertisement state as well. From independent recursive resolvers and the authoritative DNS service, verify that the public hostname has no AAAA record. Do not accept a hidden temporary address, a ULA-only management path, or a stale AAAA as “disabled.” If IPv6 is enabled instead, inventory the exact stable addresses, WAN/LAN4 pass rules, host firewall rules, and AAAA records and run the explicit Netgate/Pi IPv6 exposure tests above.
Run failure tests and verify fail closed
- Stop
cloudflared: the public hostname should fail with a tunnel/origin error, while the Pi’s private administration path remains available. No new inbound firewall rule should be needed. - Stop Nginx or the application: the connector may remain healthy but the public request must not reveal an unrelated service or management interface. Restore the service and confirm normal logs.
- Block the documented tunnel egress temporarily: public access should fail, and the Netgate rule log should identify the block. Do not “fix” this by allowing all inbound traffic.
- Disable Tailscale on the Pi: private administration should fail while the public web route continues only if Nginx and the connector are healthy. Re-enable it and verify the device policy.
- Remove or stage an incorrect AAAA record in a controlled test: confirm clients do not acquire an unprotected IPv6 route. Restore the intended DNS state and allow recursive TTLs to expire before judging the result.
- Disable the LAN4 IPv6 path, when that is the documented posture: verify no Pi global/ULA address, RA, or default route remains and that external
curl -6fails because there is no AAAA/path, while forcedcurl -4retains its intended result. - Reboot the Pi and Netgate during a maintenance window: verify the Pi address, Nginx bind, connector, Tailscale service, rule ordering, and logs recover without manual exposure changes.
Check observability and cleanup
- Correlate the test timestamp across Cloudflare events, Nginx access/error logs, Pi service journals, Tailscale events, DNS logs, and Netgate egress or deny logs.
- Confirm the successful public request used the tunnel’s outbound path, not an unnoticed WAN NAT rule. Review NAT and WAN rules after every test and remove temporary rules immediately.
- Check for leaked origin addresses, server headers, debug traces, directory listings, tokens, private URLs, and application errors in the response.
- Record test source, IPv4/IPv6 capability, DNS resolver, time, result, expected result, and remediation owner. Retest after DNS, certificate, firewall, connector, or application changes.
Acceptance criteria
Accept the deployment only when the intended public HTTPS service works from IPv4 and from the documented IPv6 posture, DNS has no stale A or AAAA exposure, Cloudflare Access behaves differently for allowed and denied identities, private administration requires Tailscale and least privilege, no public SSH or WAN forward exists, DMZ-to-LAN attempts are blocked and logged, and each induced failure recovers without broadening the policy. If any criterion is unknown, mark the service not ready and keep the public hostname disabled.
Sequence navigation
Previous: Logging Updates Backups and Recovery · Return to the access-method overview
dispelled