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 cannot turn that name into an address, because both were started without --network. This lesson shows why, then builds the setup that works: containers on a network you created, reaching each other by name, with only the services that need outside traffic 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.8.2
$ docker network ls --filter type=builtin
NETWORK ID NAME DRIVER SCOPE a61cea6118e5 bridge bridge local 3052775d188b host host local 1b4aa92363a3 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.8.2
$ docker run -d --name lab-web nginx:1.30-alpine
c8f6761edfa9ee5dd98b4502993aa2f01b7e282fcf8f62f7ffd4e833b6ba15a0
$ docker run --rm alpine:3.22 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.22 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 does not 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, which is 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.8.2
$ docker network create lab-net
b04e9367633f1e201ce6d0aa407bd6000dc02f479ef5bc45447d90d1cf071c1d
$ docker run -d --name lab-web --network lab-net nginx:1.30-alpine
b3a57370f6455e1ad8af072ed9cb126870157a8e942619406bd524c6152159c4
$ docker run --rm --network lab-net alpine:3.22 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.22 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 because 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.8.2
$ docker run --rm --network lab-net alpine:3.22 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, which is the first thing to check when a name does not resolve:

ubuntu@secopslog-docker:~/lab/networking · Docker 29.8.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.4:8080
reaches lab-pub
curl 192.168.2.4: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.8.2
$ docker run -d --name lab-pub --network lab-net -p 8080:80 nginx:1.30-alpine
3aa1013424db3718a7167575bc596808c115af9429238d2cffe84e49072d580c
$ docker run -d --name lab-local --network lab-net -p 127.0.0.1:8081:80 nginx:1.30-alpine
e1b6050eecdffc734ac8c40bdd81ec0dc8c5070c502050cd4d6b69d6edab0b91
$ 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.8.2
$ hostname -I | awk '{print $1}'
192.168.2.4
$ 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 did not 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.4 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.8.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. A "deny incoming" ufw policy therefore does not 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.8.2
$ docker run -d --name lab-loop --network lab-net node:24-alpine node -e "require('http').createServer((q, r) => r.end('hello')).listen(9000, '127.0.0.1')"
0098e4239d94b69e8e8e7a009465e90a6b60761bf6b0fc7f0fd9fd42674fca1a
$ docker exec lab-loop wget -qO- http://127.0.0.1:9000
hello
$ docker run --rm --network lab-net alpine:3.22 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) and 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.8.2
$ docker rm -f lab-loop && docker run -d --name lab-loop --network lab-net node:24-alpine node -e "require('http').createServer((q, r) => r.end('hello')).listen(9000, '0.0.0.0')"
lab-loop 2862317325db96cb02e8b3ac78f10c56df8387ca3f0c0850c505e37cdb8e29f4
$ docker run --rm --network lab-net alpine:3.22 wget -T 3 -qO- http://lab-loop:9000
hello

When two containers cannot talk, check in this order: are both attached to the same network (docker network inspect), does the name resolve (nslookup from a container on that network), and is the server listening on 0.0.0.0 on the port you are calling. Publishing (-p) is not on that list.

Host networking and the rest

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

With --network host the container uses the host's interfaces directly, so its ports are already the host's ports and 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 cannot reach a company host while 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), and 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.8.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
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.

Try this

Work through “Host networking and the rest” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

If you keep one thing from container networking basics, keep “Host networking and the rest”. Decide now which check you will run when this shows up on a live system, and write it somewhere your team will find it.

Related