DevOps Advanced

Container Hardening for Django: Non-Root Images, Read-Only Filesystems, Dropped Capabilities and Image Scanning

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.

DjangoZen Team Sep 27, 2026 23 min read 2 views

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.

Threat model: what RCE in the container buys an attacker

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:

  • Persistence: if the filesystem is writable, they can patch a module that is imported on every request, or drop a web shell into a URLconf. That survives until the container is replaced. With long-lived containers, that can be weeks.
  • Tooling: gcc, curl, apt and a warm package cache in the image let them build or fetch kernel exploits, scanners and miners.
  • Privilege: without user namespaces, root in the container is UID 0 on the host kernel. Namespaces and cgroups keep it contained, but one kernel bug, a mounted 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.
  • Lateral movement: on a flat network they can reach Postgres, Redis and internal admin services directly. Secrets in environment variables or image layers give them the credentials.
  • Resource abuse: with no PID, memory or CPU limits, a fork bomb or a miner degrades every other container on the host.

A realistic scenario, with and without hardening

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.

A hardened multi-stage Dockerfile

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:

  • The venv is copied, not rebuilt. Both stages use the same base image, so the interpreter path inside /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.
  • Numeric 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.
  • No 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.
  • Standard-library healthcheck. There is no curl in the image. A connection error or a 4xx/5xx raises an exception, so the check exits non-zero.
  • --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.

The health endpoint, and why it stays shallow

# 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.

No secrets in layers, and a strict .dockerignore

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

Making Django work on a read-only root

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:

WriterDefault locationFix
Upload spill filestempfile default, usually /tmptmpfs on /tmp, sized for your upload limit
User mediaMEDIA_ROOTObject storage, or a named volume mounted only there
Bytecode__pycache__ next to modulescompileall at build plus PYTHONDONTWRITEBYTECODE=1
Gunicorn heartbeat, Celery beat scheduleTemp 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.

Runtime hardening with docker run

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.

A complete hardened docker-compose.yml

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.

Migrations as a one-off job

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.

Secrets at runtime: files, not environment variables

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.

Rootless Docker, Podman and user namespaces

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.

OptionGainCost
"userns-remap": "default" in daemon.jsonContainer 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 DockerThe daemon itself runs as deploy, so a daemon compromise is not rootNo 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 PodmanDaemonless 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.

Image scanning and SBOMs with Trivy

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.

Keeping images patched: pin, but move the pin

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.

  • Rebuild weekly even if the code has not changed, using docker build --pull --no-cache. Without --no-cache, a cached apt-get install layer happily reuses last month's libpq5.
  • Automate the digest bump. A scheduled job resolves the current digest for the tag, rewrites the PYTHON_IMAGE default, and opens a change. The test suite and the Trivy gate decide whether it merges. A human reviews only the failures.
  • Scan what is deployed, not only what was built. An image that was clean at release time collects new CVEs every week. Run trivy image nightly against the tags actually running, and alert on new fixable CRITICALs.
  • Tag immutably. Deploy 1.4.2 or a digest, never latest, so rollbacks, SBOMs and incident timelines refer to the exact bytes that ran.

Common mistakes

  • 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.
  • Mounting a writable volume over the code to silence a read-only error. Use docker diff to find the path being written, and give that path alone a tmpfs or a volume.
  • Tokens in 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.
  • A healthcheck that follows SECURE_SSL_REDIRECT or queries the database. The first can never pass. The second restarts every replica at once whenever Postgres blips.
  • Publishing 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.
  • Sizing the /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.

Verify it

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.