Gluetun kill switch: when the VPN drops
Gluetun installs a real firewall kill switch inside its own network namespace. What your containers see when the tunnel dies, and how to prove it holds.
What the Gluetun kill switch actually is
The Gluetun kill switch is an iptables ruleset that Gluetun writes inside its own container network namespace at startup. It drops every outbound packet that is not addressed to your VPN (virtual private network) server, over the VPN interface, on the VPN port and protocol. So when the tunnel stops carrying traffic, nothing falls back to the host's normal internet connection. There is no second path to fall back to.
"Stops" is not one behaviour, and the difference matters when you are debugging. A container riding on Gluetun's network can get an instant permission error, or it can get a connection that hangs until it times out. Which one you see depends on whether the tunnel interface is still present. Neither one leaks.
Read the rules rather than trusting the word:
docker exec gluetun iptables -S
docker exec gluetun ip route showBy default the OUTPUT chain drops anything not bound for the VPN server, the INPUT chain drops anything that did not come from the Docker network on the container's non VPN default route, and the FORWARD chain drops everything. Gluetun installs this before it starts the VPN client and never takes it down, so there is no open window at startup. The compose snippets below are illustrative. Copy the option names, not the values.
The firewall is namespaced, and that is the whole trick
Every Docker container normally gets its own network namespace: its own interfaces, its own routing table, and its own netfilter rules. Gluetun's rules live in that namespace, which is why they cannot touch your host, and why your host cannot rescue a container Gluetun has cut off.
network_mode: "service:gluetun" puts a second container in the same namespace instead of giving it one of its own.
services:
gluetun:
image: qmcgaw/gluetun:v3
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
ports:
- "8080:8080"
qbittorrent:
image: lscr.io/linuxserver/qbittorrent:latest
network_mode: "service:gluetun"That second container has no eth0 of its own. It uses Gluetun's interfaces, Gluetun's routing table and Gluetun's firewall rules, because all of them belong to the namespace and not to a process. The application cannot opt out, and neither can a script running inside it. That is what separates this from a per-application kill switch, which has to recognise the application before it can stop it.
Two paths are open on purpose, and both are worth knowing before you call something a leak. FIREWALL_OUTBOUND_SUBNETS opens outbound access to the subnets you list, which is how a container reaches your LAN or another container on the host. Ports published on the Gluetun service map from the host into the namespace, and replies leave over the interface the request arrived on. Your app's web UI keeps loading while the tunnel is down for exactly this reason. Both paths are covered in reaching the host and other containers from behind Gluetun.
What your containers see when the peer goes away
WireGuard and OpenVPN fail differently, and someone expecting one shape of failure misreads the other.
WireGuard has no session to lose. wg0 exists whether or not the peer ever answers, and its routes stay in place. When the remote peer goes away, packets are still encrypted and sent to an endpoint that is no longer replying. Inside the dependent container a transfer stalls and nothing errors immediately, because from the kernel's point of view nothing is wrong. curl reports a timeout, not a refusal.
OpenVPN removes tun0 when its process exits, and every route through tun0 goes with it. The packet then has no matching route, or it matches the remaining default route and hits the drop policy. The failure arrives at once.
Run the test inside the namespace and read the exact message, because the message tells you which case you are in:
docker run --rm --network=container:gluetun alpine:3.22 ping -c 2 1.1.1.1ping: connect: Network is unreachable means the tunnel interface or its route is gone. ping: sendmsg: Operation not permitted means the packet had a route but netfilter dropped it, which is the firewall doing its job. A ping that simply gets no reply means the interface is up and the far end is not answering, the normal WireGuard case.
What does not happen in any of these cases is the packet leaving over your server's ordinary internet connection. Proving that on your own stack is the last section.
Why the health check restarts the VPN and not the container
Gluetun watches its own tunnel from inside the container. A startup check dials the addresses in HEALTH_TARGET_ADDRESSES (default cloudflare.com:443,github.com:443) over TCP and TLS (transport layer security) with a six second budget. After that, a small check pings the addresses in HEALTH_ICMP_TARGET_IPS (default 1.1.1.1,8.8.8.8) using ICMP (internet control message protocol) once a minute, falling back to a DNS query where ICMP is not permitted. A full check repeats the TCP and TLS dial every five minutes.
The data behind this chart
[
{
"label": "Docker HEALTHCHECK poll",
"runs_every_seconds": 5,
"longest_attempt_seconds": 5
},
{
"label": "Gluetun small check",
"runs_every_seconds": 60,
"longest_attempt_seconds": 30
},
{
"label": "Gluetun full check",
"runs_every_seconds": 300,
"longest_attempt_seconds": 30
}
]The small check runs every 60 seconds and retries with attempt timeouts that climb to 30 seconds, so a short stall does not trigger anything. The full check runs every 300 seconds. A tunnel that dies quietly is therefore noticed in about a minute, not instantly.
When a check fails and HEALTH_RESTART_VPN is on, which is the default, Gluetun restarts the VPN client process inside the running container. The container never stops, so the network namespace survives, so every container attached to it survives. Your download client does not need restarting each time your provider drops a session. This is the most useful single fact about Gluetun's health design.
The image also carries a Docker health check, which is a separate mechanism:
HEALTHCHECK --interval=5s --timeout=5s --start-period=10s --retries=3 CMD /gluetun-entrypoint healthcheckThat command queries an HTTP server Gluetun runs on 127.0.0.1:9999. A healthy tunnel returns 200 with an empty body. An unhealthy one returns 500 with the failure text as the body, and before the first check completes the body reads healthcheck did not run yet. Docker polls it every 5 seconds. From outside the container:
docker inspect --format '{{.State.Health.Status}}' gluetun
docker inspect --format '{{json .State.Health.Log}}' gluetunNow the part that surprises people. Docker does not restart an unhealthy container. A restart policy such as restart: unless-stopped reacts to the main process exiting, and an unhealthy status is not an exit. A Gluetun container can sit at Up 6 hours (unhealthy) for as long as you leave it there. Gluetun's internal VPN restart covers the common case, and anything beyond that needs a watcher on docker events. The wider behaviour of Docker Compose health checks and their depends_on conditions is worth reading before you build one.
Why restarting the Gluetun container takes its dependents offline
Sharing a namespace has a cost. docker restart gluetun tears the namespace down and builds a new one. The containers that were attached still point at the old one, their networking does not come back on its own, and they need restarting once Gluetun is up.
Recreating Gluetun is worse than restarting it. Change an environment variable, add a published port, or pull a new image, and any dependent that tries to start while Gluetun is not running fails with:
Error response from daemon: cannot join network namespace of a non running container: container gluetun is exitedThe same message appears after a host reboot, when the daemon starts a dependent before Gluetun is running. depends_on orders services within one docker compose up, but restart policies applied by the Docker daemon at boot do not go through Compose, so that ordering is not guaranteed. Bringing a Compose stack back after a reboot deals with that case directly.
The working rule: touching the Gluetun service means recreating everything attached to it, so run docker compose up -d --force-recreate against the whole stack rather than one service. The same rule explains why adding a web UI port for a new app is a whole stack operation, since every port for every container in the group is published on the Gluetun service. Routing Docker traffic through Gluetun covers the wiring, and a media stack sharing one VPN container shows the shape with several apps in the group.
Dead tunnel, dead resolver, or a lost port forward?
Three different failures produce the same complaint from an application. They have different fixes, so work through them in this order.
- Ask Gluetun first.
docker inspect --format '{{.State.Health.Status}}' gluetunreturningunhealthyputs the tunnel at the top of the list. Then readdocker logs --tail 50 gluetun, where a failing check is logged as a warning naming the check and each attempt with the milliseconds it took. - Separate routing from name resolution.
docker run --rm --network=container:gluetun alpine:3.22 ping -c 2 1.1.1.1tests the route.docker run --rm --network=container:gluetun alpine:3.22 nslookup ipinfo.iotests the resolver. - Read the combination. Both failing means the tunnel. Ping working while the lookup fails means DNS (domain name system) alone. Gluetun runs its own DNS server inside the namespace and forwards queries upstream over DoT (DNS over TLS) by default, and those queries ride the tunnel like everything else, so a dead tunnel also breaks names. A resolver failure with a healthy tunnel points at
DNS_UPSTREAM_RESOLVERS, at the block lists, or at an upstream refusing you. DNS failing over a WireGuard tunnel walks through the same symptom outside Docker. - Check the forwarded port on its own. Connections going out while nothing connects in is a port forwarding problem, not a kill switch problem. Gluetun asks the provider for a port after the tunnel comes up, and that port can change on every reconnect, including an internal health restart you never saw. The app is then listening on a port the provider no longer forwards.
VPN_PORT_FORWARDING_UP_COMMANDruns a command with{{PORT}}substituted so the new port reaches the app straight away, and what Gluetun's forwarded port actually gives you covers which providers support it at all.
The leak check to run after any config change
Do this on your own stack, and do it from inside the namespace. Checking from your app's web UI proves nothing, because that page reaches you through a published port on the host over the non VPN interface, and it keeps working while the tunnel is down.
- Record your server's own public address from the host:
curl -s https://ipinfo.io/ip. - Ask the same service from inside Gluetun's namespace:
docker run --rm --network=container:gluetun alpine:3.22 sh -c "apk add wget && wget -qO- https://ipinfo.io". The address must differ from step 1. If it matches, the container is not on the tunnel at all. - Break the tunnel on purpose. Set
VPN_ENDPOINT_IPto192.0.2.1, an address reserved for documentation that cannot answer, and recreate the whole stack. - Run step 2 again. It must fail. Any answer at all, and above all the address from step 1, means traffic found a way around the tunnel and you have a real leak to chase.
- Put the real endpoint back, recreate the stack again, and confirm step 2 returns the VPN address before you walk away.
Run this after any change to FIREWALL_OUTBOUND_SUBNETS, and after any edit that touches network_mode. A dropped network_mode: "service:gluetun" line is the most common real leak on these stacks, and Docker prints nothing when it happens. The container simply starts on the default bridge with working internet.
What the Gluetun kill switch does not cover
- Anything you allowed yourself. Entries in
FIREWALL_OUTBOUND_SUBNETSand ports published on the Gluetun service are open by design. They are not failures. - Anything outside the namespace. A container without
network_mode: "service:gluetun"sits on your normal Docker network, and Gluetun has no view of it and no rules over it. - IPv6 (internet protocol version 6), where your Docker network hands out IPv6 addresses and your provider does not carry IPv6. Run
docker exec gluetun ip -6 addr showand look, rather than assuming. - The setting called
FIREWALL_ENABLED_DISABLING_IT_SHOOTS_YOU_IN_YOUR_FOOT. That is its real name in the image. Leave it aton.
All of the behaviour above comes from the design rather than from one release's defaults, so it survives upgrades. A firewall lives inside a network namespace, and every container in the group shares that namespace. The numbers are the part that can move, so check them with docker exec gluetun printenv on the tag you actually run before you build a monitoring rule around one of them.
FAQ
Does Gluetun stop my containers when the VPN drops?
No. The containers keep running and lose their network. Gluetun's firewall drops outbound traffic that is not headed for the VPN server, so an application stays alive with no working path out. Gluetun also restarts the VPN client inside the running container when its own health check fails, and because the container itself does not stop, the shared network namespace survives and the containers attached to it do not need restarting.
Why does my download client lose its network when I restart Gluetun?
Because network_mode: "service:gluetun" places that container inside Gluetun's network namespace rather than giving it one. Restarting or recreating Gluetun destroys that namespace, and the dependent still references something that no longer exists. Restart every container in the group after Gluetun is back up. A dependent that starts while Gluetun is down fails with cannot join network namespace of a non running container.
How do I tell a dead tunnel from a DNS problem?
Run both tests inside the namespace. docker run --rm --network=container:gluetun alpine:3.22 ping -c 2 1.1.1.1 tests routing, and docker run --rm --network=container:gluetun alpine:3.22 nslookup ipinfo.io tests resolution. A ping that answers while the lookup fails points at Gluetun's internal DNS server or its upstream, not at the tunnel. Both failing points at the tunnel, and docker inspect --format '{{.State.Health.Status}}' gluetun confirms which one you are looking at.
Will Docker restart Gluetun automatically when it goes unhealthy?
No. Docker restart policies react to the main process exiting, and health status is not an exit, so a container can stay at Up 6 hours (unhealthy) indefinitely. Gluetun covers the common case itself by restarting the VPN client in place while HEALTH_RESTART_VPN is on, which is the default. Restarting the container on an unhealthy status needs a separate watcher on docker events, and remember that restarting the container takes every attached container's network with it.