Cloud & DevOps14 يوليو 202610 min read

Docker Compose in Production: Yes, You Can

The 'Compose is not for production' crowd is wrong. Here is the exact setup we use to run real workloads on Compose, and the honest signals that tell you when to leave.

بقلم Innovation T Team


Every DevOps thread has the same reply: "Docker Compose is not for production." It is repeated so often that nobody checks it anymore. Meanwhile, a huge share of profitable software runs happily on one or two servers with a Compose file, and it outlives the half-finished Kubernetes clusters built to replace it.

This post is the setup we actually ship for clients: what a production Compose file needs, how to deploy without downtime, how to not lose data, and the honest signals that tell you when Compose is no longer enough.

The case for boring infrastructure

Production readiness is not a logo on your architecture diagram. It is a set of properties: your app restarts when it crashes, deploys do not drop requests, you know when something breaks, and you can restore from backup. None of those properties require an orchestrator. They require discipline.

What Kubernetes buys you is multi-node scheduling, autoscaling, and a huge ecosystem. What it costs you is a control plane to operate, YAML sprawl, and a skill set your two-person team probably does not have on call at 3 a.m. If your workload fits on one beefy VM (and most do), Compose gives you 90 percent of the operational value at 10 percent of the complexity. We wrote a full decision framework in Do You Need Kubernetes? if you want the long version.

The catch: "docker compose up" on a fresh VPS is not production. The gap between a dev Compose file and a production one is real. Let's close it.

What a production Compose file actually needs

Pin everything

image: postgres:latest is a time bomb. A host reboot or a routine pull can silently jump you a major version. Pin to a specific minor version at minimum, and pin your own app images by immutable tag (a git SHA), not latest:

services:
  db:
    image: postgres:16.4
  app:
    image: registry.example.com/shop/api:9f2c1ab

For third-party images you can go further and pin by digest (postgres@sha256:...). It is uglier but makes supply-chain surprises impossible, and it turns every image change into a visible diff in your repo instead of a silent mutation on the server.

Health checks that mean something

Docker only knows your container's main process is alive. It has no idea your app is actually serving. Define a real health check, and make dependent services wait for it:

services:
  app:
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000/healthz"]
      interval: 10s
      timeout: 3s
      retries: 3
      start_period: 20s
    depends_on:
      db:
        condition: service_healthy

Two details people miss. First, start_period gives slow-booting apps grace time before failures count, so a JVM or a migration step does not get killed mid-warmup. Second, depends_on with condition: service_healthy fixes the classic "app crashed because Postgres was not ready yet" race. Your /healthz endpoint should check the process AND its critical dependency (one cheap SELECT 1), nothing more.

Restart policies and resource limits

Every long-running service gets restart: unless-stopped. Not always (which resurrects containers you deliberately stopped), and never nothing.

Then cap resources. Without limits, one leaking service can OOM the whole box and take your database down with it:

services:
  app:
    restart: unless-stopped
    deploy:
      resources:
        limits:
          cpus: "1.5"
          memory: 768M

The modern Compose CLI honors deploy.resources.limits without Swarm. Size limits from observed usage plus headroom, not guesses. And cap your logs, because the default json-file driver will happily eat your disk:

    logging:
      options:
        max-size: "20m"
        max-file: "5"

In our experience, unbounded container logs are a top three cause of "the server just died" incidents on single-VM setups. Disk fills, Postgres cannot write WAL, everything cascades.

Zero downtime deploys without an orchestrator

The naive deploy (docker compose pull && docker compose up -d) kills the old container before the new one is ready. That is 5 to 30 seconds of dropped requests per deploy. Unacceptable, and fixable.

The pattern: put a reverse proxy (Traefik or Caddy) in front of your app, then roll containers behind it. Traefik watches the Docker socket, discovers containers by label, and only routes to ones that pass their health check. The rollout becomes:

  1. Pull the new image.
  2. Start a second replica of the app (--scale app=2 --no-recreate).
  3. Wait until the new container is healthy. Traefik now load-balances across old and new.
  4. Stop and remove the old container. Traefik drops it from rotation.
  5. Scale the declared state back to one.

You can script this in 30 lines of bash, or use the docker-rollout plugin which implements exactly this dance. Either way, the proxy absorbs the transition and users never see it. For a deeper treatment of the general patterns (and what "zero downtime" actually requires from your app, like backward-compatible migrations), see Zero-Downtime Deployments.

Wire it into CI so deploys are one pipeline, not one person's terminal history:

#!/usr/bin/env bash
set -euo pipefail
export TAG="${1:?usage: deploy.sh <git-sha>}"
docker compose pull app
docker rollout app
docker image prune -f

Rollback is the same script with the previous SHA. Because your images are pinned by immutable tag, rolling back is boring, which is the entire point.

Secrets, config, and the .env trap

The .env file next to your Compose file is fine for configuration (ports, feature flags, hostnames). It is a liability for secrets, because it tends to get committed, copied to laptops, and pasted into Slack.

A pragmatic middle ground for a small team, in order of increasing effort:

  • Keep a .env on the server only, mode 600, owned by the deploy user, never in git. Add .env to .gitignore on day one and verify with git check-ignore .env.
  • Encrypt secrets in the repo with sops and age. Secrets live in git as ciphertext, CI holds the decryption key, and the deploy step renders the plaintext .env on the server. You get audit history and PR review for secret changes.
  • For file-shaped secrets (TLS keys, service account JSON), mount them read-only instead of stuffing them into environment variables, which leak into docker inspect and error reporters.

One firewall gotcha that has burned many teams: Docker publishes ports by writing its own iptables rules, and those rules bypass UFW. ufw deny 5432 does nothing if your Compose file says ports: "5432:5432". The fix is simple: do not publish ports you do not need. Services that only talk to each other should share a Compose network and expose nothing to the host. Only the reverse proxy publishes 80 and 443. If you must restrict published ports, do it in the DOCKER-USER iptables chain, which Docker respects.

Observability: knowing before your customers do

You do not need a Grafana cluster. You need three things:

  • Uptime checks from outside. Uptime Kuma self-hosted on a different box, or any hosted pinger. If your only monitoring runs on the server being monitored, you have no monitoring.
  • Host and container metrics. node_exporter plus cAdvisor scraped by a single Prometheus container, with alerts on disk over 80 percent, memory pressure, and container restart loops. Restart loops are the silent killer: restart: unless-stopped will happily mask a crashing service forever if nobody is counting restarts.
  • Centralized logs. Loki plus Promtail (or Alloy) shipping container logs, so a 2 a.m. incident does not start with SSH and docker logs --tail. Structured JSON logs from your app make this ten times more useful.

That whole stack fits in the same Compose file under a monitoring profile and costs a few hundred MB of RAM. Start it with docker compose --profile monitoring up -d and it deploys with the same muscle memory as everything else.

Backups: the part that decides if you survive

A single-server architecture means your disaster recovery story is your backup story. Non-negotiables:

  • Dump databases logically. A nightly pg_dump -Fc (or mysqldump --single-transaction) to a local staging directory. Volume snapshots of a running database are not reliably consistent; logical dumps are.
  • Ship offsite. restic or borg to object storage (S3, B2, or a second provider entirely), encrypted client-side, with retention like 7 daily, 4 weekly, 6 monthly. Your VPS provider's snapshot feature is a convenience, not a backup: same account, same blast radius.
  • Restore monthly, on a schedule. An untested backup is a hypothesis. Script the restore into a throwaway container and diff row counts.

A backup that has never been restored will fail you exactly once, at the worst possible moment. We wrote the full playbook in Backups You Can Actually Restore.

The production Compose checklist

Run this list before you call any Compose deployment production:

  1. All images pinned to versions or digests; app images tagged by git SHA.
  2. Health checks on every service, with start_period, and depends_on using service_healthy.
  3. restart: unless-stopped plus memory and CPU limits on every long-running service.
  4. Log rotation configured (max-size, max-file) on every service.
  5. Reverse proxy terminating TLS; no database or internal ports published to the host.
  6. Rolling deploy script in CI, with a tested one-command rollback.
  7. Secrets out of git, .env locked to mode 600, or sops-encrypted in the repo.
  8. External uptime checks, disk and restart-loop alerts, centralized logs.
  9. Nightly logical dumps shipped offsite with restic, restore drill on the calendar.
  10. The whole server rebuildable from a script or Ansible playbook in under an hour, because "the server" should never be a pet you cannot recreate.

Point 10 matters more than it looks. Your Compose file is already declarative infrastructure. Pair it with a short cloud-init or Ansible bootstrap (Docker install, firewall, deploy user, cron) and the single server stops being a single point of unknowable state, even while it remains a single point of hardware failure you accept consciously.

When Compose stops being enough

Compose has real limits. Leaving it at the right time is part of using it well. The honest signals:

  • You need more than one node for capacity or availability, and a bigger VM is no longer an option. Compose has no multi-host scheduling. Faking it with two servers and manual placement is worse than migrating.
  • Your traffic is spiky enough that autoscaling pays for itself. If your load varies 10x within a day, paying for peak capacity 24/7 starts losing to an orchestrator or a managed container platform.
  • Deploys per day outgrow the model. Ten teams shipping independently want namespaces, RBAC, and progressive delivery, not a shared Compose file and a deploy lock.
  • Your SLA math demands it. If a 20-minute recovery window (restore VM, run bootstrap, restore data) genuinely breaches contracts, you need multi-node failover. Most businesses claiming this requirement have never measured the cost of 20 minutes.

When those signals arrive, the migration is gentle precisely because you did Compose properly: pinned images, health checks, externalized config, and stateless app containers translate almost mechanically to any orchestrator or managed platform.

Until then, run the boring thing. Uptime comes from discipline, not from the size of your control plane.

How Innovation T can help

Innovation T designs, builds, and operates exactly this kind of infrastructure for startups and SMEs: production-hardened Compose setups, CI/CD pipelines with rolling deploys, monitoring, and tested backup strategies, sized to what your business actually needs instead of what conference talks say you need. When the growth signals show up, we handle the migration path too.

If your product is running on a "temporary" server setup that quietly became permanent, our cloud and DevOps services cover the audit, the hardening, and the ongoing operations. Talk to us and we will tell you honestly whether you need Kubernetes or just a better Compose file.

#Docker Compose#deployment#small teams#devops

جاهز للبناء مع Innovation T؟

سواء كان الأمر يتعلق بالأمن أو النمو أو الهندسة، يمكن لفريقنا مساعدتك على تنفيذه بإتقان.