Docker Swarm Overlay Networks and Routing Mesh
Publish a port on a cluster and every node answers it - including nodes running none of your containers. How the ingress mesh and the service VIP work, proved on three machines.
Swarm and Multi-Host (optional) Guide 43 of 46 Advanced
- Cluster1 manager, 2 workers
- OSUbuntu 26.04 LTS (resolute)
- Docker Engine29.7.2 on every node
- Architectureamd64
- TimeAbout 14 min
- Reviewed22 August 2026
Tested on the versions above. Captured on a real three-node cluster. Node IDs, IP addresses and join tokens are specific to that cluster - tokens shown here are truncated deliberately and were rotated afterwards. Match the shape of the output, not the values.
| Server Name | IP Address | OS | Roles | CPU | RAM | HDD |
|---|---|---|---|---|---|---|
| DOCKER01 | 192.168.0.21 | Ubuntu 26.04 LTS | Swarm Manager (Leader) | 2 Core | 4 GB | 50 GB |
| DOCKER02 | 192.168.0.22 | Ubuntu 26.04 LTS | Swarm Worker | 2 Core | 4 GB | 50 GB |
| DOCKER03 | 192.168.0.23 | Ubuntu 26.04 LTS | Swarm Worker | 2 Core | 4 GB | 50 GB |
Before you start
- A service running on a cluster - guide 42 in this path.
- Bridge networking and DNS on a single host - guide 10 in this path.
-
Overlay is a network that spans machines
A bridge network is local to one host. An overlay network is cluster-wide: containers on different machines get addresses on the same subnet and reach each other directly, with the traffic encapsulated between nodes. Note the scope -
swarm, notlocal.bash Example session docker network create -d overlay cg-netq3k901yho3i3dc5dwm650uvcrdocker network inspect cg-net --format "driver={{.Driver}} scope={{.Scope}} subnet={{range .IPAM.Config}}{{.Subnet}}{{end}}"driver=overlay scope=swarm subnet=10.0.1.0/24Expected resultAn overlay network with cluster scope and its own subnet.
Success conditionScope is
swarm. The network exists on every node that runs a task attached to it - it is created lazily, so it may not appear innetwork lson an idle node. -
Services find each other by name, across machines
Attach services to the overlay and each gets a DNS name equal to its service name, resolvable from any task on that network regardless of which machine either is on. This is the single-host behaviour from earlier in the path, extended over the cluster.
bash Example session docker service create --name cg-api --network cg-net --replicas 3 nginx:alpinedocker service ps cg-api --format "{{.Name}} {{.Node}}"cg-api.1 ahm-docker03cg-api.2 ahm-docker02cg-api.3 ahm-docker01Expected resultA successful HTTP fetch using only the service name.
Verify it worked
bash docker exec $(docker ps -q -f name=cg-probe) wget -qO- -T5 http://cg-api | head -4<!DOCTYPE html><html><head><title>Welcome to nginx!</title>Success conditionThe name resolved and the request was answered by a task that may be on another machine entirely.
-
One name, one virtual address
By default the service name resolves to a single virtual IP, not to the task addresses. The VIP is stable for the life of the service and the kernel load-balances behind it, so callers never see tasks appearing and disappearing. If you want the individual addresses - for a client that does its own balancing, or a stateful cluster -
tasks.gives them.bash Example session docker service inspect cg-api --format "vip={{range .Endpoint.VirtualIPs}}{{.Addr}}{{end}} mode={{.Spec.EndpointSpec.Mode}}"vip=10.0.1.2/24 mode=vipdocker exec $(docker ps -q -f name=cg-probe) nslookup tasks.cg-api | tail -4Address: 10.0.1.5Address: 10.0.1.4Expected resultOne VIP for the service, several addresses behind
tasks..Success conditionYou can explain why the service IP never changes while tasks come and go.
--endpoint-mode dnsrrswitches to DNS round-robin instead of a VIP. -
The routing mesh: every node answers the published port
This is the part that surprises people. Publishing a port publishes it on EVERY node in the cluster, not just the ones running tasks. Any node that receives the request forwards it to a healthy task somewhere. Nine requests, three sources, three destinations - all answered.
bash Example session for dst in 21 22 23; do curl -s -m5 -o /dev/null -w "%{http_code} " http://192.168.0.$dst:8080; done200 200 200Expected resultEvery node answering on the published port.
Success conditionAll nine succeed. That is what lets you point a load balancer at any node, or all of them, without tracking placement.
-
Proof: a node with no tasks still serves
The strongest demonstration. Drain a node so it runs none of the service's tasks, then request the port from it anyway - it still answers, by forwarding into the mesh.
bash Example session docker node update --availability drain ahm-docker03docker service ps cg-web --filter desired-state=running --format "{{.Node}}" | sort | uniq -c 3 ahm-docker01 3 ahm-docker02# ahm-docker03 now runs ZERO tasks of this servicecurl -s -m 5 -o /dev/null -w "%{http_code}" http://192.168.0.23:8080200Expected result200 from a node hosting none of the containers.
Success conditionYou have separated "where the container runs" from "where the service answers". Set the node back with
--availability active. -
ingress, and when to bypass it
The mesh is implemented by a built-in overlay network called
ingress. It costs an extra hop and it hides the client's real IP behind the mesh, which breaks IP-based logging and rate limiting.mode=hostpublishes on the node's own interface instead - no mesh, only nodes with a task answer, and the real client address arrives intact.bash Example session docker network inspect ingress --format "driver={{.Driver}} scope={{.Scope}} subnet={{range .IPAM.Config}}{{.Subnet}}{{end}}"driver=overlay scope=swarm subnet=10.0.0.0/24Expected resultThe built-in ingress network, and the alternative publish mode.
Success conditionYou can choose. Host mode means one task per node on that port, and your load balancer must know which nodes have tasks.
-
The localhost trap
Worth knowing before you waste an afternoon. The published port is DNAT'd for IPv4 only.
localhoston a modern system resolves to::1first, so a request tolocalhost:8080is refused while127.0.0.1:8080succeeds against the same working service - which reads exactly like a broken cluster.bash Example session curl -s -m 5 -o /dev/null -w "localhost:8080 = %{http_code}" http://localhost:8080; echolocalhost:8080 = 000curl -s -m 5 -o /dev/null -w "127.0.0.1:8080 = %{http_code}" http://127.0.0.1:8080; echo127.0.0.1:8080 = 200Expected resultThe same service failing by name and succeeding by address.
Success conditionYou test with an explicit IPv4 address before concluding the mesh is broken.
sudo iptables -t nat -L DOCKER-INGRESS -nshows the IPv4-only rule.
Troubleshooting
Services on the same overlay cannot reach each other
Why: Overlay data uses 4789/udp and discovery uses 7946 on TCP and UDP. Blocking any of them lets the cluster form while leaving the network unusable.
Fix:Open all of them between every pair of nodes, then retest by service name from inside a task.
bash docker exec $(docker ps -q -f name=probe) wget -qO- -T5 http://other-servicenslookup of a service name returns NXDOMAIN although connections work
Why: The resolver appended a DNS search domain to the service name, so it queried
service.your.domain. The service name itself still resolves for actual connections.Fix:Test with a real connection rather than nslookup, or query the fully qualified
tasks.<service>form.bash docker exec TASK wget -qO- -T5 http://cg-api# a successful fetch beats a confusing nslookupThe client IP is always an internal address
Why: The ingress mesh source-NATs the connection, so the application sees the mesh, not the caller.
Fix:Publish with
mode=hostwhere the true address matters, or terminate at a proxy that sets a forwarded header.bash docker service update --publish-rm 8080 --publish-add mode=host,published=8080,target=80 cg-webPort already allocated when creating a service
Why: The routing mesh reserves the published port on EVERY node, so anything already using it on any node conflicts.
Fix:Pick a free port cluster-wide, or use host mode. Check across nodes, not just the one you are on.
bash docker service ls --format "{{.Name}} {{.Ports}}"