$linuxjunkies
>

Docker Compose in Production

Deploy Docker Compose in production with compose.yaml, explicit networks, named volumes, restart policies, journald logging, and file-based secrets.

IntermediateUbuntuDebianFedoraArch9 min readUpdated June 7, 2026

Before you start

  • Docker Engine installed and running (20.10 or later recommended)
  • sudo or root access to manage the Docker daemon
  • Basic familiarity with Docker concepts: images, containers, and volumes
  • A reverse proxy (nginx, Caddy, or Traefik) if you plan to expose services publicly

Docker Compose is no longer just a development convenience. With the docker compose plugin (V2) and a carefully written compose.yaml, you can run reliable, observable, multi-container workloads on a single host without reaching for Kubernetes. This guide covers the patterns that separate a production deployment from a weekend demo: explicit networks, named volumes, restart policies, structured logging, and secret management.

Prerequisites and Compose V2

This guide uses the Compose V2 plugin (docker compose, not the legacy docker-compose Python binary). Confirm you have it:

docker compose version

You should see something like Docker Compose version v2.27.0. If the command fails, install the plugin:

# Debian/Ubuntu
sudo apt install docker-compose-plugin

# Fedora / RHEL / Rocky
sudo dnf install docker-compose-plugin

# Arch
sudo pacman -S docker-compose

All examples use compose.yaml — the canonical filename for Compose V2. The older docker-compose.yml still works but compose.yaml takes precedence when both exist.

Structuring compose.yaml for Production

A production file is explicit. Pin image tags, set resource limits, and never rely on default networking behavior. Here is an annotated, realistic starting point running a web app with a database:

cat compose.yaml
name: myapp

services:
  web:
    image: ghcr.io/example/myapp:1.4.2          # pinned — never use :latest
    restart: unless-stopped
    ports:
      - "127.0.0.1:8080:8080"                   # bind to loopback; put a reverse proxy in front
    networks:
      - frontend
      - backend
    environment:
      DB_HOST: db
      DB_PORT: "5432"
    secrets:
      - db_password
    depends_on:
      db:
        condition: service_healthy
    logging:
      driver: journald
      options:
        tag: "myapp-web"
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M

  db:
    image: postgres:16.3-alpine
    restart: unless-stopped
    networks:
      - backend
    volumes:
      - pgdata:/var/lib/postgresql/data
    environment:
      POSTGRES_USER: myapp
      POSTGRES_DB: myapp
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U myapp"]
      interval: 10s
      timeout: 5s
      retries: 5
    logging:
      driver: journald
      options:
        tag: "myapp-db"
    deploy:
      resources:
        limits:
          cpus: "0.5"
          memory: 256M

networks:
  frontend:
  backend:
    internal: true                              # db network has no external routing

volumes:
  pgdata:
    driver: local

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

Networks

Compose creates a single default bridge network and puts every service on it. That means your database is reachable from any container — including potentially compromised ones. Instead, define networks explicitly and only attach services to what they need.

  • frontend: The web service lives here, reachable from outside (via the host port binding).
  • backend: Database-only traffic. The internal: true flag tells Docker's network driver to drop all external routing for this network — containers on it can reach each other but cannot make outbound calls to the internet.

Container DNS resolution uses the service name, so DB_HOST: db resolves correctly because both services share the backend network.

Volumes

Never store persistent data in a container's writable layer. Named volumes survive container removal and are managed by Docker, making backups and migrations straightforward.

Declare volumes at the top level and reference them in each service. The driver: local line is the default but being explicit documents intent:

# List volumes and confirm pgdata was created
docker volume ls | grep pgdata

To back up the volume without stopping the database:

docker run --rm \
  -v myapp_pgdata:/data \
  -v "$(pwd)/backups":/backup \
  alpine tar czf /backup/pgdata-$(date +%F).tar.gz -C /data .

The volume is named myapp_pgdata — Compose prefixes the project name (set by the name: key) to avoid collisions.

Restart Policies

Pick the right policy deliberately:

PolicyRestarts on crash?Restarts after reboot?Restarts if manually stopped?
noNoNoNo
on-failureYesNoNo
unless-stoppedYesYesNo
alwaysYesYesYes

Use unless-stopped for long-running services. It restarts on crashes and after host reboots, but respects a deliberate docker compose stop — important when you need to take a service offline for maintenance without it immediately restarting.

Requirement: The Docker daemon itself must start on boot for restart policies to take effect after a reboot:

sudo systemctl enable --now docker

Logging

The default json-file log driver writes to files under /var/lib/docker/containers/ with no rotation by default. On a busy production host, this will eventually fill your disk. Switch to journald to forward logs into systemd-journald, which handles rotation automatically and integrates with tools like journalctl, Loki, or Datadog.

Set the driver per-service in compose.yaml (as shown above) or set a daemon-wide default in /etc/docker/daemon.json:

sudo tee /etc/docker/daemon.json <<'EOF'
{
  "log-driver": "journald",
  "log-opts": {
    "tag": "docker-{{.Name}}"
  }
}
EOF
sudo systemctl restart docker

With journald as the driver, read logs via journalctl:

# Follow logs for the web container
journalctl -f CONTAINER_TAG=myapp-web

# All myapp containers since last boot
journalctl -b SYSLOG_IDENTIFIER=myapp-web SYSLOG_IDENTIFIER=myapp-db

If you need docker compose logs to still work, it does — it reads from journald transparently.

Secrets

Environment variables are visible in docker inspect output and in /proc/<pid>/environ inside the container. For credentials, use the secrets mechanism. Compose mounts secrets as files under /run/secrets/ inside the container — a tmpfs mount not written to the image layer.

Create the secret file

mkdir -p secrets
chmod 700 secrets
openssl rand -base64 32 > secrets/db_password.txt
chmod 600 secrets/db_password.txt

Add secrets/ to your .gitignore immediately. The application reads the password from /run/secrets/db_password (note: no .txt extension — the key name from the secrets: block is used as the filename). PostgreSQL supports reading credentials from a file path via the POSTGRES_PASSWORD_FILE variable natively.

Starting and Verifying the Stack

# Pull images first to avoid partial startup failures
docker compose pull

# Start in detached mode
docker compose up -d

# Confirm all services are healthy
docker compose ps

Expected output will show each service with a status of running and the database with (healthy) once the healthcheck passes. To watch the health transitions live:

watch -n2 docker compose ps

Applying Updates Without Downtime

Update the image tag in compose.yaml, pull, and recreate only the changed service:

docker compose pull web
docker compose up -d --no-deps web

The --no-deps flag prevents Compose from also recreating the database. The old container is stopped and replaced; the volume and network persist.

Troubleshooting

Container exits immediately on start

Check logs before Docker removes the restart output:

docker compose logs --tail=50 web

If restart: unless-stopped is looping, you may also see Restarting status in docker compose ps. The underlying error is almost always in those logs.

Service can't reach the database

Verify both services are on the shared network and DNS resolves:

docker compose exec web ping -c2 db

If that fails, confirm both services list the same network name under networks: in your compose.yaml.

Secret file not found inside container

The secret must be declared in both the top-level secrets: block and the service-level secrets: list. Missing either one results in the file not being mounted. Re-check both sections and run docker compose up -d to recreate the container.

journald logging not working on Arch

Ensure the libsystemd shared library is present in the Docker daemon's environment. On Arch this is typically already available, but if the daemon was installed via a minimal method, install systemd-libs and restart Docker.

tested on:Ubuntu 24.04Debian 12Fedora 40Arch rolling

Frequently asked questions

Can I use Docker Compose V2 without Docker Swarm?
Yes. The deploy key and secrets support in compose.yaml work on a single host with the standard Docker engine. Swarm-only features like replicas and placement constraints are ignored by docker compose but are used when you deploy with docker stack deploy.
What is the difference between restart: always and restart: unless-stopped?
Both restart on crash and after a host reboot, but unless-stopped respects a deliberate docker compose stop command and will not restart the container until you explicitly start it again. Use unless-stopped for services you may need to take offline for maintenance.
How do I rotate a secret without downtime?
Write the new value to the secret file, then run docker compose up -d to recreate the containers that mount it. There is a brief restart window; for zero-downtime rotation you need a load balancer in front and rolling restarts at the application level.
Why should I avoid environment variables for passwords?
Environment variables are exposed in docker inspect output, logged by some tools, and readable from /proc/<pid>/environ by any process in the same container. File-based secrets mounted on tmpfs under /run/secrets/ are not written to any layer and are not visible in inspect output.
How do I handle multiple environments (staging vs. production) with one compose.yaml?
Use Compose's override mechanism: maintain a base compose.yaml and an environment-specific compose.override.yaml or compose.prod.yaml, then specify files explicitly with docker compose -f compose.yaml -f compose.prod.yaml up -d. Keep secrets and image tags in the environment-specific file.

Related guides