Skip to content

guide

Docker Compose in production, minus the surprises

Compose is a perfectly good way to run a handful of services on one instance. The defaults, though, are tuned for laptops: unbounded logs, ports open to the world, and containers that stay dead after a reboot. Fix those and it holds up well.

Install Docker Engine properly

On Debian 12 or Ubuntu 24.04, add Docker's repository and install the engine plus the Compose plugin:

sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
. /etc/os-release
sudo curl -fsSL https://download.docker.com/linux/$ID/gpg -o /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/$ID $VERSION_CODENAME stable" | \
sudo tee /etc/apt/sources.list.d/docker.list
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
docker compose version

On AlmaLinux and Rocky 9, add https://download.docker.com/linux/rhel/docker-ce.repo with dnf config-manager --add-repo and install the same package names. Then sudo systemctl enable --now docker.

Adding your user to the docker group saves typing sudo, but membership is equivalent to root. Only do it for accounts you would give root anyway.

Daemon defaults that matter

Create /etc/docker/daemon.json before you start any containers:

{
  "log-driver": "local",
  "log-opts": { "max-size": "20m", "max-file": "5" },
  "live-restore": true
}
sudo systemctl restart docker

The default json-file driver has no size limit. A chatty container will happily write 15 GB of logs onto a 20 GB disk and stop the database alongside it. The local driver compresses and rotates. live-restore keeps containers running while the daemon itself restarts during upgrades. Log options only apply to containers created after the change, so recreate existing ones.

A production-shaped compose.yaml

services:
  app:
    image: ghcr.io/example/api:1.8.2
    restart: unless-stopped
    env_file: .env
    ports:
      - "127.0.0.1:3000:3000"
    depends_on:
      db:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000/healthz"]
      interval: 15s
      timeout: 3s
      retries: 3
    deploy:
      resources:
        limits:
          memory: 1g

  db:
    image: postgres:16
    restart: unless-stopped
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets: [db_password]
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      retries: 5
    shm_size: 256m

volumes:
  pgdata:

secrets:
  db_password:
    file: ./secrets/db_password

Why it looks like that

  • Pinned tags: :latest means a reboot can pull a different version than the one you tested. Pin a version and change it deliberately.
  • 127.0.0.1 on ports: the app is reachable only from the host, where nginx terminates TLS. The database publishes nothing; the app reaches it by service name on the internal network.
  • restart: unless-stopped: containers come back after a reboot or crash but stay down if you stopped them on purpose.
  • Health checks with depends_on: the app waits until Postgres accepts connections, rather than crash-looping through its first thirty seconds.
  • Memory limit: a leak in one service gets that container killed, not the whole instance.
  • Secrets file: the password never appears in docker inspect output or process listings.

Run, inspect, upgrade

docker compose up -d
docker compose ps
docker compose logs -f --tail=100 app
docker stats --no-stream

docker compose ps should show healthy next to both services within a minute. To upgrade, change the tag and run:

docker compose pull app
docker compose up -d app

Only the changed service is recreated. Keep the previous tag written down; rolling back is the same two commands with the old version.

Volumes and backups

Named volumes live under /var/lib/docker/volumes/. Copying that directory while Postgres is running does not give you a consistent backup. Dump from inside the container instead:

docker compose exec -T db pg_dump -U postgres -Fc appdb > /srv/backup/appdb-$(date +%F).dump

Then ship the file off the instance. The backups guide covers where to and how often.

Housekeeping

docker system df
docker image prune -a --filter "until=168h"

Every upgrade leaves the previous image on disk. On a 40 GB volume, a few months of weekly deploys adds up. Prune images older than a week on a systemd timer, and never run docker volume prune casually: it deletes unattached volumes, which may include data you meant to keep.

Sizing

A small API with Postgres and Redis runs on Boost. Once you add a worker, a search index or a second app, Turbo gives four vCPUs and 8 GB, which leaves real room for the page cache that keeps the database fast. Image pulls and builds are disk-heavy, so NVMe-backed storage shortens deploys as well as queries.

Questions

Should I install Docker from the distribution or from Docker's repository?

Use Docker's own repository for production. Distribution packages lag behind, and the Compose packaging differs between releases, so commands from current documentation may not match what you have installed.

Why can the internet reach a container port my firewall blocks?

Publishing a port on 0.0.0.0 makes Docker add its own NAT and forward rules, which are evaluated before your host input chain. Bind published ports to 127.0.0.1 and put nginx in front, or bind them to a WireGuard address.

How much memory does a Compose stack need?

Add up what each container uses under load from docker stats, then leave at least 25 percent headroom for the kernel's page cache. A typical app, database and cache trio sits comfortably on 4 GB.

Is Compose a replacement for Kubernetes?

For a single instance, it is usually the better tool. It has no scheduler, no multi-host networking and no self-healing across machines, so once you need several nodes sharing a workload, look at something built for that.