Zum Inhalt

Multi-Stage Dockerfile with Non-Root Runtime

Einordnung ins Overtime-Projekt

Damit ein kompromittierter Overtime-Backend-Container möglichst wenig Angriffsfläche bietet, darf das Laufzeit-Image keine Build-Werkzeuge enthalten und nicht als root laufen. Dieses ADR beschreibt den Multi-Stage-Aufbau des Dockerfiles.


1. Kontext & Problemstellung (Deutsch)

Container-Images, die auf herkömmliche Weise ("Single-Stage") und mit Root-Rechten gebaut werden, stellen ein erhebliches Sicherheitsrisiko dar. In solchen Images verbleiben sämtliche Werkzeuge, die nur für den initialen Build benötigt wurden (wie Compiler, Paketmanager oder Build-Caches), auch im finalen Produktions-Image.

Sollte es einem Angreifer gelingen, eine Schwachstelle in der Applikation (z. B. Remote Code Execution) auszunutzen, stehen ihm diese Werkzeuge für weitreichende laterale Bewegungen innerhalb der Infrastruktur zur Verfügung. Läuft der Container-Prozess zudem noch als UID 0 (root), erhöht sich der sogenannte Blast Radius massiv, da ein Ausbruch aus dem Container (Container Escape) auf das Host-System deutlich erleichtert wird.

Für eine sichere Laufzeitumgebung muss die Pipeline statische Abhängigkeiten (wie uv sync oder Django collectstatic) zwar bauen können, das finale Image darf diese Build-Umgebung jedoch nicht mehr enthalten und der ausführende Prozess darf keinerlei administrative Privilegien besitzen.


2. Architecture Decision Record (English)

ADR-005: Multi-Stage Dockerfile with Non-Root Runtime

Status

Accepted

Context and Problem Statement

Container images built naively (single-stage, running as root) carry the full build toolchain into production and run with UID 0. Both expand the blast radius of a container escape or code execution vulnerability. A secure architecture requires that the runtime image is completely decoupled from the build tools and that execution privileges are strictly minimized.

Decision Drivers

  • Minimal Attack Surface: The runtime image must not contain compilers, package managers (pip, zypper), or build caches.
  • Principle of Least Privilege: The application process inside the container must not run as UID 0.
  • Build-Time Compilation: Static files (Django collectstatic) and dependency compilation (uv sync) must still execute seamlessly during the pipeline build phase.

Considered Options

  • Single-stage build, root user — Simplest to implement, but fails all fundamental security requirements.
  • Single-stage build, non-root user — Removes the root runtime execution but still ships dangerous build tools (compilers, package managers) into production.
  • Multi-stage build, non-root runtime — The builder stage handles compilation and fetching; the runtime stage copies only the final, necessary artifacts and strictly drops privileges.

Decision Outcome

Chosen: Multi-stage build with non-root runtime.

We implement a two-stage layout based on the SUSE BCI architecture defined in ADR-002:

Stage Base image Purpose
builder registry.suse.com/bci/python:3.14-base Install uv, sync dependencies, execute collectstatic
runtime registry.suse.com/bci/python:3.14-micro Run Gunicorn exclusively as UID 65532

UID 65532 is the conventional "nobody" UID in minimal/distroless images. It has no home directory, no shell, and no write access outside of explicitly defined application directories (like /app). Files are transferred between stages using --chown=65532:65532 to ensure the runtime user explicitly owns only what it needs to execute.

Consequences & Trade-offs

Positive Consequences
  • Exploitation Mitigation: The runtime image ships no pip, no zypper, and no compilers, severely reducing the exploitable surface of a process-level RCE.
  • No Interactive Foothold: Without a shell in the -micro base, commands like kubectl exec -it fail to provide an interactive terminal for attackers.
  • Secure File Ownership: Utilizing --chown during the COPY instruction eliminates the need for post-start chown commands (which require root) and prevents world-writable files.
  • Controlled Write Access: Necessary write locations (like the Prometheus multiproc directory at /app/prometheus_multiproc) are pre-created in the builder stage and securely owned by the unprivileged runtime user.
Negative Consequences / Trade-offs
  • Harder Debugging: Debugging a running container in production is significantly harder. It requires either utilizing kubectl debug with ephemeral containers or building a specific debug variant based on the -base image.
  • Immutable Static Assets: Because collectstatic is executed at image build time, any change to static files requires a completely new image build. This is highly intentional (Immutable Infrastructure Principle), but increases deployment cycle time slightly.

3. Pipeline Implementation & Code Snippet (English)

The following Dockerfile (e.g., overtime-backend/Dockerfile) implements the multi-stage architecture and enforces the non-root execution context:

ARG BUILDER_IMAGE=registry.suse.com/bci/python:3.14-base@sha256:cfe34a1d6a49d7da8c06e8173afb38fc77f23092cadfe088db17ef5a01154936
ARG RUNTIME_IMAGE=registry.suse.com/bci/python:3.14-micro@sha256:3338e229873f5d4659efa0ce04616725367c2972e03ae2cb7140af5775d939e4

# ── build ─────────────────────────────────────────────────────────────────────
FROM ${BUILDER_IMAGE} AS builder

USER root

COPY --from=ghcr.io/astral-sh/uv:0.11.21@sha256:ff07b86af50d4d9391d9daf4ff89ce427bc544f9aae87057e69a1cc0aa369946 /uv /usr/local/bin/uv

WORKDIR /app

COPY pyproject.toml uv.lock ./

# Install deps from the lock file into a fixed venv path
ENV UV_PROJECT_ENVIRONMENT=/opt/venv
ENV PATH="/opt/venv/bin:$PATH"
RUN uv sync --frozen --no-dev

COPY . .
RUN mkdir -p /app/staticfiles && \
    DJANGO_SETTINGS_MODULE=config.settings.base python manage.py collectstatic --noinput

RUN mkdir -p /app/prometheus_multiproc

# ── runtime ───────────────────────────────────────────────────────────────────
FROM ${RUNTIME_IMAGE} AS runtime

COPY --chown=65532:65532 --from=builder /opt/venv /opt/venv
COPY --chown=65532:65532 --from=builder /app /app

ENV HOME=/app
ENV PATH="/opt/venv/bin:$PATH"
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV PROMETHEUS_MULTIPROC_DIR=/app/prometheus_multiproc

# Drop privileges to the non-root user
USER 65532:65532

WORKDIR /app
EXPOSE 8000

ENTRYPOINT []
CMD ["/opt/venv/bin/gunicorn", "config.wsgi:application", "-c", "gunicorn.conf.py"]

4. Compliance Einordnung (Deutsch)

BSI IT-Grundschutz Compliance

Standard Kriterium / Inhalt Wie im Projekt adressiert
BSI IT-Grundschutz SYS.1.6.A17 (S): Ausführung von Containern ohne Privilegien Der finale Applikations-Container wird über die explizite Zuweisung USER 65532:65532 ausschließlich von einem nicht-privilegierten System-Account ausgeführt. Die Erlangung von Host-Rechten (Privilege Escalation) ist damit unterbunden.
BSI IT-Grundschutz SYS.1.6.A23 (H): Unveränderlichkeit der Container Durch die radikale Minimierung in der Runtime-Stage (fehlender Paketmanager, keine Compiler) und die Nutzung des unprivilegierten Users kann der Container zur Laufzeit sein Basis-Dateisystem nicht manipulieren. Schreibrechte existieren nur im explizit per --chown freigegebenen /app-Kontext. Diese können in Kubernetes über den SecuryContext weiter engeschränkt werden.

CIS Docker Benchmark Compliance

Standard Kriterium / Inhalt Wie im Projekt adressiert
CIS Docker Benchmark Sektion 4.1: Ensure that a user for the container has been created Das finale Runtime-Image erzwingt über die Direktive USER 65532:65532 den Verzicht auf den Root-User.
CIS Docker Benchmark Sektion 4.1: Ensure that containers use only trusted base images Die Nutzung von SUSE BCI als dediziertes Basis-Image stellt sicher, dass ausschließlich vertrauenswürdige, herstellergepflegte Images mit SLSA Level 4 verwendet werden.
CIS Docker Benchmark Sektion 4.3: Ensure that unnecessary packages are not installed in the container Die strikte Trennung in Builder- und Runtime-Stage eliminiert sämtliche für den reinen Betrieb redundanten Werkzeuge (Build-Abhängigkeiten, pip, zypper) aus dem Produktions-Image.