"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.txtfirst. 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=1makes 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
latestas 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
.dockerignorekeep 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.