Docker Best Practices in Practice: Build Smaller, Safer Images for a Python App

Docker Best Practices in Practice: Build Smaller, Safer Images for a Python App

Docker makes local development and deployment predictable, but it is easy to create images that are slow, huge, insecure, or hard to debug. This guide walks through practical Docker best practices using a small Python web app. The examples are simple enough for junior developers, but the patterns also apply to production services.

We will build a container for a tiny Flask application, improve it step by step, and finish with a clean Dockerfile, .dockerignore, and docker-compose.yml.

1. Start with a Small Example App

Create this project structure:

docker-python-demo/ ├── app.py ├── requirements.txt ├── Dockerfile ├── .dockerignore └── docker-compose.yml

Add a minimal Flask app:

# app.py from flask import Flask, jsonify import os app = Flask(__name__) @app.get("/") def index(): return jsonify({ "message": "Hello from Docker", "environment": os.getenv("APP_ENV", "development") }) @app.get("/health") def health(): return jsonify({"status": "ok"}) if __name__ == "__main__": app.run(host="0.0.0.0", port=5000)

Add the dependency:

# requirements.txt flask==3.0.3 gunicorn==22.0.0

You could run this directly with Python, but the goal is to package it in a repeatable Docker image.

2. Use a Specific, Slim Base Image

A common beginner mistake is using a vague or oversized base image such as python:latest. The latest tag can change unexpectedly, and full images include many tools your app does not need.

Prefer a specific slim image:

FROM python:3.12-slim

This gives you a predictable Python version and a smaller starting point. Smaller images usually build faster, push faster, pull faster, and expose less unnecessary surface area.

3. Write a Production-Friendly Dockerfile

Here is a practical Dockerfile for the Flask app:

# Dockerfile FROM python:3.12-slim # Prevent Python from writing .pyc files and enable unbuffered logs ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 # Create a non-root user RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser # Set the working directory WORKDIR /app # Install dependencies first to improve Docker layer caching COPY requirements.txt . RUN pip install --no-cache-dir --upgrade pip \ && pip install --no-cache-dir -r requirements.txt # Copy application code COPY app.py . # Switch away from root USER appuser EXPOSE 5000 # Use Gunicorn instead of Flask's development server CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app"]

This file applies several important practices:

  • WORKDIR /app keeps paths predictable.
  • COPY requirements.txt . happens before copying the app code, so Docker can reuse the dependency layer when only application code changes.
  • --no-cache-dir avoids storing pip’s package cache inside the image.
  • USER appuser prevents the app from running as root.
  • gunicorn runs the app with a production-grade WSGI server.

4. Build and Run the Image

From the project directory, build the image:

docker build -t docker-python-demo:1.0 .

Run the container:

docker run --rm -p 5000:5000 -e APP_ENV=local docker-python-demo:1.0

Open another terminal and test it:

curl http://localhost:5000/ curl http://localhost:5000/health

You should see JSON responses similar to:

{ "environment": "local", "message": "Hello from Docker" }

5. Add a .dockerignore File

Docker sends your build context to the Docker daemon. If you do not exclude unnecessary files, builds can become slower and images can accidentally include secrets, logs, test artifacts, or local virtual environments.

Create a .dockerignore file:

# .dockerignore .git .gitignore __pycache__ *.pyc *.pyo *.pyd .env .venv venv dist build .pytest_cache .mypy_cache .DS_Store README.md

This does not replace proper secret management, but it reduces the chance of copying local-only files into the image.

6. Avoid Baking Secrets into Images

Never put secrets directly in a Dockerfile:

# Bad ENV DATABASE_PASSWORD=my-secret-password

That value can become part of the image history. Instead, pass configuration at runtime:

docker run --rm \ -p 5000:5000 \ -e APP_ENV=production \ -e DATABASE_URL=postgresql://user:password@db:5432/app \ docker-python-demo:1.0

For local development, you can use Compose with an ignored .env file. In production, use your platform’s secret manager, such as Kubernetes Secrets, Docker Swarm secrets, AWS Secrets Manager, Google Secret Manager, or your CI/CD provider’s secret storage.

7. Use Docker Compose for Local Development

For one container, docker run is fine. Once your app depends on a database, cache, queue, or multiple services, use Compose.

Add this docker-compose.yml:

# docker-compose.yml services: web: build: context: . image: docker-python-demo:local ports: - "5000:5000" environment: APP_ENV: development healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/health')"] interval: 10s timeout: 3s retries: 3 start_period: 5s

Start it with:

docker compose up --build

The healthcheck helps Docker understand whether the application is actually responding, not just whether the process is running.

8. Keep Image Layers Cache-Friendly

Docker builds images layer by layer. When a layer changes, Docker rebuilds that layer and every layer after it. That is why this order is useful:

COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY app.py .

Your dependencies usually change less often than your application code. With this structure, editing app.py does not force Docker to reinstall all Python packages.

A less efficient version would be:

# Less efficient COPY . . RUN pip install --no-cache-dir -r requirements.txt

In that version, every source code change invalidates the dependency installation layer.

9. Tag Images Clearly

For local tests, a tag like docker-python-demo:1.0 is enough. For real deployments, avoid relying only on latest. Use tags that point to a meaningful version, commit, or release:

docker build -t registry.example.com/docker-python-demo:2026-05-04 . docker build -t registry.example.com/docker-python-demo:git-a1b2c3d .

Clear tags make rollbacks easier. If a deployment breaks, you can redeploy the previous known-good image instead of guessing what latest contained yesterday.

10. Inspect Image Size and Runtime Behavior

After building, check the image size:

docker images docker-python-demo

Run a shell inside the container only when you need to debug:

docker run --rm -it docker-python-demo:1.0 sh

Check running containers:

docker ps

View logs:

docker logs <container_id>

Stop a container:

docker stop <container_id>

These commands are simple, but they are part of daily Docker work. Junior developers often focus only on building images, but debugging containers is just as important.

11. Common Dockerfile Mistakes to Avoid

  • Using latest everywhere: it makes builds less predictable.
  • Running as root: use a dedicated application user when possible.
  • Copying the whole project too early: it breaks build caching.
  • Installing development tools in production images: keep runtime images minimal.
  • Storing secrets in the image: pass secrets at runtime through secure configuration.
  • Using the development server in production: for Flask, use gunicorn or another production-ready server.

Final Version: Clean Docker Setup

Here is the final setup again for easy copy and paste.

# Dockerfile FROM python:3.12-slim ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir --upgrade pip \ && pip install --no-cache-dir -r requirements.txt COPY app.py . USER appuser EXPOSE 5000 CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app"]
# .dockerignore .git .gitignore __pycache__ *.pyc .env .venv venv dist build .pytest_cache .mypy_cache .DS_Store README.md
# docker-compose.yml services: web: build: context: . image: docker-python-demo:local ports: - "5000:5000" environment: APP_ENV: development healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/health')"] interval: 10s timeout: 3s retries: 3 start_period: 5s

Conclusion

Good Docker practice is mostly about small, repeatable decisions: choose a specific base image, copy files in a cache-friendly order, ignore local junk, avoid secrets in images, run as a non-root user, and use a production server. These habits make containers faster to build, easier to debug, and safer to deploy.

For your next project, do not wait until production to clean up Docker. Start with a good Dockerfile on day one, then keep improving it as your application grows.


Leave a Reply

Your email address will not be published. Required fields are marked *