Run a Django Docker Container as a Non-Root User

Run Django containers as a non-root user: create a dedicated user in the Dockerfile, fix ownership of app, static, and media paths, handle volume permissions, bind high ports, and add a read-only root filesystem and dropped capabilities.

Problem statement

By default, processes in a Docker container run as root. If an attacker exploits a vulnerability in Django, a dependency, or an image library, they get root inside the container, which makes container escapes and tampering with the filesystem much easier. Running the app as an unprivileged user is a basic hardening step, and Kubernetes policies (Pod Security Standards "restricted") require it. Switching usually breaks something at first: files the app cannot write, volumes owned by root, or a port below 1024.

Root vs non-root containerDefault imageruns as rootAdd app userUSER app in DockerfileFix ownershipcode, media, tmpHarden furtherread-only, no capsA compromised Django process should hold as few privileges as possible.
  1. Create a user in the Dockerfile: RUN useradd --system --uid 10001 --no-create-home app.
  2. Copy files with COPY --chown=app:app for paths the app must write; leave code owned by root and read-only where possible.
  3. Switch with USER 10001 (numeric IDs work with Kubernetes runAsNonRoot).
  4. Bind Gunicorn to a high port such as 8000.
  5. Make writable volumes (media, temporary files) owned by the app UID, or set fsGroup in Kubernetes.
  6. Optionally add read_only: true, a tmpfs for /tmp, cap_drop: [ALL], and no-new-privileges.

Step-by-step solution

1. Dockerfile changes

FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
RUN useradd --system --uid 10001 --gid 0 --no-create-home --shell /usr/sbin/nologin app
WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN SECRET_KEY=build-only python manage.py collectstatic --noinput

RUN mkdir -p /app/media /app/tmp && chown -R 10001:0 /app/media /app/tmp && chmod -R g=u /app/media /app/tmp
USER 10001
EXPOSE 8000
CMD ["gunicorn", "-c", "gunicorn.conf.py", "config.wsgi:application"]

Code and collected static files stay owned by root and are only readable by the app, so a compromised process cannot modify them. Only media and tmp are writable. Using group 0 with group-writable directories also works on OpenShift, which assigns random UIDs in group 0.

2. Bind a high port

Unprivileged users cannot bind ports below 1024 on many systems. Gunicorn should listen on 8000, and the proxy or load balancer maps 80 and 443 to it.

who owns what inside the imageOwnerApp can write?/app coderoot✓no/app/staticfilesroot✓no/app/mediaapp (10001)!yes, or object storage/tmp or /app/tmpapp or tmpfs!yesvirtualenv / site-packagesroot✓noWritable paths should be the exception, not the defaultdjango-deployment.com
Store media in object storage and the app needs almost no writable paths.

3. Volume permissions

Named volumes inherit ownership from the image directory the first time they are created, so chown in the Dockerfile carries over. Bind mounts keep the host's ownership; make the host directory owned by UID 10001:

sudo chown -R 10001:10001 /srv/myapp/media

In Kubernetes, set fsGroup so mounted volumes are group-writable for the pod:

securityContext:
  runAsNonRoot: true
  runAsUser: 10001
  fsGroup: 10001
  seccompProfile: { type: RuntimeDefault }
containers:
  - name: web
    securityContext:
      allowPrivilegeEscalation: false
      readOnlyRootFilesystem: true
      capabilities: { drop: ["ALL"] }
    volumeMounts:
      - { name: tmp, mountPath: /tmp }
volumes:
  - { name: tmp, emptyDir: {} }

4. Read-only root filesystem

With a read-only root filesystem, anything that writes to disk fails: Python bytecode caches (PYTHONDONTWRITEBYTECODE=1 avoids them), Django's file-based caches or sessions, uploads streamed to temp files (FILE_UPLOAD_TEMP_DIR=/tmp), and libraries writing to $HOME. Mount /tmp as tmpfs or emptyDir and point those paths there. In Compose:

services:
  web:
    read_only: true
    tmpfs: ["/tmp"]
    cap_drop: ["ALL"]
    security_opt: ["no-new-privileges:true"]
container hardening, layer by layerNon-root user (USER 10001)the essential first stepRoot-owned code and staticapp cannot modify itselfRead-only root filesystemtmpfs for /tmpAll capabilities droppedno-new-privilegesSeccomp RuntimeDefaultfewer syscalls availableEach layer limits what an attacker can do after a compromisedjango-deployment.com
Add layers one at a time and test uploads and background tasks after each.

Verification check: docker run --rm myapp id prints uid=10001, uploads succeed, docker exec <container> touch /app/manage.py fails with permission denied, and Kubernetes admits the pod under the restricted Pod Security Standard.

Explanation

Container isolation relies on Linux namespaces and cgroups, but root inside a container is still UID 0 on the host kernel unless user namespaces are remapped. Running as an unprivileged UID means kernel permission checks, file ownership, and capability restrictions all limit a compromised process. Making code read-only and confining writes to a few explicit paths further reduces what an attacker can change, such as planting a backdoor in a Python module that the next request would import.

Edge cases / notes

  • Package installs at runtime fail as non-root; install everything at build time.
  • Celery runs fine as non-root; Beat's schedule file needs a writable path or the database scheduler.
  • Debugging with docker exec -u 0 gives a root shell for inspection without changing the image.
  • Base images such as distroless nonroot variants come with a non-root user preconfigured.

Next steps

FAQ

Why should Django containers not run as root?

A compromised process would have root inside the container, making escapes and tampering much easier.

Which port should Gunicorn use as non-root?

A high port such as 8000. The proxy or load balancer handles 80 and 443.

How do I fix permission errors on volumes?

Make the directory owned by the app UID, set ownership in the Dockerfile for named volumes, or use fsGroup in Kubernetes.

Can Django run with a read-only root filesystem?

Yes, with /tmp mounted as tmpfs, bytecode writing disabled, and uploads stored in object storage or a writable volume.

Learning path · step 3 of 12

Docker, from image to production

Keep going