☁ Cloud & DevOps

Docker Compose: running multiple containers from one file

How to use Docker Compose to describe an app, its database and helper services in one YAML file: networks, volumes, variables and production use.

A real application is rarely a single container. Even a modest project usually has a web application, a database, perhaps a cache and a process handling background jobs. Starting everything by hand with long docker run commands, remembering ports, networks and volumes, is awkward and error-prone. Docker Compose lets you describe the whole set in one file and manage it with a few commands.

The basic idea

Compose reads a file usually called compose.yaml (or the older docker-compose.yml) in which each service corresponds to a container, with its image, ports, volumes and variables. The file becomes executable documentation of the architecture: anyone reading it immediately understands what the project is made of.

A concrete example for an application with a PostgreSQL database and a Redis cache:

services:
  web:
    build: .
    ports:
      - "8000:8000"
    environment:
      DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/app
      REDIS_URL: redis://cache:6379/0
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started

  db:
    image: postgres:16
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: app
    volumes:
      - dbdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 5s
      retries: 10

  cache:
    image: redis:7

volumes:
  dbdata:

docker compose up -d starts everything, docker compose down stops it, and docker compose logs -f web follows the logs of a single service.

Networks: services call each other by name

Compose automatically creates a private network for the project. Each service can be reached by the others using the service name as the hostname: the application connects to the database at db:5432, not at an IP address. It is a detail that greatly simplifies configuration.

Note also that the database and the cache in the example do not publish ports to the outside. Only the web service exposes port 8000. It is a good habit: a database reachable from the Internet is one of the most frequent security problems, and here it is avoided simply by not declaring the port.

Volumes: where the data lives

The dbdata volume keeps PostgreSQL's data even when the container is recreated. There are two main ways to mount data:

Type Example Typical use
Named volume dbdata:/var/lib/postgresql/data Database and service data, managed by Docker
Bind mount ./src:/app/src Source code in development, configuration files

A bind mount is convenient in development because changes to the files on your computer are reflected immediately in the container. In production a named volume is preferable, as it is more independent of the host's folder structure.

Beware of one command: docker compose down -v also removes volumes, so it deletes the data. It is useful for starting from scratch in development, dangerous on a server.

Variables and secrets

Compose automatically reads a .env file in the same folder and substitutes variables such as ${DB_PASSWORD}. This lets you keep compose.yaml in the repository and the passwords out of it. The .env file should be excluded from version control; in its place you can commit a .env.example with dummy values, explaining which variables are needed.

For more sensitive information Compose also supports secrets, mounted as read-only files inside the container: a cleaner approach than environment variables, which can end up in logs or error messages.

Dependencies and startup order

The depends_on option sets the startup order, but on its own it does not guarantee that a service is ready. A database may take a few seconds before accepting connections. By combining depends_on with a healthcheck and the service_healthy condition, as in the example, the application starts only when the database actually responds.

It is still good practice for the application to be able to retry the connection: in production the database may restart even while everything else is running.

Multiple files for multiple environments

Development and production often differ in a few details: in development you mount the sources and enable debugging, in production you don't. Compose lets you layer files: a base compose.yaml and a compose.override.yaml loaded automatically in development, or a specific file passed explicitly with the -f option. This way the common part stays in one place.

Profiles are another option: a service tagged with a profile, such as a database administration tool, starts only when explicitly requested.

Compose in production: yes, with judgment

Compose is not just a development tool. On a single server, for a moderately sized application, it is a simple and reliable solution. With a few precautions:

  • set restart: unless-stopped so containers come back after a server reboot;
  • define memory limits to prevent a runaway service from freezing the machine;
  • configure log rotation, otherwise container log files can fill the disk;
  • schedule volume backups, which Compose does not do by itself.

When you need several servers, automatic failover or dynamic scaling, it is time to consider a real orchestrator. But many projects never reach that point, and a well-written Compose file serves them for years.

In short

  • Docker Compose describes all of a project's containers in one YAML file and manages them together.
  • Services communicate on a private network using their names; expose only the ports you need.
  • Data belongs on volumes; down -v deletes them, so use it with care.
  • Use .env for variables and healthchecks to start services only when their dependencies are ready.
  • On a single server Compose is fine for production too, with automatic restart, limits and backups.
MM
Marco Marzaduri

Marco Marzaduri works in cybersecurity, digital forensics and web development. With degrees in computer science and psychology, he combines technical analysis with an understanding of the human factor.

Read also