A container that started is not necessarily a container that is ready to serve traffic. The process may be running while the application is still loading settings, waiting for the database, or stuck in a crash loop. Treating "the container is up" as "the release succeeded" is a common source of avoidable downtime.

This article covers Docker production deployment best practices for a typical stack: Django on Gunicorn, Celery for background work, PostgreSQL and Redis for state, and Traefik as the reverse proxy. The goal is a predictable setup: releases that can be verified, failures that are visible, and a rollback plan that exists before it is needed. Where a tool has limitations (Docker Compose has several), the article says so.

1. Production Docker architecture#

A production deployment is a topology of processes, networks, storage, and configuration, and each part has its own failure modes.

Only the reverse proxy publishes ports. Application, worker and data services are reachable solely on the private network.

The diagram shows the request path and the supporting services. Traffic from the internet reaches Traefik, which terminates TLS and routes requests by hostname and path. Traefik forwards requests to one or more Django application containers, each running Gunicorn with several worker processes. The application containers talk to PostgreSQL for persistent data and to Redis for caching and as the Celery message broker. A separate Celery worker container consumes tasks from Redis and reads and writes PostgreSQL. PostgreSQL stores its data on a named Docker volume so that data survives container replacement.

The building blocks#

  • Images are immutable artefacts. The same image moves from CI to staging to production; only configuration changes.
  • Docker Compose describes services, networks, and volumes on a single host. It suits small and medium deployments if you understand its limits (section 6).
  • Networks separate public and internal traffic. Traefik and the app share a public network; PostgreSQL and Redis sit only on an internal network.
  • Persistent volumes hold database data, media, and Traefik's certificates. Anything not on a volume disappears when a container is recreated.
  • Application processes run one concern per container: Gunicorn, a Celery worker, and Celery beat. Run exactly one beat instance, or scheduled tasks are enqueued more than once.

Environment configuration and secrets#

Configuration belongs in the environment, not the image: an .env file on the host readable only by the deploy user, referenced through env_file, or Docker secrets and a secrets manager where available.

Never bake secrets into images. Files copied into a layer remain recoverable from image history even if a later layer deletes them, and build arguments are visible in image metadata. If a build needs a credential, use a BuildKit secret mount.

2. Writing production Dockerfiles#

A good production Dockerfile is small, reproducible, runs as an unprivileged user, and starts the application so it receives signals correctly.

An illustrative Django Dockerfile#

Multi-stage builds keep compilers and headers out of the runtime image: the builder stage compiles wheels from a fully pinned lock file (for example, from pip-compile), and the runtime stage installs only those wheels. The example targets Python 3.12 on the slim Debian base.

Dockerfile
dockerfile
# syntax=docker/dockerfile:1
 
ARG PYTHON_IMAGE=python:3.12-slim-bookworm
 
FROM ${PYTHON_IMAGE} AS builder
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
    PIP_NO_CACHE_DIR=1
RUN apt-get update \
    && apt-get install -y --no-install-recommends build-essential libpq-dev \
    && rm -rf /var/lib/apt/lists/*
WORKDIR /build
COPY requirements.lock .
RUN pip wheel --wheel-dir /wheels -r requirements.lock
 
FROM ${PYTHON_IMAGE} AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PIP_DISABLE_PIP_VERSION_CHECK=1 \
    PIP_NO_CACHE_DIR=1
RUN apt-get update \
    && apt-get install -y --no-install-recommends libpq5 \
    && rm -rf /var/lib/apt/lists/* \
    && groupadd --system app \
    && useradd --system --gid app --home-dir /app --no-create-home app
WORKDIR /app
COPY --from=builder /wheels /wheels
COPY requirements.lock .
RUN pip install --no-index --find-links=/wheels -r requirements.lock \
    && rm -rf /wheels
COPY --chown=app:app . .
RUN DJANGO_SETTINGS_MODULE=config.settings.build python manage.py collectstatic --noinput
USER app
EXPOSE 8000
HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=3 \
    CMD ["python", "-c", "import sys, urllib.request; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/healthz/live', timeout=3).status == 200 else 1)"]
CMD ["gunicorn", "config.wsgi:application", "--config", "gunicorn.conf.py"]

A few details are deliberate:

  • Cache ordering. The lock file is installed before the source is copied, so code changes invalidate only the final layers.
  • Non-root user. A compromised application does not start as root inside the container.
  • collectstatic at build time uses a settings module that needs no secrets or database.
  • No curl. The slim image ships neither curl nor wget, so the HEALTHCHECK uses Python. Installing curl deliberately is fine; just do not assume it exists.

Pinning and immutable references#

Tags are mutable: the publisher can push a new image under python:3.12-slim-bookworm. For strict reproducibility, pin the base image by digest (python:3.12-slim-bookworm@sha256:...) and update it deliberately through an automated dependency tool. Deploy your own images by an immutable reference too, either a digest or a unique tag such as the git commit SHA. With prod or latest you cannot say with certainty what is running or what to roll back to.

Signal handling and PID 1#

Use the exec form of CMD (the JSON array). The shell form makes /bin/sh -c PID 1, which typically does not forward SIGTERM, so the container is killed after the grace period instead of shutting down cleanly. Gunicorn handles SIGTERM itself, so it can run directly as PID 1. For processes that do not reap children or handle signals well, init: true in Compose (or docker run --init) injects tini as PID 1.

The .dockerignore file#

A .dockerignore keeps the build context small and keeps local secrets out of images:

.dockerignore
.git
.github
.env
.env.*
*.pyc
__pycache__/
.venv/
venv/
node_modules/
media/
staticfiles/
.pytest_cache/
.coverage
htmlcov/
docker-compose*.yml
*.sqlite3

3. Docker health checks#

Liveness, readiness, and startup#

Kubernetes distinguishes three questions. Liveness: should this process be restarted? Readiness: should this instance receive traffic now? Startup: has initialisation finished, so liveness checks can begin?

Docker has a single HEALTHCHECK (or healthcheck in Compose). interval sets how often it runs, timeout how long one check may take, start_period a window in which failures do not count, and retries how many consecutive failures mark the container unhealthy. The status is starting, healthy, or unhealthy.

What Docker health status does and does not do#

Incorrect assumptions are common here, so precision matters.

  • On a plain Docker host, an unhealthy container keeps running and is not restarted. Restart policies react to the process exiting, not to health status. Docker Swarm, by contrast, replaces unhealthy tasks.
  • In Compose, depends_on with condition: service_healthy only affects startup ordering. It does nothing once both services are running.
  • Health status does not, by itself, remove a container from load balancing the way a failing Kubernetes readiness probe does. Whether traffic stops depends on your reverse proxy.

With Traefik, configure routing to respect health explicitly. Traefik supports active health checks on a service's load balancer (the traefik.http.services.<name>.loadbalancer.healthcheck.path label), which takes failing backends out of rotation. Its Docker provider also considers container health status, but details vary by version, so confirm against the documentation for the version you run.

A Django health endpoint#

Separate a shallow liveness check from a dependency-aware readiness check:

core/health.py
python
import logging
 
from django.core.cache import cache
from django.db import connection
from django.http import JsonResponse
 
logger = logging.getLogger(__name__)
 
 
def live(request):
    """Shallow check: the process can serve a request. No dependencies."""
    return JsonResponse({"status": "ok"})
 
 
def ready(request):
    """Readiness check: critical dependencies are reachable."""
    checks = {}
    healthy = True
 
    try:
        connection.ensure_connection()
        with connection.cursor() as cursor:
            cursor.execute("SELECT 1")
            cursor.fetchone()
        checks["database"] = "ok"
    except Exception:
        logger.exception("Readiness check failed: database")
        checks["database"] = "error"
        healthy = False
 
    try:
        cache.set("healthcheck", "ok", timeout=5)
        if cache.get("healthcheck") != "ok":
            raise RuntimeError("cache round trip failed")
        checks["cache"] = "ok"
    except Exception:
        logger.exception("Readiness check failed: cache")
        checks["cache"] = "error"
        healthy = False
 
    status_code = 200 if healthy else 503
    return JsonResponse(
        {"status": "ok" if healthy else "error", "checks": checks},
        status=status_code,
    )
config/urls.py
python
from django.urls import path
 
from core import health
 
urlpatterns = [
    path("healthz/live", health.live),
    path("healthz/ready", health.ready),
]

These checks need timeouts, or a hung database hangs the check too. Set connect_timeout in the PostgreSQL OPTIONS of DATABASES and socket timeouts on the Redis cache client. Make sure ALLOWED_HOSTS accepts the host header the check sends (here 127.0.0.1).

Shallow versus deep checks#

Deep checks create coupling. If the database slows down, every application container fails its check at once. If anything acts on that status (a Swarm restart, a proxy removing all backends), the result is a total outage plus a restart storm against a database that is already struggling: a cascading failure.

A reasonable default is the shallow endpoint for the container HEALTHCHECK and the readiness endpoint for deployment verification and routing, limited to dependencies without which the instance genuinely cannot serve requests.

4. Reverse proxy architecture#

The reverse proxy should be the only component reachable from the internet. Nginx is a mature, explicitly configured proxy; Traefik discovers services from Docker labels and manages Let's Encrypt certificates itself, which suits Compose deployments where containers come and go. Either way, the proxy handles TLS termination with ACME certificates, the HTTP to HTTPS redirect, routing by host and path, load balancing across replicas, request timeouts, and forwarded headers (X-Forwarded-Proto, X-Forwarded-For) so the application knows the original scheme and client.

A Traefik v3 Compose example#

This Docker Compose production example runs Traefik, Django, a Celery worker and beat, PostgreSQL, and Redis. Only Traefik publishes ports.

docker-compose.yml
yaml
services:
  traefik:
    image: traefik:v3.1
    command:
      - --providers.docker=true
      - --providers.docker.exposedbydefault=false
      - --providers.docker.network=public
      - --entrypoints.web.address=:80
      - --entrypoints.web.http.redirections.entrypoint.to=websecure
      - --entrypoints.web.http.redirections.entrypoint.scheme=https
      - --entrypoints.websecure.address=:443
      - --entrypoints.websecure.transport.respondingTimeouts.readTimeout=60s
      - --entrypoints.websecure.transport.respondingTimeouts.writeTimeout=60s
      - --entrypoints.websecure.transport.respondingTimeouts.idleTimeout=180s
      - --certificatesresolvers.letsencrypt.acme.email=ops@example.com
      - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
      - --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - letsencrypt:/letsencrypt
    networks:
      - public
    restart: unless-stopped
 
  web:
    image: ghcr.io/example-org/example-app:${IMAGE_TAG:?IMAGE_TAG must be set}
    env_file: .env.production
    init: true
    stop_grace_period: 40s
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    labels:
      - traefik.enable=true
      - traefik.http.routers.app.rule=Host(`example.com`)
      - traefik.http.routers.app.entrypoints=websecure
      - traefik.http.routers.app.tls.certresolver=letsencrypt
      - traefik.http.services.app.loadbalancer.server.port=8000
      - traefik.http.services.app.loadbalancer.healthcheck.path=/healthz/ready
      - traefik.http.services.app.loadbalancer.healthcheck.interval=10s
      - traefik.http.services.app.loadbalancer.healthcheck.timeout=3s
      - traefik.http.services.app.loadbalancer.healthcheck.hostname=example.com
    networks:
      - public
      - internal
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 768M
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "5"
    restart: unless-stopped
 
  worker:
    image: ghcr.io/example-org/example-app:${IMAGE_TAG:?IMAGE_TAG must be set}
    command: ["celery", "-A", "config", "worker", "--loglevel=INFO", "--concurrency=4"]
    env_file: .env.production
    init: true
    stop_grace_period: 120s
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks:
      - internal
    deploy:
      resources:
        limits:
          memory: 1G
    restart: unless-stopped
 
  beat:
    image: ghcr.io/example-org/example-app:${IMAGE_TAG:?IMAGE_TAG must be set}
    command: ["celery", "-A", "config", "beat", "--loglevel=INFO"]
    env_file: .env.production
    init: true
    networks:
      - internal
    restart: unless-stopped
 
  db:
    image: postgres:17-bookworm
    env_file: .env.db
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
    networks:
      - internal
    restart: unless-stopped
 
  redis:
    image: redis:7-bookworm
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 5
    networks:
      - internal
    restart: unless-stopped
 
networks:
  public:
    name: public
  internal:
    internal: true
 
volumes:
  postgres_data:
  letsencrypt:

PostgreSQL and Redis have no ports section: services reach each other by name, and publishing database ports is unnecessary and dangerous (section 10). The internal: true network has no outbound route. The healthcheck.hostname label makes Traefik's probe send a host header that ALLOWED_HOSTS accepts.

Forwarded headers and trusted proxies#

Behind a TLS-terminating proxy, Django sees plain HTTP. Setting SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") fixes request.is_secure(), but it is only safe when the proxy always overwrites that header and clients cannot reach Django directly; otherwise a client can claim HTTPS itself. Leave USE_X_FORWARDED_HOST off unless you have a specific reason to trust X-Forwarded-Host.

In Traefik, forwardedHeaders.trustedIPs on an entrypoint controls which upstreams may supply X-Forwarded-* headers. Behind a cloud load balancer, list only its address ranges; at the edge, trust nothing so client-supplied values are replaced.

The Docker socket#

The :ro flag on the socket mount does not make the Docker API read-only. Anything that can talk to the socket controls the daemon, which is effectively root on the host. Treat a Traefik compromise as a host compromise, or put a Docker socket proxy in front that allows only the read endpoints Traefik needs.

5. Graceful shutdown#

When Docker stops a container, it sends SIGTERM to PID 1, waits for the grace period (10 seconds by default, set with stop_grace_period), then sends SIGKILL. Anything still in progress is lost.

Gunicorn#

On SIGTERM, Gunicorn stops accepting connections and lets workers finish in-flight requests, killing any still running after graceful_timeout (default 30 seconds). Set stop_grace_period longer than that so Gunicorn, not Docker, decides when to give up:

gunicorn.conf.py
python
import multiprocessing
import os
 
bind = "0.0.0.0:8000"
workers = int(os.environ.get("GUNICORN_WORKERS", multiprocessing.cpu_count() * 2 + 1))
timeout = int(os.environ.get("GUNICORN_TIMEOUT", "30"))
graceful_timeout = int(os.environ.get("GUNICORN_GRACEFUL_TIMEOUT", "30"))
keepalive = 5
max_requests = 1000
max_requests_jitter = 100
accesslog = "-"
errorlog = "-"

The Compose example's stop_grace_period: 40s leaves headroom over 30 seconds. Gunicorn's timeout should exceed your slowest legitimate request and stay below the proxy's read timeout. The worker formula is a starting point, not a rule: cpu_count() reports host CPUs, not the container's limit.

Database connections close when workers exit. With persistent connections (CONN_MAX_AGE), enable CONN_HEALTH_CHECKS so connections broken by a database restart are replaced rather than causing errors.

Celery#

Celery treats SIGTERM as a warm shutdown: it stops consuming tasks and waits for running ones to finish, unless the grace period expires first. Set the worker's stop_grace_period to cover your longest normal task, and split long jobs where possible.

By default a task is acknowledged when it starts, so a task killed mid-run is lost. With task_acks_late, it is acknowledged after completion and redelivered if the worker dies. With Redis as broker, redelivery waits for the visibility_timeout, and a task running longer than that can be delivered twice.

config/celery.py
python
import os
 
from celery import Celery
 
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")
 
app = Celery("config")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.conf.update(
    task_acks_late=True,
    task_reject_on_worker_lost=True,
    worker_prefetch_multiplier=1,
    task_time_limit=300,
    task_soft_time_limit=270,
    broker_transport_options={"visibility_timeout": 3600},
)
app.autodiscover_tasks()

6. Deployment strategies#

The four common Docker deployment strategies are recreate (stop old, start new), rolling (replace instances one at a time), blue-green (run a full new environment beside the old, then switch routing), and canary (send a small share of traffic to the new version, watch, then increase).

What Docker Compose actually does#

docker compose up -d with a new image tag stops and recreates each changed service's containers. With one replica, that is a recreate deployment with a short outage. Compose has no built-in rolling update, traffic shifting, or automatic rollback. Plain Compose does not give you zero-downtime deployments.

Fast startup, --wait, and good graceful shutdown narrow the gap. Scripts that scale up, wait, and remove old containers can go further, but they are custom orchestration you must test and maintain. Beyond that you need extra tooling:

  • Docker Swarm rolling updates via deploy.update_config, with order: start-first and failure_action: rollback.
  • Kubernetes Deployments, with readiness-gated rolling updates.
  • Blue-green with Compose: two stacks (two project names) on the same Traefik network, with the router switched to the new stack once healthy, for example through Traefik's file provider and weighted services.
StrategyDowntimeRollback speedExtra infrastructureSupported by plain Compose
RecreateBrief outage per releaseRedeploy previous tagNoneYes (default behaviour)
RollingNone if health-gatedRoll forward or back graduallyOrchestrator (Swarm, Kubernetes)No, only via custom scripting
Blue-greenNone if switch is atomicFast: switch routing backDouble capacity, routing controlNo, requires two stacks plus proxy config
CanaryNoneFast: shift traffic backWeighted routing, good metricsNo, requires weighted routing

7. CI/CD with GitHub Actions#

A pipeline should test, publish an immutable image, deploy that exact image, verify it, and know how to roll back. This illustrative workflow publishes to GitHub Container Registry with the built-in GITHUB_TOKEN, deploys by git SHA over SSH, and uses a protected production environment that can require approval.

.github/workflows/deploy.yml
yaml
name: Build and deploy
 
on:
  push:
    branches: [main]
 
permissions:
  contents: read
 
concurrency:
  group: production-deploy
  cancel-in-progress: false
 
env:
  IMAGE: ghcr.io/${{ github.repository }}
 
jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:17
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: test
        ports:
          - 5432:5432
        options: >-
          --health-cmd "pg_isready -U test"
          --health-interval 5s
          --health-timeout 5s
          --health-retries 10
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install -r requirements.lock -r requirements-dev.lock
      - run: python manage.py check --deploy --fail-level WARNING
        env:
          DJANGO_SETTINGS_MODULE: config.settings.ci
      - run: pytest
        env:
          DJANGO_SETTINGS_MODULE: config.settings.ci
          DATABASE_URL: postgres://test:test@localhost:5432/test
 
  build:
    needs: test
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${{ env.IMAGE }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
 
  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://example.com
    steps:
      - name: Configure SSH
        env:
          SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
          SSH_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
        run: |
          install -m 700 -d ~/.ssh
          printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/id_deploy
          chmod 600 ~/.ssh/id_deploy
          printf '%s\n' "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
 
      - name: Deploy release
        env:
          DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
          DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
          IMAGE_TAG: ${{ github.sha }}
        run: |
          ssh -i ~/.ssh/id_deploy "$DEPLOY_USER@$DEPLOY_HOST" \
            "cd /srv/app && ./deploy.sh '$IMAGE_TAG'"
 
      - name: Verify health
        run: |
          for attempt in $(seq 1 20); do
            if curl --fail --silent --max-time 5 https://example.com/healthz/ready; then
              echo "Release is healthy"
              exit 0
            fi
            sleep 6
          done
          echo "Health verification failed" >&2
          exit 1
 
      - name: Roll back on failure
        if: failure()
        env:
          DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
          DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
        run: |
          ssh -i ~/.ssh/id_deploy "$DEPLOY_USER@$DEPLOY_HOST" \
            "cd /srv/app && ./rollback.sh"

Points worth copying regardless of your pipeline:

  • Least privilege. The default is contents: read; only the build job gets packages: write.
  • Pinned actions. Major versions are the minimum; pin third-party actions to a commit SHA for stricter supply-chain control.
  • Secrets from the store. Credentials come from ${{ secrets.* }}, ideally scoped to the protected environment.
  • Immutable tag. Deploy, rollback, and monitoring all refer to the exact build via github.sha.
  • Serialised deploys. The concurrency group stops deployments racing.

The host's deploy.sh records the previous tag, pulls, migrates, and starts the release with --wait; rollback.sh redeploys the recorded previous tag.

deploy.sh
bash
#!/usr/bin/env bash
set -euo pipefail
 
NEW_TAG="$1"
cd /srv/app
 
if [ -f .current_tag ]; then
  cp .current_tag .previous_tag
fi
 
export IMAGE_TAG="$NEW_TAG"
docker compose pull web worker beat
docker compose run --rm web python manage.py migrate --noinput
docker compose up -d --wait --remove-orphans
printf '%s\n' "$NEW_TAG" > .current_tag

8. Rollback and database migrations#

Rolling back code is easy with immutable images: redeploy the previous tag. Rolling back the database is not. Old code does not reverse schema changes, and code expecting a column that no longer exists turns one incident into two.

Backward-compatible migrations#

The deploy script runs migrations before switching containers, so old code briefly runs against the new schema. With rolling or blue-green strategies, versions N and N-1 run concurrently. Every migration must be compatible with the previous release. The expand-and-contract pattern makes this manageable:

  1. Expand: add columns or tables old code tolerates (nullable or with a default), and deploy code that writes to both structures.
  2. Migrate data: backfill in batches, outside the request path.
  3. Switch: deploy code that reads from the new structure.
  4. Contract: in a later release, remove the old structure once nothing uses it.

Renaming a column in one release, or dropping one the previous release reads, breaks this. So does a long lock on a large table; review generated SQL with sqlmigrate.

Sequencing and recovery#

As a general rule, additive migrations run before deploying new code and destructive ones run in a later release. Avoid migrating automatically on container start: replicas race, and a failed migration becomes a crash loop. When a release goes wrong, roll back code if the schema is still compatible, fix forward when a schema change cannot be undone safely, and restore from backup only as a last resort for data corruption, accepting data loss since the backup (point-in-time recovery narrows that window).

9. Monitoring and observability#

Health checks answer "is this instance alive right now?". Observability answers "what is the system doing, and what changed?".

  • Metrics: Prometheus scrapes metrics and evaluates alert rules; Grafana visualises them. node-exporter covers host metrics and cAdvisor per-container usage. Expose request rate, errors, latency, and Celery queue depth from the application.
  • Logs: write to stdout and stderr so Docker's logging driver collects them, preferably as structured JSON with request IDs. Loki, with an agent such as Grafana Alloy, makes them queryable beside metrics.
  • Alerting: alert on symptoms users feel (errors, latency, failed health checks, queue backlog), and give every alert an owner and a documented response.
  • External uptime monitoring: monitoring on the same host dies with the host. An outside check verifies DNS, TLS, and the proxy end to end.

Correlating deployments with incidents#

The first incident question is usually "what changed?". Record each deployment as an event: a Grafana annotation from the pipeline, or a startup log line with the git SHA. A deployment marker at the moment errors began shortens the investigation, and the immutable tag tells you what to roll back to.

10. Common production mistakes#

  • Using latest. You cannot tell what is running, and rollback has no target.
  • No resource limits. One leaking worker can exhaust host memory. Set deploy.resources.limits (or mem_limit).
  • Missing timeouts on the proxy, Gunicorn, database connections, and outbound HTTP calls let one slow dependency tie up every worker.
  • Exposed sensitive ports. "5432:5432" binds to 0.0.0.0, and Docker's own iptables rules can bypass host firewalls such as ufw. Do not publish data store ports; if you must, bind to 127.0.0.1.
  • Logging credentials in request bodies, headers, or connection strings. Filter sensitive fields at the logger.
  • Insufficient health checks that only confirm a port is open.
  • Uncontrolled restart loops that hammer dependencies and hide the real error. Alert on restart counts.
  • No log rotation. The default json-file driver does not rotate unless configured; set max-size and max-file.
  • Untested backups and no rollback plan. If the plan is decided during the incident, it is not a plan.

11. Production deployment checklist#

  • Images are multi-stage, run as non-root, and are built from pinned base images.
  • Releases are deployed by immutable reference (git SHA or digest), never latest.
  • No secrets in images, build args, or the repository.
  • Only the reverse proxy publishes ports; data stores are on an internal network.
  • TLS, HTTP to HTTPS redirect, and trusted forwarded headers are configured and tested.
  • Liveness and readiness endpoints exist, with timeouts, and the proxy respects health.
  • stop_grace_period exceeds Gunicorn's graceful timeout and covers normal Celery task durations.
  • Resource limits and log rotation are set for every service.
  • Migrations are backward compatible with the previous release.
  • The pipeline verifies health after deploy and has a tested rollback path.
  • Backups are automated and restores are tested on a schedule.
  • Metrics, logs, alerts, external uptime checks, and deployment markers are in place.

Conclusion#

Docker production deployment best practices are less about any single tool than about removing guesswork. Health checks matter only if your proxy and pipeline act on them. Graceful shutdown and well-chosen Docker deployment strategies decide whether a release interrupts users, and being honest about what Docker Compose in production cannot do keeps expectations accurate.

Reliable deployments come from predictable releases built from immutable images, observable systems that show what changed and when, rollback procedures that account for the database, and the operational discipline to test all of it before it matters.

Further reading#