Serve Django API and Admin on Separate Subdomains
Serve one Django project on www, api, and admin subdomains: per-host URLconfs, host-only cookies, CORS and CSRF settings, Nginx server blocks, and restricting admin access by network.
Problem statement
As a Django project grows, it often serves three audiences from one codebase: the public site, an API for a frontend or mobile app, and the admin for staff. Putting them on example.com, api.example.com, and admin.example.com gives each its own security policy: the admin can be restricted to a VPN or office IP, the API can have its own rate limits and CORS rules, and session cookies from the admin are never sent to the public site.
The Django side needs per-host URL routing and careful cookie, CSRF, and CORS settings; the proxy side needs server blocks, certificates, and access rules. This guide sets up both with a single Django deployment.
- Create separate URLconf modules for the site, API, and admin.
- Add middleware that sets
request.urlconfbased on the host. - Keep
SESSION_COOKIE_DOMAINandCSRF_COOKIE_DOMAINunset so cookies are host-only. - Add all hostnames to
ALLOWED_HOSTS; configure CORS on the API for the frontend origin only. - In Nginx, add one server block per hostname, with IP restrictions on the admin block.
- Build cross-host links with absolute URLs, since
reverse()only knows the current host's URLconf.
Step-by-step solution
1. Split the URLconfs
# config/urls_site.py
from django.urls import include, path
urlpatterns = [path("", include("pages.urls"))]
# config/urls_api.py
from django.urls import include, path
urlpatterns = [path("v1/", include("api.urls"))]
# config/urls_admin.py
from django.contrib import admin
from django.urls import path
urlpatterns = [path("", admin.site.urls)]
# settings.py
ROOT_URLCONF = "config.urls_site"
ROOT_URLCONF stays the default for the main site, for management commands, and for requests to unknown hosts (which ALLOWED_HOSTS rejects anyway).
2. Route by host
# config/middleware.py
HOST_URLCONFS = {
"api.example.com": "config.urls_api",
"admin.example.com": "config.urls_admin",
}
class HostURLConfMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
host = request.get_host().split(":")[0].lower()
urlconf = HOST_URLCONFS.get(host)
if urlconf:
request.urlconf = urlconf
return self.get_response(request)
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"config.middleware.HostURLConfMiddleware",
# ...
]
Django uses request.urlconf for resolving that request and for reverse() calls made during it. request.get_host() validates the host against ALLOWED_HOSTS, so place the middleware after SecurityMiddleware and before anything that resolves URLs. The django-hosts package offers a more complete version of this, including cross-host reverse().
3. Hosts, cookies, CSRF, and CORS
ALLOWED_HOSTS = ["example.com", "www.example.com", "api.example.com", "admin.example.com"]
# Host-only cookies: do NOT set these to ".example.com"
SESSION_COOKIE_DOMAIN = None
CSRF_COOKIE_DOMAIN = None
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
CSRF_TRUSTED_ORIGINS = ["https://example.com", "https://admin.example.com"]
# django-cors-headers, applied to the API only
CORS_ALLOWED_ORIGINS = ["https://example.com"]
CORS_URLS_REGEX = r"^/v1/.*$"
CORS_ALLOW_CREDENTIALS = False
- Host-only cookies: with no cookie domain, the admin's session cookie is sent only to
admin.example.com. A vulnerability or malicious script on the public site cannot use the staff session. SettingSESSION_COOKIE_DOMAIN = ".example.com"would share sessions across all subdomains, which is sometimes wanted for single sign-on but removes this isolation. - CSRF: same-origin form posts on each host work without extra settings.
CSRF_TRUSTED_ORIGINSmatters for cross-origin unsafe requests, such as the frontend onexample.composting to an endpoint that uses session authentication. - CORS: if the frontend on
example.comcallsapi.example.comfrom the browser, the API must send CORS headers for that origin.CORS_URLS_REGEXlimits them to API paths. Keep credentials disabled when the API uses token authentication.
CORS_URLS_REGEX matches the request path, which on the API host is /v1/.... Note that this regex does not look at the host, so the same path on another host would also match; with separate URLconfs, those paths do not exist elsewhere.
4. Nginx server blocks
upstream django {
server unix:/run/gunicorn/gunicorn.sock;
}
server {
listen 443 ssl;
server_name example.com www.example.com;
# ssl_certificate lines
location /static/ { alias /srv/myapp/current/staticfiles/; }
location / { include /etc/nginx/snippets/django-proxy.conf; }
}
server {
listen 443 ssl;
server_name api.example.com;
# ssl_certificate lines
client_max_body_size 5m;
location / {
limit_req zone=api burst=30 nodelay;
include /etc/nginx/snippets/django-proxy.conf;
}
}
server {
listen 443 ssl;
server_name admin.example.com;
# ssl_certificate lines
allow 198.51.100.0/24; # office or VPN range
deny all;
location /static/ { alias /srv/myapp/current/staticfiles/; }
location / { include /etc/nginx/snippets/django-proxy.conf; }
}
# /etc/nginx/snippets/django-proxy.conf
proxy_pass http://django;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
The limit_req zone must be defined in the http block (limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;). The admin block needs its own static location because admin pages load CSS and JavaScript from /static/admin/ on the admin host.
5. Certificates and DNS
Create A/AAAA records for each hostname and include them all in one certificate:
sudo certbot --nginx -d example.com -d www.example.com -d api.example.com -d admin.example.com
Certificate Transparency logs are public, so the admin hostname will be discoverable; the IP restriction, not obscurity, is what protects it.
6. Links between hosts
reverse() resolves against the current request's URLconf, so from the public site you cannot reverse("admin:index"). Store base URLs in settings and build absolute URLs:
ADMIN_BASE_URL = "https://admin.example.com"
API_BASE_URL = "https://api.example.com"
Password reset and other emails generated on one host must also use the right base URL for links to another.
Verification check:
curl -sI https://api.example.com/v1/health/ | head -1
curl -sI https://example.com/v1/health/ | head -1 # expect 404
curl -sI https://admin.example.com/ | head -1 # 302 to login from allowed IP, 403 elsewhere
curl -sI -H "Origin: https://example.com" https://api.example.com/v1/health/ | grep -i access-control
Log in to the admin and confirm in the browser's developer tools that the sessionid cookie's domain is admin.example.com only.
Explanation
The host header is available before URL resolution, so a small middleware can choose a URLconf per hostname while everything else (models, settings, deployment) stays shared. Browser security boundaries are per origin, and host-only cookies follow those boundaries, which is what turns separate subdomains into real isolation between the admin, the API, and the public site. Nginx adds network-level policy per host that Django cannot enforce as cheaply.
Edge cases / notes
Admin behind a load balancer or CDN
If traffic arrives through another proxy, Nginx's allow/deny sees the proxy's IP. Restore the real client IP with the realip module from trusted proxy addresses first, or enforce the restriction at the load balancer or with an access proxy such as Cloudflare Access.
Separate deployments later
Because each host already has its own URLconf and proxy block, moving the API to its own pods or servers later is a routing change, not a code restructuring.
When to automate this
Template the Nginx server blocks from a list of hostnames and their policies, and add tests asserting that API paths 404 on the main host and admin paths 404 on the API host. Those tests catch URL leaks when new URLconfs are added.
Next steps
- Tune proxy behaviour with How to Configure Nginx for Django in Production.
- Fix cross-origin problems with Fix CORS Errors with a Django API in Production.
- Lock down the admin further with the Django Admin Security Checklist.
- If form posts fail across hosts, see CSRF Verification Failed in Django Production: How to Fix It.
FAQ
How do I serve different URLs per subdomain in Django?
Set request.urlconf in middleware based on request.get_host(), or use the django-hosts package.
Should the Django admin share a session cookie with the main site?
Usually not. Host-only cookies keep staff sessions away from the public site. Share sessions across subdomains only if you need single sign-on.
Why do I get CORS errors calling api.example.com from example.com?
They are different origins. The API must return Access-Control-Allow-Origin for https://example.com, for example with django-cors-headers.
Is hiding the admin on a subdomain enough security?
No. Certificate logs reveal hostnames. Restrict access by network or an access proxy, and keep strong authentication.