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.

A browser connects over HTTPS to a reverse proxy on the host. The proxy forwards to the published port 127.0.0.1:8080, which leads to the portal container. The portal, meter-api and db containers are attached to the user-defined network kestrel-net with subnet 172.21.0.0/16, and an embedded DNS server at 127.0.0.11 resolves their names. Only the portal has a published port. browser 443 host 192.0.2.10 reverse proxy 127.0.0.1:8080 embedded DNS 127.0.0.11 kestrel-net 172.21.0.0/16 portal :8080 meter-api :8000 db :5432 meter-api and db: reachable by name, never published
The Kestrel application on one host: one published port, bound to loopback; everything else stays on the private 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 localhost to reach another container. The portal's setting METER_API_URL=http://localhost:8000 worked 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.1 inside 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's eth0, where nothing listens. Configure the server to listen on all addresses inside the container (0.0.0.0 or ::); 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).

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.