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=Falsereturn 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.
Quick answer
- Set restrictive
REST_FRAMEWORKdefaults: JSON renderer only,IsAuthenticateddefault permission, explicit authentication classes. - Add a default pagination class and page size.
- Enable DRF throttling with scoped rates, backed by a shared cache such as Redis.
- Configure
django-cors-headerswith an explicit allowlist of origins. - Behind a proxy, set
SECURE_PROXY_SSL_HEADERand useNUM_PROXIESso throttling identifies clients correctly. - Return JSON for 404 and 500 errors outside DRF views.
- 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
BrowsableAPIRendererin production stops the HTML interface from being served. Keep it in development settings only. - Parsers: accepting only JSON narrows the input surface. Add
MultiPartParseron specific upload views that need it. - Permissions:
IsAuthenticatedby default means a view you forget to configure is closed rather than open. Public endpoints then opt in explicitly withpermission_classes = [AllowAny]. - Pagination: a default page size prevents unbounded list responses.
- Throttling: protects workers and the database from a single noisy client.
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",
}
}
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 = 1when exactly one trusted proxy (Nginx) adds toX-Forwarded-For.- Add one for each additional trusted proxy layer, such as a load balancer in front of Nginx.
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:
401or403without credentials- responses include
count,next,previous, andresults - the loop returns
429with aRetry-Afterheader once the limit is exceeded - the preflight response includes
Access-Control-Allow-Origin: https://app.example.com - a request with
Origin: https://evil.exampledoes 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
5xxrate per route429rate (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
Acceptheader versioning with DRF's versioning classes) - announce deprecations with a date, and add a
DeprecationorSunsetresponse header on old endpoints so client developers see it
Common mistakes
- Leaving
DEFAULT_PERMISSION_CLASSESasAllowAnybecause "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; setmax_page_sizeon the pagination class. - N+1 queries in serializers with nested relations; use
select_relatedandprefetch_relatedinget_queryset().
Next steps
- Run through the Django REST API Production Checklist.
- Troubleshoot browser access with Fix CORS Errors with a Django API in Production.
- Add proxy-level protection with Rate Limit Django Endpoints with Nginx.
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.