◀ Back to blog
Docker

Docker Compose in production

Published on 28 Feb 2024· 8 min read
#Docker#Production#DevOps

Docker Compose beyond development

Docker Compose is not just for development. With the right practices, it becomes a powerful tool for orchestrating applications in production.

For an application hosted on a single server (a VPS or a dedicated machine), Compose is an excellent trade-off: the whole infrastructure is described in a handful of versioned files, a deployment takes two commands, and there is no control plane to maintain and no cluster to monitor. Kubernetes or Docker Swarm become relevant when you need to spread load across several machines or guarantee high availability at the host level, which is not what most business applications need.

This article covers a file layout, an annotated production configuration, log, secret and data management, then a deployment procedure and the most common pitfalls.

Recommended file structure

├── docker-compose.yml          # Base configuration
├── docker-compose.prod.yml     # Production overrides
├── docker-compose.dev.yml      # Development overrides
├── .env.production             # Environment variables
└── nginx/
    └── default.conf            # Nginx configuration

The principle is overriding: the base file describes what every environment has in common (the services, their networks, their volumes), and each environment file only adds its differences. In development you mount the source code as a volume and enable Xdebug; in production you use the built image, limit resources and enable automatic restarts.

Compose merges the files in the order they are passed with the -f option: scalar values from the last file win, while lists such as ports or volumes are combined. To avoid repeating the options on every command, you can set the COMPOSE_FILE variable on the server:

# Explicit file merge
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

# Or once and for all in the server environment
export COMPOSE_FILE=docker-compose.yml:docker-compose.prod.yml
docker compose up -d

# Check the final configuration after merging
docker compose config

The docker compose config command prints the configuration that is actually applied, interpolated variables included. It is the first thing to run when a service does not behave as expected.

Production configuration

services:
  app:
    build:
      context: .
      target: production
    restart: always
    deploy:
      resources:
        limits:
          memory: 512M
          cpus: '0.5'
    healthcheck:
      test: ["CMD", "php-fpm-healthcheck"]
      interval: 30s
      timeout: 5s
      retries: 3

  nginx:
    image: nginx:alpine
    restart: always
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      app:
        condition: service_healthy

  redis:
    image: redis:7-alpine
    restart: always
    command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru

Each block of this configuration has a specific role:

  • build.target: production: builds only the production stage of a multi-stage Dockerfile, without the development tools. See the article Docker multi-stage builds.
  • restart: always: the container restarts after a crash and when the Docker daemon restarts. The unless-stopped variant behaves the same way, except that a container stopped manually stays stopped after a server reboot.
  • deploy.resources.limits: with Docker Compose v2, these limits are enforced even outside Swarm. A container that exceeds its memory limit is killed by the kernel (OOM) instead of bringing the whole server down.
  • healthcheck: php-fpm-healthcheck is a small open source script you install in the image; it queries the PHP-FPM status page, which must therefore be enabled with pm.status_path in the pool configuration.
  • depends_on with condition: service_healthy: Nginx only starts once PHP-FPM is reported healthy, which avoids 502 errors at startup.
  • Redis is capped at 256 MB with the allkeys-lru policy: once the limit is reached, the least recently used keys are evicted. That suits a cache, not a session store whose entries must not disappear.

Environment variables and secrets

The .env.production file must never be committed: it lives only on the server, with restricted permissions (chmod 600). Compose can also make a variable mandatory, so that a missing value makes the deployment fail instead of starting a misconfigured application:

services:
  app:
    env_file: .env.production
    environment:
      APP_ENV: prod
      DATABASE_URL: ${DATABASE_URL:?DATABASE_URL must be set}

For the most sensitive data (database passwords, API keys), prefer Compose secrets, mounted as files under /run/secrets/ rather than exposed in the process environment. The topic is covered in detail in the article Securing your Docker containers.

Log management

Configure a centralized logging driver to make monitoring easier:

logging:
  driver: json-file
  options:
    max-size: "10m"
    max-file: "3"

Without these options, the json-file driver keeps logs forever and a chatty container eventually fills the disk. Here, each container keeps at most three 10 MB files. Rather than repeating this block in every service, use a YAML extension and an anchor:

x-logging: &default-logging
  driver: json-file
  options:
    max-size: "10m"
    max-file: "3"

services:
  app:
    logging: *default-logging
  nginx:
    logging: *default-logging

You can also set these defaults for the whole host in /etc/docker/daemon.json, then restart Docker. They only apply to containers created after the change:

{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  }
}

For aggregation (Loki, Elasticsearch, a SaaS service), have the application write to standard output and let an agent collect the container logs: it is more robust than writing files inside the container.

Volumes and persistent data

A container is disposable; its data is not. Everything that must survive a docker compose down (database, files uploaded by users) must live in a named volume or a clearly identified host directory. Be careful: docker compose down -v deletes the project's named volumes, and therefore the data.

A volume is not a backup. For a database, run a regular logical export rather than copying the raw files of a running engine:

# MySQL export from the container, compressed on the host
docker compose exec -T db sh -c 'mysqldump -u root -p"$MYSQL_ROOT_PASSWORD" --single-transaction app' \
  | gzip > backup-$(date +%F).sql.gz

Then remember to copy these files off the server and to test restoring them regularly.

Deploying a new version

With images built in CI and pushed to a registry, an update boils down to pulling the new images and recreating the containers that changed:

docker compose pull
docker compose up -d --remove-orphans --wait
docker image prune -f

The --wait option waits for services to be running and healthy before returning, which lets a deployment script fail cleanly if a healthcheck does not pass. --remove-orphans removes containers for services deleted from the file. Keep in mind that Compose recreates a container by stopping the old one before starting the new one, so there is a short interruption. If that is not acceptable, you need a reverse proxy able to switch between two instances, or an orchestrator.

Common pitfalls

  • The latest tag in production: there is no way to know which version is running or to roll back. Use one tag per version or per commit.
  • Ports published on every interface: "3306:3306" exposes the database to the Internet, and Docker's rules often bypass the UFW firewall. Publish nothing for internal services, or bind them to 127.0.0.1.
  • Missing or overly lenient healthcheck: condition: service_healthy is useless if the test only checks that the process exists.
  • Building images on the production server: it consumes the server's resources and makes deployments non-reproducible. Build in CI, deploy images.

Key takeaways

  • Always set restart: always for resilience
  • Limit resources with deploy.resources
  • Use healthchecks for high availability
  • Keep persistent data volumes separate
  • Never expose internal ports unnecessarily
  • Rotate logs and back up data off the server

When Compose is no longer enough

Compose is limited to a single host: no automatic distribution across machines, no zero-downtime rolling updates, no recovery if the server itself goes down. If your application needs those guarantees, that is the signal to move to an orchestrator. Until then, a well-sized server driven by Compose remains a simple, readable and reliable solution.