Fix Permission Denied on Docker Volumes for Django
Fix PermissionError Errno 13 when a non-root Django container writes to media or data volumes: numeric UIDs, named volume initialisation, bind mount ownership, SELinux labels, and rootless mappings.
Problem statement
After switching the Django container to a non-root user (as you should), uploads or other writes fail:
PermissionError: [Errno 13] Permission denied: '/app/media/uploads/2026/09/photo.jpg'
The same code worked when the container ran as root, and the directory looks writable from the host. The mismatch is between the numeric user ID the container process runs as and the numeric owner of the mounted directory. Names like app or www-data mean nothing across the container boundary; only UIDs and GIDs count. SELinux labels and rootless container UID mapping add two more ways to be denied. This guide checks each and fixes it without resorting to chmod 777.
- Check the container user and the mount's numeric ownership:
docker compose exec web idanddocker compose exec web ls -ldn /app/media. - For named volumes, create the directory in the Dockerfile and
chownit to the app user beforeUSER; a new named volume copies that ownership on first use. - For bind mounts,
chownthe host directory to the container's numeric UID and GID (for example1000:1000). - Use a fixed UID and GID for the app user in the Dockerfile so ownership is predictable across rebuilds and hosts.
- On SELinux hosts, add
:zor:Zto bind mounts. - With rootless Docker or Podman, account for UID mapping (
podman unshare chown, or:Uin Podman).
Step-by-step solution
1. Compare numbers, not names
docker compose exec web id
docker compose exec web ls -ldn /app/media /app/media/uploads
docker compose exec web touch /app/media/.write-test && echo writable
Typical output: the process runs as uid=1000(app) gid=1000(app), while /app/media is owned by 0 0 (root) with mode 755. The process cannot create files there.
Also check how the directory is mounted:
docker inspect --format '{{json .Mounts}}' "$(docker compose ps -q web)" | python3 -m json.tool
"Type": "volume" is a named volume managed by Docker; "Type": "bind" is a host directory.
2. Named volumes: set ownership in the image
When a new, empty named volume is mounted over a directory that exists in the image, Docker copies the directory's contents and ownership into the volume. So create and own the directory in the Dockerfile:
FROM python:3.12-slim
ARG APP_UID=1000
ARG APP_GID=1000
RUN groupadd --gid ${APP_GID} app && useradd --uid ${APP_UID} --gid app --create-home app
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p /app/media /app/staticfiles && chown -R app:app /app/media /app/staticfiles
USER app
This only applies when the volume is created. A volume created earlier (for example when the container still ran as root) keeps its old ownership. Fix it once with a root container:
docker compose run --rm --user root web chown -R 1000:1000 /app/media
3. Bind mounts: set ownership on the host
A bind mount shows the host directory as-is; nothing is copied or re-owned. Set its numeric ownership on the host:
sudo mkdir -p /srv/myapp/media
sudo chown -R 1000:1000 /srv/myapp/media
sudo chmod 750 /srv/myapp/media
services:
web:
volumes:
- /srv/myapp/media:/app/media
The host may have no user named app, or a different user with UID 1000; that is fine as long as the numbers match what the container uses.
If another container needs to read the files (for example Nginx serving media from the same directory), give it read access through a shared group or world-read permission on files, not write access:
sudo chmod 755 /srv/myapp/media
4. Keep UIDs stable
useradd without --uid assigns the next free UID, which can change if the base image changes. Pin it with build arguments as shown above. If you need the container user to match a specific host user (for development bind mounts), build with --build-arg APP_UID=$(id -u).
Compose's user: "1000:1000" runs the container with that UID regardless of the image's USER, which is useful for images you do not build, but files created in the image for another user may then be unwritable.
5. SELinux hosts
On Fedora, RHEL, and derivatives with SELinux enforcing, a bind mount can be denied even to root inside the container because the host directory has a label containers may not access. The kernel audit log shows an AVC denial. Relabel with a mount option:
volumes:
- /srv/myapp/media:/app/media:Z
:Z gives the content a private label for this container; :z a shared label usable by several containers (needed if Nginx and Django containers mount the same directory). Do not use these options on system directories like /home or /usr.
6. Rootless Docker and Podman
In rootless mode, container UIDs map to a range of subordinate UIDs on the host (from /etc/subuid). Container UID 1000 might be host UID 100999. A host directory owned by your normal user appears as root inside the container.
- Podman:
podman unshare chown -R 1000:1000 ./mediasets ownership as seen from inside the user namespace; or mount with:Uto have Podman chown the volume to the container user automatically. - Rootless Docker: run the
chownfrom inside a container (docker run --rm -v ./media:/m alpine chown -R 1000:1000 /m), so the mapping is applied.
7. Avoid chmod 777
Making the directory world-writable hides the ownership problem and lets any process on the host, or any other container sharing it, write files that your app may later serve. Correct numeric ownership achieves the same result safely.
Verification check:
docker compose exec web id
docker compose exec web ls -ldn /app/media
docker compose exec web python -c "open('/app/media/.write-test','w').write('ok'); print('ok')"
docker compose exec web rm /app/media/.write-test
Then upload a file through the site and confirm it appears in the host directory or volume, owned by the container's UID.
Explanation
Linux permission checks compare the process's numeric UID and GIDs with the file's owner, group, and mode bits. Containers share the host kernel, so the same numeric comparison happens for mounted directories; user names are just labels in each side's /etc/passwd. Named volumes are initialised from the image once, so ownership set in the Dockerfile carries over; bind mounts reflect the host exactly. SELinux adds a label check on top, and rootless modes shift the numbers, but in every case the fix is to make the numbers (and labels) agree.
Edge cases / notes
Static files volume
If collectstatic runs at container start into a volume shared with Nginx, the Django container needs write access and Nginx read access. Running collectstatic at build time instead and serving from the image (for example with WhiteNoise) removes the shared write path.
Object storage
Moving media to S3 or compatible storage removes local permission issues entirely and is needed anyway once you run more than one host.
When to automate this
Pin the UID and GID in the Dockerfile, create writable directories there, and include host directory creation and ownership in your provisioning script. A start-up check that writes and deletes a test file in each writable path turns silent permission problems into clear start-up failures.
Next steps
- For the same error on non-container servers, see Fix Permission Denied on Django Media Uploads.
- Build a non-root image with How to Dockerize a Django App for Production.
- Review volume layout in Deploy Django with Docker Compose in Production.
- For rootless containers under systemd, see Deploy Django with Podman and Quadlet.
FAQ
Why can't my non-root Django container write to a mounted volume?
The mounted directory is owned by a different numeric UID than the container process. Set ownership to the container user's UID and GID.
Why does a named volume keep root ownership after I changed the Dockerfile?
Named volumes copy ownership from the image only when first created. Fix existing volumes with a one-off chown as root.
Is chmod 777 an acceptable fix for Docker volumes?
No. It lets any process write to the directory. Set correct numeric ownership instead.
Why is a bind mount denied even to root on Fedora or RHEL?
SELinux blocks the container from the host directory's label. Add :Z or :z to the mount.