Docker 📖 10 min read

Docker Networking Explained

Most Docker networking surprises come from three facts: containers on the default network can't find each other by name, a published port listens on every interface, and Docker's firewall rules take effect before ufw or firewalld. This guide explains each of them, then builds a layout where only a reverse proxy faces the network and the database can't reach the internet at all.

Jump to: user-defined networks, publishing ports, the firewall gap or a reverse proxy layout.

Before you start

You need Docker Engine on Linux; the Docker Installation Guide covers the install. Docker Desktop on macOS and Windows runs containers inside a virtual machine, so the host-side commands here (ip, iptables, ss) show that VM's view, not your laptop's.

A few tools make the examples easier to follow. They're in the standard repositories of every major distribution:

ip -brief address        # host interfaces, including docker0 once Docker is running
sudo ss -ltnp            # what is listening on which address and port
docker network ls        # bridge, host and none exist on every install

The network drivers you'll actually use

DriverWhat it doesTypical use
bridgeA private virtual network on the host, with NAT to the outsideAlmost everything. Compose creates one per project.
hostNo isolation: the container uses the host's network stack directlySoftware that needs broadcast or multicast discovery, such as some media servers or Home Assistant
noneOnly a loopback interfaceBatch jobs that should have no network at all
macvlan / ipvlanGives the container its own address on your physical LANServices that must look like a separate machine to the network

This guide concentrates on bridge networks, because that's where nearly every everyday question comes from.

The default bridge, and why to avoid it

A container started with plain docker run joins the network called bridge, backed by the docker0 interface on the host, usually 172.17.0.0/16. It has one important limitation: containers on the default bridge can't find each other by name. They can only use IP addresses, and those change every time a container is recreated.

docker run -d --name web nginx:alpine
docker run --rm alpine:3 ping -c 1 web      # ping: bad address 'web'
docker rm -f web

The fix isn't to look up IP addresses or to use the deprecated --link flag. Create your own network.

User-defined bridge networks

On a network you create yourself, Docker runs an embedded DNS server at 127.0.0.11 inside each container, which resolves other containers on the same network by name.

docker network create appnet
docker run -d --name web --network appnet nginx:alpine
docker run --rm --network appnet alpine:3 wget -qO- http://web | head -n 4
docker rm -f web && docker network rm appnet

Containers on appnet can't see containers on other networks unless you connect them to both, so each network is also an isolation boundary. You can connect a running container to an extra network with docker network connect appnet container.

Compose does all of this for you. Every project gets a network named project_default, and each service is reachable on it by its service name. A database service called db is simply db to every other service in the project. If you add container_name: to a service, that name resolves too, but the service name is the one to rely on.

To see what's on a network:

docker network inspect miniflux_default --format '{{range .Containers}}{{.Name}} {{.IPv4Address}}{{"\n"}}{{end}}'

Publishing ports

Containers on a bridge network can reach the outside world through NAT, but nothing outside can reach them until you publish a port. The syntax is [host address:]host port:container port:

MappingWho can connect
-p 8080:80Anyone who can reach the host on port 8080, on every interface, IPv4 and IPv6
-p 127.0.0.1:8080:80Only processes on the host itself, such as a reverse proxy
-p 192.168.1.20:8080:80Only clients that reach the host through that one address
-p 8080:80/udpThe same, for UDP instead of TCP

EXPOSE in a Dockerfile publishes nothing. It's documentation that tells you which port the app listens on. Containers on the same network can reach every port a container listens on, exposed or not.

After publishing, check what's really listening:

docker port web
sudo ss -ltnp | grep docker-proxy

The firewall gap

This is the networking behaviour that surprises people most. On Linux, Docker writes its own iptables (or nftables) rules to forward traffic for published ports, and those rules take effect before the ones ufw and firewalld manage. The result: a port published as 8080:80 is reachable from the network even if ufw status shows 8080 as blocked.

You can see the rules Docker added:

sudo iptables -t nat -L DOCKER -n
sudo iptables -L DOCKER-USER -n

There are three ways to handle it, in order of preference:

  1. Don't publish what doesn't need to be public. A database reached only by other containers needs no ports: at all. An app behind a reverse proxy on the same host can bind to 127.0.0.1.
  2. Bind to a specific address, such as a VPN or LAN address, when only that network should reach the service.
  3. Filter in the DOCKER-USER chain. Docker leaves this chain alone and sends forwarded container traffic through it first. Rules here can restrict who reaches a published port:
# Allow port 8080 only from the 192.168.1.0/24 LAN; drop it from everywhere else.
# --ctorigdstport matches the original port, before Docker's NAT rewrote it.
sudo iptables -I DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 ! -s 192.168.1.0/24 -j DROP

Replace eth0 with your external interface from ip -brief address. Rules added with iptables are lost at reboot unless you save them with your distribution's persistence tool. Docker's packet filtering and firewalls page covers the details, including how firewalld zones interact with Docker.

Whatever you choose, test it from another machine. A check from the host itself goes through the loopback interface and tells you nothing about what the network can reach.

# from a second machine on the network
nc -zv 192.168.1.20 8080

A reverse proxy layout

When several web apps run on one host, a common layout puts one reverse proxy on ports 80 and 443 and keeps every app private. Compose can express that with two networks:

compose.yaml
name: web

services:
  proxy:
    image: caddy:2
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy-data:/data
    networks:
      - frontend

  app:
    image: miniflux/miniflux:2.3.4
    restart: unless-stopped
    environment:
      DATABASE_URL: postgres://miniflux:${POSTGRES_PASSWORD}@db/miniflux?sslmode=disable
    networks:
      - frontend
      - backend

  db:
    image: postgres:17-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: miniflux
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - db-data:/var/lib/postgresql/data
    networks:
      - backend

networks:
  frontend:
  backend:
    internal: true

volumes:
  caddy-data:
  db-data:
  • Only the proxy publishes ports. The app and the database publish none.
  • The proxy and the app share frontend, so the proxy reaches the app as app:8080.
  • The app and the database share backend. The proxy isn't on it, so even a compromised proxy can't connect to the database directly.
  • internal: true means backend has no route to the internet. The database can't download anything or connect out, which it never needs to.
Caddyfile
rss.example.net {
    reverse_proxy app:8080
}

Replace the hostname with a real domain that points at the host. Caddy then obtains a certificate automatically. A proxy service using Nginx works the same way, with proxy_pass http://app:8080;; the Nginx Web Server Setup guide covers that configuration.

To share one proxy between several Compose projects, create the frontend network once with docker network create proxy, then declare it in each project as external:

networks:
  proxy:
    external: true

Host networking

network_mode: host removes the network namespace. The container binds directly to the host's interfaces, so there's no NAT, no port mapping and no per-container firewall rules, and ports: is ignored. Use it when software genuinely needs it, for example to receive multicast discovery traffic from devices on your LAN. Remember that a host-mode container listening on 0.0.0.0 is exposed exactly like a process on the host, and here your normal firewall rules do apply.

DNS inside containers

On user-defined networks, containers use Docker's embedded DNS at 127.0.0.11. It answers for container and service names and forwards everything else to the resolvers in the host's /etc/resolv.conf. If the host uses systemd-resolved, whose stub listens on 127.0.0.53, Docker detects that loopback address won't work from a container and uses the upstream servers from /run/systemd/resolve/resolv.conf instead.

To choose the upstream servers yourself, set them in /etc/docker/daemon.json and restart Docker:

{
  "dns": ["192.168.1.1", "9.9.9.9"]
}
sudo systemctl restart docker
docker run --rm alpine:3 nslookup techwilla.com

IPv6

By default, Docker bridge networks are IPv4 only. A published port still listens on the host's IPv6 addresses, because docker-proxy accepts the connection and forwards it over IPv4, but containers themselves have no IPv6 address and can't reach IPv6-only destinations.

To give a Compose network IPv6, enable it per network:

networks:
  frontend:
    enable_ipv6: true

Docker then assigns addresses from a unique local range (fd00::/8) and adds NAT rules for outbound traffic, the same way it does for IPv4. Two things to check afterwards. First, the firewall gap applies to IPv6 as well: Docker writes ip6tables rules for published ports, so filter them in the IPv6 DOCKER-USER chain. Second, if your host has no working IPv6 route, enabling it on networks makes some clients try IPv6 first and wait for a timeout before falling back. Leave it off unless you need it.

Avoiding address conflicts

Docker allocates bridge subnets from 172.17.0.0/16 upwards and then from the 192.168.0.0/16 range. If your LAN, a VPN or an office network uses one of those ranges, the host may lose its route to real machines as soon as Docker creates a network. The symptom is a host that works until a particular Compose project starts.

Pick a range nothing else uses and tell Docker to allocate from it:

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

Existing networks keep their old subnets, so remove and recreate them after restarting Docker: docker compose down and up -d for each project.

Troubleshooting

One container can't reach another by name

They're probably on different networks, or one of them is on the default bridge. docker inspect container --format '{{json .NetworkSettings.Networks}}' lists each container's networks. Test from inside with a throwaway container on the same network: docker run --rm --network miniflux_default alpine:3 nslookup db.

The app works from the host but not from other machines

Either the port is bound to 127.0.0.1, or the app inside the container listens only on its own loopback. docker port shows the first. For the second, check the app's settings for a listen address and set it to 0.0.0.0; inside a container, that only means "all of the container's interfaces".

Containers have no internet access

Check that IP forwarding is on with sysctl net.ipv4.ip_forward, which should print 1. Some hardening guides turn it off, which breaks container NAT. Also check whether the network was created with internal: true.

address already in use when publishing a port

Another process or container holds the host port. sudo ss -ltnp | grep :8080 names it.

Next steps