Serve Django Under a URL Prefix (/app) Behind Nginx
Run Django under a subpath such as example.com/app behind Nginx: forward SCRIPT_NAME correctly, set FORCE_SCRIPT_NAME, STATIC_URL, and cookie paths, and avoid broken admin links, redirects, and CSRF errors.
Problem statement
Django needs to live at https://example.com/app/ instead of at the root, because the main site is something else, or several apps share one domain. Proxying /app/ to Gunicorn is easy; making Django aware of the prefix is not. Without it, links and redirects point to /admin/ instead of /app/admin/, static files load from /static/ and 404, logins redirect out of the app, and session cookies collide with the main site.
WSGI has a standard answer: the SCRIPT_NAME variable holds the mount point, and Django builds every URL with it. The proxy must strip the prefix from the path and pass it as SCRIPT_NAME, or Django must be told the prefix with FORCE_SCRIPT_NAME.
- Proxy the prefix with the trailing slash so Nginx strips it:
location /app/ { proxy_pass http://unix:/run/gunicorn.sock:/; }. - Pass the prefix:
proxy_set_header SCRIPT_NAME /app;(Gunicorn honours it), or setFORCE_SCRIPT_NAME = "/app"in Django. - Set
STATIC_URL = "/app/static/"andMEDIA_URL = "/app/media/", and serve them under the prefix. - Set
SESSION_COOKIE_PATHandCSRF_COOKIE_PATHto/appif other apps share the domain. - Use
{% url %}andreverse()everywhere; never hard-code/admin/or/login/.
Step-by-step solution
1. Configure Nginx to strip the prefix
location /app/ {
proxy_pass http://unix:/run/gunicorn.sock:/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header SCRIPT_NAME /app;
}
location = /app {
return 301 /app/;
}
The trailing / after the upstream address tells Nginx to replace the matched /app/ with /, so Django receives /admin/. Gunicorn reads the SCRIPT_NAME header and sets the WSGI SCRIPT_NAME (and strips it from PATH_INFO if present).
2. Or set FORCE_SCRIPT_NAME
If you cannot control the proxy headers, set the prefix in Django:
FORCE_SCRIPT_NAME = "/app"
Django then prepends /app to every reversed URL regardless of the request. Use one mechanism, not both, and make sure the path Django receives no longer includes /app, otherwise URL patterns will not match.
3. Move static and media under the prefix
STATIC_URL = "/app/static/"
MEDIA_URL = "/app/media/"
location /app/static/ {
alias /srv/app/staticfiles/;
}
location /app/media/ {
alias /srv/app/media/;
}
Put these locations before or alongside the /app/ proxy location; Nginx picks the longest prefix, so /app/static/ wins. With WhiteNoise, STATIC_URL alone is enough because WhiteNoise serves under that URL through Django.
4. Scope cookies and CSRF
SESSION_COOKIE_PATH = "/app"
CSRF_COOKIE_PATH = "/app"
SESSION_COOKIE_NAME = "app_sessionid"
CSRF_TRUSTED_ORIGINS = ["https://example.com"]
If the root site is another Django project, both would otherwise use sessionid and csrftoken on /, overwriting each other's cookies and logging users out.
5. Fix hard-coded URLs
Grep for absolute paths in templates and JavaScript:
grep -rnE "href=\"/|action=\"/|fetch\(['\"]/" templates/ static/ | grep -v "{% url"
Replace them with {% url %}, reverse(), or, in JavaScript, a base URL rendered into the page. Also check LOGIN_URL, LOGIN_REDIRECT_URL, and LOGOUT_REDIRECT_URL: use URL names ("login") rather than paths so they include the prefix automatically.
Verification check:
curl -sI https://example.com/app/admin/ | grep -i location # /app/admin/login/?next=/app/admin/
curl -sI https://example.com/app/static/admin/css/base.css | head -1
Log in to the admin under the prefix and confirm every link and redirect stays inside /app/.
Explanation
The WSGI specification splits the request path into SCRIPT_NAME, the part that identifies the application's mount point, and PATH_INFO, the part the application routes. Django matches URL patterns against PATH_INFO and prepends SCRIPT_NAME when reversing URLs, so a correctly mounted app needs no changes to urls.py. The work lies in making the proxy produce the right split and in the settings that hold absolute paths outside the URL resolver: static and media URLs, cookie paths, and login redirects.
Edge cases / notes
- ASGI servers use
root_pathinstead ofSCRIPT_NAME; Uvicorn has--root-path /app. - Django REST Framework hyperlinks and pagination follow
SCRIPT_NAMEautomatically. - Single-page frontends need their router base path set to
/app/too. - Health checks from a load balancer must use the prefixed path or hit Gunicorn directly.
Next steps
- Run several projects on one machine with Host Multiple Django Sites on One Server.
- If admin styling breaks under the prefix, see Fix Django Admin CSS Missing in Production.
FAQ
Do I need to change urls.py to serve Django under a prefix?
No. Django routes PATH_INFO, which excludes the prefix, and adds the prefix when reversing URLs.
Should I use FORCE_SCRIPT_NAME or the SCRIPT_NAME header?
Either, but not both. The header keeps configuration in the proxy; FORCE_SCRIPT_NAME works when you cannot set headers.
Why do static files 404 under the prefix?
STATIC_URL still points at /static/. Set it to /app/static/ and serve that path.
Why do I get logged out of the main site?
Both apps use the same cookie names on /. Set distinct cookie names and paths.