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 /appkeeps 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-diravoids storing pip’s package cache inside the image.USER appuserprevents the app from running as root.gunicornruns 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
latesteverywhere: 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
gunicornor 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