Docker Compose networking explained
The default project bridge, DNS by service name, when host mode is worth it, sharing one network across projects, and the published port that skips UFW.
What Compose builds before your app starts
Docker Compose networking starts with one rule: docker compose up creates a private network for the project, attaches every service to it, and lets those services reach each other by service name. You do not write a single networks: line to get that. Most of the confusion around Compose networking comes from not knowing that default is already there.
Here is a small file. Save it as compose.yaml in a directory called shop.
services:
web:
image: nginx:1.27
ports:
- "8080:80"
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: exampleBring it up and look at what Docker made:
docker compose up -d
docker network lsThe list now holds a network called shop_default. Compose names it <project>_default, and the project name defaults to the lowercase directory name. Override it with docker compose -p myproject up -d or with a top-level name: myproject in the file. Its driver is bridge, which is a virtual switch inside the host. Each container gets an address on a private subnet, and outbound traffic is translated to the host's address on the way out.
docker compose down deletes that network again. This is why a stale container from an old project can hold a network open: Docker refuses with error while removing network: network shop_default has active endpoints, and the fix is to stop or remove the container still attached to it.
If Compose is new to you, the Compose file layout and lifecycle commands are worth reading first, because everything below assumes you can start and stop a project.
DNS by service name is the part beginners miss
On any user-defined network, Docker runs an embedded DNS server that each container sees at 127.0.0.11. It resolves service names to the current container addresses. So web reaches the database at the hostname db, on port 5432, with no configuration at all.
docker compose exec web getent hosts dbThat prints a line like 172.18.0.2 db. If it prints nothing, the two services are not on the same network.
The mistake almost everyone makes once is using localhost in the application config. Inside a container, localhost is that container, not the host and not the other service. Postgres clients report it clearly:
could not connect to server: Connection refused
Is the server running on host "localhost" (127.0.0.1) and accepting
TCP connections on port 5432?The connection string should be postgresql://postgres:example@db:5432/postgres. The host part is the service name.
Two details that save time later. Names resolve to whatever is running now, so docker compose up -d --scale web=3 gives one name with three addresses, and a client that caches DNS forever will pin itself to a dead container. And the legacy bridge network used by a plain docker run with no --network has no name resolution at all, which is why advice about container links from 2016 does not match what you see.
You do not need ports: to connect two services
ports: publishes a container port on the host. It is for traffic arriving from outside Docker. It has nothing to do with service-to-service traffic, which already works across the whole port range on the project network.
So the ports: - "5432:5432" many people add to their database service does no good and real harm: it exposes Postgres on the public interface of the server. Delete it. If you want it reachable from your laptop for a migration, bind it to loopback with "127.0.0.1:5432:5432" and reach it over an SSH tunnel. The difference between a listening socket, a published port, and a firewall rule is covered in how ports and listening services work on Linux.
expose: is documentation only under Compose. It does not open anything, because nothing was closed between containers on the same network.
When network_mode host is right, and what it costs
Host mode drops the container's own network namespace and lets the process use the host's interfaces directly.
services:
probe:
image: alpine:3.20
network_mode: host
command: sleep infinityThere are real reasons to want this. A process that needs to see broadcast or multicast traffic on the local network, such as device discovery for a media server or a home automation hub, cannot see it from behind a bridge, because the bridge does not forward that traffic to the container. A monitoring agent that reads the host's interface counters needs the host's interfaces. And you skip the address translation hop, which matters at high packet rates.
The costs are specific.
ports: stops working. Docker warns that published ports are discarded when using host network mode, and the container binds whatever its process binds. Two host mode containers wanting port 8080 collide, and the second one dies with bind: address already in use.
Name resolution by service name is gone in both directions. The container is not on the project network, so it cannot resolve db, and the other services cannot resolve it. It reaches them only through ports published on the host, usually at 127.0.0.1.
Isolation is gone. A process that binds 0.0.0.0 inside a host mode container is listening on every interface of your server, including the public one, exactly like a package installed with apt. There is one upside to that: this traffic follows the normal input path, so UFW rules do apply to it, which is not true of published ports.
Host mode is a Linux Docker Engine feature. Docker Desktop supports it only from version 4.34 onward and only after you enable it, with the further limits that containers cannot bind host IP addresses and only TCP and UDP are handled. If half your team is on Linux servers and half on Docker Desktop, expect the same file to behave differently.
Reach for host mode when you need the host's interfaces. Do not reach for it to fix a connection problem, because it usually replaces one problem with a harder one.
Connect two Compose projects with an external network
A network created by one project is not visible to another. That is why a reverse proxy in proxy/compose.yaml cannot see an app in app/compose.yaml, even on the same server. The fix is a network that neither project owns.
Create it once, by hand:
docker network create edgeThen declare it as external in each project. The proxy side:
services:
proxy:
image: traefik:v3.1
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- edge
networks:
edge:
name: edge
external: trueThe application side:
services:
app:
image: nginx:1.27
networks:
- edge
- internal
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: example
networks:
- internal
networks:
edge:
name: edge
external: true
internal:external: true tells Compose to attach to an existing network rather than create one, and to leave it in place on docker compose down. The separate name: key matters more than it looks: without it Compose looks for a network called exactly edge, and with it you can call the network one thing in your file and another on the host.
If the network does not exist, Compose refuses to start and reports that the network was declared as external but could not be found. Create it first.
Note what the application file does with internal. The database sits only on that project-local network, so the proxy cannot reach it and only app can. Adding internal: true under a network goes further and removes its route to the outside world entirely. That is a good default for a database, with one cost worth knowing before you set it: a container on an internal network cannot download anything, so an entrypoint that runs apt-get update or pip install at startup will hang and then fail with a timeout.
For a full worked setup with routing rules and certificates, see running several apps behind one Traefik instance.
Published ports bypass UFW
This is the part of Compose networking that turns into a security incident. You publish a port, you check that UFW is active and denies everything except SSH, and the service is still reachable from the internet.
sudo ufw status
curl http://203.0.113.10:8080UFW says the port is blocked. The curl returns the page anyway. Nothing is broken. Docker writes its own address translation and forwarding rules straight into iptables, and traffic to a published container port is forwarded to the container rather than delivered to the host, so it never passes the chain UFW manages for locally destined traffic. Docker's rules are also matched before UFW's.
The short fix is to publish only where you need it:
ports:
- "127.0.0.1:8080:80"That binds the host side to loopback, so the port is reachable from the server itself and over an SSH tunnel, and from nowhere else. Put the public entry point behind a reverse proxy that publishes 80 and 443 on purpose. The full explanation, including the DOCKER-USER chain for the cases where you must filter a published port, is in why Docker publishes straight past UFW and how to fix it.
How to debug it in four commands
Start by asking what network each container is really on:
docker network inspect shop_defaultThe Containers block lists every attached container with its address. A service missing from that list is on a different network, or in host mode, or not running.
Test name resolution from a throwaway container attached to the same network, so you do not need any tooling inside your own images:
docker run --rm --network shop_default busybox nslookup db
docker run --rm --network shop_default busybox nc -zv db 5432nslookup failing points at name resolution or network membership. nslookup succeeding while nc fails means the service is running but not listening on that port, or listening on 127.0.0.1 inside its own container instead of 0.0.0.0. That last one is common with development servers, and the fix is in the application's bind address, not in Docker.
One more failure that looks like a Docker bug. If containers can talk to each other but cannot reach a machine on your office or VPN network, the Docker subnet probably overlaps that network. Docker allocates from 172.17.0.0/16 upward by default. Move the pool in /etc/docker/daemon.json:
{
"default-address-pools": [
{ "base": "10.200.0.0/16", "size": 24 }
]
}Then run sudo systemctl restart docker and recreate the affected networks, because an existing network keeps the subnet it was created with.
FAQ
Why can my containers not reach each other by service name?
They are not on the same network. Compose puts every service on <project>_default automatically, but the moment you add a networks: list to a service, that list becomes the complete set of networks for it and the default is no longer implied. Run docker network inspect <network> and check that both containers appear in the Containers block. Also check that neither service uses network_mode: host, because a host mode container is on no Docker network and cannot resolve service names.
Do I need to publish ports for one service to reach another?
No. On a Compose network, every port of every container is reachable by the other containers on that network. ports: exists only to expose a container to traffic from outside Docker, and expose: is documentation. Publishing a database port is a common and costly habit, since it puts the database on your server's public interface.
What is the difference between bridge and host networking?
Bridge gives the container its own network namespace and address on a virtual switch, with automatic name resolution between containers and translated outbound traffic. Host gives the container the host's network stack directly: no separate address, no resolution by service name, no port publishing, and no isolation from the host's other listeners. Bridge is the default and the right answer unless the process needs the host's interfaces.
How do I connect containers from two different Compose files?
Create a shared network with docker network create edge, then declare it in both files with external: true and attach the services that need to talk. Compose will neither create nor delete it. If you skip the create step, Compose refuses to start and reports the network as declared external but not found.
Why is my container reachable from the internet when UFW blocks the port?
Because a published port is handled by the forwarding rules Docker adds to iptables. Those rules are matched before UFW's, and forwarded traffic does not pass through the chain UFW filters anyway. Bind the host side to loopback with "127.0.0.1:8080:80" and put anything public behind a reverse proxy on ports 80 and 443.