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.
- Store private files outside the public media path, for example
/srv/myapp/private/. - Add an Nginx location such as
/protected/markedinternal, aliased to that directory. - Make sure no public location (such as
/media/) exposes the private directory. - In the Django view, authorise the user, then return an empty response with
X-Accel-Redirect: /protected/<path>, an explicitContent-Type, andContent-Disposition. - Build the path from the database record, never from raw user input, and URL-encode it.
- Fall back to
FileResponsein 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;
}
}
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
Details that matter:
- Authorisation in the query.
get_object_or_404(..., account__members=request.user)returns404for 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/passwdbecomes 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/htmlis passed through, and browsers may try to render the file. filename*=UTF-8''handles non-ASCII filenames inContent-Disposition. Useinlineinstead ofattachmentfor 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: nodisables proxy buffering for the response (useful for streaming responses, not usually needed for files).X-Accel-Limit-Rate: 1048576limits the download speed in bytes per second.
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
- Clarify file categories with Django Static vs Media Files in Production.
- Review the full proxy setup in How to Configure Nginx for Django in Production.
- Secure the upload side with the Django File Upload Security Checklist.
- If Nginx returns 403 for the files, see Fix Nginx 403 Forbidden on Django Static Files.
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.