Docker Networking Explained: A Practical 2026 Guide

Docker Networking Explained: A Practical 2026 Guide

The first time I ran two containers on the same host and expected them to see each other, I got "connection refused" for an hour. Both were up, both worked alone, and pinging one by name from the other did nothing. In Docker, name resolution is not automatic. It depends on which network the containers are on.

I have deployed 30-plus Nextcloud, Jitsi, Matrix, and Moodle stacks for clients, and almost every networking ticket traces back to four problems: containers in one project that cannot talk, a service that answers curl locally but not from a laptop, a database port open to the internet even though UFW blocks it, and host mode copied from a tutorial without knowing what it gives up.

How Docker Networking Actually Works

Every container gets its own network namespace, Linux's way of giving a process its own interfaces, routes, and IP address. Docker links that namespace to the host with a virtual ethernet (veth) pair: one end inside the container, the other plugged into a bridge on the host, like a virtual switch.

That bridge is docker0 by default. Outbound traffic is translated by NAT to the host's IP, the same trick your home router uses, and publishing a port with -p sets up the reverse rule. That one mechanism explains most of the surprises below.

Docker's Network Drivers, Compared

Docker Engine 29.x ships six network drivers; five matter in practice.

DriverWhat it doesUse it when
bridgePrivate network on the host, NAT to reach outsideDefault choice for almost every single-host setup
hostContainer shares the host's namespace, no NATThe app needs raw throughput or binds ports dynamically
overlayVXLAN network spanning multiple hostsYou run Swarm across more than one server
macvlanOwn MAC address and a real IP on your LANThe container must look like a physical device
ipvlanContainers share the parent's MAC, separate IPsYour switch limits MACs per port
noneNo networking at allRare, for isolated batch jobs

New to containers? My Docker for beginners guide covers the earlier mistakes.

The Default Bridge Is Not Good Enough

Installing Docker creates a default bridge called docker0 on subnet 172.17.0.0/16 with gateway 172.17.0.1. Every container started without --network lands there.

The catch: it has no DNS between containers, so you cannot reach one by name. The old answer, the --link flag, was deprecated years ago and should not appear in new work; Docker's docs now treat the default bridge as legacy.

Create your own instead:

docker network create --driver bridge --subnet 172.20.0.0/16 mynet
docker run -d --name app --network mynet nginx
docker run -d --name db --network mynet postgres:16

Any network you create is a user-defined network, and Docker runs an embedded DNS server at 127.0.0.11 inside every container attached to it. It maps container names and aliases to their current IPs and forwards everything else upstream. That is the biggest reason to abandon the default bridge.

docker exec -it app ping -c2 db
docker exec -it app nslookup db 127.0.0.11

Containers Talking to Containers (Compose)

This is why Compose usually just works. docker compose up creates a user-defined bridge per project named <project>_default, so embedded DNS is live immediately and every service is reachable by its service name.

If services cannot reach each other, check two things: they live in the same Compose file and start together, and they connect by service name rather than localhost. A web app should talk to db:5432, not 127.0.0.1:5432, because inside a container localhost is the container itself.

One pattern I use often: give the database a network with no way out.

services:
  web:
    image: myapp:latest
    networks: [frontend, backend]
    ports:
      - "127.0.0.1:8080:80"
  db:
    image: postgres:16
    networks: [backend]

networks:
  frontend:
  backend:
    internal: true

internal: true means containers on backend cannot reach the internet and nothing outside can reach them. The database can talk to web; nothing can talk to the database directly.

Ports, UFW, and the Security Mistake Everyone Makes

This one surprises experienced admins. You configure UFW, block 5432 from outside, run docker run -p 5432:5432 postgres, and Postgres is still reachable from the internet. UFW never saw the decision.

Publishing a port is DNAT, and Docker writes its own rules into the DOCKER and DOCKER-USER iptables chains, ahead of your regular firewall rules. It is documented, expected behavior rather than a bug.

Three fixes, simplest first:

  1. Bind to localhost: -p 127.0.0.1:5432:5432. Nothing outside the host can reach it. Put a reverse proxy in front for anything public; my NGINX reverse proxy guide covers that.
  2. Add rules to the DOCKER-USER chain, evaluated before Docker's forwarding rules.
  3. Use the ufw-docker tool if you want UFW to stay in charge.

For anything holding real data I use option one on nearly every client server.

When to Use Host Mode (and When Not To)

With network_mode: host the container gives up its own network namespace and shares the host's. No veth pair, no NAT, lower latency. Fair for Home Assistant, some Pi-hole setups, and services where NAT overhead measurably matters.

The cost: ports: is ignored and the app binds host ports directly, so two host-mode containers cannot both want 8080, and you lose isolation. Default to bridge; use host mode only for a measured reason, such as multicast device discovery.

Giving a Container a Real LAN IP with Macvlan

Macvlan gives a container its own MAC address and a real IP on your LAN, so your router sees it as a separate physical machine. That matters for Home Assistant when it must see Z-Wave or Zigbee devices over multicast, and for Pi-hole when every device should use it as their DNS server by IP.

docker network create -d macvlan \
  --subnet=192.168.1.0/24 --gateway=192.168.1.1 \
  -o parent=eth0 macnet

docker run -d --name pihole --network macnet --ip 192.168.1.50 pihole/pihole

The gotcha: the host cannot talk to its own macvlan containers by default, because traffic from the parent interface back to a macvlan child on the same interface is dropped by the kernel. The fix is a macvlan shim, a small host interface bridged into the same network, created with ip link add. Skip it and the container works from every device except the one running Docker.

IPvlan is similar, but containers share the parent's MAC, which helps where your switch limits MACs per port. Overlay is the multi-host option, tunneling traffic with VXLAN on UDP 4789, and rarely worth the complexity outside Swarm. IPv6 is off by default and must be enabled in /etc/docker/daemon.json. And on rootless Docker in 2026, networking now defaults to gvisor-tap-vsock instead of slirp4netns, a slower path than rootful Docker.

Three Fixes for the Errors I See Most

  1. A container name will not resolve. Compare networks with docker network inspect mynet. Containers on the default bridge, or split across two Compose projects, will never resolve each other. Test from inside: docker exec -it app nslookup db 127.0.0.11.
  2. Connection refused although the process is running. Usually not a networking problem. Check the bind address inside the container with netstat -tlnp. If the app listens on 127.0.0.1 instead of 0.0.0.0, it only accepts connections from itself, and no port mapping can fix that.
  3. TLS handshakes hang over a VPN. Usually an MTU mismatch. Containers default to 1500 while WireGuard tunnels typically run at 1420. Set "mtu": 1420 in /etc/docker/daemon.json or per-network with driver_opts, then restart the containers.

Frequently Asked Questions

Do I need a custom Docker network?

Yes, for anything beyond a standalone container. The default bridge has no DNS between containers, so a user-defined bridge is the standard fix for name-based service discovery.

What is 127.0.0.11 in Docker?

It is the embedded DNS server Docker runs inside containers on a user-defined network. It resolves container names and aliases to their current IPs and forwards other lookups upstream.

How do I stop a container port from being public?

Bind it to localhost, for example -p 127.0.0.1:8080:80, and put a reverse proxy in front of anything that must be public. That avoids the trap where Docker's iptables rules bypass UFW.

Does host networking make Docker faster?

It removes NAT and the veth pair, lowering latency and CPU overhead slightly, but the difference is rarely noticeable for typical self-hosted apps. It also disables port mapping and isolation, so it is a tradeoff, not a free upgrade.

Final Thoughts

Most Docker networking problems come down to one root cause: not knowing which network a container is actually on, or assuming a port is private when Docker has quietly made it public. Create your own networks, check bind addresses, and most of this stops being mysterious.

Setting up Nextcloud, Jitsi, Matrix, or Moodle and want a second pair of eyes on the networking before go-live? Feel free to reach out. This is what I fix for clients every week.