Running one container with docker run is easy. Running an app, its database, a cache and a reverse proxy — each with the right ports, volumes and environment — gets messy fast. Docker Compose describes the whole stack in one YAML file and starts it with one command.

This tutorial assumes Docker is installed with the Compose plugin (docker compose version should work). If not, see installing Docker on Ubuntu.

The compose file

Create a project directory and a compose.yaml file (the older name docker-compose.yml also works):

mkdir ~/myapp && cd ~/myapp
services:
  web:
    image: nginx:stable
    ports:
      - "8080:80"
    volumes:
      - ./html:/usr/share/nginx/html:ro
    restart: unless-stopped
mkdir html && echo '<h1>Hello from Compose</h1>' > html/index.html
docker compose up -d
curl http://localhost:8080

That’s a complete Compose project. The directory name (myapp) becomes the project name, which prefixes container, network and volume names.

A realistic stack: app + database + cache

Here’s a typical web application with PostgreSQL and Redis:

services:
  app:
    image: ghcr.io/example/myapp:1.4
    ports:
      - "127.0.0.1:3000:3000"
    environment:
      DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/app
      REDIS_URL: redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
    restart: unless-stopped

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

  cache:
    image: redis:7-alpine
    restart: unless-stopped

volumes:
  db-data:

And a .env file next to it:

DB_PASSWORD=change-me-to-a-long-random-string

Key ideas in this file:

  • Service names are hostnames. Compose creates a private network for the project; app reaches the database at db:5432 and Redis at cache:6379. No IP addresses, no published database ports.
  • ${DB_PASSWORD} is substituted from .env (or the shell environment). Keep .env out of Git.
  • 127.0.0.1:3000:3000 publishes the app only on localhost — a reverse proxy on the host forwards public traffic to it. Publishing to 0.0.0.0 would bypass UFW, because Docker manages its own firewall rules.
  • depends_on with condition: service_healthy waits until the database’s health check passes, not merely until its container starts.
  • Named volume db-data keeps the database files when containers are recreated or upgraded.

Everyday commands

Run these from the directory containing compose.yaml:

Command What it does
docker compose up -d Create/start everything in the background
docker compose ps Status of the project’s containers
docker compose logs -f app Follow logs of one service
docker compose exec db psql -U app Run a command in a running container
docker compose pull Download newer images
docker compose up -d (again) Recreate only services whose image or config changed
docker compose restart app Restart a service without recreating it
docker compose stop Stop containers, keep them
docker compose down Stop and remove containers and the network
docker compose down -v Same, and delete named volumes (data loss!)
docker compose config Show the final merged config — great for debugging variables

Updating the stack

docker compose pull
docker compose up -d
docker image prune -f

Compose recreates only the services whose image changed. Pin image versions (postgres:17, not postgres:latest) so an update never surprises you with a major version jump — database major upgrades in particular need a manual migration.

Building your own image

Instead of image:, a service can build from a Dockerfile:

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    image: myapp:local

docker compose build builds it; docker compose up -d --build builds and restarts in one go. See the Dockerfile tutorial for writing the Dockerfile itself.

Bind mounts vs named volumes

volumes:
  - ./config:/etc/myapp:ro      # bind mount: a host directory
  - app-data:/var/lib/myapp     # named volume: managed by Docker

Use bind mounts for config files you edit on the host. Use named volumes for data the container owns (databases, uploads); Docker handles permissions and the location (/var/lib/docker/volumes/). Either way, include them in your backups.

Multiple environments with override files

Compose automatically merges compose.override.yaml if it exists. A common pattern:

  • compose.yaml — production settings.
  • compose.override.yaml — local development (source code mounted, debug ports), not deployed.

Or name files explicitly:

docker compose -f compose.yaml -f compose.prod.yaml up -d

Limit resources

On a small VPS, stop one runaway container from starving the rest:

services:
  app:
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M

Docker Compose applies these limits without Swarm.

Common mistakes

  • Using localhost between containers. Inside a container, localhost is the container itself. Use the service name (db, cache).
  • Running down -v by habit. It deletes your database volume.
  • Editing .env and expecting running containers to change. Run docker compose up -d to recreate them with the new values.
  • Publishing every port. Only the entry point (or reverse proxy) needs a public port.

Next steps

Compose is the simplest way to run self-hosted apps on a single server. A Docker VPS with a few gigabytes of RAM comfortably runs a full stack like the one above.

  • Docker
  • Docker Compose
  • Containers