Fix Permission Denied on Django Media Uploads

Fix PermissionError when Django saves uploads: service user ownership, MEDIA_ROOT location, upload file modes, systemd sandboxing, Docker volumes, and SELinux.

Problem statement

Uploading a file through a form or the admin fails with a 500 error, and the traceback ends with:

PermissionError: [Errno 13] Permission denied: '/srv/myapp/media/avatars/2026/09'

or:

PermissionError: [Errno 13] Permission denied: '/srv/myapp/media/avatars/2026/09/photo.jpg'
OSError: [Errno 30] Read-only file system: '/app/media'

Django's FileSystemStorage creates directories and writes files under MEDIA_ROOT as the operating system user running Gunicorn (or uWSGI, Uvicorn). If that user cannot create the date-based subdirectory or write the file, the upload fails. The problem usually appears only in production, because locally you run the development server as yourself, and you own the project directory.

This guide fixes the write side of uploads. If files save correctly but return 403 or 404 when viewed, that is a serving problem covered in the related guides.

Writing an uploaded fileRequestfile uploadDjango storagecreates directoriesApp userneeds write accessMEDIA_ROOToutside releasesSandboxing and umask settings decide whether the service may write at all.

Quick answer

  1. Identify the user running the app server: ps -o user= -C gunicorn | sort -u or User= in the systemd unit.
  2. Check MEDIA_ROOT and the permissions of every directory on its path with namei -l.
  3. Put MEDIA_ROOT outside the code directory, for example /srv/myapp/media, owned by the app user.
  4. Give the web server read access through a shared group, and set FILE_UPLOAD_PERMISSIONS and FILE_UPLOAD_DIRECTORY_PERMISSIONS.
  5. Check systemd sandboxing options (ProtectSystem, ReadWritePaths), Docker volume ownership, and SELinux labels.
  6. Verify by uploading a file through the application and reading it as the web server user.

Step-by-step solution

1. Confirm the user and path

systemctl cat gunicorn | grep -E "^User|^Group"
ps -o user=,group=,cmd= -C gunicorn | sort -u | head -3

Check what Django uses:

sudo -u myapp bash -c 'set -a; . /etc/myapp/env; set +a; cd /srv/myapp/app && \
  /srv/myapp/venv/bin/python manage.py shell -c "from django.conf import settings; print(settings.MEDIA_ROOT)"'

A relative or unexpected MEDIA_ROOT (for example inside a release directory that is replaced on every deploy) is itself a problem.

2. Inspect permissions along the path

namei -l /srv/myapp/media/avatars
what the app user needs on the path/srvx: traverse/srv/myappx: traverse/srv/myapp/mediaw and x, owned by the app user orgroupmedia/avatars/2026/created by Django, same ownernamei -l on the upload path shows every directory at oncedjango-deployment.com
A setgid bit on media keeps new directories in the shared group.

The app user needs:

  • execute (x) on every parent directory
  • write (w) and execute (x) on the directory where it creates files and subdirectories

Test directly:

sudo -u myapp touch /srv/myapp/media/.write-test && echo "writable" && sudo rm /srv/myapp/media/.write-test

3. Create a dedicated media directory

Keep uploads outside the code tree so deploys, git clean, and release directory rotation never touch them:

MEDIA_ROOT = "/srv/myapp/media"
MEDIA_URL = "/media/"

Create it with ownership that lets the app write and Nginx read:

sudo install -d -o myapp -g www-data -m 2775 /srv/myapp/media

The mode 2775 means:

  • owner (myapp) can read, write, and enter
  • group (www-data) can read, write, and enter; remove group write with 2755 if Nginx only needs to read
  • the leading 2 is the setgid bit: new files and subdirectories inherit the www-data group, so Nginx can read them without further changes

Fix existing content once:

sudo chown -R myapp:www-data /srv/myapp/media
sudo find /srv/myapp/media -type d -exec chmod 2775 {} +
sudo find /srv/myapp/media -type f -exec chmod 664 {} +

4. Set upload modes in Django

FILE_UPLOAD_PERMISSIONS defaults to 0o644, but FILE_UPLOAD_DIRECTORY_PERMISSIONS defaults to None, so directories Django creates (such as avatars/2026/09/) get their mode from the process umask, which may be restrictive under systemd or in hardened environments. Set both explicitly so behavior does not depend on how the process was started:

FILE_UPLOAD_PERMISSIONS = 0o644
FILE_UPLOAD_DIRECTORY_PERMISSIONS = 0o755
modes Django gives new uploadsFILE_UPLOAD_PERMISSIONS =0o644FILE_UPLOAD_DIRECTORY_PERMISSIONS =0o755121Owner writes, Nginx can read.2Directories Django creates for upload_to paths.Without these, the process umask decides, which differs between servicesdjango-deployment.com
The leading 0o matters: 644 without it is a different number.

With the setgid directory, files are group-owned by www-data and world-readable at 644. If you prefer not to make uploads world-readable on disk, use 0o640 and 0o750 together with the shared group.

FILE_UPLOAD_PERMISSIONS applies to files saved through FileSystemStorage. Large uploads are first streamed to a temporary file in FILE_UPLOAD_TEMP_DIR (or the system temp directory); the app user needs write access there too.

5. Check systemd sandboxing

Hardened units can make the filesystem read-only for the service:

[Service]
ProtectSystem=strict
ProtectHome=true
systemd can block writes tooProtectSystem=strict only✕whole filesystem read-only✕chmod does not help✕Read-only file system errorsWith ReadWritePaths✓ReadWritePaths=/srv/myapp/media✓everything else stays protected✓daemon-reload, then restartEROFS in the traceback points here, not at file modesdjango-deployment.com
systemctl cat gunicorn shows every sandboxing option in effect.

With ProtectSystem=strict, the entire filesystem is read-only for the service except paths you allow. Add the media and temp directories:

[Service]
ProtectSystem=strict
ReadWritePaths=/srv/myapp/media
PrivateTmp=true

The Read-only file system error in a traceback, despite correct Unix permissions, is the classic sign of this. ProtectHome=true makes /home inaccessible, which breaks apps deployed under home directories.

Reload and restart after changing the unit:

sudo systemctl daemon-reload
sudo systemctl restart gunicorn

6. Docker volumes

In containers, the app usually runs as a non-root user with a numeric UID, while a named volume or bind mount is owned by root:

docker compose exec web id
docker compose exec web ls -ld /app/media
who owns the volume?Owned byFixNamed volume, newroot, unless the image path existscreate and chown in the DockerfileBind mount from hostthe host directory ownerchown to the container UIDContainer usere.g. UID 1000 (appuser)!must match bothUIDs matter, not names: appuser inside may be nobody outsidedjango-deployment.com
docker exec id shows the UID the app really runs as.

Fixes:

  • create the directory with correct ownership in the Dockerfile before switching users, so a new named volume copies that ownership on first use:
RUN useradd --uid 10001 --create-home app \
    && mkdir -p /app/media \
    && chown app:app /app/media
USER app
  • for bind mounts, set ownership on the host to the container user's UID: sudo chown -R 10001:10001 /srv/myapp/media
  • if the image uses a read-only root filesystem, mount a writable volume at MEDIA_ROOT

7. SELinux

On RHEL-family systems, correct Unix permissions may still be denied by SELinux:

sudo ausearch -m avc -ts recent | grep -i denied
ls -Zd /srv/myapp/media

Label the directory as writable web content:

sudo semanage fcontext -a -t httpd_sys_rw_content_t "/srv/myapp/media(/.*)?"
sudo restorecon -Rv /srv/myapp/media

8. Consider object storage

If you run more than one app server or deploy containers, local media directories must be shared (NFS or similar) or uploads end up on only one machine. Object storage through django-storages avoids local permission issues entirely and scales across servers.

9. Verify

Upload a file through the real form or the admin, then:

ls -l /srv/myapp/media/avatars/$(date +%Y)/
sudo -u www-data head -c 1 /srv/myapp/media/avatars/$(date +%Y)/$(date +%m)/*.jpg >/dev/null && echo "nginx can read"
curl -sI https://www.example.com/media/avatars/$(date +%Y)/$(date +%m)/photo.jpg | head -1

Adjust paths to match your upload_to pattern.

Verification check:

  • the upload succeeds without errors in logs
  • the new file is owned by the app user with group www-data
  • the web server user can read it
  • the file URL returns 200

Explanation

Uploads are one of the few places a Django app writes to the filesystem at runtime. Everything else about a production deployment pushes toward read-only code directories, unprivileged users, and sandboxed services, which is good for security and exactly what breaks uploads when MEDIA_ROOT is not given explicit write access. A dedicated directory with a shared group, explicit Django modes, and matching sandbox exceptions lets the app write, the web server read, and nothing else change.

Edge cases / notes

Uploads work, thumbnails fail

Image processing libraries write generated files into cache directories (sometimes under MEDIA_ROOT/CACHE or similar). Those paths need the same write access.

Permission errors after a deploy

If deploys run as root or a different user and create media subdirectories, later uploads by the app user fail. Never create directories under MEDIA_ROOT in deploy scripts as another user, or fix ownership as part of the deploy.

Private files

Do not make sensitive uploads world-readable. Store them outside the Nginx-served path and serve them through a permission-checking view.

Testing uploads after every deploy

A permission regression is invisible until someone uploads a file. A management command that exercises the storage backend as the service user makes it visible immediately:

# core/management/commands/check_media_storage.py
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone


class Command(BaseCommand):
    help = "Write, read, and delete a small file in the default storage."

    def handle(self, *args, **options):
        name = f"healthchecks/{timezone.now():%Y/%m}/storage-check.txt"
        try:
            saved = default_storage.save(name, ContentFile(b"ok"))
            with default_storage.open(saved) as handle:
                content = handle.read()
            default_storage.delete(saved)
        except OSError as exc:
            raise CommandError(f"media storage check failed: {exc}") from exc
        if content != b"ok":
            raise CommandError("media storage returned unexpected content")
        self.stdout.write(self.style.SUCCESS(f"media storage OK ({saved})"))

It creates a date-based subdirectory the same way upload_to patterns do, which catches directory permission problems as well as file permission problems. Run it in the deploy with the service user and environment, for example through a wrapper script.

ACLs as an alternative to shared groups

If you cannot add the web server user to the application's group, POSIX ACLs grant access precisely, including for files created later:

sudo setfacl -R -m u:www-data:rX /srv/myapp/media
sudo setfacl -R -d -m u:www-data:rX /srv/myapp/media
getfacl /srv/myapp/media | head -12

The first command applies read and traverse permissions to existing content; the -d variant sets default ACLs that new files and directories inherit. The capital X grants execute only on directories (and files that already have an execute bit), so uploaded files do not become executable.

Common mistakes

  • Running chmod -R 777 on the media directory, which lets any local process tamper with uploads.
  • Storing media inside the release directory, so each deploy "loses" uploads.
  • Fixing permissions manually without setting FILE_UPLOAD_PERMISSIONS, so new uploads break again.
  • Adding ProtectSystem=strict to a unit without ReadWritePaths for media.

Next steps

FAQ

Why does Django get Permission denied when saving uploaded files?

The app server user cannot create directories or write files under MEDIA_ROOT. Make the directory owned by that user and ensure every parent directory is traversable.

Should MEDIA_ROOT be inside the Django project directory?

No. Put it in a stable location such as /srv/myapp/media, outside code and release directories, so deploys never replace or delete uploads.

What do FILE_UPLOAD_PERMISSIONS and FILE_UPLOAD_DIRECTORY_PERMISSIONS do?

They set the numeric modes Django applies to newly saved upload files and the directories it creates, instead of depending on the process umask.

Why do I get Read-only file system errors with correct permissions?

systemd hardening options such as ProtectSystem=strict make most of the filesystem read-only for the service. Add ReadWritePaths= for MEDIA_ROOT, or check whether a container runs with a read-only root filesystem.

Keep going