Django DRF #6: Testing and Deployment — Docker, gunicorn, nginx

13 min read

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.

blog/tests/test_posts.py — APITestCase
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 envelope

Where 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.

Alternative — with a real token
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 request

force_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.

Install
uv add --dev pytest pytest-django pytest-cov
pyproject.toml
[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 #

blog/tests/test_posts_pytest.py
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.

Install
uv add --dev factory-boy
blog/tests/factories.py
import 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")
Usage
@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 == 200
  • Sequence — unique values via auto-incrementing counter
  • LazyAttribute — dynamic value based on other fields
  • SubFactory — auto-generate FK (creates a new one if absent)
  • Faker — fake text/names/emails
  • create_batch(N) — N records at once

Test data setup code becomes declarative and short.

Response verification — status / body / headers #

Verification pattern
@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:

Flow
@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 == 404

Permission tests — different user #

Other user cannot edit
@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 == 403

A 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 #

settings.py — test environment
CELERY_TASK_ALWAYS_EAGER = True
CELERY_TASK_EAGER_PROPAGATES = True

task.delay() runs synchronously the moment it’s called, making it easy to verify that tasks actually execute in tests.

2) Mocking #

Verify just the call
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.

blog/tests/test_schema.py
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 #

Run
uv run pytest --cov=blog --cov-report=term-missing
Output
Name                       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:

LayerToolSlot
WSGI servergunicorn (or uWSGI)Run the Python app
Reverse proxynginx (or Caddy, Traefik)TLS termination, static files, load balancing
ContainerDockerEnvironment consistency
DBPostgreSQLMain data
Cache / queueRedisCache + Celery broker
WorkerCelery worker / beatBackground work (#4)

The patterns from Advanced #7 Deployment Security and Intermediate #6 Static/Media in Production carry over.

Dockerfile — multi-stage build #

Dockerfile
# === 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 — honors uv.lock, excludes dev dependencies
  • collectstatic — gathers static files in one place (Intermediate #6)

.dockerignore #

.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 #

Run
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 #

TrafficRecommended
Small traffic / single machine2 × CPU + 1
Heavy external IOgthread worker, --threads 4-8
Many async viewsSwitch 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:

LayerResponsible for
nginxTLS termination, static file serving, gzip, rate limit, header tuning
gunicornRun Django/DRF
nginx.conf
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:

settings.py
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 #

docker-compose.yml
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:
Run
docker compose up -d

One 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.

Install
uv add django-environ
mysite/settings.py
import 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 #

entrypoint.sh
#!/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 "$@"
Add to Dockerfile
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 #

.github/workflows/deploy.yml — excerpt
- 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 image

For 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.

blog/views.py
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"})
urls.py
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
RailwayLightest. Connect GitHub → auto deploy. Postgres/Redis auto-provisioned. Best for fast prototypes.
Fly.ioGlobally distributed. Deploy via flyctl CLI. One fly.toml file.
RenderHeroku alternative. Define web/worker/cron via render.yaml.
AWS ECS FargateBig-company standard. ECR + ECS + ALB + RDS + ElastiCache. Operations burden.
GCP Cloud RunContainer serverless. Zero cost at zero traffic. Not for big memory / long jobs.
Self-hosted VPSDigitalOcean / 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
1DRF getting started — Serializer, ModelSerializer, ViewSet, Router
2Auth/permissions — Token, JWT (simplejwt), permission classes, IsOwner
3Filtering / Ordering / Pagination (PageNumber/LimitOffset/Cursor)
4Celery — task, retry, Beat, Flower, transaction.on_commit
5drf-spectacular — OpenAPI, Swagger UI, schema regression test
6Testing (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) #

SeriesPostsCore
Django Basics7Setup, ORM, URL/View, Template, Form, Admin/Auth
Django Intermediate7CBV, ORM intermediate, Signals, users/permissions, messages/sessions, Static, testing
Django Advanced7Async/ASGI, custom commands, query optimization, caching, transactions, Channels, deployment security
Django DRF in Practice6DRF 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.

X