Suggest an editImprove this articleRefine the answer for “How do containers communicate in Docker Compose?”. Your changes go to moderation before they’re published.Approval requiredContentWhat you’re changing🇺🇸EN🇺🇦UAPreviewTitle (EN)Short answer (EN)**Docker Compose creates a default user-defined bridge network per project.** All services join it automatically and can reach each other by service name via Docker's embedded DNS — no IPs, no `localhost`, no manual config. ```yaml services: api: image: myapp environment: DATABASE_URL: postgres://postgres:dev@db:5432/app # ← "db", not localhost db: image: postgres:16 ``` **Key:** service names are hostnames. From `api`, `db:5432` works. From the host, you need `localhost:<published-port>`. Add `expose:` for documentation, `ports:` only when host access is required.Shown above the full answer for quick recall.Answer (EN)Image**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.For the reviewerNote to the moderator (optional)Visible only to the moderator. Helps review go faster.