# syntax=docker/dockerfile:1.7 # ============================================================================= # Stage 1: Build frontend (SvelteKit static output) # ============================================================================= FROM node:22-alpine AS frontend-build WORKDIR /build # Cache npm install layer COPY frontend/package.json frontend/package-lock.json ./ RUN npm ci # Build static site COPY frontend/ ./ RUN npm run build # ============================================================================= # Stage 2: Build Python wheels + extract external dependency list # ============================================================================= FROM python:3.12-slim AS python-build WORKDIR /build RUN pip install --no-cache-dir build # Build core package wheel COPY packages/core/ packages/core/ RUN python -m build packages/core/ --wheel --outdir /wheels # Build server package wheel COPY packages/server/ packages/server/ RUN python -m build packages/server/ --wheel --outdir /wheels # Emit /wheels/deps.txt with ONLY external (PyPI) deps — filter out # notify-bridge-* siblings, which are installed from local wheels below. # This file is the cache key for the external-deps install layer: as long as # pyproject.toml dependency lines don't change, the runtime install layer is # served from registry buildcache and no wheels are re-downloaded. RUN python <<'PY' import tomllib deps: list[str] = [] for p in ("packages/core/pyproject.toml", "packages/server/pyproject.toml"): with open(p, "rb") as f: data = tomllib.load(f) for d in data["project"].get("dependencies", []): if not d.lstrip().lower().startswith("notify-bridge-"): deps.append(d) seen: set[str] = set() with open("/wheels/deps.txt", "w") as f: for d in deps: if d not in seen: seen.add(d) f.write(d + "\n") PY # ============================================================================= # Stage 3: Runtime # ============================================================================= FROM python:3.12-slim # uv — fast pip replacement. Installed from PyPI (Fastly CDN) rather than # ghcr.io/astral-sh/uv, because GHCR pulls from this runner crawl at a few # hundred KB/s and take longer than the install savings would recoup. RUN pip install --no-cache-dir uv==0.11.7 ENV UV_COMPILE_BYTECODE=1 \ UV_LINK_MODE=copy WORKDIR /app # Install external deps first — layer cache key is deps.txt content, which # only changes when pyproject.toml dependency lines change (not on version # bumps). The cache mount persists downloaded wheels across local rebuilds; # in CI, the registry buildcache serves the whole layer when unchanged. COPY --from=python-build /wheels/deps.txt /tmp/deps.txt RUN --mount=type=cache,target=/root/.cache/uv \ uv pip install --system -r /tmp/deps.txt \ && rm /tmp/deps.txt # Install local wheels without re-resolving — all external deps are present. COPY --from=python-build /wheels/*.whl /tmp/wheels/ RUN --mount=type=cache,target=/root/.cache/uv \ uv pip install --system --no-deps /tmp/wheels/*.whl \ && rm -rf /tmp/wheels # Copy frontend build COPY --from=frontend-build /build/build/ /app/static/ # Create non-root user and data directory RUN useradd --create-home --shell /bin/bash appuser \ && mkdir -p /data \ && chown appuser:appuser /data # Environment defaults ENV NOTIFY_BRIDGE_DATA_DIR=/data \ NOTIFY_BRIDGE_STATIC_DIR=/app/static \ NOTIFY_BRIDGE_DEBUG=false VOLUME /data EXPOSE 8420 USER appuser HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=10s \ CMD python -c "import os, urllib.request; urllib.request.urlopen(f'http://localhost:{os.environ.get(\"NOTIFY_BRIDGE_PORT\", 8420)}/api/health')" CMD ["notify-bridge"]