Running Docker Compose in production is a perfectly good choice for a single server, as long as you close the gaps the development defaults leave open: small multi-stage images running as non-root, healthchecks with depends_on conditions, a restart policy, secrets as files rather than environment variables, named volumes with real backups, a reverse proxy that handles TLS, log rotation and resource limits. The file below is the production baseline I start from. At the end I cover the point where Compose stops being the right tool.
Everything here assumes Docker Engine with the Compose v2 plugin (docker compose, not the old Python docker-compose). If your file still starts with version: "3.8", remove it: Compose v2 ignores it and prints the attribute `version` is obsolete, it will be ignored.
Multi-stage builds and small, non-root images
Production images should contain the runtime and your built app, not compilers, dev dependencies or source maps you never serve. A multi-stage Dockerfile for a Node.js app:
# syntax=docker/dockerfile:1
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev
FROM node:22-slim
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist
COPY --from=build --chown=node:node /app/package.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]The points that matter: copy the lockfile and install before copying source, so dependency layers stay cached; run as the built-in node user instead of root; and use an exec-form CMD so the process receives SIGTERM directly and can shut down gracefully. Add a .dockerignore with node_modules, .git and .env so none of them end up in the build context. Pin base images to a specific major version at least, never latest.
A docker compose production baseline
Here is a complete compose.yaml for a web app, PostgreSQL and a Caddy reverse proxy:
name: myapp
x-logging: &default-logging
driver: json-file
options:
max-size: "10m"
max-file: "5"
services:
proxy:
image: caddy:2.8
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
web:
condition: service_healthy
logging: *default-logging
web:
image: ghcr.io/acme/myapp:${IMAGE_TAG:?set IMAGE_TAG}
restart: unless-stopped
init: true
env_file: .env
environment:
DATABASE_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 15s
timeout: 5s
retries: 3
start_period: 20s
stop_grace_period: 30s
deploy:
resources:
limits:
cpus: "1.5"
memory: 768M
logging: *default-logging
db:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_USER: app
POSTGRES_DB: app
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 10s
timeout: 5s
retries: 5
deploy:
resources:
limits:
memory: 1G
logging: *default-logging
secrets:
db_password:
file: ./secrets/db_password.txt
volumes:
pgdata:
caddy_data:
caddy_config:Notice what is not there: the database has no ports: entry. It is reachable from web over the Compose network by the hostname db, and from nowhere else. The rest of this post explains each block.
Healthchecks and restart policies
Without a healthcheck, Docker only knows whether the process is running, not whether it works. With one, docker compose ps shows healthy or unhealthy, and depends_on: condition: service_healthy makes the app wait until PostgreSQL actually accepts connections instead of crashing on the first boot because the database was still initializing.
The healthcheck command runs inside the container, so it can only use tools the image has. Slim images often have no curl; that is why the example uses Node’s built-in fetch (Node 18 and later). A health endpoint should check the things the app cannot work without, such as the database connection, and respond fast.
For restart policies, I use unless-stopped: the container comes back after a crash and after a server reboot (as long as the Docker service is enabled with systemctl enable docker), but stays down if you stopped it on purpose. One thing people expect that does not happen: plain Docker does not restart a container just because it is unhealthy. The restart policy reacts to the process exiting. If you need an unhealthy container replaced, make the app exit on fatal errors, or add a small watchdog.
init: true runs a tiny init process as PID 1, which reaps zombie processes and forwards signals correctly. stop_grace_period gives the app 30 seconds to finish in-flight requests on SIGTERM before Docker sends SIGKILL (the default is 10 seconds).
Secrets and configuration
Environment variables are convenient but leak easily: they show up in docker inspect, crash reports and child processes. Compose secrets mount a file at /run/secrets/<name> inside only the services that list it. Many official images support a _FILE suffix for exactly this, as with POSTGRES_PASSWORD_FILE above, and your app can read the file at startup.
On a single host, Compose secrets are bind-mounted files, not encrypted storage, so protect the source: chmod 600 secrets/*, owned by the deploy user, and never committed. Keep .env and secrets/ in .gitignore. Non-sensitive configuration (feature flags, URLs, log levels) is fine in .env. The ${IMAGE_TAG:?set IMAGE_TAG} syntax makes Compose fail loudly if the variable is missing, rather than silently pulling an empty tag.
Reverse proxy and TLS
Only the reverse proxy should publish ports. Caddy is my default on single-server setups because it obtains and renews Let’s Encrypt certificates automatically. The whole Caddyfile:
example.com, www.example.com {
encode zstd gzip
reverse_proxy web:3000
}Point DNS at the server, open ports 80 and 443, and Caddy handles the rest. The caddy_data volume is not optional: it stores the certificates and account keys. Lose it on every redeploy and you will hit Let’s Encrypt rate limits. If you prefer Nginx or Traefik, the principle is the same: one proxy container on 80/443, everything else internal. If the site sits behind Cloudflare, set the SSL mode to Full (strict) so the connection to your origin is encrypted and verified too.
One security warning: ports published by Docker bypass ufw, because Docker inserts its own iptables rules ahead of it. A "5432:5432" mapping exposes PostgreSQL to the internet even with ufw set to deny. If you need a port on the host for local tools, bind it to localhost: "127.0.0.1:5432:5432". The rest of the host-level setup is in my Linux server hardening checklist.
Volumes and backups
Named volumes survive docker compose down and image updates, but docker compose down -v deletes them, and so does a lost disk. A volume is not a backup. For databases, dump from inside the container rather than copying the data directory of a running server:
docker compose exec -T db pg_dump -U app -Fc app > /backups/app-$(date +%F).dumpRun that from cron or a systemd timer, then ship the dumps off the server with restic or aws s3 cp. For other volumes, such as uploads, a throwaway container can archive them:
docker run --rm -v myapp_uploads:/data:ro -v /backups:/backup alpine \
tar -czf /backup/uploads-$(date +%F).tar.gz -C /data .Then test a restore into a scratch database. The first restore test is where people discover the dump was empty or the password file was missing.
Docker Compose production best practices: logs, limits and updates
Logging limits
The default json-file log driver has no size limit, and a chatty container will eventually fill the disk. The x-logging anchor above caps each container at five 10 MB files. To set it host-wide, put {"log-driver": "local"} in /etc/docker/daemon.json; the local driver rotates by default. It only applies to containers created after the change.
Resource limits
Compose v2 applies deploy.resources.limits without Swarm. Memory limits are the important ones: without them, one leaking container can push the whole server into the kernel OOM killer, which may pick your database. With a limit, only the offending container is killed and restarted. docker stats shows actual usage; set limits with some headroom above the observed peak.
Deploying updates
With images tagged by commit SHA, a deploy is:
export IMAGE_TAG=3f9c2ab
docker compose pull web
docker compose up -d --wait web--wait blocks until the service is healthy and returns a non-zero exit if it is not, which is what a CI pipeline needs. Be aware that Compose recreates the container in place, so there are a few seconds where the old one has stopped and the new one is starting. I automate this from GitHub; the full pipeline is in GitHub Actions deploy to server.
When to move beyond Compose
Compose is a single-host tool. It is the right choice when one server (plus backups) meets your availability needs, and that covers many business apps, internal tools and early-stage products. It stops fitting when you need:
- More than one host for redundancy, so a server failure is not an outage.
- True zero-downtime rolling deploys across replicas without extra tooling.
- Autoscaling on traffic or queue depth.
- Several teams deploying services independently.
The usual next steps are Amazon ECS on Fargate (the smallest leap from a Compose file, and no nodes to manage) or Kubernetes when you need its ecosystem. Docker Swarm still works and reads Compose files, but I rarely recommend starting new projects on it. Move when a concrete requirement demands it, not because Compose feels too simple.
When it is worth getting help
If you are containerizing an existing app, have a Compose setup that works on a laptop but falls over on the server, or are unsure whether you have outgrown a single host, a second pair of eyes saves time. My Docker containerization service covers Dockerfiles, production Compose setups like this one, backups and the CI pipeline around them, and I can pair it with GitHub Actions CI/CD for automated deploys. Small jobs are $20/hour; defined projects are a fixed price agreed in writing. You can get your Compose setup reviewed for production before you go live.
Frequently asked questions
Is Docker Compose suitable for production?
Yes, for workloads that can run on one server. Docker's own documentation describes using Compose in production. The limit is that it manages a single host, so it gives you no failover or rolling updates across machines.
Should I use Docker Swarm instead of Compose in production?
Swarm adds multi-host scheduling, rolling updates and encrypted secrets using a similar file format. It is stable but has a small ecosystem compared with Kubernetes or ECS, so I only suggest it when a team already knows it and needs a handful of nodes.
How do I run database migrations with Docker Compose?
Run them as a one-off command before switching traffic, for example docker compose run --rm web npm run migrate, using the new image tag. Keep migrations backward compatible so the old container can keep serving while they run.
Do I need Kubernetes for a small production app?
Usually not. A single well-configured server with Compose, backups and monitoring is simpler to run and cheaper for most small apps. Kubernetes pays off when you have multiple services, several nodes and a team to operate it.