Containers answer on the office LAN, then stop answering the moment the company VPN connects. Nothing in the container changed: no restart, no image update, no config edit. What changed is the host routing table, where a route the tunnel added now covers the address Docker handed out. Before you edit a subnet or ask for a VPN exclusion, put both address plans side by side, because the repairs cost very different amounts of time.

Read the route before you change the config

Start with the network Docker actually built. The IPAM block is the address plan, and the subnet is the line that matters:

docker network inspect project_default --format '{{json .IPAM.Config}}'
# [{"Subnet":"172.18.0.0/16","Gateway":"172.18.0.1"}]

Compose allocates a /16 for each new project by default, so two services can sit inside a range of 65,536 addresses. That width is why overlaps are common: a randomly picked /16 is big enough to swallow a site you reach over the tunnel, and nobody notices until the tunnel comes up.

Then compare route selection for one container address, once with the tunnel down and once with it up:

ip route get 172.18.0.7
# 172.18.0.7 dev br-3f2a9c src 172.18.0.1

Two shapes of answer lead to different places. If the output names tun0, wg0 or another tunnel device, a VPN prefix is at least as specific as the bridge route and the packet never reaches the container. If it still names the bridge, the host can reach the container, and your fault is filtering, DNS, or a return path that fails in one direction only.

Why a /16 collision is easy to hit

Docker draws bridge networks from the private pools, and the default pool comes out of the 172.16.0.0/12 block in /16 slices. Corporate networks frequently use the same private space, and a remote-access VPN pushes those prefixes to every client that connects. When both sides believe they own 172.18.0.0/16, one routing table holds two contradictory claims.

Prefix length decides the winner, not the order routes appear in. A tunnel pushing 10.0.0.0/8 does not beat a bridge route of 172.18.0.0/16, because /8 is less specific. A tunnel pushing 172.18.0.0/16, or 172.18.5.0/24 inside it, does. That is why one laptop works on the office network and fails on hotel Wi-Fi with the same VPN: the client receives different prefixes.

The same table governs the other direction. A container that needs an internal host at 10.8.4.20 sends the packet to its bridge gateway, and the host then routes it into the tunnel. That path normally works because the host performs the NAT, but it fails when the corporate side accepts only the laptop's VPN address, or when reverse-path filtering drops the asymmetric reply.

Compare the three repairs

There are only three ways out, and they differ in who must approve them and what breaks while you apply one.

RepairWho owns itWhat it costs
Renumber the container networkYouRecreates the network, restarts services, invalidates literal addresses in configs and scripts
Exclude your range from the tunnelNetwork teamChanges what every laptop sees, needs a change request and a client profile update
Reach the service through a published portYou and the clientOne port only, no direct container-to-container routing across the tunnel

Renumbering is the only repair you can finish without a ticket, which is why most write-ups stop there. It is cheap on a young project and expensive once a migration tool or a stored connection string points at a literal address. Count those references before you edit YAML, not after.

An exclusion from the tunnel is the cleanest answer when the overlap sits in a range your company genuinely owns. It is also the slowest, and it is useless to a contractor who cannot change the client profile. A published port or a small reverse proxy avoids the argument: the client talks to an address the tunnel already routes correctly, and only that port has to be reachable.

Pin a range on purpose

For a single project, declare the subnet in the compose file so the project stops drawing from the shared pool:

networks:
  default:
    ipam:
      config:
        - subnet: 10.201.7.0/24

Take that range from a registry your team keeps, not from an example you found while searching. Check it against the prefixes the VPN pushes, the home LANs people work from, and the other container networks on the same host. Then plan the recreation, because an existing network keeps its old subnet: docker compose down, then up -d. docker network rm refuses while endpoints are attached, and any ipv4_address entry in the file has to fall inside the new range.

For networks Docker creates on its own, set a daemon pool in /etc/docker/daemon.json:

{
  "default-address-pools": [
    { "base": "10.201.0.0/16", "size": 24 },
    { "base": "10.202.0.0/16", "size": 24 }
  ]
}

The pool applies to networks created after the daemon restarts, and bridges that already exist keep their addresses. That is convenient and easy to forget, so treat an old project as still overlapping until someone recreates its network. Docker's engine networking documentation describes the allocation rules if you need to defend the pool in review.

Linux bridge and Docker Desktop fail differently

On a Linux host, containers sit on bridges in the host namespace. One table decides everything, so the comparison above is the whole story: read the bridge route, read the tunnel route, and see which one wins for a real container address.

Docker Desktop puts containers behind a VM with its own NAT. The host has no route to the container address at all; it reaches the service through a published port, usually bound to 127.0.0.1. A VPN client can therefore break the service without any subnet overlap, either by installing a route for the VM's internal range or by blocking loopback traffic while the tunnel is connected. If ip route get on the host reports no route for the container address, that is expected rather than the bug.

The practical consequence is where you look first. On Linux, start at the routing table. On Desktop, start at the published port and the VPN client's firewall rules, then check whether the VM's internal subnet collides with something the tunnel pushes.

Verify with the tunnel up

Reconnect the VPN twice before you call it fixed, because a profile that pushes routes on connect and withdraws them on disconnect can look correct after the first attempt. Then run the checks the client actually runs:

  1. ip route get from the host, using the container's real address.
  2. A request from another container, to prove the service itself is healthy.
  3. curl -sv http://127.0.0.1:8080/ from the client with the tunnel up, then traceroute -n to the container address from a machine on the LAN.
  4. sysctl net.ipv4.conf.all.rp_filter if the request reaches the host but the reply disappears.

Finally, write the chosen range into the same registry you used to pick it, with the date and the project that owns it. The next overlap will come from a project nobody documented, and the only defence is that the range was reserved on purpose rather than drawn at random.

Erthyglow kevrennek