Compose Networks and Volumes

Docker Compose does more than start several containers at once: it also creates the network and storage wiring that lets those containers behave like one application. Networks let services find each other by name, while volumes keep important data outside a container’s thin writable layer so it survives rebuilds, restarts, and container replacement.

This lesson focuses on Compose-managed networks and volumes: what Compose creates automatically, how to customize it, and how to avoid the most common data-loss and connectivity mistakes.

Overview: How Compose Networks and Volumes Work

When you run docker compose up, Compose reads compose.yml, creates any declared networks and volumes, then creates containers for each service. By default, Compose creates one bridge network for the project. Every service joins that network, and Docker’s embedded DNS server makes each service reachable by its service name. If your file has services named web and db, the web container should connect to the database host named db, not localhost.

This matters because localhost inside a container means that same container. A web app trying to connect to localhost:5432 is looking for PostgreSQL inside the web container. On a Compose network, it should use db:5432. Docker handles IP assignment, but applications should normally rely on DNS names because container IPs can change when containers are recreated.

Compose volumes solve a different problem. A container has a thin writable layer on top of its read-only image layers. That layer is disposable: removing and recreating the container removes the layer. A named volume is managed by Docker and mounted into the container at a path such as /var/lib/postgresql/data. The volume survives when the container is replaced, so database files and uploaded content remain available.

A bind mount maps a specific host path, such as ./src, into the container. Bind mounts are useful for local development because edits on the host appear immediately in the container. Named volumes are usually a better default for application data because Docker owns the storage location, initializes it predictably, and keeps it independent of your current working directory.

Syntax

The common Compose shape for networks and volumes is:

services:
  service-name:
    image: image-name:tag
    networks:
      - network-name
    volumes:
      - volume-name:/path/in/container
      - ./host/path:/path/in/container

networks:
  network-name:
    driver: bridge

volumes:
  volume-name:
Key Meaning
services The containers Compose will create for this project.
networks under a service Lists the Compose networks that service joins. If omitted, the service joins the default project network.
volumes under a service Mounts named volumes, bind mounts, or other supported mount types into the container.
networks at the top level Declares custom networks for the project. The default driver for local Compose apps is usually bridge.
volumes at the top level Declares named volumes that Docker manages and persists independently of containers.
external: true Tells Compose to use an already-existing network or volume instead of creating one for the project.

Useful inspection commands include:

docker compose up -d

docker compose ps

docker network ls

docker volume ls

docker compose down

docker compose down -v

docker compose down removes the containers and default network, but keeps named volumes. docker compose down -v also removes Compose-managed named volumes, which is useful for resetting a development database but dangerous if you wanted to keep data.

Examples

Example 1: Default Network and Service DNS

This file defines a tiny web service and a Redis service. There is no top-level networks section, so Compose creates a default network automatically.

services:
  web:
    image: nginx:1.27-alpine
    ports:
      - "8080:80"
    depends_on:
      - cache

  cache:
    image: redis:7.4-alpine

Start it:

docker compose up -d

docker compose ps

Output:

NAME                IMAGE              SERVICE   STATUS        PORTS
myapp-web-1         nginx:1.27-alpine  web       Up 5 seconds  0.0.0.0:8080->80/tcp
myapp-cache-1       redis:7.4-alpine   cache     Up 5 seconds  6379/tcp

The web container can resolve the hostname cache because both services are attached to the same project network. Notice that Redis does not need a ports entry for the web container to reach it. ports publishes a container port to the host machine; Compose networking between services works without host publication.

Example 2: Separate Frontend and Backend Networks

Larger apps often separate public-facing services from private data services. In this example, web joins both networks, but db joins only the private backend network.

services:
  web:
    image: nginx:1.27-alpine
    ports:
      - "8080:80"
    networks:
      - frontend
      - backend

  db:
    image: postgres:16.4-alpine
    environment:
      POSTGRES_PASSWORD: changeme
      POSTGRES_DB: appdb
    volumes:
      - db-data:/var/lib/postgresql/data
    networks:
      - backend

networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge

volumes:
  db-data:

Start and inspect the project resources:

docker compose up -d

docker network ls

docker volume ls

Output:

NETWORK ID     NAME                 DRIVER    SCOPE
f1a2b3c4d5e6   myapp_frontend       bridge    local
b6c7d8e9f0a1   myapp_backend        bridge    local

DRIVER    VOLUME NAME
local     myapp_db-data

Compose prefixes resource names with the project name, usually the directory name. The database is reachable as db from web, but not from containers that only join frontend. This is not a replacement for database authentication or firewalls in production, but it reduces accidental exposure in local and single-host Compose environments.

Example 3: Named Volume for Data and Bind Mount for Source

A realistic development stack often combines both storage styles: a bind mount for code you edit frequently and a named volume for stateful service data.

services:
  app:
    image: node:20-alpine
    working_dir: /workspace
    command: sh -c "npm install && npm run dev"
    ports:
      - "3000:3000"
    volumes:
      - ./app:/workspace
    environment:
      DATABASE_URL: postgres://postgres:changeme@db:5432/appdb
    depends_on:
      - db
    networks:
      - appnet

  db:
    image: postgres:16.4-alpine
    environment:
      POSTGRES_PASSWORD: changeme
      POSTGRES_DB: appdb
    volumes:
      - db-data:/var/lib/postgresql/data
    networks:
      - appnet

networks:
  appnet:
    driver: bridge

volumes:
  db-data:

Check that the database files are on a volume:

docker compose up -d

docker compose exec db sh -c 'ls /var/lib/postgresql/data | head'

Output:

base
global
pg_commit_ts
pg_dynshmem
pg_hba.conf

The app source comes from ./app on your host, which is convenient while editing. PostgreSQL data goes into db-data, so recreating the db container does not wipe the database. The database URL uses db as the hostname because db is the Compose service name.

How It Works Step by Step

  1. Compose chooses a project name. Unless you set one, it usually uses the directory name.
  2. Compose creates declared networks such as myapp_backend, plus a default network if services need one and no explicit network is declared.
  3. Compose creates declared named volumes such as myapp_db-data if they do not already exist.
  4. For each service, Docker creates a container from the specified image. The image layers remain read-only; the container gets a writable layer for temporary container-local changes.
  5. Docker attaches the container to its networks and registers service names with embedded DNS. Other containers on the same network can resolve those names.
  6. Docker mounts each volume or bind mount into the container before the main process starts. If a named volume is empty and the image has files at that destination path, Docker may copy the image’s existing directory contents into the volume on first use.
  7. When a container is recreated, Compose attaches the new container to the same networks and remounts the existing named volumes. Data stored in the volume remains; data stored only in the old writable layer is gone.

Common Mistakes

Using localhost Between Containers

Wrong:

environment:
  DATABASE_URL: postgres://postgres:changeme@localhost:5432/appdb

This points the app at itself, not at the database container. Fix it by using the database service name:

environment:
  DATABASE_URL: postgres://postgres:changeme@db:5432/appdb

Publishing Ports Unnecessarily

Wrong:

db:
  image: postgres:16.4-alpine
  ports:
    - "5432:5432"

This exposes PostgreSQL to the host even if only the app service needs it. Omit ports for internal-only services. Containers on the same Compose network can still reach db:5432.

Losing Data with down -v

Wrong when you want to keep database data:

docker compose down -v

-v removes named volumes declared by the Compose file. Use plain docker compose down to remove containers and networks while keeping named volumes:

docker compose down

Replacing a Named Volume with a Bind Mount by Accident

A bind mount like ./postgres-data:/var/lib/postgresql/data stores database files in your project directory. That can work, but permissions and platform differences are more painful, especially on Docker Desktop where Linux containers run inside a VM. Prefer a named volume for database state unless you specifically need host-path access.

Best Practices

  • Use service names, such as db or cache, for container-to-container hostnames.
  • Publish only the ports that humans or host tools need to access. Internal services usually do not need ports.
  • Use named volumes for databases, queues, uploaded files, and other persistent service data.
  • Use bind mounts for local development source code, configuration experiments, or files you intentionally edit from the host.
  • Keep frontend and backend services on separate networks when it clarifies access boundaries.
  • Do not rely on container IP addresses. Containers are replaceable, and IP addresses can change.
  • Be careful with docker compose down -v; treat it as a data reset command.
  • Pin image tags such as postgres:16.4-alpine instead of using latest, because latest can change under you.
  • Use obvious placeholders such as changeme in examples, and use proper secret management for real credentials.

Practice Exercises

  1. Create a Compose file with web, api, and db. Put web and api on a frontend network, but put only api and db on a backend network. The expected result is that web cannot directly resolve db.
  2. Modify a PostgreSQL Compose service so its data survives docker compose down. Hint: mount a named volume at /var/lib/postgresql/data and declare it at the top level.
  3. Take a development app that currently uses a named volume for source code and change it to a bind mount. The expected result is that editing files on the host changes what the container sees.

Summary

  • Compose creates a project network automatically, and services on that network can reach each other by service name.
  • localhost inside a container means that container, not another service.
  • ports publishes a container port to the host; it is not required for service-to-service traffic on a Compose network.
  • Named volumes are Docker-managed persistent storage and are the default choice for database data.
  • Bind mounts map host paths into containers and are especially useful for local development files.
  • docker compose down keeps named volumes; docker compose down -v removes them.