How do containers communicate in Docker Compose?
Communication between containers in Docker Compose is the most magical part of Compose for newcomers. You write db in a connection string, the container resolves it to the right IP, and everything just works. Underneath, Compose is creating a user-defined bridge network and using Docker's embedded DNS — that is the whole trick.
Theory
TL;DR
- Compose creates a default network for every project:
<projectname>_default, type bridge, user-defined. - All services in
compose.yamljoin it automatically; service names become hostnames inside the network. - From service A,
<service-B-name>:<port>resolves to service B's IP. - Containers reach each other on any port the destination is listening on — not just published ports.
ports:only matters for host access. - Multiple networks are possible:
networks:block at the top defines them; per-servicenetworks:attaches. - The mechanism is the same Docker bridge networking + embedded DNS, just declarative through Compose.
Quick example
# compose.yaml
services:
api:
image: myapp
environment:
DATABASE_URL: postgres://postgres:dev@db:5432/app
REDIS_URL: redis://redis:6379
ports:
- "3000:3000" # only api is exposed to the host
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: dev
redis:
image: redis:7$ docker compose up -d
[+] Running 4/4
✔ Network myapp_default Created
✔ Container myapp-db-1 Started
✔ Container myapp-redis-1 Started
✔ Container myapp-api-1 Started
# From inside any container, resolve siblings by name
$ docker compose exec api nslookup db
Name: db
Address: 172.18.0.2
$ docker compose exec api curl http://redis:6379 # connectsThree services, one network created automatically (myapp_default), DNS resolves db and redis to the right IPs.
What Compose creates under the hood
When you docker compose up:
- Compose reads
compose.yaml. - Creates a network named
<project>_default(project = directory name unless overridden). - Creates one container per service, each attached to that network.
- Each container's
/etc/resolv.confpoints to Docker's embedded DNS (127.0.0.11). - The DNS knows about every container on the network, by both
<service-name>and<container-name>.
This is just Docker's standard user-defined bridge — Compose is a thin wrapper that automates the docker network create + docker run --network ... --name ... dance.
Why localhost does NOT work between services
A common newbie mistake:
# WRONG
services:
api:
environment:
DATABASE_URL: postgres://postgres@localhost:5432/app # ← BAD
db:
image: postgres:16From inside the api container, localhost is the api container itself. localhost:5432 would try to connect to api on port 5432, which has nothing listening. The right form:
api:
environment:
DATABASE_URL: postgres://postgres@db:5432/app # ← service nameEvery container has its own loopback. Service-to-service traffic travels by name (or IP), never localhost.
ports vs expose vs nothing
services:
web:
image: nginx
ports:
- "80:80" # publish to host: yes
api:
image: myapp
expose:
- "3000" # documentation only; reachable internally on port 3000
db:
image: postgres:16
# nothing — but still reachable from `api` and `web` on db:5432ports:= publish to the host (-pequivalent). Only what the outside world should reach.expose:= pure documentation/metadata. Does not affect inter-container reach.- Nothing = service is still reachable by other services on any port it listens on. Only the host cannot reach it.
Multiple networks for separation
services:
web:
image: nginx
networks: [frontend]
ports: ["80:80"]
api:
image: myapp
networks: [frontend, backend] # bridge between frontend and backend
db:
image: postgres:16
networks: [backend]
networks:
frontend:
backend:web cannot reach db directly — they share no network. Only api straddles both. This is the classic three-tier isolation: edge ↔ app ↔ data.
External networks
Sometimes a service needs to reach into a network that exists outside the Compose project (e.g., a shared reverse-proxy network):
services:
api:
image: myapp
networks: [shared]
networks:
shared:
external: true # already exists, do not createUseful for things like Traefik that watch a single shared network for new containers across many Compose projects.
Common mistakes
Using localhost between services
Covered above. The most common Compose bug.
Forgetting that ports: is host-facing
services:
api:
image: myapp
ports: ["3000:3000"] # NOT needed for db→api or web→api trafficIf your api is only reached by other services in the project, drop ports:. It only helps when YOU on the host need to hit localhost:3000.
Hardcoding container names instead of service names
# WRONG: works by accident if project name happens to match
DATABASE_URL: postgres://postgres@myapp-db-1:5432/app
# RIGHT: service name is portable
DATABASE_URL: postgres://postgres@db:5432/appContainer name is <project>-<service>-<index> and changes if you rename the project. Service name is stable.
Cross-project communication without an external network
Two separate Compose projects (projectA and projectB) cannot reach each other by default — they each have their own network. To connect, declare an external: true network and have services in both projects join it.
Real-world usage
- Three-tier dev stacks: web/api/db, all in one Compose file, all communicating by service name. Most common Compose use.
- Shared infrastructure: Traefik or nginx-proxy on a
proxyexternal network; many Compose projects attach to it for routing. - Database access from one-off jobs:
docker compose run --rm migratorruns in a new container on the project network, can reachdbby name. - Test fixtures in CI:
docker compose up -d db redis && run-tests && docker compose down. Tests run on the same network and reach services by name.
Follow-up questions
Q: What is the project name and where does it come from?
A: By default, the directory containing compose.yaml. Override with -p myname flag, COMPOSE_PROJECT_NAME env var, or name: at the top of compose.yaml. Network name is <project>_default, container names are <project>-<service>-<index>.
Q: Why can web reach db on port 5432 if I never published it?
A: Because publishing (ports:) is about HOST access, not container-to-container. Inside the project network, every container can reach every port any other container is listening on.
Q: Can I disable the default network?
A: Yes — set default: to null-equivalent and define your own. Rare in practice; usually you just override its driver or subnet via networks: { default: { driver: bridge, driver_opts: ... } }.
Q: How do containers in different Compose projects find each other?
A: Either define an external: true network and have both projects join it, or docker network connect the running container to the other project's network manually. The common pattern is a shared proxy network.
Q: (Senior) How does Compose differ from Swarm for service discovery?
A: Compose runs services as plain containers; service discovery is via the bridge network's embedded DNS (one IP per service, the container's own). Swarm runs services as replicated tasks on overlay networks; discovery is via a virtual IP that load-balances across replicas (IPVS-backed). Compose's mechanism is simpler and single-host; Swarm's is multi-host and load-balanced. Both look the same to the application (db:5432 works in both), but the path the packet takes is different.
Examples
Three-service stack with cross-service communication
services:
web:
image: nginx:1.27-alpine
ports:
- "80:80"
depends_on: [api]
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
api:
image: myapp
environment:
DATABASE_URL: postgres://postgres:dev@db:5432/app
REDIS_URL: redis://redis:6379
depends_on:
db:
condition: service_healthy
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: dev
POSTGRES_DB: app
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
redis:
image: redis:7
volumes:
pgdata:Four services. Only web is reachable from the host (port 80). Internally, web → api → (db, redis) all by service name.
Shared proxy network across projects
# One-time setup
$ docker network create --driver bridge proxy
# Project A's compose.yaml
services:
app-a:
image: app-a
networks: [proxy]
networks:
proxy:
external: true
# Project B's compose.yaml
services:
app-b:
image: app-b
networks: [proxy]
networks:
proxy:
external: true
# Now, from app-a, `app-b:8080` resolves and connects.Traefik typically sits on this network, watches for new containers via Docker labels, and routes traffic accordingly. The external: true is what makes the cross-project connectivity possible.
Verifying DNS works
$ docker compose exec api sh -c 'cat /etc/resolv.conf && nslookup db'
nameserver 127.0.0.11
options ndots:0
Server: 127.0.0.11
Name: db
Address 1: 172.18.0.2 db.myapp_defaultThe embedded resolver at 127.0.0.11 is what makes db work. It is part of every user-defined bridge.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.
Comments
No comments yet