Cache Django Responses with Nginx proxy_cache

Use Nginx proxy_cache in front of Django safely: cache zones, bypassing logged-in users, respecting Cache-Control, stale serving, and purging.

Problem statement

Some Django pages are expensive to render but identical for every anonymous visitor: a homepage, blog posts, documentation, product listings. Serving each request through Gunicorn wastes CPU and limits how many visitors a server can handle during traffic spikes.

Nginx's proxy_cache can store full responses on disk and serve them without contacting Django. It can turn a site that handles tens of requests per second into one that handles thousands for cached pages. It also has sharp edges specific to Django:

  • responses rendered for a logged-in user can be served to anonymous visitors if cookies are ignored
  • CSRF tokens embedded in forms become shared across users
  • Set-Cookie headers can be cached and replayed
  • stale content persists after edits unless you design expiry or purging

This guide configures a conservative micro-cache for anonymous traffic, with explicit bypass rules and verification.

Caching at the proxyRequestcache keyNginx cachehit or missDjangoonly on missPurgeon content changeCache-Control and Vary from Django decide what the proxy is allowed to store.

Quick answer

  1. Define a cache zone with proxy_cache_path in the http block.
  2. Cache only GET and HEAD requests for anonymous users: bypass when sessionid or csrftoken cookies are present, or for paths such as /admin/, /accounts/, and /api/.
  3. Let Django control lifetimes with Cache-Control headers, and set a short default proxy_cache_valid for pages that opt in.
  4. Never cache responses with Set-Cookie, and do not cache pages containing CSRF tokens.
  5. Use proxy_cache_use_stale and proxy_cache_lock to survive backend errors and prevent stampedes.
  6. Add an X-Cache-Status header and verify MISS then HIT.
  7. Plan invalidation: short TTLs (micro-caching) or a purge mechanism.

Step-by-step solution

1. Decide what is cacheable

Good candidates:

  • public pages whose HTML is identical for all anonymous visitors
  • pages without forms, or forms whose CSRF protection does not rely on a token embedded in the cached HTML
  • endpoints where a delay of seconds to minutes in reflecting changes is acceptable
may Nginx cache this response?Is it the same for every visitor?anonymous GETCacheshort TTL from Djangologged in or cookiesBypassper-user contentPOST, admin, APIBypassnever cachedWhen unsure, bypass: a wrong cache leaks one user’s page to anotherdjango-deployment.com
Public pages with heavy traffic benefit most.

Not cacheable at the proxy:

  • any page for logged-in users
  • pages with a CSRF token in the HTML (login, signup, contact forms) unless the form is loaded separately
  • responses that vary by cookie, language negotiation, or geography unless the variation is part of the cache key
  • POST requests and API endpoints with authentication

2. Define the cache zone

In the http context, for example /etc/nginx/conf.d/proxy-cache.conf:

proxy_cache_path /var/cache/nginx/django
    levels=1:2
    keys_zone=django_cache:50m
    max_size=2g
    inactive=60m
    use_temp_path=off;
the cache zone, piece by pieceproxy_cache_path /var/cache/nginx/djangolevels=1:2keys_zone=django_cache:50mmax_size=2ginactive=60muse_temp_path=off;12341Key metadata in shared memory: about 400k keys.2Disk cap; least recently used entries go first.3Unused entries are dropped after an hour.4Write straight into the cache directory.The http block defines the zone; locations opt into itdjango-deployment.com
inactive removes unused entries even if they have not expired.
  • keys_zone=django_cache:50m: shared memory for cache keys and metadata (roughly 8,000 keys per megabyte).
  • max_size=2g: disk limit; the cache manager removes least recently used entries beyond it.
  • inactive=60m: entries not accessed for 60 minutes are removed regardless of freshness.
  • use_temp_path=off: writes directly into the cache directory, avoiding extra file copies.

Create the directory with ownership for the Nginx user if it does not exist:

sudo install -d -o www-data -g www-data /var/cache/nginx/django

3. Define bypass conditions

Use map blocks in the http context to decide when to skip the cache:

map $http_cookie $skip_cache_cookie {
    default 0;
    ~*sessionid 1;
    ~*csrftoken 1;
}

map $request_uri $skip_cache_uri {
    default 0;
    ~^/admin/ 1;
    ~^/accounts/ 1;
    ~^/api/ 1;
    ~^/checkout/ 1;
}

map $request_method $skip_cache_method {
    default 1;
    GET 0;
    HEAD 0;
}

Any visitor with a session cookie (logged in, or with a cart stored in the session) bypasses the cache and gets a freshly rendered page. A csrftoken cookie usually means the visitor loaded a form; bypassing keeps their token consistent.

Adjust cookie names if you changed SESSION_COOKIE_NAME or CSRF_COOKIE_NAME.

4. Configure the cached location

server {
    listen 443 ssl;
    server_name www.example.com;

    location / {
        proxy_pass http://unix:/run/myapp/gunicorn.sock;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_cache django_cache;
        proxy_cache_key "$scheme$host$request_uri";
        proxy_cache_bypass $skip_cache_cookie $skip_cache_uri $skip_cache_method;
        proxy_no_cache $skip_cache_cookie $skip_cache_uri $skip_cache_method;

        proxy_cache_valid 200 301 1m;
        proxy_cache_valid 404 10s;

        proxy_cache_lock on;
        proxy_cache_lock_timeout 5s;
        proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
        proxy_cache_background_update on;

        add_header X-Cache-Status $upstream_cache_status always;
    }
}

Key directives:

  • proxy_cache_bypass skips reading from the cache; proxy_no_cache skips writing to it. Use both with the same conditions.
  • proxy_cache_key includes scheme, host, and full URI with query string. Different query strings are different entries.
  • proxy_cache_valid applies only when the response has no caching headers of its own. Nginx honors Cache-Control, Expires, and X-Accel-Expires from Django by default.
  • proxy_cache_lock lets one request populate a missing entry while others wait, preventing stampedes.
  • proxy_cache_use_stale serves an expired entry when Django errors or times out, and during background updates.

By default, Nginx does not cache responses that include Set-Cookie, and it respects Cache-Control: private and no-store. Keep these defaults; do not add proxy_ignore_headers Set-Cookie for Django apps unless you strip the header and are certain no session is created.

headers that stop cachingNginx by defaultSet-Cookie!not cachedCache-Control: private!not cachedVary: Cookie✕one entry per cookie: effectively noneCache-Control: max-age=60✓cached for 60 sCSRF and session middleware add Set-Cookie and Vary more often than expecteddjango-deployment.com
Check real headers with curl -I before trusting the cache.

Django adds Vary: Cookie when the session is accessed. Nginx treats Vary by storing variants keyed on the listed request headers (since Nginx 1.7.7), which for Cookie effectively means little sharing between visitors. The bypass maps above are more predictable than relying on Vary.

6. Control lifetimes from Django

Mark cacheable views explicitly and keep everything else private:

from django.views.decorators.cache import cache_control, never_cache


@cache_control(public=True, max_age=120)
def article_detail(request, slug):
    ...


@never_cache
def dashboard(request):
    ...

With public, max-age=120, Nginx caches the page for two minutes. Views without cache headers fall back to proxy_cache_valid. To make the proxy cache opt-in only, set proxy_cache_valid to a very short value (or omit it) and rely on views to send Cache-Control.

If you want the proxy to keep a page longer than browsers should, give Nginx its own lifetime with the X-Accel-Expires header, which takes priority over Cache-Control and Expires for Nginx and is not passed on to clients:

from django.shortcuts import get_object_or_404, render
from django.views.decorators.cache import cache_control

from blog.models import Article


@cache_control(public=True, max_age=0)
def article_detail(request, slug):
    article = get_object_or_404(Article, slug=slug, published=True)
    response = render(request, "blog/article.html", {"article": article})
    response["X-Accel-Expires"] = "300"
    return response

Browsers revalidate on every visit (max-age=0), while Nginx serves the cached copy for five minutes. This is more explicit than relying on how a given Nginx version interprets s-maxage.

7. Invalidation

Options, from simplest to most involved:

  • Micro-caching: cache for 10 to 60 seconds. Edits appear quickly, and traffic spikes are still absorbed.
  • Deploy-time purge: clear the cache directory after deploys that change templates: stop writes, remove files, reload (or accept a brief period of misses).
expire or purge?Short TTL✓no extra moving partsstale for at most the TTL✓use_stale covers slow refreshesPurge on change✓fresh immediatelyneeds a purge module or Nginx Plus✕every write must know its URLsShort TTLs are enough for most Django sitesdjango-deployment.com
Even a 10-second TTL absorbs most of a traffic spike.
sudo find /var/cache/nginx/django -type f -delete
  • Targeted purge: the open source Nginx does not include a purge endpoint; third-party modules add one, and Nginx Plus has proxy_cache_purge. Alternatively, include a version token in cache keys for groups of pages.

8. Verify

URL=https://www.example.com/blog/
curl -sI "$URL" | grep -i x-cache-status
curl -sI "$URL" | grep -i x-cache-status
curl -sI -H "Cookie: sessionid=test" "$URL" | grep -i x-cache-status
curl -sI https://www.example.com/admin/login/ | grep -i x-cache-status

Expected:

X-Cache-Status: MISS
X-Cache-Status: HIT
X-Cache-Status: BYPASS
X-Cache-Status: BYPASS

Also test for leaks: log in as a user, load a page that shows the username, then load the same URL in a private window without cookies and confirm the username does not appear.

Verification check:

  • anonymous repeat requests show HIT
  • requests with session cookies and admin paths show BYPASS
  • responses with Set-Cookie are not cached
  • load tests on cached pages show large throughput gains without increased Gunicorn load
  • no personalized content appears for anonymous visitors

Explanation

A reverse proxy cache stores the output of Django for requests that will produce the same output, and answers future identical requests itself. The critical design decision is the definition of "identical": a cache key that ignores cookies must be paired with rules that exclude any request whose response depends on cookies. Keeping the cache opt-in, short-lived, and bypassed for sessions gives most of the performance benefit with very little risk.

Edge cases / notes

Analytics and A/B testing cookies

Third-party cookies such as analytics identifiers are present for most visitors. Match only Django's session and CSRF cookies in the bypass map, or almost nobody hits the cache.

Multiple servers

Each Nginx instance has its own cache. With many servers, a CDN or a shared cache tier gives better hit rates.

Language and currency

If pages vary by Accept-Language or a currency cookie, include that value in proxy_cache_key or bypass the cache for those requests.

Common mistakes

  • Caching pages that include CSRF tokens in forms.
  • Adding proxy_ignore_headers Set-Cookie Cache-Control to "make caching work".
  • Omitting proxy_no_cache, so bypassed responses are still written to the cache.
  • Long cache lifetimes without an invalidation plan.

Next steps

FAQ

Is it safe to cache Django pages in Nginx?

Yes for anonymous, public pages, if you bypass the cache for requests with session or CSRF cookies, never cache responses with Set-Cookie, and exclude admin, account, and API paths.

Why does Nginx not cache my Django responses?

Common reasons: the response has Set-Cookie, Cache-Control: private or no-cache, or Vary: Cookie combined with visitor cookies, or bypass conditions match all requests.

What is micro-caching?

Caching responses for a very short time, such as 10 to 60 seconds. It absorbs traffic spikes while keeping content nearly real-time, and avoids complex invalidation.

How do I clear the Nginx proxy cache?

Delete the cached files in the proxy_cache_path directory, or use short lifetimes. Targeted purging requires a third-party module or Nginx Plus.

Keep going