# syntax=docker/dockerfile:1.7 # ============================================================================= # Django API image. # # Two stages: wheels are built with a compiler present, the runtime image gets # only the compiled artefacts. The result is small, has no build toolchain to # exploit, and runs as an unprivileged user on a read-only-friendly layout. # ============================================================================= # ----------------------------------------------------------------------------- # Stage 1 - build wheels # ----------------------------------------------------------------------------- FROM python:3.12-slim-bookworm AS builder ENV PIP_NO_CACHE_DIR=1 \ PIP_DISABLE_PIP_VERSION_CHECK=1 RUN apt-get update \ && apt-get install --no-install-recommends -y build-essential libpq-dev \ && rm -rf /var/lib/apt/lists/* # The build context is the repository root so the image can also carry the # dataset; paths below are therefore prefixed with the directory name. WORKDIR /build COPY backend/requirements/ requirements/ RUN pip wheel --wheel-dir /wheels -r requirements/base.txt # ----------------------------------------------------------------------------- # Stage 2 - runtime # ----------------------------------------------------------------------------- FROM python:3.12-slim-bookworm AS runtime ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ PIP_NO_CACHE_DIR=1 \ PIP_DISABLE_PIP_VERSION_CHECK=1 \ DJANGO_SETTINGS_MODULE=config.settings.prod \ PATH="/home/app/.local/bin:${PATH}" # libpq5 is the only runtime library psycopg needs; curl backs the healthcheck. RUN apt-get update \ && apt-get install --no-install-recommends -y libpq5 curl \ && rm -rf /var/lib/apt/lists/* \ && groupadd --gid 10001 app \ && useradd --uid 10001 --gid app --create-home --shell /usr/sbin/nologin app COPY --from=builder /wheels /wheels COPY backend/requirements/ /tmp/requirements/ RUN pip install --no-index --find-links=/wheels -r /tmp/requirements/base.txt \ && rm -rf /wheels /tmp/requirements WORKDIR /app # Explicit list, not `COPY backend/ /app/`: the runtime image must not ship # test code (or stray repo files like this Dockerfile itself), only what the # server needs to run. `tests/` is added back for the `dev` stage below. COPY --chown=app:app backend/apps/ /app/apps/ COPY --chown=app:app backend/config/ /app/config/ COPY --chown=app:app backend/manage.py backend/entrypoint.sh backend/gunicorn.conf.py backend/pyproject.toml /app/ COPY --chown=app:app data/ /data/ # Static assets are only needed for the admin and the API docs, but baking them # in keeps the container stateless. The step runs against the production # settings on purpose: they are the ones this image ships with, and the dev # module pulls in django-extensions which is not part of base.txt. # WORKDIR creates /app as root before the COPY above populates it, and # --chown on COPY only touches what it copies - the directory entry itself # stays root-owned, which is enough to make anything that writes a *new* file # straight into /app (e.g. pytest-cov's .coverage) fail with EACCES as the # app user. # The secret is generated per build and thrown away with the layer: nothing # secret-shaped is ever committed to the repo or baked into the image, and # collectstatic only needs *a* valid key, never a specific one. (A literal # placeholder here would also trip the guard in config/settings/prod.py.) RUN chown app:app /app \ && chmod +x /app/entrypoint.sh \ && DJANGO_SECRET_KEY="$(python -c 'import secrets; print(secrets.token_urlsafe(64))')" \ DATABASE_URL=postgres://build:build@localhost:5432/build \ DJANGO_SETTINGS_MODULE=config.settings.prod \ python manage.py collectstatic --noinput --clear USER app EXPOSE 8000 HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \ CMD curl -fsS http://localhost:8000/api/v1/health/live/ || exit 1 ENTRYPOINT ["/app/entrypoint.sh"] CMD ["gunicorn", "config.wsgi:application", "--config", "/app/gunicorn.conf.py"] # ----------------------------------------------------------------------------- # Stage 3 - development # # Same layout as the runtime image plus the tooling from dev.txt (pytest, # django-extensions, ...) so `config.settings.dev` can actually be imported. # Selected through `target: dev` in docker-compose.dev.yml. # ----------------------------------------------------------------------------- FROM runtime AS dev USER root ENV DJANGO_SETTINGS_MODULE=config.settings.dev COPY backend/requirements/ /tmp/requirements/ RUN pip install -r /tmp/requirements/dev.txt \ && rm -rf /tmp/requirements # The one directory the runtime stage deliberately left out. COPY --chown=app:app backend/tests/ /app/tests/ USER app