Django DRF #6: Testing and Deployment — Docker, gunicorn, nginx
The last post in the Django DRF series — testing and deployment. This post pulls together how to automatically verify that the API built across #1–#5 actually works, and how to build a container with one command and ship it to production.
DRF tests — APIClient and APITestCase
#
Django’s Client (Intermediate #7 Testing) defaults to form encoding, which is awkward for JSON API tests. DRF’s APIClient handles JSON natively and includes auth helpers as well.
from django.contrib.auth import get_user_model
from rest_framework import status
from rest_framework.test import APITestCase
User = get_user_model()
class PostAPITests(APITestCase):
def setUp(self):
self.user = User.objects.create_user(username="alice", password="pw")
self.client.force_authenticate(user=self.user)
def test_create_post(self):
response = self.client.post(
"/api/posts/",
{"title": "First post", "body": "Body content"},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.data["title"], "First post")
self.assertEqual(response.data["author"], self.user.id)
def test_list_posts(self):
response = self.client.get("/api/posts/")
self.assertEqual(response.status_code, 200)
self.assertIn("results", response.data) # pagination envelopeWhere APITestCase fits
#
APITestCase inherits Django’s TestCase and swaps self.client for APIClient. DB transaction auto-rollback carries over.
force_authenticate — bypass authentication
#
Building a token and attaching headers in every test is tedious. force_authenticate(user=...) forces the auth state in one line — it bypasses the auth middleware and simply stamps request.user.
def test_with_real_token(self):
response = self.client.post("/api/auth/token/",
{"username": "alice", "password": "pw"}, format="json")
token = response.data["access"]
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
# Header auto-added on every subsequent requestforce_authenticate is fast, while credentials is for verifying the auth flow itself (login tests, etc.).
pytest-django — a more modern flow #
unittest-based APITestCase is enough, but pytest is lighter and its fixtures are powerful.
uv add --dev pytest pytest-django pytest-cov[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "mysite.settings"
python_files = ["test_*.py", "*_test.py"]
addopts = "--strict-markers --reuse-db"The --reuse-db option is key — it skips reapplying migrations on every run, so test startup is faster. After changing migrations themselves, run once with --create-db to rebuild.
Function-style tests #
import pytest
from rest_framework.test import APIClient
from django.contrib.auth import get_user_model
User = get_user_model()
@pytest.fixture
def user(db):
return User.objects.create_user(username="alice", password="pw")
@pytest.fixture
def auth_client(user):
client = APIClient()
client.force_authenticate(user=user)
return client
@pytest.mark.django_db
def test_create_post(auth_client, user):
response = auth_client.post(
"/api/posts/",
{"title": "First post", "body": "body"},
format="json",
)
assert response.status_code == 201
assert response.data["author"] == user.id
@pytest.mark.django_db
def test_unauthenticated_blocked():
client = APIClient()
response = client.post("/api/posts/", {"title": "x", "body": "y"}, format="json")
assert response.status_code == 401@pytest.mark.django_db enables DB access. Fixtures work like dependency injection — the same fixture is automatically shared across tests.
factory_boy — fixture data #
Creating the same model inline in every test gets messy. factory_boy solves that.
uv add --dev factory-boyimport factory
from django.contrib.auth import get_user_model
from blog.models import Post, Comment
class UserFactory(factory.django.DjangoModelFactory):
class Meta:
model = get_user_model()
username = factory.Sequence(lambda n: f"user{n}")
email = factory.LazyAttribute(lambda obj: f"{obj.username}@example.com")
password = factory.PostGenerationMethodCall("set_password", "pw")
class PostFactory(factory.django.DjangoModelFactory):
class Meta:
model = Post
author = factory.SubFactory(UserFactory)
title = factory.Faker("sentence", nb_words=4)
body = factory.Faker("paragraph", nb_sentences=5)
published = True
class CommentFactory(factory.django.DjangoModelFactory):
class Meta:
model = Comment
post = factory.SubFactory(PostFactory)
author = factory.SubFactory(UserFactory)
body = factory.Faker("sentence")@pytest.mark.django_db
def test_post_with_comments(auth_client):
post = PostFactory()
CommentFactory.create_batch(3, post=post)
response = auth_client.get(f"/api/posts/{post.id}/")
assert response.status_code == 200Sequence— unique values via auto-incrementing counterLazyAttribute— dynamic value based on other fieldsSubFactory— auto-generate FK (creates a new one if absent)Faker— fake text/names/emailscreate_batch(N)— N records at once
Test data setup code becomes declarative and short.
Response verification — status / body / headers #
@pytest.mark.django_db
def test_post_detail_shape(auth_client):
post = PostFactory(title="for verification")
response = auth_client.get(f"/api/posts/{post.id}/")
# status
assert response.status_code == 200
# body
data = response.data
assert data["id"] == post.id
assert data["title"] == "for verification"
assert "author" in data
assert "created_at" in data
# Prevent accidental field exposure
assert "secret_field" not in data
# headers
assert response["Content-Type"].startswith("application/json")API tests center on response structure verification — field names, types, missing keys, accidentally exposed fields. They catch when the contract with the client breaks.
Flow tests — scenarios #
A scenario chaining multiple requests:
@pytest.mark.django_db
def test_full_post_lifecycle(auth_client, user):
# Create
create = auth_client.post("/api/posts/",
{"title": "lifecycle", "body": "x"}, format="json")
assert create.status_code == 201
post_id = create.data["id"]
# Read
read = auth_client.get(f"/api/posts/{post_id}/")
assert read.data["published"] is False
# Publish action
publish = auth_client.post(f"/api/posts/{post_id}/publish/")
assert publish.status_code == 200
read2 = auth_client.get(f"/api/posts/{post_id}/")
assert read2.data["published"] is True
# Delete
delete = auth_client.delete(f"/api/posts/{post_id}/")
assert delete.status_code == 204
# Verify
final = auth_client.get(f"/api/posts/{post_id}/")
assert final.status_code == 404Permission tests — different user #
@pytest.mark.django_db
def test_other_user_cannot_edit():
owner = UserFactory()
other = UserFactory()
post = PostFactory(author=owner)
client = APIClient()
client.force_authenticate(user=other)
response = client.patch(f"/api/posts/{post.id}/",
{"title": "malicious edit"}, format="json")
assert response.status_code == 403A regression test verifying that the IsOwnerOrReadOnly from #2 works.
Celery task tests #
The tasks from #4 need tests too. Two modes.
1) Eager mode — synchronous execution #
CELERY_TASK_ALWAYS_EAGER = True
CELERY_TASK_EAGER_PROPAGATES = Truetask.delay() runs synchronously the moment it’s called, making it easy to verify that tasks actually execute in tests.
2) Mocking #
from unittest.mock import patch
@pytest.mark.django_db
def test_publish_triggers_notification(auth_client):
post = PostFactory(author=auth_client.handler._force_user)
with patch("blog.views.notify_subscribers.delay") as mock_task:
response = auth_client.post(f"/api/posts/{post.id}/publish/")
assert response.status_code == 200
mock_task.assert_called_once_with(post.id)For verifying just the call itself rather than the task’s internal behavior.
Schema regression test #
The schema diff from #5, as a test.
import json
from django.core.management import call_command
from io import StringIO
def test_schema_unchanged():
out = StringIO()
call_command("spectacular", "--validate", stdout=out)
current = json.loads(out.getvalue() or "{}")
with open("schema.snapshot.json") as f:
snapshot = json.load(f)
assert current == snapshot, "API schema changed — snapshot needs updating"With a test like this, it’s difficult to merge a breaking change inadvertently. For intentional changes, update the snapshot in a separate PR.
Coverage #
uv run pytest --cov=blog --cov-report=term-missingName Stmts Miss Cover Missing
--------------------------------------------------------
blog/models.py 42 0 100%
blog/serializers.py 28 2 93% 45-46
blog/views.py 67 4 94% 89-92
blog/permissions.py 18 0 100%
blog/tasks.py 23 3 87% 34-36
--------------------------------------------------------
TOTAL 178 9 95%The goal isn’t 100% — it’s whether the important paths are verified. Because APIs are integration-test heavy, 80–90% coverage comes naturally.
That’s the testing part. Now to deployment.
Deployment — the standard Django + DRF stack #
In production you don’t use manage.py runserver. The standard combination:
| Layer | Tool | Slot |
|---|---|---|
| WSGI server | gunicorn (or uWSGI) | Run the Python app |
| Reverse proxy | nginx (or Caddy, Traefik) | TLS termination, static files, load balancing |
| Container | Docker | Environment consistency |
| DB | PostgreSQL | Main data |
| Cache / queue | Redis | Cache + Celery broker |
| Worker | Celery worker / beat | Background work (#4) |
The patterns from Advanced #7 Deployment Security and Intermediate #6 Static/Media in Production carry over.
Dockerfile — multi-stage build #
# === 1) build stage ===
FROM python:3.13-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
WORKDIR /app
# Deps first (cache friendly)
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
# Code
COPY . .
# Collect static files (collectstatic with DEBUG=False)
ENV DJANGO_SETTINGS_MODULE=mysite.settings
ENV SECRET_KEY=build-time-dummy
RUN uv run python manage.py collectstatic --noinput
# === 2) runtime stage ===
FROM python:3.13-slim AS runtime
# System packages (C deps like psycopg)
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
&& rm -rf /var/lib/apt/lists/*
# Non-root user
RUN useradd --create-home --uid 1000 appuser
WORKDIR /app
# Copy venv + code + static files from builder
COPY --from=builder --chown=appuser:appuser /app /app
ENV PATH="/app/.venv/bin:$PATH"
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
USER appuser
EXPOSE 8000
CMD ["gunicorn", "mysite.wsgi", \
"--bind", "0.0.0.0:8000", \
"--workers", "4", \
"--access-logfile", "-", \
"--error-logfile", "-"]Key patterns:
- Multi-stage — build tools don’t end up in the final image
- Deps first, code later — only code changes reuse the deps layer cache
- Non-root user — security
--frozen --no-dev— honorsuv.lock, excludes dev dependenciescollectstatic— gathers static files in one place (Intermediate #6)
.dockerignore
#
.venv/
__pycache__/
*.pyc
.pytest_cache/
.git/
.github/
*.md
.env
.env.*
db.sqlite3
media/
node_modules/Things that don’t need to be in the image. Build speed + security.
gunicorn — WSGI server #
gunicorn mysite.wsgi --bind 0.0.0.0:8000 \
--workers 4 \ # ~ CPU cores × 2 + 1
--worker-class sync \ # default (or gthread, gevent)
--timeout 30 \ # worker timeout
--max-requests 1000 \ # worker restart (avoid memory leaks)
--max-requests-jitter 50 \ # avoid simultaneous restarts
--access-logfile - \
--error-logfile -Picking the worker count #
| Traffic | Recommended |
|---|---|
| Small traffic / single machine | 2 × CPU + 1 |
| Heavy external IO | gthread worker, --threads 4-8 |
| Many async views | Switch to uvicorn (ASGI) — --worker-class uvicorn.workers.UvicornWorker |
If lots of Advanced #1 Async views, going ASGI (uvicorn) is natural.
nginx — reverse proxy #
nginx receives in front and proxies to gunicorn. Slot split:
| Layer | Responsible for |
|---|---|
| nginx | TLS termination, static file serving, gzip, rate limit, header tuning |
| gunicorn | Run Django/DRF |
upstream django {
server web:8000;
}
server {
listen 80;
server_name api.example.com;
# HTTPS redirect is usually handled by a cloud LB
# If self-hosted: letsencrypt + listen 443 ssl
client_max_body_size 10M;
# Static files — nginx serves directly, not Django
location /static/ {
alias /app/staticfiles/;
expires 30d;
add_header Cache-Control "public, immutable";
}
location /media/ {
alias /app/media/;
expires 7d;
}
# API gets proxied to gunicorn
location / {
proxy_pass http://django;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
proxy_connect_timeout 5s;
}
}X-Forwarded-Proto and Django
#
nginx terminates HTTPS, but gunicorn receives the request over plain HTTP. To make Django still recognize it as an HTTPS request:
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
USE_X_FORWARDED_HOST = True
ALLOWED_HOSTS = ["api.example.com"]Goes together with the header settings from Advanced #7 Deployment Security.
docker-compose — full stack at once #
services:
web:
build: .
command: >
sh -c "python manage.py migrate &&
gunicorn mysite.wsgi --bind 0.0.0.0:8000 --workers 4
--access-logfile - --error-logfile -"
environment:
DJANGO_SETTINGS_MODULE: mysite.settings
SECRET_KEY: ${SECRET_KEY}
DATABASE_URL: postgresql://blog:blog@db:5432/blog
REDIS_URL: redis://redis:6379/0
ALLOWED_HOSTS: api.example.com,localhost
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
volumes:
- static-files:/app/staticfiles
- media-files:/app/media
expose: ["8000"]
worker:
build: .
command: celery -A mysite worker -l info --concurrency=4
environment:
DJANGO_SETTINGS_MODULE: mysite.settings
SECRET_KEY: ${SECRET_KEY}
DATABASE_URL: postgresql://blog:blog@db:5432/blog
REDIS_URL: redis://redis:6379/0
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
beat:
build: .
command: celery -A mysite beat -l info
environment:
DJANGO_SETTINGS_MODULE: mysite.settings
SECRET_KEY: ${SECRET_KEY}
DATABASE_URL: postgresql://blog:blog@db:5432/blog
REDIS_URL: redis://redis:6379/0
depends_on: [db, redis]
nginx:
image: nginx:alpine
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
- static-files:/app/staticfiles:ro
- media-files:/app/media:ro
ports: ["80:80"]
depends_on: [web]
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: blog
POSTGRES_USER: blog
POSTGRES_PASSWORD: blog
healthcheck:
test: ["CMD-SHELL", "pg_isready -U blog"]
interval: 5s
timeout: 3s
retries: 5
volumes:
- pg-data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
volumes:
- redis-data:/data
volumes:
pg-data:
redis-data:
static-files:
media-files:docker compose up -dOne command brings up web, worker, beat, nginx, db, and redis together. nginx shares the static files volume with web, serving Django’s collectstatic output directly.
Environment variables — django-environ
#
To keep secrets out of the codebase, use environment variables.
uv add django-environimport environ
env = environ.Env(
DEBUG=(bool, False),
ALLOWED_HOSTS=(list, []),
)
environ.Env.read_env()
SECRET_KEY = env("SECRET_KEY")
DEBUG = env("DEBUG")
ALLOWED_HOSTS = env("ALLOWED_HOSTS")
DATABASES = {"default": env.db()} # DATABASE_URL=postgres://...
CACHES = {"default": env.cache()} # REDIS_URL=redis://...
CELERY_BROKER_URL = env("REDIS_URL")
CELERY_RESULT_BACKEND = env("REDIS_URL")Exclude the .env file from git; in production, use a secrets manager (AWS Secrets Manager / GCP Secret Manager / Doppler, etc.) or platform environment variables.
Migrations — automate at deploy time #
Two most common patterns.
Option 1 — apply at container start #
#!/usr/bin/env bash
set -e
# Wait until DB is ready (with compose's healthcheck)
python manage.py migrate --noinput
# Static files (already done at build time, for refresh)
python manage.py collectstatic --noinput
exec "$@"COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
CMD ["gunicorn", "mysite.wsgi", "--bind", "0.0.0.0:8000", "--workers", "4"]Simple for small services. The downside is that multiple instances may attempt to migrate simultaneously.
Option 2 — separate CI/CD step #
- name: Run migrations
run: |
docker run --rm \
-e DATABASE_URL=${{ secrets.DATABASE_URL }} \
${{ env.IMAGE }} \
python manage.py migrate --noinput
- name: Deploy
run: |
# Rolling update web/worker with the new imageFor larger services, option 2 is safer: migrations run exactly once, and a failure halts the deploy.
Health checks #
The load balancer / orchestrator needs to know the container’s state.
from django.db import connection
from django.http import JsonResponse
def health(request):
return JsonResponse({"status": "ok"})
def ready(request):
"""Ready only if DB / Redis is healthy."""
try:
with connection.cursor() as cur:
cur.execute("SELECT 1")
except Exception as exc:
return JsonResponse({"status": "db_down", "error": str(exc)}, status=503)
return JsonResponse({"status": "ready"})urlpatterns = [
path("health/", health),
path("ready/", ready),
...
]/health/— liveness — is the process alive (restart on failure)/ready/— readiness — ready to accept traffic (drop from LB on failure)
Kubernetes / Fargate / cloud LBs poll these two endpoints regularly.
Deployment targets — lightweight options #
| Slot | |
|---|---|
| Railway | Lightest. Connect GitHub → auto deploy. Postgres/Redis auto-provisioned. Best for fast prototypes. |
| Fly.io | Globally distributed. Deploy via flyctl CLI. One fly.toml file. |
| Render | Heroku alternative. Define web/worker/cron via render.yaml. |
| AWS ECS Fargate | Big-company standard. ECR + ECS + ALB + RDS + ElastiCache. Operations burden. |
| GCP Cloud Run | Container serverless. Zero cost at zero traffic. Not for big memory / long jobs. |
| Self-hosted VPS | DigitalOcean / Hetzner. Docker Compose in one go. Cheapest, biggest ops burden. |
Small side projects are a natural fit for Railway / Fly, while production company services typically land on AWS / GCP.
Production checklist #
Verify before deploying — use this alongside the security checklist from Advanced #7.
-
DEBUG=False -
SECRET_KEY— env var, 64+ random bytes -
ALLOWED_HOSTS— explicit domains (no wildcards) - HTTPS enforced (
SECURE_SSL_REDIRECT, HSTS header) - CORS — explicit origins (no
*) - DB password — strong random
- JWT
SIGNING_KEY— separate strong key - Log level — INFO or higher
- Sentry — error tracking
- Health checks —
/health,/ready - Migrations — automated
- Backups — DB auto-backups enabled
- Monitoring — APM (DataDog / New Relic / Grafana)
- rate limiting — DRF throttling + nginx
- Static files — nginx or CDN
- Media files — S3 / GCS (django-storages)
This checklist is a starting point and grows with traffic.
Series retrospective #
Across 6 posts, the Django DRF in Practice flow is complete.
| # | Covered |
|---|---|
| 1 | DRF getting started — Serializer, ModelSerializer, ViewSet, Router |
| 2 | Auth/permissions — Token, JWT (simplejwt), permission classes, IsOwner |
| 3 | Filtering / Ordering / Pagination (PageNumber/LimitOffset/Cursor) |
| 4 | Celery — task, retry, Beat, Flower, transaction.on_commit |
| 5 | drf-spectacular — OpenAPI, Swagger UI, schema regression test |
| 6 | Testing (APITestCase, pytest-django, factory_boy) + Docker/gunicorn/nginx |
Taking one blog API domain and building it up incrementally — model → API → auth → filter/pagination → async → docs → testing/deployment — everything comes together in one place.
Django track retrospective (4 series / 27 posts) #
| Series | Posts | Core |
|---|---|---|
| Django Basics | 7 | Setup, ORM, URL/View, Template, Form, Admin/Auth |
| Django Intermediate | 7 | CBV, ORM intermediate, Signals, users/permissions, messages/sessions, Static, testing |
| Django Advanced | 7 | Async/ASGI, custom commands, query optimization, caching, transactions, Channels, deployment security |
| Django DRF in Practice | 6 | DRF full stack |
If you go back to Basics #1 What is Django after completing this track, you read the same post from a different vantage point — as soon as you build a model, you can see the whole flow that follows: ORM optimization, caching, signals, async, deployment. That’s the payoff of going through one full track.
Where to go next #
- Django Channels deeper (started in Advanced #6) — WebSocket, real-time notifications, presence
- DRF + GraphQL — GraphQL gateways with Strawberry / Graphene
- Microservices vs monolith — Django monolith + some FastAPI microservices (compared to the FastAPI track)
- DataOps / ML integration — Celery + scikit-learn / LangChain
- Performance — Django profiling, Postgres deep dive, distributed cache
- Operations — Kubernetes deployment, observability (OpenTelemetry), GitOps
Each direction deserves its own track.
Thanks for following through the long track. As you type out the code yourself and carry the patterns from this series into your own projects, there’s a moment when what you’ve read on the page finally settles into your hands. See you there.