21.15. Production Docker Best Practices
Table of Contents
Why Production Docker Is Different
Running Docker in production is not the same as running containers on your laptop. In production, you care about:
- Security
- Reliability
- Performance
- Repeatability
- Observability
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:
- Build faster
- Pull faster
- Have fewer packages to attack
- Are easier to cache
Common choices:
| Use case | Base image example |
|---|---|
| Python app | python:3.12-slim |
| Minimal Linux base | debian:bookworm-slim |
| Extremely small | alpine:3.20 (with care) |
Example (Python, production image):
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:
# 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:
- No build tools in the final image
- Smaller image size
- Clear separation of build and runtime
Pin dependency versions
Unpinned versions can change when you rebuild, even from the same Dockerfile, which makes debugging production issues harder.
Good:
fastapi==0.115.0
uvicorn[standard]==0.30.0
SQLAlchemy==2.0.34Bad:
fastapi
uvicorn[standard]
SQLAlchemy>=2.0Rule: 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
- User uploads
- Database data
- Cache data that must survive restarts
- Logs that you need for debugging (these should be streamed, not stored)
Instead:
- Use volumes for database data
- Use object storage (S3 and compatible) for user uploads
- Stream logs to stdout and stderr, not to files
Example docker-compose.yml snippet for a database:
services:
db:
image: postgres:16
volumes:
- db-data:/var/lib/postgresql/data
volumes:
db-data:Stateless containers can be:
- Scaled horizontally
- Replaced when you deploy new versions
- Restarted without losing state
Use Environment Variables for Configuration
In production, configuration must be:
- Separate from code
- Easy to change without rebuilding images
- Different per environment (dev, staging, prod)
Use environment variables, not hard-coded values in the image.
Docker example:
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_keyYour 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:
- Dockerfiles
- Image layers
- Git repositories
docker-compose.ymlin a public repo
Better options
- Docker secrets (Swarm) or orchestrator secrets (Kubernetes)
- External secret managers (Vault, AWS Secrets Manager, etc.)
- Mounted files with restricted permissions
Example using file-based secret:
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:
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:
- Avoid
--privileged - Grant only needed capabilities
- Use read-only root filesystem when possible
Example in docker-compose.yml:
services:
api:
image: myorg/task-api:1.2.3
read_only: true
tmpfs:
- /tmpRule: 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:
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
docker logsin simple setups - Use a logging driver or sidecar to send logs to a central system
Use structured logging
Structured logs are easier to search and analyze.
Example JSON log line:
{"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:
- A liveness endpoint: is the process up?
- A readiness endpoint: is the app ready to receive traffic?
Examples in a FastAPI app:
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:
services:
api:
image: myorg/task-api:1.2.3
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health/live"]
interval: 30s
timeout: 5s
retries: 3Graceful shutdown
Your app must handle termination signals such as SIGTERM and stop cleanly:
- Stop accepting new requests
- Finish in-flight requests
- Close database connections
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:
image: myorg/task-api:latestGood:
image: myorg/task-api:1.2.3Benefits:
- Predictable deployments
- Easier rollbacks
- Clear mapping between code and running containers
You can also use multi-part tags:
1.2.3exact version1.2latest in that minor series1latest in that major series
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:
services:
api:
image: myorg/task-api:1.2.3
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
reservations:
cpus: "0.25"
memory: 256MIn plain Docker:
docker run --cpus=1.0 --memory=512m myorg/task-api:1.2.3Use appropriate process model
For CPU bound apps:
- You may run multiple worker processes inside one container
- Or multiple containers with 1 process each
For typical Python web apps, many teams use:
- Several Uvicorn or Gunicorn workers per container
- Multiple containers behind a load balancer
Example Gunicorn command:
gunicorn "app.main:app" -w 4 -k uvicorn.workers.UvicornWorkerNumber 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.
- Rebuild images regularly
- Apply security updates promptly
- Keep a changelog of image updates
Scan images for vulnerabilities
Use scanners such as:
- Trivy
- Grype
- Docker Scout
Automate scanning in your CI pipeline.
Remove unnecessary tools
Remove:
- Shells if not needed
- Compilers and build tools from runtime images
- Debug tools
They all increase attack surface.
Networking and Exposed Ports
Expose only what is necessary
Inside the Dockerfile, you can indicate the port:
EXPOSE 8000In production:
- Bind internal ports to internal networks
- Use a reverse proxy such as Nginx or Traefik to expose only HTTP or HTTPS to the internet
Example docker-compose.yml with an API behind Traefik:
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:
- Loggable
- Measurable
- Inspectable
Metrics
Expose metrics for:
- Request rate
- Latencies
- Error rate
- Resource usage
You can:
- Run a metrics sidecar with Prometheus exporters
- Export application metrics directly
Traces
In more advanced setups, use tracing for:
- Following requests through multiple services
- Debugging slow operations
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:
- Roll out a new version
- Roll back to a previous version
Good practices:
- Use image tags that map to releases
- Keep configuration for both current and previous versions
- Test new images in staging environments that match production
In simple setups with plain Docker or Docker Compose:
- Pull the new image
- Start containers with the new tag
- If something goes wrong, switch back to the previous tag
In more advanced environments, use:
- Blue/green deployments
- Canary releases
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:
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:
- Explicit image tags
- Secrets not in plain environment variables
- Restart policies
- Health checks
- Persistent volumes only for stateful services
- Read-only filesystem for the stateless API
Summary Checklist
You can use this as a quick review before deploying Docker to production:
| Area | Question | Done? |
|---|---|---|
| Image | Is the image small, multi-stage, and without build tools at runtime? | |
| Dependencies | Are system and app dependencies pinned to exact versions? | |
| Statelessness | Does the container avoid storing important data inside its filesystem? | |
| Configuration | Is all config provided via environment or secret files, not hard-coded? | |
| Secrets | Are secrets outside image layers and not in Git or docker-compose.yml? | |
| User | Does the container run as a non-root user? | |
| Logging | Do logs go to stdout and stderr in a structured format? | |
| Health checks | Are health endpoints and Docker healthcheck configured? | |
| Resources | Are CPU and memory limits defined and tested? | |
| Security | Are images scanned and unnecessary tools removed? | |
| Networking | Are only required ports exposed, behind a reverse proxy if needed? | |
| Versioning | Are 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
KAHIBARO