Container networking basics

Published ports, user-defined networks and name resolution.

Beginner12 min · lesson 12 of 14

ping: bad address 'lab-web' is the first networking error most people meet. The container lab-web is running. The second container simply can't turn that name into an address, because both were started without --network. You'll see why, then build the setup that works. Containers sit on a network you created and reach each other by name. Only the services that need outside traffic are published on the host. Everything runs on the main lab VM (secopslog-docker), and there are no lesson files. Work in a new directory, mkdir -p ~/lab/networking && cd ~/lab/networking.

The default bridge has addresses, not names

Docker creates three networks when it is installed:

ubuntu@secopslog-docker:~/lab/networking · Docker 29.9.2
$ docker network ls --filter type=builtin
NETWORK ID NAME DRIVER SCOPE f6ea016bfcf4 bridge bridge local ef80cf9cbf32 host host local 75a866341239 none null local

bridge is a private network inside the host, behind a Linux bridge interface called docker0. Every container started without --network joins it. host removes network isolation and puts the container directly on the host's network stack. none gives the container only a loopback interface. The network IDs differ on every machine.

Start Nginx on the default bridge and try to reach it by name:

ubuntu@secopslog-docker:~/lab/networking · Docker 29.9.2
$ docker run -d --name lab-web nginx:1.31-alpine
c00fdecb2f643fbed51fc75db37a4aacbee8f2509bb57424763c41365efe7121
$ docker run --rm alpine:3.24 ping -c 1 lab-web
ping: bad address 'lab-web'
$ docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' lab-web
172.17.0.2
$ docker run --rm alpine:3.24 wget -qO- http://$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' lab-web) | grep '<title>'
<title>Welcome to nginx!</title>
$ docker rm -f lab-web
lab-web

The name fails and the IP address works. The default bridge doesn't register container names in any DNS, so lab-web means nothing to the second container, while 172.17.0.2 is reachable. The {{range .NetworkSettings.Networks}} template reads the address from the container's per-network settings. Docker 29 removed the old top-level .NetworkSettings.IPAddress field, so templates in older guides fail. The address is handed out when the container starts and can be different after a restart. That's why an IP address in a config file is a bug waiting to happen.

A user-defined network gives you names

Create a bridge network of your own and put the web server on it:

ubuntu@secopslog-docker:~/lab/networking · Docker 29.9.2
$ docker network create lab-net
d5a7b20777c382b20d5e885a23b48cabb68e03ee4d7f357131396ae32dc3bd9d
$ docker run -d --name lab-web --network lab-net nginx:1.31-alpine
8694cf2e7a68e36c3bae454a2916c173f9f58afcce8a0df9d1972610bb6005bf
$ docker run --rm --network lab-net alpine:3.24 nslookup lab-web
Server: 127.0.0.11 Address: 127.0.0.11:53 Non-authoritative answer: Non-authoritative answer: Name: lab-web Address: 172.18.0.2
$ docker run --rm --network lab-net alpine:3.24 wget -qO- http://lab-web | grep '<title>'
<title>Welcome to nginx!</title>

Containers attached to a user-defined network get Docker's embedded DNS server, which answers at 127.0.0.11 inside every container on that network. nslookup shows it as the Server, and the answer maps lab-web to its current address. The output has two Non-authoritative answer: headers. nslookup asked for an IPv4 (A) record and an IPv6 (AAAA) record, and the network has no IPv6. The subnet comes from Docker's default address pools, so you may see 172.18 or another 172.x range. The wget by name proves the whole path, name to address to port 80.

The container's resolver configuration shows how this is wired:

ubuntu@secopslog-docker:~/lab/networking · Docker 29.9.2
$ docker run --rm --network lab-net alpine:3.24 cat /etc/resolv.conf
# Generated by Docker Engine. # This file can be edited; Docker Engine will not make further changes once it # has been modified. nameserver 127.0.0.11 search . options edns0 trust-ad ndots:0 # Based on host file: '/etc/resolv.conf' (internal resolver) # ExtServers: [host(127.0.0.53)] # Overrides: [] # Option ndots from: internal

Docker generated /etc/resolv.conf with nameserver 127.0.0.11. Names of containers on the network are answered locally. Anything else (a package mirror, a public API) is forwarded to the host's resolver, listed as ExtServers. docker network inspect shows which containers are attached. That's the first thing you should check when a name doesn't resolve:

ubuntu@secopslog-docker:~/lab/networking · Docker 29.9.2
$ docker network inspect lab-net -f '{{range .Containers}}{{.Name}} {{.IPv4Address}}{{println}}{{end}}'
lab-web 172.18.0.2/16

A container can also answer to extra names on a network with --network-alias. Compose uses exactly this mechanism to make each service name resolvable, as the next lesson shows. Drivers, aliases and the DNS server in more depth are in "Networks, drivers and DNS" in Docker in depth.

One host, two ways in
Clients outside the VM
curl 192.168.2.6:8080
reaches lab-pub
curl 192.168.2.6:8081
refused: bound to 127.0.0.1
Host addresses
0.0.0.0:8080 and [::]:8080
-p 8080:80
127.0.0.1:8081
-p 127.0.0.1:8081:80
lab-net (user-defined bridge, DNS at 127.0.0.11)
lab-web
not published, reachable by name
lab-pub
published on all addresses
lab-local
published on loopback only
lab-loop
port 9000, reached by name
Containers on lab-net talk to each other on container ports without -p. Publishing only decides what reaches a container from outside the network.

Publishing decides who can reach a port

Traffic between containers on lab-net needs no -p. Publishing is for traffic that starts outside: a browser, another machine, a tool on the host. Publish one container on all addresses and one on loopback only:

ubuntu@secopslog-docker:~/lab/networking · Docker 29.9.2
$ docker run -d --name lab-pub --network lab-net -p 8080:80 nginx:1.31-alpine
4d3b46531237b8710b924584e36c6f2c8947535ca4720c738bbd9ffab5ec8688
$ docker run -d --name lab-local --network lab-net -p 127.0.0.1:8081:80 nginx:1.31-alpine
5546f4adfc01f7fddacbb8fc10d7326ee2500d7c31b6ac85d4caf935dfaa28a1
$ docker port lab-pub; docker port lab-local
80/tcp -> 0.0.0.0:8080 80/tcp -> [::]:8080 80/tcp -> 127.0.0.1:8081

-p 8080:80 was published twice, on 0.0.0.0 (every IPv4 address of the host) and [::] (every IPv6 address). -p 127.0.0.1:8081:80 exists only on the loopback address. Now test both from the VM's own LAN address, the way another machine would see them:

ubuntu@secopslog-docker:~/lab/networking · Docker 29.9.2
$ hostname -I | awk '{print $1}'
192.168.2.6
$ curl -s -o /dev/null -w '%{http_code}\n' http://$(hostname -I | awk '{print $1}'):8080/
200
$ curl -s -o /dev/null -w '%{http_code}\n' http://$(hostname -I | awk '{print $1}'):8081/
000
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8081/
200

Port 8080 answered 200 on the LAN address. Port 8081 didn't answer at all on that address (curl exit status 7, could not connect) but answered on 127.0.0.1. On a laptop or a server, a database or admin UI that only you need belongs on 127.0.0.1, or not published at all. Your 192.168.2.6 will be a different address.

The daemon writes firewall rules for every published port, through a firewall backend that docker info reports:

ubuntu@secopslog-docker:~/lab/networking · Docker 29.9.2
$ docker info -f '{{.FirewallBackend.Driver}}'
iptables
Watch out
Published ports are opened with Docker's own packet-filtering rules, which the daemon inserts ahead of the rules that tools such as ufw and firewalld manage. So a "deny incoming" ufw policy doesn't cover a port you published on 0.0.0.0. This VM uses the iptables backend, the default. Docker 29 can also use nftables, still marked experimental. How the packet gets through, and how to filter it, is in "Publishing ports and the packet path" in Docker in depth.

Reachable by name, still refused

A name that resolves is half the job. The server inside the container also has to listen on an address other containers can reach. A small Node server bound to 127.0.0.1:

ubuntu@secopslog-docker:~/lab/networking · Docker 29.9.2
$ docker run -d --name lab-loop --network lab-net node:26-alpine node -e "require('http').createServer((q, r) => r.end('hello')).listen(9000, '127.0.0.1')"
6a413265ed50650fca470eea754a86d799343192d726e28bfd3d50c58c99526f
$ docker exec lab-loop wget -qO- http://127.0.0.1:9000
hello
$ docker run --rm --network lab-net alpine:3.24 wget -T 3 -qO- http://lab-loop:9000
wget: can't connect to remote host (172.18.0.5): Connection refused

From inside its own container the server answers. From a neighbour on the same network, the name resolves (the error shows the address), but the connection is refused. Each container has its own network stack and its own loopback interface, so 127.0.0.1 inside lab-loop is reachable only from inside lab-loop. Development servers that default to localhost (many do) cause this. Bind to 0.0.0.0 instead:

ubuntu@secopslog-docker:~/lab/networking · Docker 29.9.2
$ docker rm -f lab-loop && docker run -d --name lab-loop --network lab-net node:26-alpine node -e "require('http').createServer((q, r) => r.end('hello')).listen(9000, '0.0.0.0')"
lab-loop 2e589a8bf3a2b64975b654c76f39e6fe246154e39edcc356726c3c7d932d3fd4
$ docker run --rm --network lab-net alpine:3.24 wget -T 3 -qO- http://lab-loop:9000
hello

When two containers can't talk, check three things in order. Are both attached to the same network (docker network inspect)? Does the name resolve (nslookup from a container on that network)? Is the server listening on 0.0.0.0 on the port you're calling? Publishing (-p) isn't on that list.

Host networking and the rest

ubuntu@secopslog-docker:~/lab/networking · Docker 29.9.2
$ docker run --rm --network host -p 8082:80 alpine:3.24 true
WARNING: Published ports are discarded when using host network mode

With --network host the container uses the host's interfaces directly. Its ports are already the host's ports, so Docker discards -p with a warning. Host networking avoids NAT, at the price of isolation and of port clashes with everything else on the host. Use it only when you have measured a reason. --network none leaves only loopback, for batch jobs that should have no network at all.

One problem appears only on some machines: the container can't reach a company host while you're connected to a VPN. The usual cause is an address overlap. Docker takes subnets from its default pools: 172.17.0.0/16 for the default bridge, then further 172.x and 192.168.x ranges for user-defined networks. If a VPN or office LAN routes the same range, traffic meant for it stays inside Docker. The fix is to give Docker different pools (default-address-pools), covered in "Configuring the daemon safely" in Docker in depth.

Clean up the lab:

ubuntu@secopslog-docker:~/lab/networking · Docker 29.9.2
$ docker rm -f lab-web lab-pub lab-local lab-loop && docker network rm lab-net
lab-web lab-pub lab-local lab-loop lab-net

Try this

While lab-web is still on lab-net, run docker run --rm alpine:3.24 nslookup lab-web once without --network and once with --network lab-net. Predict which lookup finds the name, using the default-bridge ping failure above as your hint, then compare the Server lines of the two answers.

Remember

Put containers that talk to each other on a network you create, call them by name, and bind servers inside to 0.0.0.0. The two failures here look alike but aren't. bad address 'lab-web' on the default bridge is a name problem, while lab-loop resolved to 172.18.0.5 and was still refused because it listened on 127.0.0.1. Adding -p fixes neither.

Quick check
01web and api were both started with plain docker run -d and no network option. Inside web, the config says API_URL=http://api:8000, and the logs show bad address 'api'. What fixes it without hard-coding an IP?
Incorrect — Publishing opens a port on the host. It registers no names for other containers.
Correct — A user-defined network gives both containers the embedded DNS server at 127.0.0.11, which resolves container names.
Incorrect — The default bridge never registers container names, however often you restart.
Incorrect — The host's resolver knows nothing about container names either, and you lose isolation.
02A Postgres container should be reachable by your app container on the same user-defined network and by nobody else. How do you start it?
Incorrect — Containers on the same network connect on container ports directly. This publishes the database on every host address.
Incorrect — Host networking exposes the database on the host's interfaces and removes the isolation you wanted.
Incorrect — Docker's published-port rules are evaluated before ufw's, so the deny rule does not protect it.
Correct — The app reaches it by name on 5432 over the network; with nothing published, there is no way in from outside.
03From a neighbour container, wget http://lab-loop:9000 prints can't connect to remote host (172.18.0.5): Connection refused, while docker exec lab-loop wget -qO- http://127.0.0.1:9000 works. What is wrong?
Correct — The name resolved (the address is in the error), and the server only listens on its own loopback. Bind to 0.0.0.0.
Incorrect — On the default bridge the error would be bad address; here the name resolved to 172.18.0.5.
Incorrect — Container-to-container traffic on a shared network never needs -p.
Incorrect — The embedded DNS answers with the current address. A stale address would not explain why the same server works on 127.0.0.1.

Related