Docker Compose in Production
Deploy Docker Compose in production with compose.yaml, explicit networks, named volumes, restart policies, journald logging, and file-based secrets.
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: trueflag 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:
| Policy | Restarts on crash? | Restarts after reboot? | Restarts if manually stopped? |
|---|---|---|---|
no | No | No | No |
on-failure | Yes | No | No |
unless-stopped | Yes | Yes | No |
always | Yes | Yes | Yes |
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.
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
Configure Prometheus Alertmanager
Configure Prometheus Alertmanager with routing trees, receivers, inhibition rules, grouping, Go templates, and PagerDuty/Slack on-call integrations.
Build an Intranet Server on Linux
Set up a complete small-office intranet on one Linux box: Nginx web server, dnsmasq local DNS, Samba file sharing, and a Wiki.js team wiki.
Build an nftables Firewall Script
Build a complete nftables firewall from scratch: tables, chains, sets, default-deny input policy, service allowlisting, and persistent systemd configuration.
Caddy as a Reverse Proxy
Set up Caddy as a reverse proxy with automatic HTTPS, load balancing, WebSocket passthrough, reusable snippets, and header control — no certbot required.