Jump to: user-defined networks, publishing ports, the firewall gap or a reverse proxy layout.
Before you start
You need Docker Engine on Linux; the Docker Installation Guide covers the install. Docker Desktop on macOS and Windows runs containers inside a virtual machine, so the host-side commands here (ip, iptables, ss) show that VM's view, not your laptop's.
A few tools make the examples easier to follow. They're in the standard repositories of every major distribution:
ip -brief address # host interfaces, including docker0 once Docker is running
sudo ss -ltnp # what is listening on which address and port
docker network ls # bridge, host and none exist on every install
The network drivers you'll actually use
| Driver | What it does | Typical use |
|---|---|---|
bridge | A private virtual network on the host, with NAT to the outside | Almost everything. Compose creates one per project. |
host | No isolation: the container uses the host's network stack directly | Software that needs broadcast or multicast discovery, such as some media servers or Home Assistant |
none | Only a loopback interface | Batch jobs that should have no network at all |
macvlan / ipvlan | Gives the container its own address on your physical LAN | Services that must look like a separate machine to the network |
This guide concentrates on bridge networks, because that's where nearly every everyday question comes from.
The default bridge, and why to avoid it
A container started with plain docker run joins the network called bridge, backed by the docker0 interface on the host, usually 172.17.0.0/16. It has one important limitation: containers on the default bridge can't find each other by name. They can only use IP addresses, and those change every time a container is recreated.
docker run -d --name web nginx:alpine
docker run --rm alpine:3 ping -c 1 web # ping: bad address 'web'
docker rm -f web
The fix isn't to look up IP addresses or to use the deprecated --link flag. Create your own network.
User-defined bridge networks
On a network you create yourself, Docker runs an embedded DNS server at 127.0.0.11 inside each container, which resolves other containers on the same network by name.
docker network create appnet
docker run -d --name web --network appnet nginx:alpine
docker run --rm --network appnet alpine:3 wget -qO- http://web | head -n 4
docker rm -f web && docker network rm appnet
Containers on appnet can't see containers on other networks unless you connect them to both, so each network is also an isolation boundary. You can connect a running container to an extra network with docker network connect appnet container.
Compose does all of this for you. Every project gets a network named project_default, and each service is reachable on it by its service name. A database service called db is simply db to every other service in the project. If you add container_name: to a service, that name resolves too, but the service name is the one to rely on.
To see what's on a network:
docker network inspect miniflux_default --format '{{range .Containers}}{{.Name}} {{.IPv4Address}}{{"\n"}}{{end}}'
Publishing ports
Containers on a bridge network can reach the outside world through NAT, but nothing outside can reach them until you publish a port. The syntax is [host address:]host port:container port:
| Mapping | Who can connect |
|---|---|
-p 8080:80 | Anyone who can reach the host on port 8080, on every interface, IPv4 and IPv6 |
-p 127.0.0.1:8080:80 | Only processes on the host itself, such as a reverse proxy |
-p 192.168.1.20:8080:80 | Only clients that reach the host through that one address |
-p 8080:80/udp | The same, for UDP instead of TCP |
EXPOSE in a Dockerfile publishes nothing. It's documentation that tells you which port the app listens on. Containers on the same network can reach every port a container listens on, exposed or not.
After publishing, check what's really listening:
docker port web
sudo ss -ltnp | grep docker-proxy
The firewall gap
This is the networking behaviour that surprises people most. On Linux, Docker writes its own iptables (or nftables) rules to forward traffic for published ports, and those rules take effect before the ones ufw and firewalld manage. The result: a port published as 8080:80 is reachable from the network even if ufw status shows 8080 as blocked.
You can see the rules Docker added:
sudo iptables -t nat -L DOCKER -n
sudo iptables -L DOCKER-USER -n
There are three ways to handle it, in order of preference:
- Don't publish what doesn't need to be public. A database reached only by other containers needs no
ports:at all. An app behind a reverse proxy on the same host can bind to127.0.0.1. - Bind to a specific address, such as a VPN or LAN address, when only that network should reach the service.
- Filter in the
DOCKER-USERchain. Docker leaves this chain alone and sends forwarded container traffic through it first. Rules here can restrict who reaches a published port:
# Allow port 8080 only from the 192.168.1.0/24 LAN; drop it from everywhere else.
# --ctorigdstport matches the original port, before Docker's NAT rewrote it.
sudo iptables -I DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 ! -s 192.168.1.0/24 -j DROP
Replace eth0 with your external interface from ip -brief address. Rules added with iptables are lost at reboot unless you save them with your distribution's persistence tool. Docker's packet filtering and firewalls page covers the details, including how firewalld zones interact with Docker.
Whatever you choose, test it from another machine. A check from the host itself goes through the loopback interface and tells you nothing about what the network can reach.
# from a second machine on the network
nc -zv 192.168.1.20 8080
A reverse proxy layout
When several web apps run on one host, a common layout puts one reverse proxy on ports 80 and 443 and keeps every app private. Compose can express that with two networks:
name: web
services:
proxy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
networks:
- frontend
app:
image: miniflux/miniflux:2.3.4
restart: unless-stopped
environment:
DATABASE_URL: postgres://miniflux:${POSTGRES_PASSWORD}@db/miniflux?sslmode=disable
networks:
- frontend
- backend
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_USER: miniflux
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- db-data:/var/lib/postgresql/data
networks:
- backend
networks:
frontend:
backend:
internal: true
volumes:
caddy-data:
db-data:
- Only the proxy publishes ports. The app and the database publish none.
- The proxy and the app share
frontend, so the proxy reaches the app asapp:8080. - The app and the database share
backend. The proxy isn't on it, so even a compromised proxy can't connect to the database directly. internal: truemeansbackendhas no route to the internet. The database can't download anything or connect out, which it never needs to.
rss.example.net {
reverse_proxy app:8080
}
Replace the hostname with a real domain that points at the host. Caddy then obtains a certificate automatically. A proxy service using Nginx works the same way, with proxy_pass http://app:8080;; the Nginx Web Server Setup guide covers that configuration.
To share one proxy between several Compose projects, create the frontend network once with docker network create proxy, then declare it in each project as external:
networks:
proxy:
external: true
Host networking
network_mode: host removes the network namespace. The container binds directly to the host's interfaces, so there's no NAT, no port mapping and no per-container firewall rules, and ports: is ignored. Use it when software genuinely needs it, for example to receive multicast discovery traffic from devices on your LAN. Remember that a host-mode container listening on 0.0.0.0 is exposed exactly like a process on the host, and here your normal firewall rules do apply.
DNS inside containers
On user-defined networks, containers use Docker's embedded DNS at 127.0.0.11. It answers for container and service names and forwards everything else to the resolvers in the host's /etc/resolv.conf. If the host uses systemd-resolved, whose stub listens on 127.0.0.53, Docker detects that loopback address won't work from a container and uses the upstream servers from /run/systemd/resolve/resolv.conf instead.
To choose the upstream servers yourself, set them in /etc/docker/daemon.json and restart Docker:
{
"dns": ["192.168.1.1", "9.9.9.9"]
}
sudo systemctl restart docker
docker run --rm alpine:3 nslookup techwilla.com
IPv6
By default, Docker bridge networks are IPv4 only. A published port still listens on the host's IPv6 addresses, because docker-proxy accepts the connection and forwards it over IPv4, but containers themselves have no IPv6 address and can't reach IPv6-only destinations.
To give a Compose network IPv6, enable it per network:
networks:
frontend:
enable_ipv6: true
Docker then assigns addresses from a unique local range (fd00::/8) and adds NAT rules for outbound traffic, the same way it does for IPv4. Two things to check afterwards. First, the firewall gap applies to IPv6 as well: Docker writes ip6tables rules for published ports, so filter them in the IPv6 DOCKER-USER chain. Second, if your host has no working IPv6 route, enabling it on networks makes some clients try IPv6 first and wait for a timeout before falling back. Leave it off unless you need it.
Avoiding address conflicts
Docker allocates bridge subnets from 172.17.0.0/16 upwards and then from the 192.168.0.0/16 range. If your LAN, a VPN or an office network uses one of those ranges, the host may lose its route to real machines as soon as Docker creates a network. The symptom is a host that works until a particular Compose project starts.
Pick a range nothing else uses and tell Docker to allocate from it:
{
"default-address-pools": [
{ "base": "10.200.0.0/16", "size": 24 }
]
}
Existing networks keep their old subnets, so remove and recreate them after restarting Docker: docker compose down and up -d for each project.
Troubleshooting
One container can't reach another by name
They're probably on different networks, or one of them is on the default bridge. docker inspect container --format '{{json .NetworkSettings.Networks}}' lists each container's networks. Test from inside with a throwaway container on the same network: docker run --rm --network miniflux_default alpine:3 nslookup db.
The app works from the host but not from other machines
Either the port is bound to 127.0.0.1, or the app inside the container listens only on its own loopback. docker port shows the first. For the second, check the app's settings for a listen address and set it to 0.0.0.0; inside a container, that only means "all of the container's interfaces".
Containers have no internet access
Check that IP forwarding is on with sysctl net.ipv4.ip_forward, which should print 1. Some hardening guides turn it off, which breaks container NAT. Also check whether the network was created with internal: true.
address already in use when publishing a port
Another process or container holds the host port. sudo ss -ltnp | grep :8080 names it.
Next steps
- Docker Compose: Multi-Container Apps: the stack used in these examples.
- Hardening Docker Containers: limit what a compromised container can do once it's on the network.
- Networking Fundamentals: subnets, NAT and DNS from the ground up.
- Docker's networking overview and bridge network driver reference.