Self-Hosting
VPN kill-switch: verify a container's egress IP
Make a container egress only through the VPN and prove it — the network_mode: service:gluetun pattern, the fail-closed firewall, the curl-from-inside egress-IP check, the tunnel-down leak test, and DNS/IPv6 leak traps.
Problem summary
When you route a container through a VPN, the operator concern is leakage: if the tunnel drops, does it silently fall back to your ISP's IP? The standard pattern is a VPN-gateway container (commonly gluetun) with the workload attached via `network_mode: service:gluetun`, so the workload has no other route out — fail-closed by default (tunnel down = no egress). The non-negotiable step is verification: exec into the workload and `curl` an IP-echo service to confirm it returns the VPN IP, not your ISP IP; then stop the gateway and confirm the curl now fails (the kill-switch holds). Allow only your LAN subnet through, and watch the DNS and IPv6 leak paths.
Confirm the workload uses network_mode: service:<vpn>.
docker exec <workload> curl -s ifconfig.me # or ipinfo.io/ip
The workload shares the VPN container's network namespace.
Don't run sensitive traffic until this passes.
Layer path
Step-by-step runbook
Start here. Do each check in order, compare it to the expected result, and stop when the evidence explains the failure or the safe stop point applies.
Attach the workload to the VPN namespace
Check: Set network_mode: service:gluetun on the workload.
Expected result: The workload has no non-tunnel interface.
If not: Remove its own ports/networks that bypass the gateway.
Verify the egress IP
Check: Curl an IP-echo from inside the workload.
Expected result: It returns the VPN IP, not your home IP.
If not: If it's your ISP IP, fix routing before continuing.
Test fail-closed
Check: Stop gluetun and re-curl from the workload.
Expected result: Egress fails (no IP) while the tunnel is down.
If not: If an IP comes back, the kill-switch is broken — fix the firewall.
Safe stop: Don't run sensitive traffic until this passes.
Allow the LAN explicitly
Check: Add your LAN subnet to FIREWALL_OUTBOUND_SUBNETS.
Expected result: The workload reaches local services with the firewall still on.
If not: Never disable the firewall to get LAN access.
Close the DNS leak
Check: Keep DoT default or tunnel/encrypt any local resolver.
Expected result: Lookups don't leak to your ISP.
If not: A forced plaintext local DNS is a leak.
Close the IPv6 leak
Check: Disable container IPv6 or confirm the tunnel carries v6; verify with curl -6.
Expected result: No v6 egress outside the tunnel.
If not: Host v6 + v4-only tunnel leaks — handle it explicitly.
Decision tree
If: Inside-curl returns your ISP IP
Then: The workload isn't actually on the VPN namespace, or the firewall allows direct egress.
Action: Fix network_mode/firewall; re-verify the egress IP.
Safe stop: Don't run anything sensitive until the egress IP is the VPN's.
If: Tunnel-down test still returns an IP
Then: The kill-switch isn't fail-closed.
Action: Correct the firewall so all non-tunnel egress is blocked.
Safe stop: Treat this as broken until egress fails closed.
If: Workload can't reach LAN services
Then: LAN traffic is blocked by the (correct) default firewall.
Action: Add your LAN subnet to FIREWALL_OUTBOUND_SUBNETS — don't disable the firewall.
If: DNS resolves via a local plaintext server
Then: Lookups can leak even if data goes through the tunnel.
Action: Use the tunnel's DoT default or tunnel/encrypt the local resolver.
If: Host has IPv6, tunnel is IPv4-only
Then: v6 traffic egresses outside the VPN.
Action: Disable container/stack IPv6 or use a v6-capable tunnel; verify with curl -6.
Evidence table
| Symptom | Evidence to collect | Likely layer | Next action |
|---|---|---|---|
| Inside-curl shows home IP | network_mode and gluetun firewall config | Workload not on the VPN namespace | Attach via network_mode: service:gluetun; re-verify. |
| Egress works with gluetun stopped | Result of the tunnel-down curl | Kill-switch not fail-closed | Fix the firewall to block all non-tunnel egress. |
| Workload can't reach NAS/proxy on LAN | FIREWALL_OUTBOUND_SUBNETS value | LAN not allowed through | Add the LAN subnet (keep the firewall on). |
| ISP resolver shows the lookups | DNS config (DoT vs forced local) | DNS leak | Use DoT default or encrypt/tunnel local DNS. |
| curl -6 returns your ISP's v6 | Host/container IPv6 state vs tunnel | IPv6 leak | Disable container IPv6 or use a v6 tunnel. |
Commands and settings paths
Verify the egress IP from inside the workload
docker exec <workload> curl -s ifconfig.me # or ipinfo.io/ip
Where: On the Docker host
Expected: Returns the VPN exit IP/geo, not your home IP.
Failure means: Your ISP IP means the workload isn't routed through the tunnel.
Safe next step: Fix network_mode/firewall and re-run.
Test the kill-switch fails closed
docker stop gluetun && docker exec <workload> curl -s --max-time 8 ifconfig.me; docker start gluetun
Where: On the Docker host
Expected: The curl times out / returns nothing while gluetun is down.
Failure means: Any IP returned means egress leaks when the tunnel drops.
Safe next step: Correct the firewall; wait ~10–15s after restart before re-testing.
Check for an IPv6 leak
docker exec <workload> curl -6 -s --max-time 8 ifconfig.co
Where: On the Docker host
Expected: Fails, or shows the VPN's v6 — not your ISP's v6 address.
Failure means: Your ISP's v6 means IPv6 is bypassing the tunnel.
Safe next step: Disable container IPv6 or use a v6-capable tunnel.
Hardware and platform boundary
Change only when
- Adopt the gateway-container pattern the moment any container should be VPN-only.
- Add the tunnel-down and curl -6 checks to a periodic verification routine, not just initial setup.
Evidence that matters
- network_mode: service:<vpn> so the workload has no non-tunnel route.
- A verified VPN egress IP and a tunnel-down test that fails closed.
- FIREWALL_OUTBOUND_SUBNETS for LAN, DoT for DNS, and IPv6 handled.
Evidence that does not matter
- Which VPN brand, beyond it working with your gateway (WireGuard/OpenVPN).
- Raw throughput before correctness — verify no-leak first.
Avoid
- Trusting the setup without verifying the egress IP.
- Disabling the firewall to reach the LAN.
- Ignoring DNS/IPv6 leak paths.
Related tool
Use the linked tool to turn this runbook into a guided check for your exact setup.
Device setup troubleshooterRelated problems
Last reviewed
2026-06-03 · Reviewed by HomeTechOps. Built from 2026-06 research verified against the gluetun project (fail-closed firewall, FIREWALL_OUTBOUND_SUBNETS), its DNS options (DoT default), and WireGuard. Scoped strictly to privacy hygiene and egress verification — no acquisition guidance. The operator differentiator is proving the kill-switch with the inside-curl egress check and the tunnel-down fail-closed test, plus the DNS/IPv6 leak paths.
Sources/assumptions
- Assumes a Docker Compose stack using a VPN-gateway container (gluetun is the common choice) with a workload container attached via network_mode: service:gluetun; the tunnel is WireGuard or OpenVPN.
- The fail-closed firewall, FIREWALL_OUTBOUND_SUBNETS LAN allow-through, and DNS-over-TLS default are from the gluetun project; the curl-egress-IP and tunnel-down verification are the standard operator checks.
- This is strictly privacy hygiene and egress verification — no acquisition/piracy guidance; the point is proving traffic exits where you intend.
Source-backed checks
HomeTechOps turns official docs and conservative safety rules into a shorter runbook. These links are the source trail for the page direction.