Fix Django Media Files Not Serving (Uploads Broken in Production)
Fix Django media files returning 404 or 403 in production: MEDIA_URL and MEDIA_ROOT, the static() helper, Nginx alias rules, permissions, and object storage.
Problem statement
Uploads appear to work: the form submits, the admin shows a filename, and the database has a value. But the file itself does not display. Depending on the cause you see:
404 Not Foundfor/media/avatars/2026/09/photo.jpg403 Forbiddenfor the same URL- broken image icons on pages that worked in development
- a working URL for old files and
404for new ones
In development, runserver can serve media through a URL pattern added by the static() helper. In production with DEBUG = False, that helper does nothing, and Django deliberately does not serve user uploads. Something else (Nginx, WhiteNoise's separate media handling, or object storage) must do it.
This guide separates the three questions that produce these symptoms: is the file on disk, is the URL mapped to it, and may the web server read it?
Quick answer
- Confirm the file exists on the server: check
MEDIA_ROOTand the exact relative path stored in the database. - Confirm
MEDIA_URLandMEDIA_ROOTare set, and remember thatstatic()inurls.pydoes nothing whenDEBUG = False. - Serve
/media/from Nginx with analiasthat points atMEDIA_ROOT, with matching trailing slashes. - For
403, fix filesystem permissions: the Nginx user needs read access to the file and execute on every parent directory. - Keep
MEDIA_ROOToutside release directories so deploys do not replace it. - For multiple servers or containers, move uploads to object storage with django-storages.
- Verify with
curl -Ion a real media URL and an upload through the application.
Step-by-step solution
1. Find out what Django stored
python manage.py shell -c "
from django.conf import settings
print('MEDIA_URL:', settings.MEDIA_URL)
print('MEDIA_ROOT:', settings.MEDIA_ROOT)
from accounts.models import Profile
p = Profile.objects.exclude(avatar='').order_by('-id').first()
print('name:', p.avatar.name)
print('url:', p.avatar.url)
print('path:', getattr(p.avatar, 'path', 'n/a (remote storage)'))
print('exists:', p.avatar.storage.exists(p.avatar.name))
"
Adjust the model and field names to your project. This single command answers most of the diagnosis:
exists: Falsemeans the file is not in storage at all: the upload failed, the file was deleted, orMEDIA_ROOTchanged between upload and now (a common result of storing media inside a release directory that deploys replace).exists: Truemeans the file is there, and the problem is in URL mapping or permissions.urlshows exactly which URL the browser requests, which you can then test withcurl.
2. Check settings
MEDIA_URL = "/media/"
MEDIA_ROOT = "/srv/myapp/media"
Both must be set in production settings. MEDIA_ROOT should be an absolute path outside the code directory, so that deploys, git clean, and release directory rotation never touch uploads.
A urls.py pattern seen in many projects:
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [
# ...
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
static() returns an empty list when DEBUG = False, so this works locally and silently stops working in production. That is intended: Django's development server is not a file server. Do not "fix" it by serving media through Django in production; use the web server or object storage.
3. Serve /media/ from Nginx
server {
listen 443 ssl;
server_name www.example.com;
location /media/ {
alias /srv/myapp/media/;
expires 7d;
add_header Cache-Control "public";
add_header X-Content-Type-Options "nosniff" always;
}
location /static/ {
alias /srv/myapp/staticfiles/;
}
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;
}
}
Details that cause 404 when wrong:
- Trailing slashes must match. With
location /media/andalias /srv/myapp/media/, a request for/media/a/b.jpgmaps to/srv/myapp/media/a/b.jpg. Dropping the slash from the alias produces/srv/myapp/mediaa/b.jpg. aliasversusroot. Withroot /srv/myapp;, Nginx appends the whole URI, giving/srv/myapp/media/a/b.jpgas well; mixing the two conventions is what produces doubled or missing path segments. Pick one and check the computed path in the error log.MEDIA_URLand the location must agree. IfMEDIA_URL = "/uploads/", the location must be/uploads/.
Apply and test:
sudo nginx -t && sudo systemctl reload nginx
sudo tail -n 20 /var/log/nginx/error.log
The error log prints the exact filesystem path Nginx tried to open, which immediately shows path-mapping mistakes.
4. Fix 403 Forbidden
A 403 means the path mapping is right but the Nginx worker cannot read the file:
namei -l /srv/myapp/media/avatars/2026/09/photo.jpg
sudo -u www-data test -r /srv/myapp/media/avatars/2026/09/photo.jpg && echo readable || echo NOT readable
The Nginx user needs execute permission on every directory in the path and read permission on the file. A media directory shared between the app user and the web server:
sudo install -d -o myapp -g www-data -m 2755 /srv/myapp/media
sudo chown -R myapp:www-data /srv/myapp/media
sudo find /srv/myapp/media -type d -exec chmod 2755 {} +
sudo find /srv/myapp/media -type f -exec chmod 644 {} +
The setgid bit (2) makes new subdirectories inherit the www-data group, so files uploaded later stay readable. Also set Django's upload modes so new files and directories are created predictably:
FILE_UPLOAD_PERMISSIONS = 0o644
FILE_UPLOAD_DIRECTORY_PERMISSIONS = 0o755
On RHEL-family systems, check SELinux labels if permissions look correct but access is still denied (httpd_sys_content_t for read-only media).
5. Media disappearing after deploys
If old files vanish or only recent uploads are missing, MEDIA_ROOT is probably inside the deployed code:
/srv/myapp/releases/2026-09-10-abc123/media/ <- replaced on every deploy
Move it out and keep the path stable:
MEDIA_ROOT = "/srv/myapp/media"
Then move existing files once:
sudo rsync -a /srv/myapp/releases/*/media/ /srv/myapp/media/
In Docker, the equivalent mistake is writing uploads into the container filesystem; use a named volume or object storage.
6. Multiple servers or containers
With more than one application server, a file uploaded on server A does not exist on server B, so images load intermittently depending on which server answers. Options:
- Object storage (S3, R2, GCS) through django-storages: the usual answer, and it also removes the permission and backup problems.
- Shared filesystem (NFS or a managed file service): works, but adds a failure mode where a slow mount hangs workers.
With object storage, FileField.url returns the storage URL, so Nginx no longer needs a /media/ location at all.
7. Private files
Anything that should not be public must not be served directly by Nginx from a public path. Serve it through a view that checks permissions:
from django.contrib.auth.decorators import login_required
from django.http import FileResponse, Http404
from django.shortcuts import get_object_or_404
from invoices.models import Invoice
@login_required
def invoice_pdf(request, pk):
invoice = get_object_or_404(Invoice, pk=pk, customer=request.user.customer)
if not invoice.pdf:
raise Http404
return FileResponse(invoice.pdf.open("rb"), content_type="application/pdf")
For large private files, X-Accel-Redirect lets Django authorize the request while Nginx streams the file from an internal location, keeping worker processes free.
8. Verify
url=$(python manage.py shell -c "
from accounts.models import Profile
p = Profile.objects.exclude(avatar='').order_by('-id').first()
print(p.avatar.url)
")
curl -sI "https://www.example.com${url}" | head -3
Then upload a new file through the real form and request its URL.
Verification check:
- an existing media URL returns
200with the rightContent-Type - a newly uploaded file is readable immediately, without manual permission changes
- media files survive a deploy and a service restart
- private files are not reachable without authentication
- the Nginx error log shows no "No such file or directory" entries for media paths
Explanation
Django treats user uploads as data, not as part of the application: it stores them through a storage backend and hands out URLs, but does not serve them in production. That means three separate things must line up: the file exists in the configured storage, a URL prefix maps to that storage, and the component serving it has permission. Development hides this because runserver serves media for convenience, which is why these errors surface only after DEBUG = False.
Edge cases / notes
Old files work, new ones do not
Usually a permissions or umask problem for newly created directories: the date-based subdirectory (2026/09/) is created by the app user with a restrictive mode. FILE_UPLOAD_DIRECTORY_PERMISSIONS and the setgid bit fix it.
Files served but downloaded instead of displayed
Nginx sets Content-Type from the file extension using its MIME map. Check that include /etc/nginx/mime.types; is present in the http block.
Broken images only on some pages
Templates that build URLs manually (/media/{{ obj.avatar.name }}) break when storage changes. Always use {{ obj.avatar.url }}.
CDN in front
A CDN caches media aggressively. After fixing a 404, purge the affected paths or wait for the cache to expire.
Common mistakes
- Relying on
static()inurls.pyto serve media in production. - Putting
MEDIA_ROOTinside the release directory. - Serving private documents from a public
/media/location. - Fixing permissions by hand without setting Django's upload permission settings, so the next upload breaks again.
Next steps
- If uploads fail to save at all, see Fix Permission Denied on Django Media Uploads.
- For CSS and JavaScript problems, see Django Static Files Not Loading in Production.
- Move uploads off the server with How to Store Django Media Files on Amazon S3.
- Understand the split in Django Static vs Media Files in Production.
FAQ
Why do Django media files return 404 in production?
Nothing is serving MEDIA_ROOT at MEDIA_URL. The static() helper only works with DEBUG = True, so production needs an Nginx location, another web server rule, or object storage.
Should Django serve media files in production?
No. Serving user uploads through Django ties up application workers and bypasses the web server's efficient file handling. Use Nginx, a CDN, or object storage, and use X-Accel-Redirect when access control is required.
Why do media files disappear after a deploy?
MEDIA_ROOT points inside the deployed code directory, which is replaced on each release. Move it to a stable path such as /srv/myapp/media or to object storage.
How do I serve private user files in Django?
Keep them out of the public media location and serve them through a view that checks permissions, optionally using X-Accel-Redirect so Nginx streams the file after Django authorizes the request.