Introduction
Docker has revolutionized how we package and deploy applications. However, building Dockerfiles is just the beginning. Many teams create containers that work in development but fail in production—causing security vulnerabilities, bloated images, and runtime issues.
In this guide, we’ll explore essential Docker best practices that separate production-ready containers from amateur attempts.
Part 1: Image Size and Layers
Problem: Bloated Images
A common mistake is starting with a large base image:
# ❌ DON'T: 1.2GB image
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y python3 python3-pip
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY app.py .
CMD ["python3", "app.py"]
Solution: Use Minimal Base Images
# ✅ DO: 150MB image
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
CMD ["python", "app.py"]
Key improvements:
python:3.11-slimvsubuntu:22.04(8x smaller)--no-cache-dirprevents pip from storing cache- Multi-stage builds for even smaller final images
Multi-Stage Builds
For compiled languages, use multi-stage builds to keep final images tiny:
# Build stage
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY . .
RUN go build -o app .
# Final stage (only runtime needed)
FROM alpine:3.20
WORKDIR /app
COPY --from=builder /app/app .
EXPOSE 8080
CMD ["./app"]
Result: 50MB final image instead of 900MB
Part 2: Security
Don’t Run as Root
# ❌ DON'T: Running as root (default)
FROM python:3.11-slim
COPY app.py .
CMD ["python", "app.py"]
# ✅ DO: Create non-root user
FROM python:3.11-slim
RUN groupadd -r appuser && useradd -r -g appuser appuser
WORKDIR /app
COPY --chown=appuser:appuser . .
USER appuser
CMD ["python", "app.py"]
Why? If container is compromised, attacker has root access to the host.
Scan for Vulnerabilities
# Use tools like Trivy
trivy image myapp:1.0
# Or Docker Scout
docker scout cves myapp:1.0
Minimize Secrets Exposure
# ❌ DON'T: Secrets in layers
RUN apt-get install -y && echo "secret_key=xyz" > /app/.env
# ✅ DO: Use BuildKit secret mounts (never bake tokens into layers or ARGs)
RUN --mount=type=secret,id=build_token \
SECRET_TOKEN=$(cat /run/secrets/build_token) && \
make build
Note: Avoid passing secrets via ARG/ENV—they persist in the image history and can be extracted with docker history. The --mount=type=secret flag keeps the value out of the final image and cache.
Part 3: Layer Caching and Build Speed
Order Matters
Dockerfile layers are cached. Put frequently changing code last:
# ❌ DON'T: Changes app.py → rebuilds everything
FROM python:3.11-slim
COPY app.py .
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["python", "app.py"]
# ✅ DO: Changes app.py → only rebuilds last layer
FROM python:3.11-slim
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
CMD ["python", "app.py"]
Result: 10s rebuild vs 2m rebuild
.dockerignore
# .dockerignore
.git
.gitignore
node_modules/
npm-debug.log
.env
.env.local
dist/
build/
*.md
Reduces context size sent to Docker daemon.
Part 4: Healthchecks
Define Health Checks
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
# Tell Docker how to check if container is healthy
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
CMD ["python", "app.py"]
Docker, Docker Compose, and Swarm use this to:
- Restart unhealthy containers
- Remove containers from load balancing
- Alert on failures
Note: Kubernetes does not read the Dockerfile HEALTHCHECK instruction—it ignores it in favor of its own livenessProbe, readinessProbe, and startupProbe defined in the pod spec. Define both if you deploy to Kubernetes.
Part 5: Environment Variables
Use .env Files (Don’t Commit)
# app.py
import os
database_url = os.getenv('DATABASE_URL', 'sqlite:///app.db')
debug = os.getenv('DEBUG', 'false').lower() == 'true'
Pass at Runtime
# Development
docker run -e DATABASE_URL=postgres://... myapp:1.0
# Production (use environment files)
docker run --env-file production.env myapp:1.0
Never:
# ❌ DON'T EVER DO THIS
RUN echo "API_KEY=secret123" > .env
Part 6: Logging
Write to STDOUT/STDERR
# ✅ DO: Logs go to STDOUT
import sys
print("Application started", file=sys.stdout)
print("Error occurred", file=sys.stderr)
# Docker captures these and orchestrators aggregate them
# ❌ DON'T: Log to files inside container
RUN mkdir -p /var/log/app && \
python app.py > /var/log/app/output.log
Why?
- Orchestrators can’t access container files
- Logs are lost when container is deleted
- STDOUT/STDERR are standard for containerized apps
Part 7: Resource Limits
Define CPU and Memory Limits
# Prevent containers from consuming all resources
docker run \
--memory=512m \
--cpus=1 \
--memory-swap=1g \
myapp:1.0
In Docker Compose
services:
app:
image: myapp:1.0
deploy:
resources:
limits:
cpus: '1'
memory: 512M
reservations:
cpus: '0.5'
memory: 256M
Part 8: Version Tagging
Use Semantic Versioning
# ❌ DON'T: Unclear what version is running
docker build -t myapp .
docker run myapp
# ✅ DO: Clear versioning (build once, then tag)
docker build -t myapp:1.2.3 .
docker tag myapp:1.2.3 myapp:1.2
docker tag myapp:1.2.3 myapp:1
docker tag myapp:1.2.3 myapp:latest
# Push all
docker push myapp:1.2.3
docker push myapp:1.2
docker push myapp:1
docker push myapp:latest
Part 9: Registry Best Practices
Private Registry
# Login to registry
docker login myregistry.com
# Tag with registry prefix
docker tag myapp:1.0 myregistry.com/team/myapp:1.0
# Push
docker push myregistry.com/team/myapp:1.0
# Pull
docker pull myregistry.com/team/myapp:1.0
Image Retention Policy
The Docker CLI has no built-in “keep last N” option—docker image prune only supports filters like -a (all unused images) and --filter:
# Remove dangling images
docker image prune
# Remove all unused images older than 30 days
docker image prune -a --filter "until=720h"
For real retention policies (e.g., “keep the last 10 tags”), configure lifecycle rules at the registry level instead, such as AWS ECR lifecycle policies, GitLab/Harbor cleanup policies, or Docker Hub retention rules.
Part 10: Dockerfile Linting
Use Hadolint
# Install
brew install hadolint
# Lint Dockerfile
hadolint Dockerfile
Catches common mistakes:
- Using
apt-getwithoutapt-get update - Not pinning package versions
- Running as root
- Using deprecated syntax
Example
# ❌ Issues found
FROM ubuntu
RUN apt-get install -y python3
COPY . /app
WORKDIR /app
CMD python3 app.py
# ✅ Hadolint approved
FROM python:3.11-slim
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* && \
groupadd -r appuser && useradd -r -g appuser appuser
WORKDIR /app
COPY --chown=appuser:appuser . .
USER appuser
CMD ["python", "app.py"]
Production Checklist
Before deploying to production:
- ✅ Image size < 500MB (aim for < 200MB)
- ✅ Running as non-root user
- ✅ Healthchecks defined
- ✅ No secrets hardcoded
- ✅ Logs to STDOUT/STDERR
- ✅ Resource limits set
- ✅ Semantic versioning on images
- ✅ Vulnerability scanning passed
- ✅ Hadolint passed
- ✅ Built with
--no-cache-dirand clean steps - ✅ Private registry configured
- ✅ Image retention policy defined
Complete Production Dockerfile
# Build stage
FROM python:3.11-slim as builder
WORKDIR /tmp
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
# Final stage
FROM python:3.11-slim
RUN groupadd -r appuser && useradd -r -g appuser appuser
WORKDIR /app
COPY --from=builder /root/.local /home/appuser/.local
COPY --chown=appuser:appuser app.py .
ENV PATH=/home/appuser/.local/bin:$PATH
USER appuser
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
EXPOSE 8000
CMD ["python", "app.py"]
Conclusion
Docker best practices aren’t just nice-to-have—they’re essential for:
- Security: Running as non-root, scanning vulnerabilities
- Performance: Small images, fast builds, cached layers
- Reliability: Healthchecks, resource limits, proper logging
- Maintainability: Clear versioning, linting, documentation
Apply these practices consistently, and your containers will be production-ready from day one.