Container Networking
How containers talk to each other and the outside world: bridge, host and none networks, user-defined networks with built-in DNS, publishing ports and common connection problems.
Network drivers
Every container has its own network namespace: its own interfaces, IP addresses, routing table and ports (see What Are Containers?). A network driver decides how that private stack is connected to anything else. The ones you meet on a single host:
| Driver | What the container gets | Typical use |
|---|---|---|
bridge |
A private IP address on a virtual switch inside the host; outgoing traffic is translated (NAT) to the host's address | The default; almost every container |
host |
No network of its own: it uses the host's interfaces | Rare cases that need the host's network directly |
none |
Only a loopback interface | Batch jobs that must not talk to anything |
overlay |
One network spread over several hosts | Docker Swarm clusters |
macvlan, ipvlan |
An address directly on the physical network | Legacy applications that must look like a real machine |
Multi-host networking in Kubernetes works differently again; see Services, Ingress and the Gateway API and overlay networks.
Bridge networks
A bridge network is a software
switch inside the host (a Linux bridge, the same idea as a
virtual switch
in a hypervisor). Each container on it gets a virtual network cable,
a pair of connected interfaces: one end is
eth0 inside the container, the other is plugged into
the bridge. The container receives an address from a
private range, and the host translates its outgoing traffic to the host's own
address, exactly like a home router does (NAT).
Docker creates one bridge network at installation, called
bridge (the Linux interface is docker0,
usually with the subnet 172.17.0.0/16). Every container
you start without --network lands there.
The default bridge has no DNS
On the default bridge, containers can reach each other only by IP
address. A meter-api container called meter-api-v1 runs
on the default bridge; a client container cannot find it by name,
but its address works:
$ docker inspect --format '{{.NetworkSettings.IPAddress}}' meter-api-v1
$ docker run --rm --name client busybox:1.37 wget -qO- -T 3 http://meter-api-v1:8000/health
$ docker run --rm --name client busybox:1.37 wget -qO- -T 3 http://172.17.0.3:8000/health
172.17.0.3
wget: bad address 'meter-api-v1:8000'
{"status": "ok"}
"bad address" means the name could not be resolved. Hard-coding
172.17.0.3 is no solution: a container gets an address
when it starts, and after a restart or a redeploy it may get another
one.
User-defined networks
A user-defined network
is a bridge network you create yourself with
docker network create. It does two things the default
bridge does not: containers on it resolve each other's names, and
containers on different user-defined networks cannot reach each
other at all. That makes it the right home for an application's
containers: the portal, meter-api and database of Kestrel Energy
share one network, and an unrelated container on the same host
cannot see them.
$ docker network create kestrel-net
$ docker network ls --filter name=kestrel-net
$ docker network inspect --format '{{.Driver}} {{range .IPAM.Config}}{{.Subnet}} gw {{.Gateway}}{{end}}' kestrel-net
68df7a10c55b84b45c151aac91243e5bccd18f4a03155719127f70190aeb9566 NETWORK ID NAME DRIVER SCOPE 68df7a10c55b kestrel-net bridge local bridge 172.21.0.0/16 gw 172.21.0.1
Docker picked the free subnet 172.21.0.0/16; the
gateway 172.21.0.1 is the host's side of the bridge. A
container started on this network is reachable by its name:
$ docker run -d --name api --network kestrel-net kestrel-meter-api:1.1
$ docker run --rm --name client --network kestrel-net busybox:1.37 wget -qO- http://api:8000/readings
891ed11cbd08a1876039c8ede6e800ed917ab009199b9a43d997fd602745e815
[{"meter": "KE-0001", "kwh": 4123.7}, {"meter": "KE-0002", "kwh": 977.1}, {"meter": "KE-0003", "kwh": 15.0}]
A running container can join a network later, and it can get an
extra name (an alias) there. The application can then use a stable,
meaningful name such as meter-api, whatever the
container itself is called:
$ docker network connect --alias meter-api kestrel-net meter-api-v1
$ docker run --rm --name client --network kestrel-net busybox:1.37 wget -qO- http://meter-api:8000/health
{"status": "ok"}
A container can be on several networks at once, with one interface per network. That is how you connect a reverse proxy to the front-end network while the database sits only on a back-end network.
The embedded DNS server
Name resolution on a user-defined network comes from Docker's
embedded DNS server, a
small resolver that Docker runs for every container on such a
network at the address 127.0.0.11. It answers for the
names and aliases of the containers on the same networks and
forwards every other question to the host's DNS servers. Each
container's /etc/resolv.conf points at it:
$ docker run --rm --name client --network kestrel-net alpine:3.22 grep nameserver /etc/resolv.conf
$ docker run --rm --name client --network kestrel-net busybox:1.37 nslookup api.
nameserver 127.0.0.11 Server: 127.0.0.11 Address: 127.0.0.11:53 Non-authoritative answer: Non-authoritative answer: Name: api Address: 172.21.0.2
(busybox's nslookup prints "Non-authoritative answer"
once for the IPv4 and once for the IPv6 question; the trailing dot
after the name stops it from appending the host's search domain.)
getent hosts asks the same way an application does, so
it is the better test of what your program will see. Compare a
user-defined network with the default bridge:
$ docker run --rm --name client --network kestrel-net alpine:3.22 getent hosts api
$ docker run --rm --name client alpine:3.22 getent hosts meter-api-v1
172.21.0.2 api api
The first command finds the address; the second, on the default
bridge, prints nothing at all and ends with exit status 2, which
means "not found". When a name does not resolve, check first which
network each container is on:
docker inspect --format '{{json .NetworkSettings.Networks}}'
portal. For DNS itself, see
name resolution.
Publishing ports
A container's ports are reachable from the host and from containers
on the same network, but not from anywhere else. To accept
connections from outside, you publish a port: Docker then forwards a
port of the host to a port of the container, which is
port forwarding
done with NAT rules in the host's kernel. The details of
-p and docker port are on
Running Containers; what
matters for the network is which host address a port is bound to:
def parse_publish(spec):
"""Split a docker run -p value into its parts."""
proto = "tcp"
if "/" in spec:
spec, proto = spec.rsplit("/", 1)
host_ip = ""
if spec.startswith("["): # IPv6 address in brackets
end = spec.index("]")
host_ip, spec = spec[1:end], spec[end + 2:]
parts = spec.split(":")
if len(parts) == 3:
host_ip, host_port, container_port = parts
elif len(parts) == 2:
host_port, container_port = parts
else:
host_port, container_port = "(random)", parts[0]
return host_ip or "ALL (0.0.0.0 and ::)", host_port, container_port, proto
specs = ["8080:8080", "127.0.0.1:8080:8080", "[::1]:8443:8443",
"5432", "9000-9002:9000-9002/udp"]
print(f"{'-p value':26}{'host address':22}{'host port':12}{'container':12}proto")
for spec in specs:
host_ip, host_port, container_port, proto = parse_publish(spec)
print(f"{spec:26}{host_ip:22}{host_port:12}{container_port:12}{proto}")
-p value host address host port container proto 8080:8080 ALL (0.0.0.0 and ::) 8080 8080 tcp 127.0.0.1:8080:8080 127.0.0.1 8080 8080 tcp [::1]:8443:8443 ::1 8443 8443 tcp 5432 ALL (0.0.0.0 and ::) (random) 5432 tcp 9000-9002:9000-9002/udp ALL (0.0.0.0 and ::) 9000-9002 9000-9002 udp
Three of the five forms listen on every interface of the host, on
IPv4 and IPv6. The short form -p 8080:8080 from every
tutorial is the risky one: on a server with a public address, the
port is now open to the internet. Bind to the
loopback address
and put a reverse proxy in front for anything public (see
reverse proxies). A port that only other containers need, like the database's
5432, should not be published at all.
The localhost trap
In a container, localhost and 127.0.0.1
mean the container's own loopback interface. Two mistakes follow
from that, and they cause a large share of "it works on my laptop"
tickets:
-
Connecting to
localhostto reach another container. The portal's settingMETER_API_URL=http://localhost:8000worked when both programs ran directly on a laptop, and fails in containers with "connection refused". Use the other container's name on a shared network:http://meter-api:8000. -
The application listening on
127.0.0.1inside the container. The port is published, the container runs, but connections from outside fail (curl typically reports the connection as reset or an empty reply): forwarded traffic arrives on the container'seth0, where nothing listens. Configure the server to listen on all addresses inside the container (0.0.0.0or::); the host-side binding is what limits who can reach it.
To reach a service on the host itself from a container on Linux, add
--add-host=host.docker.internal:host-gateway and
connect to host.docker.internal; Docker Desktop
provides that name automatically.
Published ports and the host firewall
Docker writes its own packet-filtering rules (iptables, by default)
when it publishes a port. Its documentation is explicit about the
consequence for ufw, the usual firewall front end on Ubuntu and
Debian: Docker routes container traffic in the
nat table, so the packets are diverted before they reach
the INPUT and OUTPUT chains that ufw uses.
A ufw rule that denies port 8080 therefore does not stop traffic to
a container published on port 8080.
Docker Engine 28.0 tightened the default rules: hosts on the same local network can no longer reach a container directly by its internal address unless a port is published, and ports published on a loopback address are protected from neighboring machines. Docker Engine 29 added an nftables back end for these rules, still experimental as of October 2026. For host firewalls in general, see Linux Security and firewalls.
Host and none networks
Host network mode
In host network mode (--network host) the container does not get a network namespace of its own: it
uses the host's interfaces, addresses and ports directly. A server
in the container that listens on port 8000 is listening on port 8000
of the host. There is no NAT and no -p (published ports
are ignored), and there is also no network isolation: the container
can bind any host port and see all host traffic its privileges
allow.
Host mode is the right choice for a few cases, such as a network-monitoring agent that must see the host's interfaces. It is the wrong answer to "my container cannot connect": it hides the real problem (usually a wrong name, a missing network or the localhost trap) and removes a layer of protection.
The none network
--network none gives the container only a loopback
interface. Nothing goes in or out, which suits a job that processes
files and must not be able to send data anywhere:
$ docker run --rm --name client --network none busybox:1.37 ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host
valid_lft forever preferred_lft forever
2: sit0@NONE: <NOARP> mtu 1480 qdisc noop qlen 1000
link/sit 0.0.0.0 brd 0.0.0.0
(sit0 is an unused tunnel device that the kernel adds;
it is down and carries nothing.) On a bridge network the same
command shows an eth0 with the container's address:
$ docker run --rm --name client --network kestrel-net busybox:1.37 ip -4 addr show eth0
3: eth0@if515: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue
inet 172.21.0.4/16 brd 172.21.255.255 scope global eth0
valid_lft forever preferred_lft forever
eth0@if515 says that the other end of this virtual
cable is interface number 515 on the host, the end plugged into the
bridge.
When Docker's subnets clash with yours
Docker chooses private subnets for its networks, such as
172.17.0.0/16 for the default bridge and
172.21.0.0/16 for the network created above. If an
office network or a VPN uses an overlapping range, the host now has
two routes for the same addresses, and traffic for a real server
disappears into a Docker bridge. The symptom is confusing: one
server on the VPN is unreachable from one particular machine, the
one where Docker runs. A quick overlap check with Python's
ipaddress module:
import ipaddress
docker_networks = {
"bridge (docker0)": "172.17.0.0/16",
"kestrel-net": "172.21.0.0/16",
}
office_routes = {
"office LAN": "10.20.0.0/16",
"VPN to data center": "172.21.8.0/22",
"guest Wi-Fi": "192.168.50.0/24",
}
for net_name, net in docker_networks.items():
for route_name, route in office_routes.items():
a, b = ipaddress.ip_network(net), ipaddress.ip_network(route)
if a.overlaps(b):
print(f"CLASH: {net_name} {a} overlaps {route_name} {b}")
server = ipaddress.ip_address("172.21.9.40") # a server behind the VPN
for net_name, net in docker_networks.items():
if server in ipaddress.ip_network(net):
print(f"Traffic to {server} goes into {net_name}, not into the VPN")
CLASH: kestrel-net 172.21.0.0/16 overlaps VPN to data center 172.21.8.0/22 Traffic to 172.21.9.40 goes into kestrel-net, not into the VPN
The fix is to give Docker address ranges nobody else uses, in the
daemon configuration /etc/docker/daemon.json, and then
restart Docker and recreate the networks. Agree the range with the
network team first:
{
"bip": "10.250.0.1/24",
"default-address-pools": [
{ "base": "10.251.0.0/16", "size": 24 }
]
}
bip sets the default bridge's own address and subnet;
default-address-pools is where Docker takes the subnets
for new networks from, here in blocks of /24 (see
prefix notation).
Legacy links
Older tutorials connect containers with
docker run --link db:db. Links on the default bridge
are a legacy feature: they are deprecated, Docker Engine 29 no
longer sets the environment variables that links used to create, and
their removal is planned for Docker Engine 30. Use a user-defined
network and container names (or aliases) instead. The output on this
page was recorded with Docker 28.2, so it can differ in small ways
from a current Docker 29 installation.
With more than two containers, typing networks and aliases by hand becomes error-prone; Docker Compose creates a user-defined network for each application automatically.