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.
Quick answer
- Identify the user running the app server:
ps -o user= -C gunicorn | sort -uorUser=in the systemd unit. - Check
MEDIA_ROOTand the permissions of every directory on its path withnamei -l. - Put
MEDIA_ROOToutside the code directory, for example/srv/myapp/media, owned by the app user. - Give the web server read access through a shared group, and set
FILE_UPLOAD_PERMISSIONSandFILE_UPLOAD_DIRECTORY_PERMISSIONS. - Check systemd sandboxing options (
ProtectSystem,ReadWritePaths), Docker volume ownership, and SELinux labels. - 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
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 with2755if Nginx only needs to read - the leading
2is the setgid bit: new files and subdirectories inherit thewww-datagroup, 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
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
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
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 777on 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=strictto a unit withoutReadWritePathsfor media.
Next steps
- If files save but do not display, see Fix Django Media Files Not Serving.
- For read errors in Nginx, see Fix Nginx 403 Forbidden on Django Static Files.
- Remove local storage issues with How to Store Django Media Files on Amazon S3.
- If large uploads fail before reaching Django, see Fix Nginx 413 Request Entity Too Large.
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.