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.

How Django learns its prefixGET /app/admin/reaches NginxNginx strips /appsends /admin/SCRIPT_NAME=/apppassed to DjangoURLs rebuilt/app/admin/ linksDjango prepends SCRIPT_NAME to every reversed URL, redirect, and admin link.
  1. Proxy the prefix with the trailing slash so Nginx strips it: location /app/ { proxy_pass http://unix:/run/gunicorn.sock:/; }.
  2. Pass the prefix: proxy_set_header SCRIPT_NAME /app; (Gunicorn honours it), or set FORCE_SCRIPT_NAME = "/app" in Django.
  3. Set STATIC_URL = "/app/static/" and MEDIA_URL = "/app/media/", and serve them under the prefix.
  4. Set SESSION_COOKIE_PATH and CSRF_COOKIE_PATH to /app if other apps share the domain.
  5. Use {% url %} and reverse() 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.

browser URL vs what Django seesSCRIPT_NAMEPATH_INFO (Django urls)/app//app✓//app/admin//app✓/admin//app/static/css/a.cssserved by Nginx✓never reaches Djangourls.py matches PATH_INFO; reverse() prepends SCRIPT_NAMEdjango-deployment.com
urls.py stays unchanged; the prefix is added and removed outside your URL patterns.

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.

everything that needs the prefixNginx strips /app and sends SCRIPT_NAMEor FORCE_SCRIPT_NAMESTATIC_URL and MEDIA_URL under /app/Nginx alias matchesCookie names and paths scopedno clash with the root siteLOGIN_URL etc. as URL namesreverse adds the prefixHard-coded /admin/ linksbypass the prefixAnything built with reverse() follows the prefix automaticallydjango-deployment.com
Templates that use the url tag need no changes at all.

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_path instead of SCRIPT_NAME; Uvicorn has --root-path /app.
  • Django REST Framework hyperlinks and pagination follow SCRIPT_NAME automatically.
  • 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

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.

Keep going