/back-to-blog
DevOpsFebruary 23, 20269 min read

Docker Compose in Production: A Tested Setup for Self-Hosted Apps

A tested compose.yaml for a self-hosted app: three web replicas behind Traefik, Postgres 18 with a file-based secret and resource limits, plus the deploy gaps to plan around.

A

Acefina

Performance · Infrastructure · Security

Illustration of string instruments and music stands inside glowing translucent containers joined by orange and purple light trails

Docker Compose runs a multi-container app on one host from a single YAML file. For many self-hosted products (a web app, a database, a reverse proxy) that is all the orchestration they need. This guide gives you a compose.yaml we ran end to end on Docker Engine 29.8.2 with Docker Compose v5.5.0 on 17 October 2026, explains each production setting, and shows where Compose stops and Swarm or Kubernetes start.

Where Docker Compose fits in container orchestration

Container orchestration means starting, connecting, restarting and scaling containers for you. Kubernetes is the default answer at scale: CommandLinux puts its share of the container orchestration tools market at 92%, and, citing the CNCF 2024 Annual Survey, reports that 80% of organizations ran Kubernetes in production in 2024 while 93% were using, piloting or evaluating it. The market is growing fast too. Grand View Research values container orchestration at USD 1.7 billion in 2024 and projects USD 8.5 billion by 2030, a 31.8% CAGR.

Those numbers describe companies running clusters. A product that fits on one server pays for Kubernetes in control-plane upkeep and YAML it doesn't need. Compose is the smaller tool for that case.

Docker Compose vs Kubernetes for a self-hosted stack

QuestionDocker ComposeKubernetes
Where it runsOne Docker hostA cluster of nodes
ConfigOne compose.yamlDeployments, Services, Ingress and more
ScalingFixed replica count you setCan scale automatically on CPU usage
Updating a serviceStops and recreates its containersReplaces containers at a controlled rate
Survives a host failureNoHandles failover across nodes

If the last two rows are deal-breakers, read the section on deploying updates before you commit to Compose.

A production compose.yaml you can run

Install Docker Engine with the Compose plugin from the official install guide, then check both:

docker --version
docker compose version

Use docker compose with a space. Docker's Compose history page explains that docker-compose with a hyphen was Compose v1, the original Python tool. The supported versions today, Compose v2 and v5, are written in Go and invoked as docker compose.

Create a project folder with this compose.yaml:

services:
  proxy:
    image: traefik:v3.7
    command:
      - --providers.docker=true
      - --providers.docker.exposedbydefault=false
      - --providers.docker.network=frontend
      - --entrypoints.web.address=:80
    ports:
      - "80:80"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
    networks:
      - frontend
    restart: unless-stopped

  web:
    image: traefik/whoami:v1.12   # replace with your own app image
    deploy:
      replicas: 3
      resources:
        limits:
          cpus: "0.50"
          memory: 256M
    labels:
      - traefik.enable=true
      - traefik.http.routers.web.rule=PathPrefix(`/`)
      - traefik.http.routers.web.entrypoints=web
      - traefik.http.services.web.loadbalancer.server.port=80
    environment:
      DATABASE_HOST: db
    depends_on:
      db:
        condition: service_healthy
    networks:
      - frontend
      - backend
    restart: unless-stopped

  db:
    image: postgres:18
    environment:
      POSTGRES_DB: app
      POSTGRES_USER: app
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password
    volumes:
      - db-data:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d app"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - backend
    restart: unless-stopped

networks:
  frontend:
  backend:
    internal: true

volumes:
  db-data:

secrets:
  db_password:
    file: ./secrets/db_password.txt

Create the secret file, validate the config and start the stack:

mkdir -p secrets
openssl rand -base64 24 > secrets/db_password.txt
docker compose config -q
docker compose up -d --wait
curl -s localhost | grep Hostname

--wait returns once the services are running or healthy, according to the docker compose up reference. Repeat the curl and the hostname changes as Traefik spreads requests across the three web containers. In our run, six requests cycled through all three replicas in turn.

What each production setting does

  • restart: unless-stopped restarts a container whatever its exit code and stops only when you stop or remove the service (services reference).
  • depends_on with condition: service_healthy holds web back until the Postgres healthcheck passes.
  • deploy.resources.limits caps each web container at half a CPU core and 256 MB of memory. The deploy reference gives the syntax, and docker stats showed the 256 MiB limit on every replica in our test.
  • The backend network is internal: true, which the networks reference describes as externally isolated. The database sits only on backend, so Traefik and the outside world can't reach it.
  • Pin image tags (postgres:18, traefik:v3.7) so a later docker compose pull never jumps to a new major version.

Mistakes this file avoids

  • The file has no version: line. Docker's version and name reference says the field is only informative and Compose warns that it is obsolete. Compose v5.5.0 printed exactly that warning when we added version: '3.8'.
  • The database volume is mounted. Declaring db-data under volumes: without mounting it into the service keeps nothing. Since PostgreSQL 18 the official image expects the mount at /var/lib/postgresql; for 17 and below it is /var/lib/postgresql/data (postgres image docs).
  • The database password lives in a secret file instead of the YAML; the security section covers how that works.

Scaling replicas on one host

deploy.replicas sets how many containers of a service Compose keeps running. The catch is the host port. A host port can be bound by only one container, so a service with ports: - "8089:80" and replicas: 3 fails. When we tried it, one replica started and the second stopped with Bind for 0.0.0.0:8089 failed: port is already allocated.

The fix is the pattern in the file above: publish a port only on the reverse proxy, give the scaled service no ports:, and let the proxy find the replicas. Traefik reads routing from container labels (Traefik Docker provider docs). With exposedbydefault=false, only containers labelled traefik.enable=true get routed.

Replicas on one host keep the app answering if one container crashes. They don't help if the host itself goes down.

Deploying updates without dropping requests

docker compose up -d is not a rolling update. The up reference says that when a service's configuration or image has changed, Compose picks up the change "by stopping and recreating the containers". We changed one environment variable on the running stack and ran docker compose up -d while sending a request every 100 ms. All three web replicas were recreated together, and 13 of 120 requests failed (11 returned 404 from Traefik, one 502, one timed out).

What you can do about it:

  1. Redeploy only the service you changed. Docker's production guide uses docker compose build web then docker compose up --no-deps -d web, which leaves the database alone. The web replicas still restart together.
  2. Deploy in a low-traffic window.
  3. Move to Docker Swarm when you need real rolling updates. docker stack deploy reads a Compose file (stack deploy docs) and honours deploy.update_config, where order: start-first starts the new task before stopping the old one. Swarm updates one task at a time by default (rolling update tutorial). It ignores build:, so push your images to a registry first.

Security checks before going live

  • In plain Compose, a secret is a file mounted at /run/secrets/<name>, and a service sees only the secrets it lists (Compose secrets guide). The official Postgres image reads POSTGRES_PASSWORD_FILE from that path. Docker's guide warns that passwords in environment variables can leak into logs. Swarm secrets go further: they are encrypted in transit and at rest in the Raft log, and they exist only for Swarm services (Swarm secrets). Keep secrets/ out of git.
  • Traefik needs the Docker socket to discover containers, and its docs warn that unrestricted access to the Docker API is a security concern because a compromised Traefik could reach the host. The file mounts it read-only; for stricter setups, Traefik's docs list options such as a socket proxy.
  • Run trivy image postgres:18 (and the same for your own image) before each release; vulnerability scanning is on by default (Trivy docs).
  • A container runs as root unless the image sets a USER or the service sets user: (services reference). Build your app image with a non-root user.
  • A published port without a host IP binds to 0.0.0.0 and bypasses host firewall rules, per the same reference. Publish only the proxy, and put TLS on it before real traffic arrives.

Troubleshooting and backups

  • docker compose config renders the configuration Compose will apply, merging every file you pass with -f (for example a compose.production.yaml override). Add -q to validate without printing.
  • docker compose ps shows each container's health, and docker compose logs -f web follows one service's output.
  • Back up the database with docker compose exec -T db pg_dump -U app app > app.sql. The -T flag stops Compose from allocating a TTY, so the dump file stays clean. Copy the file off the host.

Frequently asked questions

Is Docker Compose suitable for production?

Yes, for apps that fit on one host and can accept a few seconds of disruption during deploys. You get restarts, health checks, resource limits, replicas and file-based secrets. You don't get failover to another machine or rolling updates; those need Swarm or Kubernetes.

What is the difference between docker-compose and docker compose?

docker-compose is Compose v1, written in Python. docker compose runs the supported versions (v2 and v5), written in Go. Use docker compose.

Do I still need the version key in docker-compose.yml?

No. Compose validates against the latest schema whatever the version: value says, and prints an "obsolete" warning when the key is present.

Why does deploy.replicas fail with "port is already allocated"?

Every replica tries to bind the same host port. Remove ports: from the scaled service and route to it through a reverse proxy such as Traefik.

Get help moving a stack to production

We have migrated 700+ websites without downtime. If your Compose stack needs deploys that don't drop requests, a CI pipeline or a move to Swarm, see our CI/CD and zero-downtime deployments service and ask for a free review.

Need help with your infrastructure?

Let's turn this article into action on your site.

If your site needs faster performance, better security, or streamlined DevOps, we can help you plan and deliver.