"It works on my machine" is the oldest deployment problem there is. Packaging a Django app into a container fixes most of it, and a CI pipeline that builds and ships that container on every merge fixes the rest. This is a step-by-step setup I would be comfortable running in production.

What we are building

The goal is simple: every merge to main runs the tests, builds a Docker image, pushes it to a registry, and makes it available to deploy. The pieces are a multi-stage Dockerfile, a small local docker-compose.yml, and one GitHub Actions workflow.

A multi-stage Dockerfile

A naive Dockerfile ships compilers and build caches in the final image. A multi-stage build compiles dependencies in one stage and copies only the results into a slim runtime stage:

# ---- build stage ----
FROM python:3.12-slim AS builder
ENV PIP_NO_CACHE_DIR=1 PYTHONDONTWRITEBYTECODE=1

RUN apt-get update && apt-get install -y --no-install-recommends \
    build-essential libpq-dev && rm -rf /var/lib/apt/lists/*

WORKDIR /build
COPY requirements.txt .
RUN pip wheel --wheel-dir /wheels -r requirements.txt

# ---- runtime stage ----
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1

RUN apt-get update && apt-get install -y --no-install-recommends libpq5 \
    && rm -rf /var/lib/apt/lists/* \
    && useradd --create-home --uid 10001 app

WORKDIR /app
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir /wheels/* && rm -rf /wheels

COPY --chown=app:app . .
RUN python manage.py collectstatic --noinput

USER app
EXPOSE 8000
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"]

A few choices worth noting:

  • Copy requirements.txt first. Docker caches layers, so dependencies are only rebuilt when that file changes, not on every code edit.
  • Run as a non-root user. If the app is ever compromised, the attacker does not start as root.
  • PYTHONUNBUFFERED=1 makes logs appear immediately instead of sitting in a buffer.
  • Gunicorn, not runserver. The Django development server is not meant for production.

Add a .dockerignore so the build context stays small and secrets stay out:

.git
.venv
__pycache__
*.pyc
.env
node_modules

Configuration through environment variables

Nothing environment-specific belongs in the image. Read settings from the environment in settings.py:

import os

SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
DEBUG = os.environ.get("DJANGO_DEBUG", "0") == "1"
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "").split(",")

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": os.environ["POSTGRES_DB"],
        "USER": os.environ["POSTGRES_USER"],
        "PASSWORD": os.environ["POSTGRES_PASSWORD"],
        "HOST": os.environ.get("POSTGRES_HOST", "db"),
        "PORT": os.environ.get("POSTGRES_PORT", "5432"),
    }
}

Static files need a plan too. WhiteNoise serving them from the app container is the simplest option, and a CDN or object storage is the better one once traffic grows.

Local development with Compose

A Compose file gives every developer the same app and database with one command:

services:
  web:
    build: .
    command: python manage.py runserver 0.0.0.0:8000
    volumes:
      - .:/app
    ports:
      - "8000:8000"
    env_file: .env
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16
    environment:
      POSTGRES_DB: app
      POSTGRES_USER: app
      POSTGRES_PASSWORD: app
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 5s
      retries: 10
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

Start it with docker compose up --build. The healthcheck stops Django from starting before PostgreSQL is ready, which removes a whole category of flaky first-run errors.

The GitHub Actions pipeline

One workflow, two jobs. Tests run against a real PostgreSQL service container. The image is only built and pushed if tests pass and the branch is main:

name: ci
on:
  pull_request:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: app
          POSTGRES_USER: app
          POSTGRES_PASSWORD: app
        ports: ["5432:5432"]
        options: >-
          --health-cmd "pg_isready -U app"
          --health-interval 5s
          --health-retries 10
    env:
      DJANGO_SECRET_KEY: test-only-key
      POSTGRES_DB: app
      POSTGRES_USER: app
      POSTGRES_PASSWORD: app
      POSTGRES_HOST: localhost
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
          cache: pip
      - run: pip install -r requirements.txt
      - run: python manage.py test

  build:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: |
            ghcr.io/${{ github.repository }}:latest
            ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

The cache-from and cache-to lines reuse Docker layers between runs, which usually cuts build time dramatically. Tagging with the commit SHA gives every build a unique, traceable identity, so you can always say exactly which code is running and roll back to a previous SHA.

Deploying the image

How the image reaches a server depends on where you run it. The common patterns are:

  • A VPS with Docker. A final workflow step connects over SSH, pulls the new tag and restarts the container.
  • A managed container service. Update the service's image tag and let the platform handle the rollout.
  • Kubernetes. Update the image in your manifests or Helm values, ideally through a GitOps tool.

Whichever you choose, run python manage.py migrate as a separate, deliberate release step, not inside every container start, so that several replicas do not race to migrate the same database.

Pitfalls to avoid

  • Baking secrets into the image. Anything in a layer can be extracted. Inject secrets at runtime.
  • Using latest as the only tag. It tells you nothing about what is deployed.
  • Skipping health checks. An orchestrator cannot restart what it cannot detect is broken. Expose a lightweight /health/ endpoint.
  • Huge images. A slim base, a multi-stage build and a good .dockerignore keep pushes and pulls quick.

The short version

Multi-stage Dockerfile, environment-based config, Compose for local work, and a two-job pipeline that tests first and ships an image tagged with the commit. It takes an afternoon to set up and removes most of the drama from deployments.