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 Found for /media/avatars/2026/09/photo.jpg
  • 403 Forbidden for the same URL
  • broken image icons on pages that worked in development
  • a working URL for old files and 404 for 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?

Serving user uploadsUpload savedMEDIA_ROOTURL builtFileField.urlNginx /media/alias to MEDIA_ROOTBrowser200 or 404/403Django stores uploads but never serves them in production; the web server does.

Quick answer

  1. Confirm the file exists on the server: check MEDIA_ROOT and the exact relative path stored in the database.
  2. Confirm MEDIA_URL and MEDIA_ROOT are set, and remember that static() in urls.py does nothing when DEBUG = False.
  3. Serve /media/ from Nginx with an alias that points at MEDIA_ROOT, with matching trailing slashes.
  4. For 403, fix filesystem permissions: the Nginx user needs read access to the file and execute on every parent directory.
  5. Keep MEDIA_ROOT outside release directories so deploys do not replace it.
  6. For multiple servers or containers, move uploads to object storage with django-storages.
  7. Verify with curl -I on 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: False means the file is not in storage at all: the upload failed, the file was deleted, or MEDIA_ROOT changed between upload and now (a common result of storing media inside a release directory that deploys replace).
  • exists: True means the file is there, and the problem is in URL mapping or permissions.
  • url shows exactly which URL the browser requests, which you can then test with curl.

2. Check settings

MEDIA_URL = "/media/"
MEDIA_ROOT = "/srv/myapp/media"
two settings, two jobsControlsExampleMEDIA_ROOTwhere files are written/srv/myapp/mediaMEDIA_URLthe URL prefix in links/media/upload_tosubfolder inside MEDIA_ROOTavatars/%Y/%m/Nginx must map MEDIA_URL onto MEDIA_ROOT exactlydjango-deployment.com
MEDIA_ROOT must be an absolute path outside the release directory.

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;
    }
}
serving /media/ from Nginxlocation/media/{alias/srv/myapp/media/;expires 7d;add_header X-Content-Type-Options "nosniff" always;}1231Must equal MEDIA_URL.2Must equal MEDIA_ROOT, trailing slash on both sides.3Browsers will not guess an uploaded file is a script.alias replaces the prefix; root would append itdjango-deployment.com
A mismatched trailing slash is the most common cause of media 404s.

Details that cause 404 when wrong:

  • Trailing slashes must match. With location /media/ and alias /srv/myapp/media/, a request for /media/a/b.jpg maps to /srv/myapp/media/a/b.jpg. Dropping the slash from the alias produces /srv/myapp/mediaa/b.jpg.
  • alias versus root. With root /srv/myapp;, Nginx appends the whole URI, giving /srv/myapp/media/a/b.jpg as 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_URL and the location must agree. If MEDIA_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
every directory on the path matters/srvx for www-data (traverse)/srv/myappx for www-data/srv/myapp/mediar and x for www-dataavatars/2026/04/photo.jpgr for www-dataOne missing x bit anywhere on the path gives 403django-deployment.com
namei -l on the full file path shows every directory and its permissions at once.

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.
one server writes, another servesLocal disk per serverupload lands on server A✕request for it hits server B: 404✕containers lose files on redeployShared object storage✓every server writes to one bucket✓URLs point at the bucket or CDN✓survives any redeployIntermittent media 404s usually mean more than one serverdjango-deployment.com
django-storages with an S3-compatible backend is the usual fix.

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 200 with the right Content-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() in urls.py to serve media in production.
  • Putting MEDIA_ROOT inside 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

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.

Keep going