Your container still answers on its control bridge, yet every outbound packet leaves through the office macvlan. Nothing has failed. Two attachments each supply a gateway, and the single default route went to the one you did not intend, so the service keeps working while its traffic takes a longer path. By the time anyone notices, the evidence is a routing table rather than an error message.

The usual explanation is that Docker picked the network by accident. In practice the old decision followed the alphabetical order of the attached network names, which turned routing into a side effect of a label. gw_priority replaces that with a number you choose on purpose, and adopting it takes about a minute once the layout is written down.

Why the default route lands on the wrong network

Every network a service joins can offer its own gateway, and the container still ends up with exactly one default route. Before Compose 2.33.1 the tie was broken by sorting the network names, so a label such as control coming before office decided where traffic went. Nothing inside the container records that the decision was arbitrary, which is why it survives code review and surfaces later as an odd path. Putting a policy in the compose file is the only way to make the choice inspectable.

Two things genuinely change which network wins: the gw_priority values you assign, and which networks are attached at all. Add a third network and the candidate set grows; drop the macvlan and there is nothing left to compete with the bridge. Neither move shows up in application logs, because a wrong default route rarely breaks a connection outright. It only makes replies arrive from an address the other side may not expect.

Pin the gateway with gw_priority

Compose exposes gw_priority on each service-to-network attachment from version 2.33.1, and the highest number wins. There is no per-service gateway key, so the value belongs under the service's networks block. The example below hands the default to control while leaving office reachable for its directly connected LAN.

services:
  vpn:
    networks:
      control:
        gw_priority: 1
      office:
        gw_priority: 0

networks:
  control:
    driver: bridge
    ipam:
      config:
        - subnet: 172.30.50.0/24
  office:
    driver: macvlan
    driver_opts:
      parent: eno1.120
      macvlan_mode: bridge
    ipam:
      config:
        - subnet: 192.168.120.0/24
          ip_range: 192.168.120.192/27
          gateway: 192.168.120.1

Read the block as two separate controls: gw_priority decides the default route, and the ipam stanza decides addressing. Changing only the subnet leaves the exit choice untouched, which is the most common way this fix gets half applied. Keep the priorities next to the network they govern so the next reader does not have to hunt for the decision.

Do not confuse gw_priority with the plain priority property beside it. That one orders connection creation and says nothing about routing, so it cannot select a default gateway and does not fix interface naming. If someone recommends raising priority to steer traffic, the recommendation is wrong even when the resulting order happens to look correct.

Verify which network really carries the default route

Check inside the running container, because the host route table describes the host and not the namespace the service uses. Run the config check first so a typo fails before a restart, then read the route the container actually installed.

docker compose config --quiet
docker exec vpn ip -4 route show default
docker exec vpn cat /proc/net/route

A correct result names the bridge device and 172.30.50.1; a wrong one names 192.168.120.1 and the macvlan device. If the image ships no iproute2 package, the /proc file still reports the default route, with a destination of 00000000 and the gateway bytes reversed. Compare the interface column against docker inspect when the device names are unclear.

Macvlan is a physical port, not a bridge clone

Macvlan gives the container a second identity on the wire, which is exactly what makes local-device access work. It also means the switch port and the hypervisor must accept an extra MAC address, and plenty of cloud networks quietly refuse that. Test one container before planning a fleet, because a refused MAC usually appears as a link that is up and passing nothing.

The macvlan_mode: bridge setting is a mode of the macvlan driver, not Docker's software bridge, and passthru is a separate specialised mode rather than a default. Linux also keeps a macvlan container from reaching its own host through the same parent, which is the reason to keep the control bridge rather than expecting one attachment to do both jobs.

Name the VLAN parent before Compose sees it

Docker treats a dotted parent such as eno1.120 as an 802.1Q subinterface and creates the VLAN device itself. That convenience has limits: the derived name depends on the physical interface name, and long names can push the result past what the surrounding tooling expects. Creating a named VLAN interface in the host network configuration and pointing parent at it keeps both ends predictable.

Remember that the key under networks is a Compose name, not a Linux device. Searching the host for an interface called office will fail, because the kernel only ever sees the parent interface and the veth pair behind the container.

Make room for the VPN on top

A VPN client attaches after these networks exist, so it can add more specific routes, policy rules and filters regardless of gw_priority. Plan the remote prefixes against both local subnets, including the replies the control path needs. When a remote prefix overlaps the office LAN, fix the address plan instead of stripping an entire private range from the tunnel.

Re-check addresses, routes and the service path after the VPN starts and again after it reconnects, since a reconnect is where a hand-added route disappears. Write the expected table down before the first test so the comparison afterwards is a diff rather than a memory exercise. Keep persistent network ownership in Compose and let the VPN configuration own only its own policy.

مۇناسىۋەتلىك ماقالىلەر