Turn an RCE in your Django app into a contained, unprivileged, read-only process: a digest-pinned multi-stage Dockerfile, a hardened Compose stack with dropped capabilities and an isolated database, file-based secrets, and Trivy scanning with SBOMs in CI.
The typical Django image runs as root, with a writable filesystem, a compiler, every default capability and a base layer that has not been patched in months. When a vulnerable dependency or a deserialisation bug gives an attacker code execution, those defaults decide whether the damage stays inside one process or reaches the host and your data. Hardening is cheap and mostly declarative, and it makes most post-exploitation steps fail loudly. It does add some friction around writable paths, secrets and debugging, and you need to plan for that rather than discover it during an incident.
Start with the assumption that the attacker can run arbitrary code as whatever user Gunicorn runs as. This is not a far-fetched scenario. It is the normal consequence of a vulnerable image-parsing library, an unsafe pickle load of cache data, a template injection, or a compromised package in your dependency tree. What they can do next depends entirely on your defaults:
gcc, curl, apt and a warm package cache in the image let them build or fetch kernel exploits, scanners and miners.docker.sock, a writable host bind mount or --privileged turns it into host root. The default capability set (CHOWN, DAC_OVERRIDE, NET_RAW, SETUID and others) gives them more to work with.Say an attacker uploads a crafted file, and a parsing bug gives them a Python shell inside a worker. On the default image they run id and see uid=0(root). They append a credential-harvesting middleware to your settings module, apt-get install a scanner, and connect straight to Postgres on its published port. If the host also exposes the Docker socket for a monitoring agent, they start a new container with -v /:/host and own the machine.
On the hardened image, id returns uid=10001(app). Writing to /srv/app fails with Read-only file system. There is no compiler and no package manager they can use, because apt-get needs root and no-new-privileges blocks every setuid path back to it. A binary downloaded into /tmp will not execute, because the mount is noexec. A fork bomb hits the PID limit and fails on its own. They can still do real damage. They can read the secret files mounted for the app, query the database with the app's own role, and run Python scripts from /tmp, since noexec does not stop an interpreter from reading a file. Container hardening limits the escalation, not the app's legitimate access. It sits alongside least-privilege database roles, egress filtering and patching, and does not replace them.
Compilers and headers stay in the build stage. The runtime stage gets only the virtualenv, the code and the shared libraries it needs. The base image is pinned by digest, so python:3.13-slim cannot change under you between two builds of the same commit. Get the current digest with docker buildx imagetools inspect python:3.13-slim and keep the tag in the reference for readability. When both are present, the digest wins.
# syntax=docker/dockerfile:1
ARG PYTHON_IMAGE=python:3.13-slim@sha256:REPLACE_WITH_CURRENT_DIGEST
FROM ${PYTHON_IMAGE} AS build
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential libpq-dev \
&& rm -rf /var/lib/apt/lists/*
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH" PIP_NO_CACHE_DIR=1
COPY requirements.txt /tmp/requirements.txt
# The token-bearing index URL exists only for this RUN step
RUN --mount=type=secret,id=pip_index_url \
PIP_INDEX_URL="$(cat /run/secrets/pip_index_url)" \
pip install --require-hashes -r /tmp/requirements.txt
FROM ${PYTHON_IMAGE} AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/opt/venv/bin:$PATH" \
DJANGO_SETTINGS_MODULE=myproject.settings.production
RUN apt-get update \
&& apt-get install -y --no-install-recommends libpq5 \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --system --gid 10001 app \
&& useradd --system --uid 10001 --gid app --no-create-home \
--home-dir /nonexistent --shell /usr/sbin/nologin app
COPY --from=build /opt/venv /opt/venv
WORKDIR /srv/app
# Owned by root: the app user can read its code but not rewrite it
COPY . /srv/app
RUN DJANGO_SECRET_KEY=collectstatic-only python manage.py collectstatic --noinput \
&& python -m compileall -q /srv/app
USER 10001:10001
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz/', timeout=4)"]
CMD ["gunicorn", "myproject.wsgi:application", "--bind", "0.0.0.0:8000", \
"--workers", "3", "--worker-tmp-dir", "/dev/shm", "--access-logfile", "-"]
The decisions worth defending in code review:
/opt/venv matches and compiled extensions link against the same glibc. If you change one stage's base without the other, you get import errors at runtime. That is one reason to keep the digest in a single ARG.USER 10001:10001. Files written to volumes always get the same owner, host tooling can grant access by number, and a platform that enforces non-root can check the value without resolving a name from /etc/passwd.chown -R app on the code. COPY leaves files owned by root and world-readable, which is exactly what you want. The app needs to read its code, not modify it.compileall plus PYTHONDONTWRITEBYTECODE. Bytecode is built into the image, so nothing tries to write __pycache__ to a read-only root.collectstatic at build time. Static files become part of the immutable image. The dummy secret key is set only in that RUN step's environment, so it never reaches image config. Your production settings must not do anything at import time that needs the database or real secrets, otherwise this step fails.--require-hashes. A compromised or spoofed index cannot substitute a different wheel. Generate the hashes with pip-compile --generate-hashes. Every transitive dependency needs a hash, which is the point.# myproject/health.py
from django.http import HttpResponse
from django.views.decorators.http import require_GET
@require_GET
def healthz(request):
return HttpResponse("ok", content_type="text/plain")
# myproject/urls.py
from django.urls import path
from myproject.health import healthz
urlpatterns = [
path("healthz/", healthz),
# ...
]
# myproject/settings/production.py
SECURE_SSL_REDIRECT = True
SECURE_REDIRECT_EXEMPT = [r"^healthz/$"]
Two edge cases catch people out. With SECURE_SSL_REDIRECT enabled, the plain-HTTP healthcheck gets a 301 to https://127.0.0.1:8000/. urllib follows the redirect, which fails, and the container is marked unhealthy forever. Hence the exemption. Also, 127.0.0.1 must be in ALLOWED_HOSTS, or Django answers with a 400. Keep the check shallow on purpose. If it queries the database, a Postgres restart marks every web container unhealthy at the same moment. Whatever acts on health status then restarts all of them, which turns a short outage into a longer one. Report database health through monitoring, not through liveness.
ARG and ENV values are recorded in image history. Every COPY becomes a layer. Deleting a file in a later layer does not remove it from the earlier one, and anyone who can pull the image can unpack the layers and read it. A BuildKit secret mount exposes the file at /run/secrets/<id> for a single RUN step and never commits it:
docker build --pull \
--secret id=pip_index_url,src=/srv/app/.secrets/pip_index_url \
-t registry.example.com/myproject:1.4.2 .
The build context is the other leak. Without a .dockerignore, COPY . ships your .env file, local SQLite databases, and the entire .git history, including every secret that was ever committed and then “removed”:
.git
.env
.env.*
.secrets/
*.sqlite3
__pycache__/
.venv/
node_modules/
media/
docker-compose*.yml
A read-only root filesystem is the control most likely to break something, so find out what writes before you turn it on. Run the container normally, exercise the application (log in, upload a file, trigger a task), then ask Docker what changed:
docker diff "$(docker compose ps -q web)"
# C /tmp
# A /tmp/tmpk2x9w1ab.upload.jpg
# A /srv/app/myproject/__pycache__/local_settings.cpython-313.pyc
Every A or C line is a path that needs a decision. Most Django writes fall into four groups:
| Writer | Default location | Fix |
|---|---|---|
| Upload spill files | tempfile default, usually /tmp | tmpfs on /tmp, sized for your upload limit |
| User media | MEDIA_ROOT | Object storage, or a named volume mounted only there |
| Bytecode | __pycache__ next to modules | compileall at build plus PYTHONDONTWRITEBYTECODE=1 |
| Gunicorn heartbeat, Celery beat schedule | Temp dir or working directory | --worker-tmp-dir /dev/shm, celery beat --schedule /tmp/celerybeat-schedule |
A tmpfs is backed by RAM and counts towards the container's memory limit. If FILE_UPLOAD_MAX_MEMORY_SIZE is the default 2.5 MB and users upload 200 MB videos, uploads fill a 64 MB /tmp and fail with ENOSPC. Worse, a larger tmpfs pushes the container towards the OOM killer. Either cap upload size at the reverse proxy and in Django (DATA_UPLOAD_MAX_MEMORY_SIZE does not cover file content, so enforce the limit in nginx with client_max_body_size), or point FILE_UPLOAD_TEMP_DIR at a dedicated disk-backed volume. Never make the whole root writable to fix one path.
The same policy as plain flags. This is useful for understanding each control on its own before you move it into Compose:
docker run -d --name myproject-web \
--user 10001:10001 \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,nodev,size=64m \
--cap-drop ALL \
--security-opt no-new-privileges:true \
--pids-limit 256 --memory 512m --cpus 1.0 \
-p 127.0.0.1:8000:8000 \
registry.example.com/myproject:1.4.2
--user repeats the image's USER at runtime. If someone ships an image that forgets it, the run still does not happen as root.--read-only mounts the root filesystem read-only. /dev/shm is already a tmpfs, which is why Gunicorn's heartbeat files go there. /tmp gets a size-capped tmpfs with noexec, nosuid and nodev.--cap-drop ALL empties the bounding set. A non-root process already has no effective capabilities, but the bounding set is also the ceiling for anything it might gain through a setuid binary or file capabilities. A non-root Gunicorn on port 8000 needs nothing. Keep the high port and let the reverse proxy handle 443, rather than adding back NET_BIND_SERVICE.no-new-privileges sets the kernel's no_new_privs flag, so execve never grants privileges through setuid or file capabilities. Together with the empty bounding set, su, sudo or a planted helper is a dead end.--pids-limit counts threads as well as processes. With gthread workers, allow for workers × threads plus headroom. 256 is generous for three sync workers and far too few to sustain a fork bomb.--memory and --cpus contain a runaway request. When a worker exceeds the memory limit, the kernel OOM-kills it inside the cgroup and the Gunicorn master starts a new one. Size the limit from observed resident memory per worker, not from guesses. With --cpus 1.0, the usual “2 × cores + 1” rule means three workers, not a count based on the host's cores.Keep the default seccomp and AppArmor profiles. Docker's default seccomp profile blocks dozens of syscalls that Django never needs, such as mount, kexec_load and keyctl, which are a common first step in kernel exploits. Never use --privileged or seccomp=unconfined to make an error go away. Find the one missing permission instead.
services:
web:
image: registry.example.com/myproject:1.4.2
build:
context: .
secrets:
- pip_index_url
user: "10001:10001"
read_only: true
volumes:
- type: tmpfs
target: /tmp
tmpfs:
size: 67108864
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
pids_limit: 256
mem_limit: 512m
cpus: 1.0
environment:
DJANGO_ALLOWED_HOSTS: app.example.com,127.0.0.1
DJANGO_SECRET_KEY_FILE: /run/secrets/django_secret_key
DATABASE_HOST: db
DATABASE_USER: myproject
DATABASE_PASSWORD_FILE: /run/secrets/db_password
secrets: [django_secret_key, db_password]
ports:
- "127.0.0.1:8000:8000"
networks: [frontend, backend]
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:17@sha256:REPLACE_WITH_CURRENT_DIGEST
environment:
POSTGRES_DB: myproject
POSTGRES_USER: myproject
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets: [db_password]
volumes:
- pgdata:/var/lib/postgresql/data
cap_drop: [ALL]
cap_add: [CHOWN, DAC_OVERRIDE, FOWNER, SETGID, SETUID]
security_opt:
- no-new-privileges:true
pids_limit: 512
mem_limit: 1g
shm_size: 256m
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myproject -d myproject"]
interval: 10s
retries: 5
networks: [backend]
restart: unless-stopped
networks:
frontend: {}
backend:
internal: true
volumes:
pgdata: {}
secrets:
pip_index_url:
file: ./.secrets/pip_index_url
django_secret_key:
file: ./.secrets/django_secret_key
db_password:
file: ./.secrets/db_password
The backend network is internal. It has no route out, and the database publishes no port, so Postgres is reachable only from containers attached to backend. The web service joins both networks because it needs Postgres and it needs to be reachable. A Celery worker that does not talk to external APIs could join only backend and would have no outbound internet at all, which is a cheap and effective egress control.
The web port is published on 127.0.0.1 only, for an nginx on the host. A bare 8000:8000 binds to all interfaces, and Docker's own iptables rules put the port in front of any host firewall rules you wrote with a front-end tool. The Postgres entrypoint starts as root, fixes ownership of its data directory and drops to the postgres user, so it gets a short list of capabilities back. That still removes NET_RAW, MKNOD, SYS_CHROOT and SETFCAP.
There is no /var/run/docker.sock mount anywhere, and there should never be one in an application stack. Anything that can talk to the socket can start a privileged container with / bind-mounted, so socket access is root on the host. A read-only mount (:ro) does not help, because the API is still fully usable over a read-only socket file. If a log shipper or dashboard demands the socket, run it on a different host or through a proxy that allows only the specific read-only API calls it needs.
Keep this file production-only. Developer conveniences such as a writable bind mount of the source, runserver and published database ports belong in a docker-compose.override.yml, which Compose merges automatically in development and which you do not deploy. Otherwise the hardening gets loosened one “temporary” change at a time.
Do not run migrate in the web container's entrypoint. It races when you scale to several replicas, and it forces the long-running role to hold DDL rights. Run it as a one-off job from the same image and the same hardening, ideally with a separate database role that owns the schema:
docker compose run --rm \
-e DATABASE_USER=myproject_owner \
-e DATABASE_PASSWORD_FILE=/run/secrets/db_owner_password \
web python manage.py migrate --noinput
This assumes you added a db_owner_password secret to the service. The runtime myproject role then needs only SELECT, INSERT, UPDATE and DELETE on the application tables. That is the control that limits what the attacker in the earlier scenario can do with the credentials they read.
Environment variables are the default, and they leak in more places than people expect. docker inspect prints them for anyone with Docker access. Every subprocess inherits them. Error reporters, debug pages and “print the config” management commands like to dump them. Secret files stay out of all of those, and they can be rotated by replacing the file and restarting, without a rebuild. They do not stop an attacker who already runs as the app user, because that attacker can read /run/secrets just as the app does. Read *_FILE first and fall back to the environment for local development:
# myproject/settings/production.py
import os
from pathlib import Path
def secret(name: str) -> str:
path = os.environ.get(f"{name}_FILE")
if path:
return Path(path).read_text(encoding="utf-8").strip()
return os.environ[name]
SECRET_KEY = secret("DJANGO_SECRET_KEY")
ALLOWED_HOSTS = os.environ["DJANGO_ALLOWED_HOSTS"].split(",")
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"HOST": os.environ["DATABASE_HOST"],
"NAME": "myproject",
"USER": os.environ["DATABASE_USER"],
"PASSWORD": secret("DATABASE_PASSWORD"),
}
}
The .strip() matters. Most editors add a trailing newline, and a secret key with a newline is a different key, which silently invalidates every session and signed token. Plain Compose (without Swarm) implements file secrets as bind mounts. The long-syntax uid, gid and mode options are not applied, so the host file's own permissions apply inside the container, and UID 10001 must be able to read it:
sudo install -d -m 0750 -o root -g 10001 /srv/app/.secrets
sudo chown 10001:10001 /srv/app/.secrets/django_secret_key
sudo chmod 0400 /srv/app/.secrets/django_secret_key
The Postgres entrypoint reads db_password as root before dropping privileges, so a root-owned 0400 file works for the database. The web container, however, reads the same file as UID 10001. Either give that file group 10001 and mode 0440, or use separate files for the two consumers.
A non-root USER protects you from the application. User namespaces protect you from a container escape, because UID 0 inside the container maps to an unprivileged UID on the host. Even a full breakout lands the attacker as a nobody.
| Option | Gain | Cost |
|---|---|---|
"userns-remap": "default" in daemon.json | Container root maps to a subordinate UID range from /etc/subuid. Workflows do not change. | Applies to the whole daemon. Existing images and volumes are stored separately after the switch. Bind mounts need ownership that matches the remapped range. Some features need --userns=host per container. |
| Rootless Docker | The daemon itself runs as deploy, so a daemon compromise is not root | No ports below 1024 unless you lower net.ipv4.ip_unprivileged_port_start. User-mode networking is slower. Resource limits need cgroup v2 with delegation. Tied to a login session unless lingering is enabled. |
| Rootless Podman | Daemonless and rootless by default. --userns=keep-id simplifies volume ownership. | Different tooling and systemd integration to learn. Compose support goes through a compatibility layer. |
For a single-host Django deployment you control, a non-root image plus the runtime flags above covers most of the realistic risk. The remaining escape paths need a kernel bug, and you should be patching the kernel anyway. Add a user namespace when the host runs multi-tenant or untrusted workloads, when policy forbids a root daemon, or when a container escape would expose much more than this one application. Skip it if the friction would push the team into disabling other controls.
Scan two things. Scan the built image, which catches Debian packages in the base and the Python packages actually installed in the venv. Scan the source tree, which catches vulnerable pins in your requirements, Dockerfile misconfigurations such as a missing USER, and committed secrets. Run both before pushing, so a failing image never reaches the registry:
#!/bin/sh
# ci/build-and-scan.sh
set -eu
VERSION="$1"
IMAGE="registry.example.com/myproject:${VERSION}"
trivy fs --scanners vuln,misconfig,secret \
--severity HIGH,CRITICAL --exit-code 1 .
docker build --pull \
--secret id=pip_index_url,src="${PIP_INDEX_URL_FILE}" \
-t "${IMAGE}" .
trivy image --severity HIGH,CRITICAL --ignore-unfixed \
--exit-code 1 "${IMAGE}"
trivy image --format cyclonedx \
--output "sbom-${VERSION}.cdx.json" "${IMAGE}"
docker push "${IMAGE}"
--ignore-unfixed is a deliberate trade-off. Debian-based images always carry some HIGH findings with no patched package available. A gate that fails on those cannot be passed, so it gets marked allow-failure within a week and stops protecting anything. Gate on what you can fix, and review the unfixed ones on a schedule. When you knowingly accept a fixable finding, for example a vulnerable code path your app never reaches, record it in .trivyignore with a reason and an expiry, so the exception is visible in code review:
# .trivyignore
# Library only used by the admin PDF export, input is staff-only.
# Owner: platform team. Re-review by 2026-11-30.
CVE-2026-XXXXX
Keep the CycloneDX SBOM with the release, keyed by image digest. When the next widely exploited library bug is announced, trivy sbom sbom-1.4.2.cdx.json re-checks the shipped contents against the current vulnerability database without pulling or rebuilding anything. The vulnerability database is itself a download, so cache it between CI runs with --cache-dir to keep scans fast.
Pinning the base by digest gives reproducible builds and protects you from a surprise upstream change. It also means you get no security fixes until you move the pin. Treat the digest like a lockfile entry: pinned, but updated on purpose and often.
docker build --pull --no-cache. Without --no-cache, a cached apt-get install layer happily reuses last month's libpq5.PYTHON_IMAGE default, and opens a change. The test suite and the Trivy gate decide whether it merges. A human reviews only the failures.trivy image nightly against the tags actually running, and alert on new fixable CRITICALs.1.4.2 or a digest, never latest, so rollbacks, SBOMs and incident timelines refer to the exact bytes that ran.chown -R app /srv/app after USER app. The process can now rewrite its own code, which brings back the persistence problem the non-root user was meant to remove.docker diff to find the path being written, and give that path alone a tmpfs or a volume.ARG or ENV “only in the build stage”. Build args are recorded in history, and a pushed or cached builder image leaks them. Use --mount=type=secret.SECURE_SSL_REDIRECT or queries the database. The first can never pass. The second restarts every replica at once whenever Postgres blips.5432:5432 “for debugging”. Docker's iptables rules can bypass the host firewall and expose the database publicly. Use docker compose exec db psql instead./tmp tmpfs without looking at upload limits. Large uploads either fail with ENOSPC or push the container into the OOM killer. Tmpfs pages count as container memory.Run these against the running stack. Each command proves one control is in place rather than merely configured:
# Non-root, with the expected numeric IDs
docker compose exec web id
# uid=10001(app) gid=10001(app) groups=10001(app)
# Root filesystem is read-only; /tmp is writable but not executable
docker compose exec web touch /srv/app/x # Read-only file system
docker compose exec web sh -c 'cp /bin/true /tmp/t && /tmp/t' # Permission denied
# No capabilities, no_new_privs set
docker compose exec web grep -E 'CapEff|CapBnd|NoNewPrivs' /proc/self/status
# CapEff: 0000000000000000
# CapBnd: 0000000000000000
# NoNewPrivs: 1
# Engine view of the policy and health
docker inspect --format '{{.HostConfig.ReadonlyRootfs}} {{.HostConfig.CapDrop}} {{.HostConfig.PidsLimit}} {{.State.Health.Status}}' \
"$(docker compose ps -q web)"
# Secrets are files, not environment values
docker compose exec web env | grep -i -E 'secret|password' # only *_FILE paths
# Database is not published, and the image history holds no tokens
docker compose port db 5432 # no published port
docker history --no-trunc registry.example.com/myproject:1.4.2 | grep -i -E 'token|index_url'
# Scan gate
trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 \
registry.example.com/myproject:1.4.2; echo "exit=$?"
If every check comes back as shown, an RCE in your Django app gets the attacker an unprivileged process with no capabilities and no writable code. It cannot escalate, cannot exhaust the host, and cannot reach the database from outside the private network. It runs from an image that was rebuilt and scanned this week, and it holds only the database privileges the app needs. The bug still needs fixing, but the incident stays small enough to fix calmly.