skip to content

Networks & Volumes

Compose puts every service on one project network where service names resolve as hostnames, so publishing a port is only for reaching the stack from outside. Interviewers pair it with volumes because named data survives down while anonymous data does not.

on this pageshow

explore

questions

2

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?

level: middleimportance: must knowfreq 60%

answer

  1. service name = DNS name on the project network
  2. container port, never the published host port
  3. localhost in a container = that container
  4. ports publishes to host; expose is a comment
  5. isolation = separate networks, not expose

basics

~20 s

Compose 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 s

Compose 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 lines
yaml
services:
  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: true

go deeper

for a junior

Know that services reach each other by service name on the container's own port, and that ports is only for host access.

for a middle

Explain embedded DNS at 127.0.0.11, the difference between publishing and intra-network reachability, and why expose is documentation.

for a senior

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.

for a principal

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.

context

open as a page

How does a Docker Compose stack keep a database's data across `docker compose down` and a later `up`, and what is the difference between a named volume and a bind mount declared in a compose file?

level: middleimportance: should knowfreq 55%

basics

~20 s

Declare a named volume under the top-level volumes: and mount it at the data path. Named volumes are Docker-managed, survive down, and are only deleted by down -v. Bind mounts map a host path into the container — great for source code, but host-permission and performance sensitive.

open as a page