Serve Private Django Media Files with Nginx X-Accel-Redirect

Serve permission-checked Django downloads efficiently: Django authorises the request and returns X-Accel-Redirect, and an internal Nginx location streams the file without tying up workers.

Problem statement

Invoices, contracts, exports, and paid downloads must only be downloadable by the right users. The usual shortcut, putting them under MEDIA_ROOT served by Nginx at /media/, makes every file public to anyone who guesses or shares the URL. The obvious fix, streaming the file through a Django view with FileResponse, works but ties up a Gunicorn worker for the whole download; a few slow clients downloading large files can exhaust the workers.

Nginx's X-Accel-Redirect combines the two: Django checks permissions and returns a tiny response with a header naming the file, and Nginx serves the file itself from an internal location that cannot be requested directly.

Private download with X-Accel-RedirectBrowserGET /downloads/42/Django viewchecks permissionX-Accel-Redirect/protected/...Nginx internalstreams the fileDjango decides who may download; Nginx does the slow part, so no worker waits on the client.
  1. Store private files outside the public media path, for example /srv/myapp/private/.
  2. Add an Nginx location such as /protected/ marked internal, aliased to that directory.
  3. Make sure no public location (such as /media/) exposes the private directory.
  4. In the Django view, authorise the user, then return an empty response with X-Accel-Redirect: /protected/<path>, an explicit Content-Type, and Content-Disposition.
  5. Build the path from the database record, never from raw user input, and URL-encode it.
  6. Fall back to FileResponse in development, where Nginx is not present.

Step-by-step solution

1. Separate private storage

# settings.py
PRIVATE_MEDIA_ROOT = Path("/srv/myapp/private")
# documents/models.py
from django.conf import settings
from django.core.files.storage import FileSystemStorage
from django.db import models

private_storage = FileSystemStorage(location=settings.PRIVATE_MEDIA_ROOT)


def invoice_path(instance, filename):
    return f"invoices/{instance.account_id}/{filename}"


class Invoice(models.Model):
    account = models.ForeignKey("accounts.Account", on_delete=models.CASCADE)
    file = models.FileField(storage=private_storage, upload_to=invoice_path)

The storage has no base_url, so invoice.file.url does not produce a public URL by accident.

sudo mkdir -p /srv/myapp/private
sudo chown myapp:www-data /srv/myapp/private
sudo chmod 750 /srv/myapp/private

Gunicorn (running as myapp) writes files; Nginx (in group www-data) only needs to read them.

2. Add the internal Nginx location

server {
    # ...

    location /media/ {
        alias /srv/myapp/media/;
    }

    location /protected/ {
        internal;
        alias /srv/myapp/private/;
        add_header Cache-Control "private, no-store" always;
    }

    location / {
        proxy_pass http://unix:/run/gunicorn/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;
    }
}
a location only Django can send you tolocation/protected-media/{internal;alias /srv/myapp/private-media/;}1231Never linked publicly.2Direct requests get 404; only redirects reach it.3Outside MEDIA_ROOT and any public alias.Guessing the URL is useless; the header is the only way indjango-deployment.com
Test with curl: a direct request must return 404.

internal means a direct request for /protected/... returns 404; only an internal redirect, such as one triggered by X-Accel-Redirect from the upstream, can reach it. Keep the private directory outside /srv/myapp/media/ so the public location cannot serve it.

sudo nginx -t && sudo systemctl reload nginx

3. Write the download view

# documents/views.py
import mimetypes
from urllib.parse import quote

from django.conf import settings
from django.http import FileResponse, HttpResponse
from django.shortcuts import get_object_or_404
from django.contrib.auth.decorators import login_required

from .models import Invoice


@login_required
def download_invoice(request, pk):
    invoice = get_object_or_404(Invoice, pk=pk, account__members=request.user)
    name = invoice.file.name                      # e.g. invoices/7/2026-09.pdf
    filename = name.rsplit("/", 1)[-1]
    content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
    disposition = f"attachment; filename*=UTF-8''{quote(filename)}"

    if settings.DEBUG:
        response = FileResponse(invoice.file.open("rb"), content_type=content_type)
    else:
        response = HttpResponse(content_type=content_type)
        response["X-Accel-Redirect"] = f"/protected/{quote(name)}"

    response["Content-Disposition"] = disposition
    return response
Django decides, Nginx deliversGET /downloads/42/to DjangoPermission checkowner or staff?Empty responseX-Accel-Redirect headerNginx streams filefrom the internal locationWorker already freeno Python in the transferLarge files never tie up a Gunicorn workerdjango-deployment.com
Set Content-Type and Content-Disposition in the Django response.

Details that matter:

  • Authorisation in the query. get_object_or_404(..., account__members=request.user) returns 404 for files the user may not see, without revealing that they exist.
  • Path from the database. The path comes from invoice.file.name, which Django's storage already sanitised on upload. Never build it from a query parameter, or a request like ?file=../../etc/passwd becomes a path traversal.
  • Encoding. quote() percent-encodes spaces and non-ASCII characters in the header; Nginx decodes it when mapping to the file.
  • Content type. Set it explicitly. Otherwise Django's default text/html is passed through, and browsers may try to render the file.
  • filename*=UTF-8'' handles non-ASCII filenames in Content-Disposition. Use inline instead of attachment for PDFs you want displayed in the browser.
# documents/urls.py
urlpatterns = [path("downloads/invoices/<int:pk>/", download_invoice, name="download-invoice")]

Link to {% url 'download-invoice' invoice.pk %} in templates, never to a file path.

4. Control buffering and caching

Nginx serves the file with sendfile and handles range requests, so large downloads and resumed downloads work. Two optional response headers adjust behaviour per response:

  • X-Accel-Buffering: no disables proxy buffering for the response (useful for streaming responses, not usually needed for files).
  • X-Accel-Limit-Rate: 1048576 limits the download speed in bytes per second.
headers on the Django responseValueX-Accel-Redirect/protected-media/<path>Content-Dispositionattachment; filename="report.pdf"Cache-Control✓private, no-storeX-Accel-Bufferingno, for very large or streamed filesShared caches must never store a private filedjango-deployment.com
Quote and encode file names that contain spaces or non-ASCII characters.

The Cache-Control: private, no-store header in the internal location keeps shared caches and CDNs from storing private files.

Verification check:

# Direct access to the internal location must fail
curl -s -o /dev/null -w "%{http_code}\n" https://example.com/protected/invoices/7/2026-09.pdf
# Expect 404

# An authenticated download must succeed (use a session cookie from a logged-in browser)
curl -s -o /tmp/out.pdf -w "%{http_code} %{content_type}\n" \
  -H "Cookie: sessionid=..." https://example.com/downloads/invoices/42/
# Expect 200 application/pdf

Also confirm that another user's invoice ID returns 404, and that the Gunicorn access log shows a fast response time even for large files.

Explanation

When Nginx proxies a request and the upstream response includes X-Accel-Redirect, Nginx discards the response body and performs an internal redirect to the given URI. The internal location maps that URI to a file and serves it with Nginx's efficient static file machinery. Django's work ends as soon as it returns the headers, so a Gunicorn worker is busy for milliseconds, not for the length of the download. The internal flag is what keeps the files private: the location exists only for redirects Nginx performs on behalf of the application.

Edge cases / notes

Object storage instead of disk

With S3 or another object store, the equivalent is a short-lived presigned URL: the view checks permissions and redirects to a signed URL valid for a minute or two. Nginx can also proxy to object storage from an internal location, but presigned URLs are simpler.

Multiple app servers

All servers need access to the same private files, through shared storage or object storage. Local disks on several servers diverge.

Other servers

Apache and Lighttpd use X-Sendfile for the same pattern. Caddy has no built-in equivalent; use presigned object storage URLs or stream with FileResponse there.

When to automate this

Wrap the pattern in a reusable helper (a function that takes a FieldFile and returns the right response for the environment) and add a test that asserts protected views return X-Accel-Redirect in production settings and 404 for unauthorised users. Include the internal location in your Nginx configuration template so new environments get it automatically.

Next steps

FAQ

What does X-Accel-Redirect do?

It tells Nginx to replace the upstream response with the content of another location, usually an internal location that maps to files on disk. The application authorises; Nginx serves.

Why does the downloaded file open as HTML or show the wrong type?

The response kept Django's default text/html content type. Set content_type explicitly on the HttpResponse.

Can users access the internal location directly?

No. A location marked internal returns 404 to external requests and is reachable only through internal redirects.

Is X-Accel-Redirect safe against path traversal?

Only if the path comes from trusted data, such as a stored FileField name, and never from user input.

Keep going