Skip to content

14 — Dockerfile

Previous: 13 — Cloud Tasks & Scheduler

A Dockerfile is a recipe for building a Docker image. This chapter creates one optimized for Django on Cloud Run.


The Dockerfile

Create Dockerfile at the repo root (next to manage.py):

# ── Base image ────────────────────────────────────────────────────────────────
# python:3.12-slim is a minimal Debian image with Python 3.12.
# "slim" means no build tools, compilers, or docs — smaller image size.
FROM python:3.12-slim

# ── Install uv ───────────────────────────────────────────────────────────────
# uv is a fast Python package manager (much faster than pip).
# We copy the uv binary from the official Docker image rather than installing it.
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/

# ── Working directory ─────────────────────────────────────────────────────────
# All subsequent commands run from /app inside the container.
WORKDIR /app

# ── Install Python dependencies ───────────────────────────────────────────────
# Copy only the dependency files first (not the full source).
# Docker caches each layer — if pyproject.toml and uv.lock haven't changed,
# this layer is reused on the next build, making builds much faster.
COPY web/pyproject.toml web/uv.lock ./
RUN uv sync --frozen --no-dev
# --frozen: fail if uv.lock is out of date (ensures reproducible builds)
# --no-dev: skip dev-only dependencies (django_extensions, test tools, etc.)

# ── Copy application source ───────────────────────────────────────────────────
# Copied after dependency install so code changes don't invalidate the dep cache.
COPY web/ .

# ── Environment variables ───────────────────────────────────────────────────────
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    DJANGO_SETTINGS_MODULE=core.settings.prod \
    PORT=8080
# PYTHONDONTWRITEBYTECODE: don't write .pyc files (unnecessary in containers)
# PYTHONUNBUFFERED: flush stdout/stderr immediately (so logs appear in real time)
# DJANGO_SETTINGS_MODULE: tells Django to use prod.py settings
# PORT: Cloud Run injects this; Gunicorn reads it

# ── Port ──────────────────────────────────────────────────────────────────────
EXPOSE 8080

# ── Start command ─────────────────────────────────────────────────────────────
# Gunicorn is a production-grade WSGI server. It runs Django's wsgi.py application.
# Django's built-in dev server (manage.py runserver) must NEVER be used in prod.
CMD ["uv", "run", "gunicorn", \
     "--bind", "0.0.0.0:8080", \
     "--workers", "2", \
     "--timeout", "60", \
     "--log-file", "-", \
     "core.wsgi"]

.dockerignore

Create .dockerignore at the repo root to prevent unnecessary files from being sent to the Docker build context:

.git
web/.venv
web/media/
web/staticfiles/
web/htmlcov/
**/__pycache__
**/*.pyc
**/*.pyo
.env
*.md
DEPLOY/

This keeps the image small and prevents secrets from being accidentally included.


Gunicorn options explained

Option Value Why
--bind 0.0.0.0:8080 Listen on all interfaces, port 8080 Cloud Run expects port 8080
--workers 2 2 worker processes Handle concurrent requests
--timeout 60 60 second timeout Kill slow requests to prevent hanging
--log-file - Write logs to stdout Cloud Logging captures stdout
core.wsgi WSGI entry point Located at web/core/wsgi.py

Build and test locally

Build the image:

docker build -t mycoolproject-app .

Test it locally (requires a local Postgres or mock DATABASE_URL):

docker run --rm \
  -e DATABASE_URL="postgresql://postgres:postgres@host.docker.internal:5432/mycoolproject_dev" \
  -e SECRET_KEY="local-test-key" \
  -e ALLOWED_HOSTS="localhost" \
  -p 8080:8080 \
  mycoolproject-app

Visit http://localhost:8080 to verify it starts.


The image naming

In main.tf, the Cloud Run service references the image:

image = "${google_artifact_registry_repository.app.repository_url}/app:latest"

This resolves to:

southamerica-east1-docker.pkg.dev/mycoolproject-prod/app-repo/app:latest

GitHub Actions will push images with: - latest tag — always points to the most recent build - <git-sha> tag — unique per commit, for rollbacks


Two-stage build (optional)

For smaller images, you can use a two-stage build:

# Stage 1: Build
FROM python:3.12-slim as builder
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
WORKDIR /app
COPY web/pyproject.toml web/uv.lock ./
RUN uv sync --frozen --no-dev
COPY web/ .

# Stage 2: Runtime
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /app/web/ ./web/
COPY --from=builder /app/.venv/ ./.venv/
ENV PATH="/app/.venv/bin:$PATH"
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    DJANGO_SETTINGS_MODULE=core.settings.prod \
    PORT=8080
EXPOSE 8080
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "--timeout", "60", "--log-file", "-", "core.wsgi"]

This results in a smaller image because it excludes build tools. For simplicity, we use the single-stage version in this guide.