Docker container can’t write uploads after switching from root to non-root user

2026-11-13 · 3 min read

Docker container can't write uploads after switching from root to non-root user

The app comes up fine, the login page loads, then the first file upload dies with PermissionError: [Errno 13] Permission denied on /app/uploads. Nothing else changed except the Dockerfile: you replaced the default root user with USER appuser. That one line is enough to break every write path in the container, and the error only shows up at runtime, after deploy, on the paths that matter.

Here's what's actually happening and how to fix it without going back to root.

Who owns the upload directory?

A bind mount doesn't inherit ownership from the image. -v ./uploads:/app/uploads overlays the host directory onto that path, and the container sees the host's UID/GID numbers — not the names. If host ./uploads is owned by 1000:1000 and your container user is appuser with UID 1001, the write fails regardless of what the Dockerfile says.

The same applies to the app's working directory when it's baked into the image: COPY --chown on a file and chown on a directory inside the Dockerfile decide who can write. If you ran USER appuser before dumping files in as root, those files belong to root, and the container user gets permission denied.

Symptoms to check before touching anything:

If those numbers disagree, that's your Docker permission denied uploads non-root case. It's arithmetic, not a Docker bug.

Fix the mismatch step by step

1. Pick one UID/GID and use it everywhere. For a bind mount, the simplest correct move is to run the container as the host directory's owner: docker run --user "$(id -u):$(id -g)" .... Check id -u on the host first; a non-root user on a typical Linux box gets 1000.

2. If you'd rather keep a fixed UID in the image, create the user with a matching number instead of letting adduser pick one: RUN groupadd -g 1000 app && useradd -u 1000 -g app -m app. Then chown -R app:app /app/uploads in the same layer.

3. Use COPY --chown=app:app for application files so code and writable directories land with the right owner. A plain COPY followed by USER is the most common source of this breakage.

4. Put USER appuser after the chown and COPY layers, never before. Docker applies subsequent layers as that user.

5. For named volumes, the volume is initialized from the image, so the ownership baked in at build time carries over — verify with docker volume inspect and a throwaway container running ls -ln.

6. Rebuild, redeploy, and re-test the actual upload path with a real file, not just curl -I.


FROM python:3.12-slim
RUN groupadd -g 1000 app && useradd -u 1000 -g app -m app
WORKDIR /app
COPY --chown=app:app . .
RUN mkdir -p /app/uploads && chown -R app:app /app/uploads
USER appuser

For directories the host needs to write to as well, chmod 2775 and a shared group keeps both sides working without giving the container wider access than it needs.

When NOT to do this

Don't chase UID matching if the volume is NFS with root_squash, or if the host is Docker Desktop on macOS — ownership mapping there is handled by the VM, and id -u on the Mac tells you nothing useful. Use a named volume and let the image set ownership instead. And skip the whole exercise if uploads are going to S3 or another object store: a container-local directory you never mount is already writable if you created it after USER.

Checklist

Once ownership and UID line up, the Docker permission denied uploads non-root error disappears and the container stays on a non-root user.

ad slot — paste the network tag here