Troubleshooting
Docker & Compose

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.

Who owns the mounted filesContainer useruid 1000Mount point/app/mediaHost directoryowned by uid 0Kernel checknumeric IDs onlyPermissions compare numbers; app in the container may be a different uid on the host.
  1. Check the container user and the mount's numeric ownership: docker compose exec web id and docker compose exec web ls -ldn /app/media.
  2. For named volumes, create the directory in the Dockerfile and chown it to the app user before USER; a new named volume copies that ownership on first use.
  3. For bind mounts, chown the host directory to the container's numeric UID and GID (for example 1000:1000).
  4. Use a fixed UID and GID for the app user in the Dockerfile so ownership is predictable across rebuilds and hosts.
  5. On SELinux hosts, add :z or :Z to bind mounts.
  6. With rootless Docker or Podman, account for UID mapping (podman unshare chown, or :U in 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
compare numbers, not namesuid=1000(app) gid=1000(app)drwxr-xr-x20 04096 /app/media1231The process user inside the container.2Owned by root: UID 1000 cannot write.3Mode 755: only the owner writes.ls -ln shows numeric IDs, which is what the kernel comparesdjango-deployment.com
Names inside and outside the container can map to different numbers.

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
the first mount decides ownershipVolume createdemptyFirst mountimage path copied inOwnership copied toofrom the image directoryPath missing in image?volume owned by rootFixmkdir and chown in the DockerfileAn existing volume keeps its ownership; fix it once by handdjango-deployment.com
docker run --rm -v vol:/data alpine chown fixes an existing volume.

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
SELinux needs a label on bind mountsEffect:z✓shared label: several containers may use it:Z✓private label: this container onlyno option✕denied on enforcing hostsNever :Z a system directory like /home or /etcdjango-deployment.com
Relabelling a system directory can break the host itself.

: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 ./media sets ownership as seen from inside the user namespace; or mount with :U to have Podman chown the volume to the container user automatically.
  • Rootless Docker: run the chown from 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.

777 hides the problemchmod -R 777✕any process can write or replace files✕uploads can be tampered with✕breaks again on the next volumeMatching ownership✓chown to the app UID✓modes 755 and 644✓stable UID in the DockerfileFix who owns it, not who is allowed to writedjango-deployment.com
A fixed UID like 10001 keeps host and container in agreement.

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

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.

Keep going