Skip to main content

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.yaml join 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-service networks: attaches.
  • The mechanism is the same Docker bridge networking + embedded DNS, just declarative through Compose.

Quick example

yaml
# 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
bash
$ 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 # connects

Three 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:

  1. Compose reads compose.yaml.
  2. Creates a network named <project>_default (project = directory name unless overridden).
  3. Creates one container per service, each attached to that network.
  4. Each container's /etc/resolv.conf points to Docker's embedded DNS (127.0.0.11).
  5. 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:

yaml
# WRONG services: api: environment: DATABASE_URL: postgres://postgres@localhost:5432/app # ← BAD db: image: postgres:16

From 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:

yaml
api: environment: DATABASE_URL: postgres://postgres@db:5432/app # ← service name

Every container has its own loopback. Service-to-service traffic travels by name (or IP), never localhost.

ports vs expose vs nothing

yaml
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:5432
  • ports: = publish to the host (-p equivalent). 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

yaml
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):

yaml
services: api: image: myapp networks: [shared] networks: shared: external: true # already exists, do not create

Useful 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

yaml
services: api: image: myapp ports: ["3000:3000"] # NOT needed for db→api or web→api traffic

If 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

yaml
# 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/app

Container 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 proxy external network; many Compose projects attach to it for routing.
  • Database access from one-off jobs: docker compose run --rm migrator runs in a new container on the project network, can reach db by 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

yaml
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

bash
# 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

bash
$ 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_default

The embedded resolver at 127.0.0.11 is what makes db work. It is part of every user-defined bridge.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Comments

No comments yet