Containers & Orchestration

Docker Compose Networks and Volumes in Practice

Why containers cannot reach each other over localhost, and when to choose a named volume over a bind mount. This guide covers the DNS difference between the default bridge and user-defined networks, service name resolution, published ports versus exposed ports, and the operational trade-offs of both mount styles.

By LaoHand Team·8 min read·Updated 2026-09-30

Why service names do not resolve on the default network

Compose creates a user-defined bridge network for the project by default, attaches every service to it, and lets containers resolve each other by service name. An application can therefore reach its database at db or redis with no IP address and no published port.

Problems appear when you declare network_mode: bridge explicitly, or attach services to the default bridge network. The default bridge has no embedded DNS, so you are left with the deprecated --link mechanism or hard-coded IPs. Both approaches also break on multi-host deploys because container IPs change on every recreate.

The recommended pattern is to omit ports for services that only talk to each other, and publish ports only for services accessed from the host or outside. When extra names are needed, declare aliases under networks to register them inside that network.

services:
  api:
    image: acme/api:1.4
    networks: [app]
  db:
    image: postgres:16
    networks:
      app:
        aliases: [database, pg]
    volumes: [pgdata:/var/lib/postgresql/data]

networks:
  app:
    driver: bridge

volumes:
  pgdata:

expose versus ports

expose only states that a container uses a given port, for documentation and for the benefit of clients on the same network. It creates no host mapping and grants no DNS resolution; it is essentially metadata.

ports performs the actual publication. The short form "8080:80" maps host 8080 to container 80. The long form accepts a protocol and a bind address, so "127.0.0.1:8080:80/tcp" binds only to loopback and is unreachable from outside, which suits local debugging.

Mind the difference between -p and -P. -p states the mapping explicitly, while -P publishes every EXPOSE port from the image onto random high host ports. Relying on -P alone can expose a service on every interface, so always write -p explicitly.

services:
  web:
    image: acme/web:2.0
    expose:
      - "3000"
    ports:
      - "127.0.0.1:8080:3000"
  metrics:
    image: prom/node-exporter:v1.8.1
    ports:
      - "9100:9100"

Named volumes versus bind mounts

A bind mount maps a host directory at a path you choose. It suits cases where the host must edit files directly, where an existing data directory must be used, or where `--mount` is needed to set uid, gid, or read-only flags. The downside is dependence on host layout, which makes migration and CI runs awkward.

A named volume is managed by Docker, behaves consistently across hosts and in CI, and makes ownership and permissions easier to set. When first mounted onto an empty location, Docker copies the existing image content into the volume; a bind mount instead hides image content unless you explicitly ask for the copy.

Operational rule of thumb: back up and migrate named volumes with a helper container such as docker run --rm -v name:/data -v "$PWD":/backup alpine tar czf /backup/x.tar.gz -C /data . ; use bind mounts for config that humans edit or that comes straight from the repository. Mixing both in one service is common: a volume for data, a bind mount for config.

services:
  db:
    image: postgres:16
    volumes:
      - pgdata:/var/lib/postgresql/data     # named volume:数据
      - ./conf/postgresql.conf:/etc/postgresql/postgresql.conf:ro   # bind mount:配置

# 备份 named volume
docker run --rm -v pgdata:/data -v "$PWD":/backup alpine tar czf /backup/pgdata.tar.gz -C /data .

Startup order and debugging

depends_on controls startup order and shutdown ordering, but it does not guarantee the dependency is usable. A running database container does not imply it accepts connections, so applications repeatedly reporting connection refused is a normal ordering artifact rather than a misconfiguration.

To wait for actual readiness, use the long syntax with condition: service_healthy and define a healthcheck on the dependency. The check must be lightweight and deterministic; do not embed the dependent application logic, which creates a deadlock-style wait.

Debug networking in this order: confirm container state and ports with `docker compose ps`, test reachability by service name from inside a container (getent hosts db, curl), then inspect network membership and aliases with `docker network inspect`. If a service name fails to resolve, first verify that the service actually declares networks.

services:
  db:
    image: postgres:16
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 3s
      retries: 10
  api:
    depends_on:
      db:
        condition: service_healthy

Multiple Compose files and environment isolation

Compose stacks multiple files with -f, and later files override earlier ones. That makes it cheap to separate a base definition from a development or production overlay: keep service definitions and image names in the base, and put only ports, environment and volumes in the overlay.

Each project creates a network and volumes named after the directory, so two same-named projects running on one host collide. Either pass `docker compose -p <name>` explicitly or fix the name with the top-level name field. In production, never let two environments share one named volume.

`docker network inspect` is the most direct tool for network details: look at the service names and aliases in the Containers list and the subnet in IPAMConfig. When several projects share one external service, declare it as an external network so a single network is managed centrally instead of each project building its own and failing to reach each other.

# 基础 + 覆盖
docker compose -f compose.yaml -f compose.prod.yaml up -d
docker compose -f compose.yaml -f compose.prod.yaml config   # 校验合并结果

# 固定项目名,避免同名目录互相污染
docker compose -p acme-api up -d

networks:
  shared:
    external: true
    name: acme-shared

Official References

Each command links to its official documentation below, so you can verify the latest usage and read deeper.