Docker Networking
Docker networking determines how containers reach each other, the host, and the outside world. Most day-to-day problems — a container that cannot resolve a service name, a published port that is unexpectedly reachable from the internet, a firewall rule that appears to do nothing — come from a small number of behaviors that are worth understanding directly rather than by trial and error.
This page covers the Docker engine's networking. For Kubernetes networking (CNI plugins, Services, Ingress, NetworkPolicy), see Container Networking.
Table of Contents
- How Docker Networking Works
- Network Drivers
- Bridge Networks
- Host and None Networks
- Macvlan and Ipvlan
- Overlay Networks
- DNS and Service Discovery
- Publishing Ports
- Firewall Interaction
- IPv6
- Troubleshooting
- Performance
How Docker Networking Works
On a default Linux install, the daemon creates a Linux bridge device named docker0,
usually on 172.17.0.0/16:
ip addr show docker0
docker network inspect bridge --format '{{json .IPAM.Config}}'
When a container starts on a bridge network, the daemon creates a veth pair — a virtual
cable with two ends. One end becomes eth0 inside the container's network namespace; the
other appears on the host as a veth* interface enslaved to the bridge:
# Host side: veth interfaces attached to the bridge
ip link show master docker0
# Container side
docker exec mycontainer ip addr show eth0
Three rules follow from this design, and they explain most behavior:
- Outbound traffic is masqueraded. The daemon adds a
MASQUERADErule so container traffic leaving the host appears to come from the host's IP. - Inbound traffic requires publishing. Nothing outside the host can reach a container unless a port is published, which installs a DNAT rule.
- Containers on the same bridge talk directly. No publishing is involved for container-to-container traffic; they are on a shared layer 2 segment.
Note
docker0 only exists for the default bridge network. Every user-defined bridge network
gets its own bridge device named br-<network-id-prefix>.
Network Drivers
| Driver | Scope | Use case | Container gets |
|---|---|---|---|
bridge |
Single host | Default for standalone containers | Private IP behind NAT |
host |
Single host | Maximum network performance, port-heavy apps | The host's network stack |
none |
Single host | Fully isolated workloads | Loopback only |
macvlan |
Single host | Container as a first-class device on the LAN | Own MAC and LAN IP |
ipvlan |
Single host | Same, where the switch restricts MAC count | LAN IP, shares host MAC |
overlay |
Multi-host | Swarm services across nodes | VXLAN-tunnelled private IP |
# List networks and their drivers
docker network ls
# Inspect a network in full
docker network inspect bridge
Bridge Networks
Default Bridge vs User-Defined Bridges
The difference matters more than it looks. Use a user-defined bridge for anything real.
| Behavior | Default bridge |
User-defined bridge |
|---|---|---|
| DNS resolution by container name | No | Yes |
| Network aliases | No | Yes |
| Containers isolated from other networks | No | Yes |
| Connect/disconnect a running container | No | Yes |
Shares environment variables via --link |
Yes (deprecated) | No |
# Create a user-defined bridge
docker network create mynet
# Containers on it resolve each other by name
docker run -d --name api --network mynet myapp:latest
docker run --rm --network mynet alpine ping -c1 api
Custom Subnets and Static Addresses
docker network create \
--driver bridge \
--subnet 172.20.0.0/16 \
--ip-range 172.20.240.0/20 \
--gateway 172.20.0.1 \
custom-net
# Static IP (only valid on user-defined networks)
docker run -d --name web --network custom-net --ip 172.20.0.10 nginx
--ip-range narrows the pool Docker allocates from, leaving the rest of the subnet free for
static assignments — useful when addresses must be predictable for firewall rules.
Connecting a Container to Several Networks
A container can be attached to multiple networks, which is the usual way to place a reverse proxy in both a public-facing and a backend network:
docker network create frontend
docker network create --internal backend
docker run -d --name proxy --network frontend nginx
docker network connect backend proxy
# Disconnect when no longer needed
docker network disconnect backend proxy
--internal creates a network with no external connectivity — containers on it can reach
each other but not the internet, and the internet cannot reach them. It is the simplest way
to isolate a database tier.
Bridge Driver Options
docker network create \
--driver bridge \
--opt com.docker.network.bridge.name=br-custom \
--opt com.docker.network.bridge.enable_icc=true \
--opt com.docker.network.bridge.enable_ip_masquerade=true \
--opt com.docker.network.driver.mtu=1450 \
tuned-net
enable_icc=falseblocks container-to-container traffic within the network.enable_ip_masquerade=falsedisables outbound NAT — traffic leaves with the container's own source IP, which requires routes on the upstream network.driver.mtumatters when the host sits behind a VPN or tunnel; see Performance.
Host and None Networks
# Share the host network stack entirely — no NAT, no port mapping
docker run -d --network host nginx
# No networking at all beyond loopback
docker run --rm --network none alpine ip addr
With --network host the container binds directly to host ports, so -p is ignored and
port conflicts with host services are real. It removes the NAT hop, which is worth measuring
for high-packet-rate workloads, but it also removes network isolation.
Important
--network host behaves differently on Docker Desktop for Mac and Windows, where
containers run inside a Linux VM. "Host" there means the VM, not your workstation.
Macvlan and Ipvlan
These drivers put containers directly onto the physical LAN, each with its own IP that other machines can reach without port publishing. They are the right answer for appliances that need to be addressable — DHCP servers, PXE, media servers advertising via mDNS — and for migrating legacy services that assume a real network presence.
Macvlan
Each container gets its own MAC address on the parent interface:
docker network create -d macvlan \
--subnet 192.168.1.0/24 \
--gateway 192.168.1.1 \
--ip-range 192.168.1.192/27 \
-o parent=eth0 \
lan-net
docker run -d --name dns-server --network lan-net --ip 192.168.1.200 pihole/pihole
Restrict --ip-range to a block excluded from your DHCP scope so Docker and the DHCP server
do not hand out the same addresses.
Warning
The host cannot reach its own macvlan containers by default. Traffic from the parent interface to a macvlan child is blocked in the kernel. This surprises people setting up health checks or reverse proxies on the host.
The workaround is a macvlan shim on the host:
# Create a host-side macvlan interface in the same subnet
sudo ip link add macvlan-shim link eth0 type macvlan mode bridge
sudo ip addr add 192.168.1.253/32 dev macvlan-shim
sudo ip link set macvlan-shim up
# Route to the container range through the shim
sudo ip route add 192.168.1.192/27 dev macvlan-shim
This is not persistent across reboots — add it to a systemd unit or your network configuration. See Daemon Configuration for the systemd pattern.
Other constraints worth knowing before committing to macvlan:
- Most wireless interfaces reject multiple MAC addresses, so macvlan generally does not work over Wi-Fi.
- Cloud provider networks (AWS, Azure) usually drop frames with unknown source MACs.
- Managed switches with port security may shut the port when extra MACs appear.
- The parent interface must be in promiscuous mode, which some virtualized environments disallow.
Ipvlan
Ipvlan avoids the multiple-MAC problem: every container shares the parent interface's MAC and is distinguished by IP alone.
# L2 mode — containers on the same subnet as the host
docker network create -d ipvlan \
--subnet 192.168.1.0/24 \
--gateway 192.168.1.1 \
-o parent=eth0 \
-o ipvlan_mode=l2 \
ipvlan-net
# L3 mode — Docker routes between subnets; no broadcast, no gateway
docker network create -d ipvlan \
--subnet 10.10.10.0/24 \
-o parent=eth0 \
-o ipvlan_mode=l3 \
ipvlan-l3
Choose ipvlan L2 when the switch or cloud fabric restricts MAC addresses. Choose ipvlan L3 when you want Docker to act as a router for container subnets — it scales well but requires static routes on upstream equipment, since there is no ARP to discover the container networks.
Overlay Networks
Overlay networks span multiple hosts using VXLAN encapsulation, and require Swarm mode:
docker swarm init
# Attachable lets standalone containers join, not just services
docker network create -d overlay --attachable multi-host
# Encrypt the data plane (IPsec between nodes)
docker network create -d overlay --opt encrypted secure-overlay
See Docker Swarm for the full picture, including how service discovery and routing mesh build on overlay networks.
DNS and Service Discovery
The Embedded Resolver
Containers on a user-defined network get an embedded DNS server at 127.0.0.11:
docker run --rm --network mynet alpine cat /etc/resolv.conf
# nameserver 127.0.0.11
It resolves:
- Container names (
api) - Network aliases (
--network-alias) - Swarm service names
- Everything else — forwarded to the host's upstream resolvers
Containers on the default bridge get no such resolver; they receive the host's
/etc/resolv.conf directly and cannot resolve each other by name. This is the single most
common reason "my containers can't talk to each other by name" — the fix is to create a
user-defined network.
Network Aliases
docker run -d --name db \
--network mynet \
--network-alias database \
--network-alias postgres-primary \
postgres:16
The container is reachable as db, database, or postgres-primary. When several
containers share one alias, the resolver returns all their addresses in rotating order — a
crude but effective round-robin load balance:
docker run -d --network mynet --network-alias web nginx
docker run -d --network mynet --network-alias web nginx
docker run --rm --network mynet alpine nslookup web
Note
Round-robin DNS is not a health-aware load balancer. Clients cache DNS answers, and a stopped container's address may still be handed out briefly. Use a real proxy for production traffic distribution.
Custom DNS Settings
docker run -d \
--dns 1.1.1.1 --dns 9.9.9.9 \
--dns-search internal.example.com \
--dns-opt ndots:2 \
--add-host legacy-app:192.168.1.50 \
myapp:latest
--add-host writes an /etc/hosts entry, which takes effect before DNS is consulted. The
special value host-gateway resolves to the host itself, which is the supported way for a
container to reach a service running on the host:
docker run --rm --add-host host.docker.internal:host-gateway alpine \
ping -c1 host.docker.internal
To change DNS for every container, set it on the daemon — see Daemon Configuration.
Publishing Ports
EXPOSE vs -p
These are frequently confused, and the distinction is simple:
EXPOSEin a Dockerfile is documentation. It records which ports the image intends to serve on. It opens nothing, publishes nothing, and has no effect on connectivity.-p/--publishactually publishes, installing a DNAT rule that forwards a host port to the container.
EXPOSE 8080 # metadata only
docker run -p 8080:8080 myapp # this is what makes it reachable
EXPOSE has exactly one functional use: -P (capital P) publishes every exposed port to a
randomly chosen high host port.
docker run -d -P nginx
docker port <container> # discover the assigned ports
Critically, containers on the same user-defined network reach each other on any port
regardless of EXPOSE or -p. Publishing is only about reaching a container from outside
the Docker network. A database that only serves other containers should never be published.
Publishing Syntax
# hostPort:containerPort
docker run -p 8080:80 nginx
# Bind to a specific host interface — omit this and you bind 0.0.0.0
docker run -p 127.0.0.1:8080:80 nginx
# Random host port
docker run -p 80 nginx
# UDP, and both protocols
docker run -p 53:53/udp coredns
docker run -p 53:53/tcp -p 53:53/udp coredns
# A range
docker run -p 8000-8010:8000-8010 myapp
# Explicit long form
docker run --publish published=8080,target=80 nginx
Warning
-p 8080:80 binds to all host interfaces, including public ones. Combined with the
firewall behavior below, this is how databases end up exposed to the internet. Bind to
127.0.0.1 unless you genuinely intend the service to be publicly reachable.
Firewall Interaction
Why UFW and firewalld Appear to Be Ignored
Docker manipulates iptables directly. When you publish a port, Docker inserts a DNAT rule
in the nat table's PREROUTING chain and an ACCEPT rule in the DOCKER chain of the
FORWARD path.
Traffic destined for a container is forwarded, not delivered locally — so it traverses
FORWARD, never INPUT. UFW rules overwhelmingly target INPUT. The result is that a
published port stays reachable no matter what UFW says.
This is a genuine, long-standing source of accidental exposure. There are three sound responses:
1. Bind published ports to localhost and put a host-level reverse proxy in front. The simplest and most robust option:
docker run -d -p 127.0.0.1:5432:5432 postgres:16
2. Use the DOCKER-USER chain. Docker guarantees this chain is evaluated before its own
rules and never flushes it:
# Allow an internal management network, drop other external access to containers
sudo iptables -I DOCKER-USER -i eth0 -s 192.168.1.0/24 -j RETURN
sudo iptables -I DOCKER-USER -i eth0 -j DROP
Rules are order-sensitive and -I inserts at the top, so add the broad DROP first and the
narrower RETURN exceptions after, or use explicit rule numbers. Persist them with
iptables-persistent or an equivalent, since they are lost on reboot.
3. Disable Docker's iptables management and write every rule yourself, via
"iptables": false in daemon.json. This breaks container networking until you supply your
own NAT and forwarding rules, so only take this path deliberately — see
Daemon Configuration.
Docker's Chains
sudo iptables -t nat -L -n --line-numbers
sudo iptables -L DOCKER-USER -n
sudo iptables -L DOCKER-ISOLATION-STAGE-1 -n
| Chain | Purpose |
|---|---|
DOCKER |
Per-published-port ACCEPT and DNAT rules. Docker owns this — do not edit. |
DOCKER-USER |
Empty by default and reserved for your rules. Evaluated first. |
DOCKER-ISOLATION-STAGE-1/2 |
Keeps user-defined bridge networks from reaching each other. |
POSTROUTING (nat) |
MASQUERADE for outbound container traffic. |
nftables
Most modern distributions ship nftables with an iptables-nft compatibility shim, and
Docker's rules land in the ip filter/ip nat tables where they can be viewed natively:
sudo nft list ruleset | less
sudo nft list chain ip filter DOCKER-USER
The compatibility layer works well, but avoid mixing native nft rules and legacy
iptables commands for the same traffic — inspect with one toolchain consistently.
IPv6
IPv6 is off by default. Enable it on the daemon:
{
"ipv6": true,
"fixed-cidr-v6": "fd00:dead:beef::/48",
"experimental": false
}
sudo systemctl restart docker
docker run --rm alpine ip -6 addr show eth0
Use a unique local address (fd00::/8) range with NAT, or delegate a routed prefix from
your ISP or cloud provider if you want globally routable containers.
Per-network IPv6:
docker network create \
--ipv6 \
--subnet 2001:db8:1::/64 \
--subnet 172.30.0.0/16 \
dual-stack
Note
IPv6 handling — in particular whether the daemon manages ip6tables rules and performs
IPv6 NAT — has changed across Docker Engine releases, and older guides describing an
"ip6tables": true experimental flag may not match your version. Check what your daemon
actually does with docker info and verify published-port behavior on IPv6 before relying
on it, because a port that is firewalled on IPv4 is not necessarily firewalled on IPv6.
Troubleshooting
Inspection
# Which networks exist, and what is on them
docker network ls
docker network inspect mynet
# A container's network settings
docker inspect -f '{{json .NetworkSettings.Networks}}' mycontainer | jq
# Published port mappings
docker port mycontainer
Testing From Inside a Container
Minimal images lack network tools. Rather than installing them into the container, join its network namespace from a throwaway container that has them:
docker run --rm -it --network container:mycontainer nicolaka/netshoot bash
# Now ip/ss/dig/tcpdump/curl all operate in the target's namespace
The same trick works for a whole network:
docker run --rm -it --network mynet nicolaka/netshoot dig api
Common Failures
| Symptom | Likely cause |
|---|---|
| Cannot resolve another container by name | Both are on the default bridge; create a user-defined network |
| Published port unreachable | Container not listening on 0.0.0.0 inside — check with ss -tlnp |
| Published port reachable despite firewall | Docker bypasses INPUT; see Firewall Interaction |
port is already allocated |
Another container or host process holds it: ss -tlnp \| grep :8080 |
| Connections hang, small requests fine | MTU mismatch; see Performance |
| Container cannot reach host service | Use --add-host host.docker.internal:host-gateway |
| Two networks cannot reach each other | Working as designed — DOCKER-ISOLATION rules; attach the container to both |
A service bound to 127.0.0.1 inside the container is unreachable from outside no matter
how it is published, because the published port forwards to the container's external
address. Bind to 0.0.0.0 in the application's configuration.
Performance
MTU
If the host is behind a VPN, an overlay, or a cloud fabric with a reduced MTU, containers that inherit the default 1500 will send frames that cannot be fragmented, producing connections that establish and then stall on the first large payload.
# Check the host's real MTU
ip link show eth0
# Match it on the container network
docker network create --opt com.docker.network.driver.mtu=1450 vpn-safe
Set "mtu" in daemon.json to change the default for all networks.
Driver Choice
hostremoves the NAT and bridge hop; measurably better for high connection churn or high packet rates, at the cost of isolation.macvlanandipvlanalso bypass NAT while keeping separate addresses.- Bridge networking is adequate for the overwhelming majority of workloads. Measure before optimizing.
Reducing Port Publishing
Every published port adds NAT rules and a userland proxy process (docker-proxy) unless the
daemon has "userland-proxy": false. Services that only serve other containers should
communicate over a shared user-defined network rather than through the host.
Related Topics
- Docker Storage — volumes, drivers, and persistence
- Daemon Configuration —
daemon.json, DNS defaults, MTU, and iptables control - Docker Swarm — overlay networks and the routing mesh
- Working with Containers — container lifecycle and operations
- Container Networking — Kubernetes CNI, Services, and NetworkPolicy
- Container Security — network isolation as a security control