Python Docker — Containerized Applications
Docker packages applications with their dependencies into portable containers. This eliminates "it works on my machine" problems and enables consistent deployments.
Learning Objectives
- Write efficient Dockerfiles for Python
- Use multi-stage builds to reduce image size
- Manage secrets and environment variables in containers
- Use docker-compose for multi-service applications
- Understand Docker networking, volumes, and security best practices
Why Docker?
Imagine you built a Python app on your laptop. It works perfectly. But when you deploy it to a server, it breaks because the server has a different Python version, missing system packages, or conflicting dependencies.
Docker solves this by creating a snapshot of your entire application environment — code, dependencies, system libraries, and runtime. This snapshot (called an image) runs the same way everywhere.
Without Docker: With Docker:
+-------------+ +-------------+
| Your Laptop | | Container |
| Python 3.11 | | +---------+ |
| pandas 2.0 | --► | | Your App| |
| Ubuntu 22 | | | Python | |
| Custom libs | | | deps | |
+-------------+ | +---------+ |
+-------------+ | Ubuntu 22 |
| Server | +-------------+
| Python 3.9 | Same everywhere!
| Missing pkg |
+-------------+
Docker Terminology
| Term | Definition | Example |
|---|---|---|
| Image | Read-only template with code + dependencies | python:3.11-slim |
| Container | Running instance of an image | docker run myapp |
| Dockerfile | Recipe for building an image | FROM python:3.11 |
| Registry | Storage for images | Docker Hub, ECR |
| Volume | Persistent data storage | -v pgdata:/var/lib/postgresql/data |
| Layer | Each instruction creates a layer | COPY, RUN, ADD |
Your First Dockerfile
A Dockerfile is a recipe for building a Docker image. Each line creates a layer — a snapshot of changes.
# Dockerfile
# 1. Start from a base image (Python 3.11 slim)
FROM python:3.11-slim
# 2. Set working directory inside container
WORKDIR /app
# 3. Copy dependency file first (for caching)
COPY requirements.txt .
# 4. Install dependencies
RUN pip install --no-cache-dir -r requirements.txt
# 5. Copy application code
COPY . .
# 6. Expose port (documentation — does not actually publish)
EXPOSE 8000
# 7. Command to run when container starts
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Dockerfile Instructions Reference
| Instruction | Purpose | Example |
|---|---|---|
FROM | Base image | FROM python:3.11-slim |
WORKDIR | Set working directory | WORKDIR /app |
COPY | Copy files from host to image | COPY . . |
RUN | Execute a command | RUN pip install -r requirements.txt |
CMD | Default command at runtime | CMD ["python", "app.py"] |
EXPOSE | Document listening ports | EXPOSE 8000 |
ENV | Set environment variables | ENV DEBUG=false |
ARG | Build-time variables | ARG PYTHON_VERSION=3.11 |
ENTRYPOINT | Fixed executable | ENTRYPOINT ["python"] |
ADD | Copy with auto-extraction | ADD app.tar.gz /app/ |
Why This Order Matters
Layer 1: FROM python:3.11-slim (~150MB, cached)
Layer 2: WORKDIR /app (tiny)
Layer 3: COPY requirements.txt . (small file)
Layer 4: RUN pip install ... (~200MB, SLOW — cache this!)
Layer 5: COPY . . (your code changes often)
If you change your code but not requirements.txt, Docker reuses layers 1-4 from cache. This makes rebuilds fast (seconds instead of minutes).
Multi-Stage Builds
Multi-stage builds separate the "build" phase from the "runtime" phase, producing much smaller images.
# Stage 1: Build
FROM python:3.11 AS builder
WORKDIR /app
# Install build dependencies
RUN apt-get update && apt-get install -y gcc
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
# Stage 2: Runtime (much smaller)
FROM python:3.11-slim
WORKDIR /app
# Copy only installed packages from builder
COPY --from=builder /root/.local /root/.local
COPY . .
# Add local packages to PATH
ENV PATH=/root/.local/bin:$PATH
# Run as non-root user (security)
RUN useradd --create-home appuser
USER appuser
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Result: Build stage might be 800MB, but final image is only ~200MB.
Multi-Stage Build with Node (for Frontend + API)
# Stage 1: Build frontend
FROM node:20-alpine AS frontend
WORKDIR /frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ .
RUN npm run build
# Stage 2: Python API
FROM python:3.11-slim
WORKDIR /app
COPY api/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY api/ .
# Copy built frontend into static files
COPY --from=frontend /frontend/dist ./static/
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
docker-compose.yml
For applications with multiple services (web app + database + cache), use docker-compose.
version: '3.8'
services:
web:
build: .
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql://user:pass@db:5432/mydb
- REDIS_URL=redis://cache:6379
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
volumes:
- ./src:/app/src # Hot reload in development
restart: unless-stopped
db:
image: postgres:15-alpine
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
POSTGRES_DB: mydb
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U user -d mydb"]
interval: 5s
timeout: 5s
retries: 5
cache:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redisdata:/data
volumes:
pgdata:
redisdata:
Running with Compose
# Build and start all services
docker-compose up --build
# Start in background
docker-compose up -d
# Stop everything
docker-compose down
# View logs
docker-compose logs -f web
# Rebuild a specific service
docker-compose up -d --build web
# Execute command in running container
docker-compose exec web python manage.py migrate
# Scale a service
docker-compose up -d --scale worker=3
Docker Networking
Docker creates a default bridge network for containers. Containers communicate using service names as hostnames.
# docker-compose networking is automatic
services:
web:
# Can reach db at "db:5432"
environment:
- DATABASE_URL=postgresql://user:pass@db:5432/mydb
db:
# Can reach web at "web:8000" if needed
# Custom networks for isolation
networks:
frontend:
driver: bridge
backend:
driver: bridge
services:
nginx:
networks:
- frontend
web:
networks:
- frontend
- backend
db:
networks:
- backend
Network Commands
# List networks
docker network ls
# Inspect network
docker network inspect mynetwork
# Create custom network
docker network create mynet
# Connect container to network
docker network connect mynet mycontainer
# Disconnect
docker network disconnect mynet mycontainer
Volume Mounting
Volumes persist data beyond container lifecycle. Without volumes, data is lost when the container stops.
services:
db:
image: postgres:15-alpine
volumes:
# Named volume (managed by Docker)
- pgdata:/var/lib/postgresql/data
# Bind mount (mount host directory)
- ./init-scripts:/docker-entrypoint-initdb.d
web:
build: .
volumes:
# Bind mount for development (hot reload)
- ./src:/app/src
# Named volume for uploads
- uploads:/app/uploads
volumes:
pgdata:
uploads:
Volume Commands
# List volumes
docker volume ls
# Create volume
docker volume create mydata
# Inspect volume
docker volume inspect mydata
# Run with bind mount
docker run -v /host/path:/container/path myimage
# Run with read-only mount
docker run -v /host/path:/container/path:ro myimage
.dockerignore
Just like .gitignore, .dockerignore tells Docker which files to exclude when building.
__pycache__
*.pyc
*.pyo
.env
.git
.venv
venv/
*.egg-info
dist/
build/
.pytest_cache
.mypy_cache
.dockerignore
Dockerfile
docker-compose.yml
*.md
tests/
.coverage
htmlcov/
Without this, Docker copies unnecessary files into the image, making it larger and potentially leaking secrets.
Essential Docker Commands
# Build an image
docker build -t myapp:latest .
# Build with no cache
docker build --no-cache -t myapp:latest .
# Run a container
docker run -p 8000:8000 myapp:latest
# Run in background
docker run -d -p 8000:8000 --name myapp myapp:latest
# Run with environment variables
docker run -e DEBUG=true -e DATABASE_URL=... myapp:latest
# View running containers
docker ps
# View all containers
docker ps -a
# View logs
docker logs myapp
docker logs -f myapp # Follow logs
# Execute command in running container
docker exec -it myapp bash
# Stop and remove
docker stop myapp
docker rm myapp
# Force remove
docker rm -f myapp
# Clean up unused images
docker system prune -a
# Check disk usage
docker system df
# View image layers
docker history myapp:latest
Environment Variables and Secrets
import os
# Read from environment (set by Docker)
DATABASE_URL = os.environ["DATABASE_URL"]
API_KEY = os.environ.get("API_KEY") # Optional
DEBUG = os.environ.get("DEBUG", "false").lower() == "true"
# Set defaults in Dockerfile
ENV DEBUG=false
ENV PYTHONUNBUFFERED=1
# Override in docker-compose.yml
environment:
- DEBUG=true
- DATABASE_URL=postgresql://...
# Or use .env file (never commit this!)
env_file:
- .env
Docker Secrets (for Swarm or Compose)
version: '3.8'
services:
web:
image: myapp
secrets:
- db_password
- api_key
secrets:
db_password:
file: ./secrets/db_password.txt
api_key:
environment: API_KEY
Container Security Best Practices
1. Run as Non-Root User
FROM python:3.11-slim
# Create non-root user
RUN useradd --create-home --shell /bin/bash appuser
WORKDIR /home/appuser/app
COPY --chown=appuser:appuser requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY --chown=appuser:appuser . .
# Switch to non-root user
USER appuser
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
2. Scan for Vulnerabilities
# Scan image with Trivy
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy image myapp:latest
# Scan with Docker Scout
docker scout cves myapp:latest
3. Use Specific Tags
# BAD — floating tag, unpredictable
FROM python:latest
# GOOD — specific version
FROM python:3.11.7-slim
# BETTER — pin digest for exact reproducibility
FROM python:3.11.7-slim@sha256:abc123...
4. Minimize Installed Packages
# Install only what you need
RUN apt-get update && \
apt-get install -y --no-install-recommends curl && \
rm -rf /var/lib/apt/lists/*
# Use slim or alpine base images
FROM python:3.11-slim # ~150MB
# FROM python:3.11-alpine # ~50MB (but may need extra setup)
Real-World Example: FastAPI + PostgreSQL
Project Structure
myproject/
+-- app/
| +-- __init__.py
| +-- main.py
| +-- models.py
| +-- database.py
| +-- requirements.txt
+-- Dockerfile
+-- docker-compose.yml
+-- .dockerignore
+-- .env
app/main.py
from fastapi import FastAPI
from sqlalchemy import create_engine, Column, Integer, String
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
import os
DATABASE_URL = os.environ["DATABASE_URL"]
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(bind=engine)
Base = declarative_base()
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
name = Column(String)
email = Column(String, unique=True)
app = FastAPI()
@app.on_event("startup")
def startup():
Base.metadata.create_all(bind=engine)
@app.get("/users")
def list_users():
db = SessionLocal()
users = db.query(User).all()
return [{"id": u.id, "name": u.name, "email": u.email} for u in users]
@app.post("/users")
def create_user(name: str, email: str):
db = SessionLocal()
user = User(name=name, email=email)
db.add(user)
db.commit()
return {"status": "created", "id": user.id}
@app.get("/health")
def health():
return {"status": "ok"}
Dockerfile
FROM python:3.11-slim
RUN useradd --create-home appuser
WORKDIR /home/appuser/app
COPY app/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY --chown=appuser:appuser app/ .
USER appuser
HEALTHCHECK --interval=30s --timeout=3s \
CMD curl -f http://localhost:8000/health || exit 1
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
.env
POSTGRES_USER=admin
POSTGRES_PASSWORD=securepassword123
POSTGRES_DB=myapp
DATABASE_URL=postgresql://admin:securepassword123@db:5432/myapp
Common Mistakes
| Mistake | Problem | Solution |
|---|---|---|
Using latest tag | Unpredictable builds | Pin specific version |
Not using .dockerignore | Bloated images, leaked secrets | Add .dockerignore |
| Running as root | Security risk | Create non-root user |
| COPY before requirements | Slow rebuilds | Copy requirements first |
| Not setting PYTHONUNBUFFERED | Delayed log output | Set ENV PYTHONUNBUFFERED=1 |
Using ADD for simple copies | Unnecessary auto-extraction | Use COPY instead |
| No health checks | Can't detect failures | Add HEALTHCHECK |
| Storing secrets in image | Exposed credentials | Use env vars or Docker secrets |
Key Takeaways
- Always use slim base images to reduce size
- Copy requirements.txt before code for layer caching
- Never run containers as root in production
- Use multi-stage builds for smaller production images
- Use
.dockerignoreto exclude unnecessary files - Use docker-compose for multi-service applications
- Set
PYTHONUNBUFFERED=1for proper log output - Use
HEALTHCHECKfor container health monitoring - Pin image versions for reproducible builds
- Use named volumes for persistent data