Deploy a Django REST Framework API to Production

Prepare a Django REST Framework API for production: safe defaults, throttling, pagination, CORS, proxy headers, Gunicorn sizing, and API checks.

Problem statement

A Django REST Framework (DRF) API that works in development often ships with defaults tuned for exploration rather than production:

  • the browsable API renderer is enabled for everyone, exposing a clickable interface to endpoints
  • default permissions allow any request unless each view overrides them
  • list endpoints return every row, so one request can load an entire table
  • no throttling, so a single client can exhaust workers
  • CORS is either missing (the frontend breaks) or wide open (any site can call the API with credentials)
  • errors under DEBUG=False return HTML instead of JSON when they occur outside DRF views

The server layer (Gunicorn, Nginx, TLS) is the same as for any Django site. This guide focuses on the API-specific settings and checks you should apply on top of it before exposing the API publicly.

A DRF API request in productionClienttoken or sessionNginxTLS, rate limitsDRF viewauth, throttle,permissionsJSON responsepaginatedRestrictive defaults mean a view you forget to configure stays closed.

Quick answer

  1. Set restrictive REST_FRAMEWORK defaults: JSON renderer only, IsAuthenticated default permission, explicit authentication classes.
  2. Add a default pagination class and page size.
  3. Enable DRF throttling with scoped rates, backed by a shared cache such as Redis.
  4. Configure django-cors-headers with an explicit allowlist of origins.
  5. Behind a proxy, set SECURE_PROXY_SSL_HEADER and use NUM_PROXIES so throttling identifies clients correctly.
  6. Return JSON for 404 and 500 errors outside DRF views.
  7. Serve with Gunicorn behind Nginx, then verify auth, throttling, pagination, and CORS with curl.

Step-by-step solution

1. Production REST_FRAMEWORK defaults

REST_FRAMEWORK = {
    "DEFAULT_RENDERER_CLASSES": [
        "rest_framework.renderers.JSONRenderer",
    ],
    "DEFAULT_PARSER_CLASSES": [
        "rest_framework.parsers.JSONParser",
    ],
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "rest_framework.authentication.SessionAuthentication",
        "rest_framework.authentication.TokenAuthentication",
    ],
    "DEFAULT_PERMISSION_CLASSES": [
        "rest_framework.permissions.IsAuthenticated",
    ],
    "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
    "PAGE_SIZE": 50,
    "DEFAULT_THROTTLE_CLASSES": [
        "rest_framework.throttling.AnonRateThrottle",
        "rest_framework.throttling.UserRateThrottle",
    ],
    "DEFAULT_THROTTLE_RATES": {
        "anon": "30/minute",
        "user": "600/minute",
        "login": "5/minute",
    },
    "NUM_PROXIES": 1,
}

Why each setting matters:

  • Renderers: removing BrowsableAPIRenderer in production stops the HTML interface from being served. Keep it in development settings only.
  • Parsers: accepting only JSON narrows the input surface. Add MultiPartParser on specific upload views that need it.
  • Permissions: IsAuthenticated by default means a view you forget to configure is closed rather than open. Public endpoints then opt in explicitly with permission_classes = [AllowAny].
  • Pagination: a default page size prevents unbounded list responses.
  • Throttling: protects workers and the database from a single noisy client.
safe defaults for every view"DEFAULT_RENDERER_CLASSES": ["…JSONRenderer"],"DEFAULT_PERMISSION_CLASSES": ["…IsAuthenticated"],"PAGE_SIZE":50,"DEFAULT_THROTTLE_RATES": {"anon": "30/minute", …},"NUM_PROXIES":1,12341No browsable API in production unless you want it.2Closed by default; public views opt out explicitly.3No endpoint can return an unbounded list.4Must equal the real number of proxies, or throttles key on the wrong IP.A view that forgets its permissions falls back to these, so make them strictdjango-deployment.com
Strict defaults turn a forgotten setting into a 403 instead of a data leak.

TokenAuthentication requires rest_framework.authtoken in INSTALLED_APPS and its migrations. If you use JWT or another scheme, list that class instead.

2. Throttling that works across processes

DRF stores throttle history in Django's cache. With the default local-memory cache, each Gunicorn worker keeps its own counts, so the effective limit is multiplied by the number of workers and servers. Use a shared cache:

CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.redis.RedisCache",
        "LOCATION": "redis://127.0.0.1:6379/2",
    }
}
throttle counters must be sharedLocMemCache (default)✕one counter per Gunicorn worker✕4 workers: the limit is really 4×✕resets on every restartRedis cache✓one counter for every process✓limits hold across servers✓survives deploysThrottling is only as accurate as the cache behind itdjango-deployment.com
For login endpoints, add a dedicated scope with a much lower rate.

Apply a strict scope to authentication endpoints:

from rest_framework.authtoken.views import ObtainAuthToken
from rest_framework.throttling import ScopedRateThrottle


class LoginView(ObtainAuthToken):
    throttle_classes = [ScopedRateThrottle]
    throttle_scope = "login"

DRF throttling is an application-level control, not DDoS protection. Pair it with limits at Nginx or your CDN for abusive traffic.

3. Identify clients correctly behind a proxy

Anonymous throttling keys on the client IP. Behind Nginx, REMOTE_ADDR is the proxy's address, so every anonymous user would share one bucket. DRF's NUM_PROXIES tells it how many proxies to skip in X-Forwarded-For:

  • NUM_PROXIES = 1 when exactly one trusted proxy (Nginx) adds to X-Forwarded-For.
  • Add one for each additional trusted proxy layer, such as a load balancer in front of Nginx.
who is the client?Client 203.0.113.7the address you wantLoad balancer or CDNappends to X-Forwarded-ForNginxappends again, sets X-Real-IPDjango REMOTE_ADDRalways the last proxyNUM_PROXIES tells DRF how many entries to skip from the rightdjango-deployment.com
Trusting the leftmost entry lets any client spoof its address and dodge throttles.

Make sure Nginx sets the header:

proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;

A wrong NUM_PROXIES either groups all clients together or lets clients spoof their IP by sending their own X-Forwarded-For values.

4. CORS for browser clients

Install django-cors-headers and allow only your frontends:

INSTALLED_APPS += ["corsheaders"]

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "corsheaders.middleware.CorsMiddleware",
    "django.middleware.common.CommonMiddleware",
    # ...
]

CORS_ALLOWED_ORIGINS = [
    "https://app.example.com",
]
CORS_ALLOW_CREDENTIALS = True
CSRF_TRUSTED_ORIGINS = ["https://app.example.com"]

CorsMiddleware should be placed as high as possible, before any middleware that can generate responses such as CommonMiddleware. Never combine CORS_ALLOW_ALL_ORIGINS = True with credentials. Server-to-server API clients and mobile apps do not need CORS at all.

5. JSON errors outside DRF views

DRF formats exceptions raised inside API views as JSON, but a URL that does not match any route returns Django's HTML 404 page, and a crash in middleware returns HTML 500. Add JSON handlers in the root URLconf:

# config/urls.py
from django.http import JsonResponse


def api_not_found(request, exception=None):
    return JsonResponse({"detail": "Not found."}, status=404)


def api_server_error(request):
    return JsonResponse({"detail": "Server error."}, status=500)


handler404 = api_not_found
handler500 = api_server_error

The custom handlers are only used when DEBUG = False. Keep error bodies generic; details belong in logs and error tracking.

6. Security settings

DEBUG = False
ALLOWED_HOSTS = ["api.example.com"]
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 31536000
SECURE_CONTENT_TYPE_NOSNIFF = True

For APIs consumed by non-browser clients, consider disabling SECURE_SSL_REDIRECT and rejecting plain HTTP at the proxy instead. Redirecting a POST from HTTP to HTTPS makes most clients re-send it as a GET or fail, and it means credentials were already sent in cleartext.

7. Serve with Gunicorn

API endpoints are often I/O-heavy (database, other services). A threaded worker configuration uses memory efficiently:

gunicorn config.wsgi:application \
  --bind unix:/run/myapi/gunicorn.sock \
  --workers 3 \
  --threads 4 \
  --worker-class gthread \
  --timeout 30 \
  --max-requests 2000 \
  --max-requests-jitter 200 \
  --access-logfile -

Keep timeout short for APIs; clients usually have their own timeouts, and long-running work should move to a task queue that returns 202 Accepted with a status URL.

Nginx must forward the Authorization header, which it does by default with proxy_pass. Set a request body limit appropriate for your largest payload with client_max_body_size.

8. API documentation exposure

If you generate an OpenAPI schema with a package such as drf-spectacular, decide whether the schema and Swagger UI should be public. For internal or partner APIs, restrict the schema views with authentication.

9. Verify

# Unauthenticated access is rejected
curl -s -o /dev/null -w "%{http_code}\n" https://api.example.com/api/orders/

# Authenticated access returns paginated JSON
curl -s -H "Authorization: Token $API_TOKEN" https://api.example.com/api/orders/ | head -c 300

# Unknown route returns JSON 404
curl -s https://api.example.com/api/does-not-exist/

# Throttling kicks in (expect 429 after the anon limit)
for i in $(seq 1 40); do
  curl -s -o /dev/null -w "%{http_code} " https://api.example.com/api/public/status/
done; echo

# CORS preflight from the allowed origin
curl -s -i -X OPTIONS https://api.example.com/api/orders/ \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: GET" | grep -i access-control

Verification check:

  • 401 or 403 without credentials
  • responses include count, next, previous, and results
  • the loop returns 429 with a Retry-After header once the limit is exceeded
  • the preflight response includes Access-Control-Allow-Origin: https://app.example.com
  • a request with Origin: https://evil.example does not get that header

Rollback

API rollbacks have an extra constraint: clients may already depend on new fields or endpoints. Add fields in a backward-compatible way, version breaking changes (for example /api/v2/), and roll back code using your normal release process only when clients are unaffected.

Explanation

DRF's defaults favor developer experience: open permissions, the browsable renderer, and no limits. Production settings should invert that so every safety property is on by default and loosened per view. Throttling and client identification depend on infrastructure (cache and proxies), which is why they must be configured together with the deployment rather than only in application code.

Edge cases / notes

Session authentication and CSRF

With SessionAuthentication, unsafe methods require a valid CSRF token, even for JSON requests. Single-page apps must send the X-CSRFToken header; token-authenticated clients are not subject to CSRF checks.

Large exports

Endpoints that stream large CSV exports should use StreamingHttpResponse or background jobs; pagination limits do not apply to custom export views.

Health checks

Expose a health endpoint with AllowAny and no throttling, and keep it cheap, so load balancers do not get throttled or rejected.

Observability for APIs

HTML sites are usually debugged by looking at a page; APIs need logs and metrics that describe behavior across many clients.

Structured request logs

Log one line per request with fields you can filter on: method, path pattern, status, duration, authenticated user or client ID, and a request ID. A minimal middleware:

import logging
import time
import uuid

logger = logging.getLogger("api.requests")


class RequestLogMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        request_id = request.headers.get("X-Request-ID") or uuid.uuid4().hex
        start = time.monotonic()
        response = self.get_response(request)
        duration_ms = int((time.monotonic() - start) * 1000)
        match = getattr(request, "resolver_match", None)
        logger.info(
            "request",
            extra={
                "request_id": request_id,
                "method": request.method,
                "route": match.route if match else request.path,
                "status": response.status_code,
                "duration_ms": duration_ms,
                "user_id": getattr(request.user, "pk", None),
            },
        )
        response["X-Request-ID"] = request_id
        return response

Logging the route pattern (api/orders/<int:pk>/) instead of the raw path keeps cardinality low and makes aggregation meaningful. Never log request bodies or Authorization headers.

Metrics worth alerting on

alerts that catch real API problems5xx rate per endpointnot only site-widep95 latencyon the busiest endpoints429 spikesabuse, or limits set too low401 and 403 surgesbroken clients or token leaksAlert on symptoms clients feel, then use logs to find the causedjango-deployment.com
Per-endpoint numbers need the view name in your structured logs.
  • 5xx rate per route
  • 429 rate (a spike can mean an abusive client or limits that are too tight for a legitimate integration)
  • p95 latency per route
  • authentication failures per minute (credential stuffing or a broken client release)

Versioning and deprecation

Once external clients depend on your API, releases need a compatibility policy:

  • add fields and endpoints freely; clients should ignore unknown fields
  • never change the meaning or type of an existing field in place
  • introduce breaking changes under a new version (URL path or Accept header versioning with DRF's versioning classes)
  • announce deprecations with a date, and add a Deprecation or Sunset response header on old endpoints so client developers see it

Common mistakes

  • Leaving DEFAULT_PERMISSION_CLASSES as AllowAny because "all views set permissions anyway".
  • Returning full serializer output for list endpoints, including heavy nested objects, instead of a lighter list serializer.
  • Unbounded ?page_size= parameters; set max_page_size on the pagination class.
  • N+1 queries in serializers with nested relations; use select_related and prefetch_related in get_queryset().

Next steps

FAQ

Should I disable the DRF browsable API in production?

For public APIs, yes: set DEFAULT_RENDERER_CLASSES to the JSON renderer only in production settings. Keep the browsable API in development, or enable it only for staff if your team relies on it.

Why does DRF throttling not work with multiple Gunicorn workers?

Throttle history is stored in Django's cache. The default local-memory cache is per process, so each worker counts separately. Use Redis or another shared cache backend.

What should NUM_PROXIES be in Django REST Framework?

The number of trusted proxies in front of Django that append to X-Forwarded-For. With only Nginx on the same host, use 1. A wrong value groups all clients together or allows IP spoofing.

Why does my API return HTML error pages in production?

Errors outside DRF views, such as unmatched URLs or middleware exceptions, use Django's default handlers. Define JSON handler404 and handler500 functions in the root URLconf.

Keep going