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.

One Django app, three hostnamesexample.compublic URLconfapi.example.comAPI URLconf, CORSadmin.example.comadmin, IP allowlistGunicornone deploymentEach hostname gets its own URLconf, access rules, and host-only cookies from the same code.
  1. Create separate URLconf modules for the site, API, and admin.
  2. Add middleware that sets request.urlconf based on the host.
  3. Keep SESSION_COOKIE_DOMAIN and CSRF_COOKIE_DOMAIN unset so cookies are host-only.
  4. Add all hostnames to ALLOWED_HOSTS; configure CORS on the API for the frontend origin only.
  5. In Nginx, add one server block per hostname, with IP restrictions on the admin block.
  6. 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)
one project, a URLconf per hostHOST_URLCONFS = {"api.example.com": "config.urls_api","admin.example.com": "config.urls_admin",}request.urlconf= HOST_URLCONFS.get(host)1231Only API routes exist on api.2The admin is unreachable from www.3Unknown hosts fall back to ROOT_URLCONF.Place it after SecurityMiddleware, before CommonMiddlewaredjango-deployment.com
reverse() uses the current request URLconf, which affects cross-host links.
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
settings for three hostsValueALLOWED_HOSTSwww, api, admin hostsSESSION_COOKIE_DOMAIN✓None: admin cookie stays on adminCSRF_TRUSTED_ORIGINShttps:// origin of each host with formsCORS_ALLOWED_ORIGINShttps://www.example.com for the APISharing one cookie domain spreads admin sessions to every subdomaindjango-deployment.com
Set a domain cookie only if you truly need one login across hosts.
  • 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. Setting SESSION_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_ORIGINS matters for cross-origin unsafe requests, such as the frontend on example.com posting to an endpoint that uses session authentication.
  • CORS: if the frontend on example.com calls api.example.com from the browser, the API must send CORS headers for that origin. CORS_URLS_REGEX limits 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
certificates for the subdomainsOne cert with each name✓HTTP-01 workscertbot -d www -d api -d adminnew subdomain = reissueWildcard *.example.com✓covers future subdomainsneeds DNS-01 and an API tokenapex needs its own nameA fixed set of subdomains rarely needs a wildcarddjango-deployment.com
Admin on its own host also allows IP restrictions just for it.

Certificate Transparency logs are public, so the admin hostname will be discoverable; the IP restriction, not obscurity, is what protects it.

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

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.

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.

Keep going