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.
- Create a user in the Dockerfile:
RUN useradd --system --uid 10001 --no-create-home app. - Copy files with
COPY --chown=app:appfor paths the app must write; leave code owned by root and read-only where possible. - Switch with
USER 10001(numeric IDs work with KubernetesrunAsNonRoot). - Bind Gunicorn to a high port such as 8000.
- Make writable volumes (media, temporary files) owned by the app UID, or set
fsGroupin Kubernetes. - Optionally add
read_only: true, a tmpfs for/tmp,cap_drop: [ALL], andno-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.
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"]
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 0gives a root shell for inspection without changing the image. - Base images such as distroless
nonrootvariants come with a non-root user preconfigured.
Next steps
- If volumes break after the switch, see Fix Docker Volume Permission Denied for Django.
- Scan the image for vulnerable packages with Scan Django Docker Images for Vulnerabilities With Trivy.
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.