How do containers in a Docker Compose project reach each other over the network, and what is the difference between the `ports` and `expose` keys in a compose file?
answer
- service name = DNS name on the project network
- container port, never the published host port
- localhost in a container = that container
- ports publishes to host; expose is a comment
- isolation = separate networks, not expose
basics
~20 sCompose puts every service on a project network where the service name resolves via Docker's embedded DNS, so the app connects to db:5432 using the container-side port. ports publishes a container port to the host; expose publishes nothing — it is documentation only.
solid answer
~50 sCompose creates a user-defined bridge network per project and attaches every service. Docker's embedded DNS resolver (at 127.0.0.11 inside the container) resolves each **service name** to its container IP, so the API talks to `db:5432` — the *container's* port, never the host-published one, and never `localhost`, because each container has its own network namespace. `ports: ["8080:5000"]` publishes container port 5000 on host port 8080, adding NAT/proxy rules so something outside Docker can reach it. It has no effect on container-to-container traffic — on a user-defined network all ports are reachable between attached containers regardless of publishing. `expose:` is effectively documentation: it records that the service listens on a port. It does not publish to the host and does not gate intra-network traffic. Practical rules: publish only the edge service, use `"127.0.0.1:8080:8080"` to avoid binding all host interfaces, and split services onto separate networks when you actually want isolation.
code
yaml · 23 linesservices:
proxy:
image: nginx:alpine
ports:
- "127.0.0.1:8080:80"
networks: [frontend]
api:
build: .
environment:
DB_URL: postgres://db:5432/app # container port, service name
expose:
- "8000" # documentation only
networks: [frontend, backend]
db:
image: postgres:16
networks: [backend]
networks:
frontend:
backend:
internal: truego deeper
Know that services reach each other by service name on the container's own port, and that ports is only for host access.
Explain embedded DNS at 127.0.0.11, the difference between publishing and intra-network reachability, and why expose is documentation.
Design the topology: separate frontend/backend networks, internal: true, binding published ports to 127.0.0.1, and debug with getent/nc from inside containers.
Treat network segmentation as the security control and be explicit about what dev-time compose networking does and does not model compared with the production network policy.
## The project network When you run `docker compose up`, Compose creates a **user-defined bridge network** named `<project>_default` and attaches every service to it, unless the file declares its own `networks:`. User-defined bridges differ from Docker's legacy `bridge` in one crucial way: they provide **automatic service discovery** through Docker's embedded DNS server, reachable inside containers at `127.0.0.11`. Every service is registered under its **service key** from the compose file. If the file says `db:`, then any container on that network resolves `db` to the container's IP. Compose also registers network **aliases**; you can add your own with `networks: { backend: { aliases: [postgres] } }`, which is how you keep a hostname stable while renaming a service. When a service is scaled to several replicas, the name resolves to multiple A records and clients round-robin over them. ## Container-side ports, not host-side The single most common bug: an app configured with `localhost:5432` or with the host-published port. - **`localhost` inside a container is that container itself.** Each container has its own network namespace and its own loopback. `localhost:5432` from the API container means "a Postgres inside the API container", which does not exist. - **Use the container's own listening port.** With `ports: ["5433:5432"]` on the db service, other containers still connect to `db:5432`. The `5433` mapping exists only for tools running on your host (psql, an IDE). If a container genuinely must reach a service running on the developer's host machine, Docker Desktop provides `host.docker.internal`; on Linux you add `extra_hosts: ["host.docker.internal:host-gateway"]`. ## `ports` — publishing to the host ```yaml ports: - "8080:8080" # host 8080 -> container 8080, all host interfaces - "127.0.0.1:5432:5432" # bind to loopback only - "5432" # container 5432 -> random ephemeral host port ``` Publishing sets up NAT (iptables DNAT on Linux) plus a userland proxy so traffic arriving at the host port is forwarded into the container. Consequences worth stating in an interview: - Publishing **bypasses host firewalls** in the naive mental model: Docker inserts its own rules, so `ufw`-style rules on the INPUT chain often do not block a published port. Binding explicitly to `127.0.0.1` is the reliable way to keep a dev database off the network. - A published host port is a **host-wide singleton**: two projects publishing 5432 collide with "port is already allocated". Using `"5432"` (container port only) or variable interpolation (`"${DB_PORT:-5432}:5432"`) avoids the clash. - Publishing is irrelevant to container-to-container traffic. ## `expose` — documentation, not a firewall `expose: ["5432"]` records intent: this service listens on 5432. It does **not** publish to the host, and it does **not** restrict which ports other containers may reach. On a user-defined bridge network, every port of every attached container is reachable by every other attached container; there is no per-port ACL. So `expose` neither opens nor closes anything — its practical value is readability and tooling. Candidates who claim `expose` "opens the port to other containers" have the model wrong. ## Getting real isolation Because the default network is flat, isolation comes from **network topology**, not from `expose`: ```yaml services: proxy: { networks: [frontend] } api: { networks: [frontend, backend] } db: { networks: [backend] } networks: frontend: backend: internal: true # no outbound route to the outside world ``` Now `proxy` cannot resolve or reach `db` at all, and `internal: true` denies the backend network egress. This is the compose-level answer to "how do you stop the frontend container talking to the database". ## Other network modes `network_mode: host` (Linux only) drops the container's own namespace and uses the host's, so `ports` becomes meaningless and service-name DNS no longer applies. `network_mode: "service:other"` shares another service's namespace — the pattern used for sidecars, where both containers share `localhost`. `external: true` on a network attaches the project to a network created outside the project, which is how two separate compose stacks talk to each other. ## Debugging checklist From inside a container: `docker compose exec api getent hosts db` (does DNS resolve?), then `nc -zv db 5432` (is it listening?). From the host: `docker compose port api 8080` prints the actual published mapping. If DNS fails, the two services are on different networks; if DNS resolves but the connection is refused, the process inside the target container is bound to `127.0.0.1` instead of `0.0.0.0`.
- An app in a container connects to `localhost:5432` and gets connection refused, though the db service is running. Why?Each container has its own network namespace, so `localhost` resolves to the app container itself, where nothing listens on 5432. It must use the service name — `db:5432` — which Docker's embedded DNS resolves to the database container's IP on the shared project network.
- How do you stop a frontend container from being able to reach the database at all?Put them on different networks. Declare `frontend` and `backend` networks, attach the DB and API to `backend`, the proxy/frontend and API to `frontend`, and leave the DB off `frontend` entirely. `expose` cannot do this — on a shared user-defined network every port of every container is reachable.
- Two compose projects both publish host port 5432 and the second fails to start. What are the options?Host ports are a host-wide resource. Either stop publishing from one project (containers reach each other by service name without publishing), interpolate the host port from a variable such as `"${DB_PORT:-5432}:5432"`, or publish with only the container port so Docker picks a random ephemeral host port and read it back with `docker compose port`.
The project network is an office intranet where everyone is listed in the directory by job title; ports is the one desk phone with an outside line.
saying these in an interview costs you the question
- Thinking `expose` opens a port to other containers or blocks the ones not listed.
- Connecting to `localhost` from inside a container to reach another service.
- Using the host-published port (e.g. 5433) for container-to-container traffic instead of the container port.
- Believing a published port is protected by the host's iptables/ufw rules, when Docker inserts its own DNAT rules.
- Assuming containers in different compose projects can resolve each other by service name without a shared external network.