111 lines
4.7 KiB
Docker
111 lines
4.7 KiB
Docker
# 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
|