KAHIBARO
Discord Login Register

21.15. Production Docker Best Practices

Why Production Docker Is Different

Running Docker in production is not the same as running containers on your laptop. In production, you care about:

You must assume that containers will crash, nodes will reboot, and traffic will spike. Good production Docker practices reduce the impact of these events and make your system easier to operate.

Rule: Never treat a production Docker setup as "just like my local environment." Always harden, monitor, and test it under realistic conditions.


Build Images for Production

Use a small base image

Smaller images:

Common choices:

Use caseBase image example
Python apppython:3.12-slim
Minimal Linux basedebian:bookworm-slim
Extremely smallalpine:3.20 (with care)

Example (Python, production image):

dockerfile
FROM python:3.12-slim
WORKDIR /app
# Install system dependencies only if needed
RUN apt-get update && \
    apt-get install -y --no-install-recommends build-essential && \
    rm -rf /var/lib/apt/lists/*
# Copy dependency files first to leverage layer caching
COPY pyproject.toml poetry.lock ./
RUN pip install --no-cache-dir poetry && \
    poetry config virtualenvs.create false && \
    poetry install --only main --no-root --no-interaction --no-ansi
# Now copy the application code
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

Use multi-stage builds

In production you should not ship build tools or test dependencies. Multi-stage builds let you build in one stage and run in a smaller, cleaner stage.

Example:

dockerfile
# Build stage
FROM python:3.12-slim AS builder
WORKDIR /build
COPY pyproject.toml poetry.lock ./
RUN pip install --no-cache-dir poetry && \
    poetry config virtualenvs.create false && \
    poetry install --only main --no-root --no-interaction --no-ansi
COPY . .
RUN poetry build -f wheel
# Runtime stage
FROM python:3.12-slim AS runtime
WORKDIR /app
COPY --from=builder /build/dist/*.whl /tmp/app.whl
RUN pip install --no-cache-dir /tmp/app.whl && rm /tmp/app.whl
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

Benefits:

Pin dependency versions

Unpinned versions can change when you rebuild, even from the same Dockerfile, which makes debugging production issues harder.

Good:

text
fastapi==0.115.0
uvicorn[standard]==0.30.0
SQLAlchemy==2.0.34

Bad:

text
fastapi
uvicorn[standard]
SQLAlchemy>=2.0

Rule: Always pin both system packages and application dependencies in production images to get repeatable builds.


Keep Containers Stateless

A stateless container does not keep important data in its own filesystem. If you delete it and start another one, the application still works.

What should not live inside a container

Instead:

Example docker-compose.yml snippet for a database:

yaml
services:
  db:
    image: postgres:16
    volumes:
      - db-data:/var/lib/postgresql/data
volumes:
  db-data:

Stateless containers can be:

Use Environment Variables for Configuration

In production, configuration must be:

Use environment variables, not hard-coded values in the image.

Docker example:

yaml
services:
  api:
    image: myorg/task-api:1.2.3
    environment:
      - APP_ENV=production
      - DATABASE_URL=postgresql+psycopg://user:pass@db:5432/app
      - REDIS_URL=redis://redis:6379/0
      - SECRET_KEY_FILE=/run/secrets/secret_key

Your app reads from environment variables at startup.

Rule: Never bake secrets or environment-specific values directly into Docker images. Always inject them through environment variables or secret files.


Manage Secrets Safely

Do not store secrets in:

Better options

  1. Docker secrets (Swarm) or orchestrator secrets (Kubernetes)
  2. External secret managers (Vault, AWS Secrets Manager, etc.)
  3. Mounted files with restricted permissions

Example using file-based secret:

yaml
services:
  api:
    image: myorg/task-api:1.2.3
    environment:
      - DB_PASSWORD_FILE=/run/secrets/db_password
    volumes:
      - ./secrets/db_password:/run/secrets/db_password:ro

Your app reads the content of /run/secrets/db_password at startup.


Run Containers with Least Privilege

Production containers should assume they could be compromised, so you must limit the damage they can do.

Avoid root inside the container

Create an unprivileged user in the Dockerfile:

dockerfile
FROM python:3.12-slim
RUN useradd -u 1001 -m appuser
WORKDIR /app
COPY . .
RUN chown -R appuser:appuser /app
USER appuser
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

Also:

Example in docker-compose.yml:

yaml
services:
  api:
    image: myorg/task-api:1.2.3
    read_only: true
    tmpfs:
      - /tmp

Rule: Do not run application containers as root in production unless you have a very specific, justified reason.


Handle Logs Correctly

Write logs to stdout and stderr

In containers you usually do not want to write application logs to files.

Good:

python
import logging
import sys
logging.basicConfig(
    level=logging.INFO,
    stream=sys.stdout,
    format="%(asctime)s %(levelname)s %(name)s %(message)s",
)

Then you can:

Use structured logging

Structured logs are easier to search and analyze.

Example JSON log line:

json
{"ts":"2026-08-28T14:32:15Z","level":"INFO","msg":"user_logged_in","user_id":123}

Health Checks and Graceful Shutdown

Health checks

You should expose:

Examples in a FastAPI app:

python
from fastapi import FastAPI
app = FastAPI()
@app.get("/health/live")
def live():
    return {"status": "ok"}
@app.get("/health/ready")
def ready():
    # Optionally check database, cache, etc.
    return {"status": "ok"}

Container configuration:

yaml
services:
  api:
    image: myorg/task-api:1.2.3
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health/live"]
      interval: 30s
      timeout: 5s
      retries: 3

Graceful shutdown

Your app must handle termination signals such as SIGTERM and stop cleanly:

Most application servers like Uvicorn or Gunicorn already handle this. You only need to ensure that long-running tasks are not abruptly killed without retry or cleanup, for example, delegate them to background workers.


Use Tags and Versioning Properly

Avoid using `latest` in production

latest means "whatever was pushed last," not a specific version.

Bad:

yaml
image: myorg/task-api:latest

Good:

yaml
image: myorg/task-api:1.2.3

Benefits:

You can also use multi-part tags:

Rule: Always deploy explicit image tags that map to specific application versions.


Optimize for Performance and Resource Usage

Use resource limits

Without limits a single container can use all CPU or memory on a host.

Example:

yaml
services:
  api:
    image: myorg/task-api:1.2.3
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M
        reservations:
          cpus: "0.25"
          memory: 256M

In plain Docker:

bash
docker run --cpus=1.0 --memory=512m myorg/task-api:1.2.3

Use appropriate process model

For CPU bound apps:

For typical Python web apps, many teams use:

Example Gunicorn command:

bash
gunicorn "app.main:app" -w 4 -k uvicorn.workers.UvicornWorker

Number of workers can follow a rough rule like:

$$\text{workers} = 2 \cdot \text{cores} + 1$$

Rule: Always test your container under realistic load before production. Default settings are rarely optimal.


Image Maintenance and Security

Regularly rebuild and update images

Security fixes often come from base images and system packages.

Scan images for vulnerabilities

Use scanners such as:

Automate scanning in your CI pipeline.

Remove unnecessary tools

Remove:

They all increase attack surface.


Networking and Exposed Ports

Expose only what is necessary

Inside the Dockerfile, you can indicate the port:

dockerfile
EXPOSE 8000

In production:

Example docker-compose.yml with an API behind Traefik:

yaml
services:
  api:
    image: myorg/task-api:1.2.3
    networks:
      - internal
  traefik:
    image: traefik:v3.1
    ports:
      - "80:80"
      - "443:443"
    networks:
      - internal
      - public
networks:
  internal:
  public:

The database and cache should be on internal networks only, not directly exposed to the internet.


Observability in Production

Your containers in production must be:

Metrics

Expose metrics for:

You can:

Traces

In more advanced setups, use tracing for:

Tracing is usually integrated via libraries and environment variables, not directly through Docker, but your Docker configuration must ensure the tracer can reach its backend.


Rollouts and Rollbacks

In production you must think about how to:

Good practices:

In simple setups with plain Docker or Docker Compose:

In more advanced environments, use:

These are orchestrator-level topics, but Docker images and tagging must support them.


Example: Production Docker Compose for a Simple Backend

Here is a small but realistic example for a backend API, PostgreSQL, and Redis:

yaml
version: "3.9"
services:
  api:
    image: myorg/task-api:1.2.3
    env_file:
      - ./env/production.env
    environment:
      - APP_ENV=production
      - DB_PASSWORD_FILE=/run/secrets/db_password
    secrets:
      - db_password
    depends_on:
      - db
      - redis
    ports:
      - "8000:8000"
    restart: unless-stopped
    read_only: true
    tmpfs:
      - /tmp
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health/live"]
      interval: 30s
      timeout: 5s
      retries: 3
  db:
    image: postgres:16
    environment:
      - POSTGRES_DB=taskdb
      - POSTGRES_USER=taskuser
      - POSTGRES_PASSWORD_FILE=/run/secrets/db_password
    secrets:
      - db_password
    volumes:
      - db-data:/var/lib/postgresql/data
    restart: unless-stopped
  redis:
    image: redis:7-alpine
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - redis-data:/data
    restart: unless-stopped
secrets:
  db_password:
    file: ./secrets/db_password
volumes:
  db-data:
  redis-data:

This example illustrates several best practices:

Summary Checklist

You can use this as a quick review before deploying Docker to production:

AreaQuestionDone?
ImageIs the image small, multi-stage, and without build tools at runtime?
DependenciesAre system and app dependencies pinned to exact versions?
StatelessnessDoes the container avoid storing important data inside its filesystem?
ConfigurationIs all config provided via environment or secret files, not hard-coded?
SecretsAre secrets outside image layers and not in Git or docker-compose.yml?
UserDoes the container run as a non-root user?
LoggingDo logs go to stdout and stderr in a structured format?
Health checksAre health endpoints and Docker healthcheck configured?
ResourcesAre CPU and memory limits defined and tested?
SecurityAre images scanned and unnecessary tools removed?
NetworkingAre only required ports exposed, behind a reverse proxy if needed?
VersioningAre explicit image tags used, with a clear rollback plan?

If you can answer "yes" to these questions, your Docker setup is much closer to being production ready.

Views: 8

Comments

Please login to add a comment.

Don't have an account? Register now!