From 483a4dfd3b72153a1ef05e807b767c9219d13d4c Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 18 May 2026 23:32:51 +0200 Subject: [PATCH] Add multi-stage Dockerfile for container deployment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three-stage build: - assets: Node 22 + Vite + Tailwind CSS 4 → static/dist/main.css - python-deps: uv sync --frozen --no-dev against pyproject + uv.lock, with the issue-core path dependency satisfied via a BuildKit named context (--build-context issue-core=...) - runtime: python:3.12-slim-bookworm + libpq5 + curl, non-root 'app' user, collectstatic at build time, gunicorn on :8000, /health/ HEALTHCHECK every 30s Adds gunicorn>=22 to project dependencies (was missing). Build: docker build --build-context issue-core=/home/worsch/issue-core \ -t gitea.coulomb.social/coulomb/vergabe-teilnahme: . Smoke-verified: container reports (healthy) and /health/ returns {"status": "ok"} without a database connection. Co-Authored-By: Claude Opus 4.7 --- .dockerignore | 33 ++++++++++++++++++++ Dockerfile | 85 ++++++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 1 + uv.lock | 14 +++++++++ 4 files changed, 133 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..97438d1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,33 @@ +.git +.gitignore +.dockerignore +Dockerfile + +__pycache__ +*.py[cod] +.venv +.pytest_cache +.ruff_cache +.mypy_cache +.coverage +htmlcov + +node_modules +static/dist +staticfiles +media + +*.log +*.sqlite3 +.issue-facade + +.env +.env.* +docker-compose*.yml + +.claude +.custodian-brief.md +workplans +wiki +LICENSE +README.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..23a06eb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,85 @@ +# syntax=docker/dockerfile:1.7 +# +# Build: +# docker build \ +# --build-context issue-core=/home/worsch/issue-core \ +# -t gitea.coulomb.social/coulomb/vergabe-teilnahme: . +# +# The `issue-core` named context is required because pyproject.toml has +# a path dependency on a sibling repo; uv.lock pins it as "../issue-core". + + +# ─── Stage 1 ─── Vite + Tailwind asset build ──────────────────────────────── +FROM node:22-alpine AS assets +WORKDIR /build +COPY package.json package-lock.json ./ +RUN npm ci --no-audit --no-fund +COPY vite.config.js ./ +COPY static/src ./static/src +RUN npm run build +# Output: /build/static/dist/main.css + + +# ─── Stage 2 ─── Python deps via uv ───────────────────────────────────────── +FROM python:3.12-slim-bookworm AS python-deps + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + UV_LINK_MODE=copy \ + UV_PYTHON_DOWNLOADS=never + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential libpq-dev curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=ghcr.io/astral-sh/uv:0.5.11 /uv /usr/local/bin/uv + +COPY --from=issue-core . /issue-core/ +WORKDIR /app +COPY pyproject.toml uv.lock ./ +RUN uv sync --frozen --no-dev --no-install-project + + +# ─── Stage 3 ─── Runtime image ────────────────────────────────────────────── +FROM python:3.12-slim-bookworm AS runtime + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + DJANGO_SETTINGS_MODULE=vergabe_teilnahme.settings.prod \ + PATH=/app/.venv/bin:$PATH \ + PORT=8000 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpq5 curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd -r app && useradd -r -g app -d /app -s /sbin/nologin app + +WORKDIR /app + +COPY --from=python-deps /issue-core /issue-core +COPY --from=python-deps /app/.venv ./.venv + +COPY --chown=app:app manage.py pyproject.toml ./ +COPY --chown=app:app vergabe_teilnahme ./vergabe_teilnahme +COPY --chown=app:app static/src ./static/src +COPY --chown=app:app static/vendor ./static/vendor +COPY --chown=app:app templates ./templates +COPY --from=assets --chown=app:app /build/static/dist ./static/dist + +RUN mkdir -p ./media ./staticfiles ./.issue-facade && chown -R app:app /app + +# collectstatic needs SECRET_KEY + ALLOWED_HOSTS but no DB; provide placeholders. +RUN SECRET_KEY=build-only ALLOWED_HOSTS=localhost \ + python manage.py collectstatic --noinput + +USER app +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD curl -fsS http://127.0.0.1:8000/health/ || exit 1 + +CMD ["gunicorn", "vergabe_teilnahme.wsgi:application", \ + "--bind", "0.0.0.0:8000", \ + "--workers", "3", \ + "--access-logfile", "-", \ + "--error-logfile", "-"] diff --git a/pyproject.toml b/pyproject.toml index 600d1a6..a38ea68 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ "python-decouple>=3.8", "dj-database-url>=2.1", "issue-core @ file:///home/worsch/issue-core", + "gunicorn>=22.0", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index f0dbba8..b4eaabb 100644 --- a/uv.lock +++ b/uv.lock @@ -329,6 +329,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/a7/a600f8f30d4505e89166de51dd121bd540ab8e560e8cf0901de00a81de8c/faker-40.15.0-py3-none-any.whl", hash = "sha256:71ab3c3370da9d2205ab74ffb0fd51273063ad562b3a3bb69d0026a20923e318", size = 2004447 }, ] +[[package]] +name = "gunicorn" +version = "26.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/b7/a4a3f632f823e432ce6bc65f62961b7980c898c77f075a2f7118cb3846fe/gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf", size = 727286 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/40/9c2384fc2be4ad25dd4a49decd5ad9ea5a3639814c11bd40ab77cb9f0a14/gunicorn-26.0.0-py3-none-any.whl", hash = "sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc", size = 212009 }, +] + [[package]] name = "idna" version = "3.15" @@ -756,6 +768,7 @@ dependencies = [ { name = "dj-database-url" }, { name = "django" }, { name = "django-storages" }, + { name = "gunicorn" }, { name = "issue-core" }, { name = "psycopg", extra = ["binary"] }, { name = "python-decouple" }, @@ -777,6 +790,7 @@ requires-dist = [ { name = "dj-database-url", specifier = ">=2.1" }, { name = "django", specifier = ">=5.2" }, { name = "django-storages", specifier = ">=1.14" }, + { name = "gunicorn", specifier = ">=22.0" }, { name = "issue-core", directory = "../issue-core" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, { name = "python-decouple", specifier = ">=3.8" },