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-slim vs ubuntu:22.04 (8x smaller)
  • --no-cache-dir prevents 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-get without apt-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-dir and 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.