Skip to content
DevOps devops containers 5 min read

Multi-Container Apps with Docker Compose

Most real applications are not just one container. A typical web app needs at least two pieces running together: the app itself, and a database to store data. Starting each one by hand with long docker run commands gets painful fast, and you have to remember every flag every time. Docker Compose fixes this by letting you describe your whole application in a single file (docker-compose.yml) and start everything with one command.

What Docker Compose is

Docker Compose is a tool that reads a YAML file (YAML is a simple, human-readable text format for configuration) and uses it to create and run several containers at once. Each container is called a service in Compose terms. You write the file once, commit it to your project, and anyone on the team can run the exact same setup.

In modern Docker (2026), Compose is built in as a plugin. You run it as docker compose (two words, a space, no hyphen). The old standalone docker-compose (with a hyphen) is deprecated, so use the new form.

If you installed Docker using the official Docker packages on Ubuntu, the Compose plugin is already included. If docker compose version says “command not found”, install it with sudo apt install docker-compose-plugin.

When to use Compose (and when not to)

SituationUse Docker Compose?
Local development with app + database + cacheYes — this is the main use case
Running a small app on a single serverYes — simple and reliable
A demo or test environment you spin up and tear down oftenYes
Production across many servers needing auto-healing and scalingNo — use Kubernetes or a managed service instead
A single one-off container with no dependenciesOptional — a plain docker run is fine

Compose is designed for one host (one machine). It does not spread containers across a cluster of servers. For that you move to an orchestrator like Kubernetes.

Why Compose beats long docker run commands

Imagine starting a Postgres database by hand:

docker run -d --name db \
  -e POSTGRES_PASSWORD=secret \
  -e POSTGRES_DB=appdb \
  -v pgdata:/var/lib/postgresql/data \
  --network mynet \
  postgres:16

Then you start the app, remembering to attach the same network, pass the database URL, map the port, and so on. If you reboot or want to share this with a teammate, you retype all of it. Compose removes that pain:

  • The whole setup lives in one version-controlled file.
  • Containers on the same Compose project automatically share a network and can reach each other by service name.
  • One command brings everything up; one command tears it down.
  • Settings are documented and repeatable instead of living in your shell history.

A real compose file: app + Postgres

Here is a runnable example. Create a folder for your project and put this file inside it as docker-compose.yml.

services:
  app:
    image: nginx:1.27          # replace with your own app image
    ports:
      - "8080:80"              # host port 8080 -> container port 80
    environment:
      DATABASE_URL: "postgres://appuser:secret@db:5432/appdb"
    depends_on:
      - db
    networks:
      - backend
    restart: unless-stopped

  db:
    image: postgres:16
    environment:
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: appdb
    volumes:
      - pgdata:/var/lib/postgresql/data
    networks:
      - backend
    restart: unless-stopped

volumes:
  pgdata:

networks:
  backend:

A few things to notice:

  • app and db are services — Compose creates one container for each.
  • The app’s DATABASE_URL uses the hostname db. Compose creates an internal DNS name for every service, so db resolves to the database container automatically. You never need an IP address.
  • depends_on tells Compose to start db before app. (Note: this only waits for the container to start, not for Postgres to be ready to accept connections — see the gotcha below.)
  • volumes keeps your database data on disk. A volume is Docker-managed storage that survives even when the container is deleted. Without it, every docker compose down would wipe your data.
  • networks puts both services on a private network named backend so they can talk to each other but stay isolated from other projects.
  • restart: unless-stopped restarts a crashed container automatically, but respects a manual stop.

Never hard-code real passwords in a committed compose file. For anything beyond local testing, move secrets into a .env file (referenced with ${VARIABLE}) and add that file to .gitignore.

Bringing it up

From inside the project folder, start everything in the background:

docker compose up -d

The -d flag means “detached” — it runs the containers in the background and returns your shell.

Output:

[+] Running 4/4
 ✔ Network myapp_backend     Created
 ✔ Volume "myapp_pgdata"     Created
 ✔ Container myapp-db-1      Started
 ✔ Container myapp-app-1     Started

Check what is running:

docker compose ps

Output:

NAME           IMAGE        COMMAND                  SERVICE   STATUS         PORTS
myapp-app-1    nginx:1.27   "/docker-entrypoint.…"   app       Up 5 seconds   0.0.0.0:8080->80/tcp
myapp-db-1     postgres:16  "docker-entrypoint.s…"   db        Up 6 seconds   5432/tcp

View logs from all services (follow live with -f):

docker compose logs -f

When you are done, stop and remove the containers and network:

docker compose down

To also delete the named volumes (this erases your database data, so be careful):

docker compose down -v

Common everyday commands

CommandWhat it does
docker compose up -dBuild (if needed) and start all services in the background
docker compose downStop and remove containers + network (keeps volumes)
docker compose psList the running services in this project
docker compose logs -f dbFollow logs for just the db service
docker compose exec db psql -U appuser appdbOpen a shell/psql inside a running container
docker compose restart appRestart a single service
docker compose pullDownload newer versions of the images

The exec command is the one you will use most for debugging. For example, to open a Postgres prompt inside the running database:

docker compose exec db psql -U appuser -d appdb

Output:

psql (16.3)
Type "help" for help.

appdb=#

Best Practices

  • Keep one docker-compose.yml per project, committed to version control so the whole team runs the same stack.
  • Store passwords and other secrets in a .env file, reference them as ${VAR}, and add .env to .gitignore.
  • Always use a named volume for databases so data survives docker compose down.
  • Pin image versions (postgres:16, not postgres:latest) so builds are predictable and reproducible.
  • Don’t rely on depends_on alone for startup order — add a health check or have your app retry the database connection on boot.
  • Use restart: unless-stopped for long-running services so they recover after crashes and reboots.
  • Keep services on an explicit private network and only publish (map) the ports you actually need to expose.
Last updated June 15, 2026
Was this helpful?