# syntax=docker/dockerfile:1.7 # # Föräldraledigplannerare — backend (FastAPI + uvicorn, Python 3.14, uv). # # Build context: backend/ # # podman build -t foraldraledig-backend backend/ # slim default # podman build --target scraper -t foraldraledig-backend:scraper backend/ # + Chromium # # --------------------------------------------------------------------------- # WHY TWO RUNTIMES # # `playwright` sits in [project].dependencies (not an extra), so the Python # package is always installed — but the *browser* it drives is a separate # ~170 MB download plus its system libraries, and Playwright publishes no # musl/Alpine build at all. Shipping Chromium in the default image would # several-fold inflate it for a feature most deployments never turn on. # # So: # * target `runtime` (default, and deliberately the LAST stage so a plain # `podman build` lands here) — Alpine, NO Chromium. Runs with # APP_SCRAPER_ENABLED=false baked in; the FK-scraper endpoints are the # only thing unavailable. # * target `scraper` (opt-in) — Debian slim + Chromium + its libs, built # from its own Debian dependency stage because musl wheels cannot be # reused on glibc. # --------------------------------------------------------------------------- # =========================================================================== # Stage 1 — dependency build (glibc / Debian), only for the scraper variant # =========================================================================== FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim AS deps-glibc ENV UV_COMPILE_BYTECODE=1 \ UV_LINK_MODE=copy \ UV_PYTHON_DOWNLOADS=never \ # Build the venv at its FINAL path. A uv/virtualenv venv is not # relocatable: console-script shebangs are absolute, so a venv built at # /build/.venv and copied to /app/.venv yields "uvicorn: not found" # (exit 127 — a missing interpreter, not a missing script). UV_PROJECT_ENVIRONMENT=/app/.venv WORKDIR /build COPY pyproject.toml uv.lock ./ RUN uv sync --locked --no-dev --no-install-project # =========================================================================== # Stage 2 — opt-in scraper runtime: Debian slim + Chromium # =========================================================================== FROM python:3.14-slim-trixie AS scraper ENV PYTHONUNBUFFERED=1 \ PYTHONDONTWRITEBYTECODE=1 \ PATH="/app/.venv/bin:$PATH" \ APP_SQLITE_PATH=/data/foraldraledig.db \ APP_SCRAPER_ENABLED=true \ APP_SCRAPER_HEADLESS=true \ # Browsers outside $HOME so they survive a read-only /app and any UID. PLAYWRIGHT_BROWSERS_PATH=/opt/playwright WORKDIR /app # --chown here, not a `chown -R /app` below: see the note in the default # runtime stage — a recursive chown duplicates the whole venv into a new layer. COPY --from=deps-glibc --chown=10001:10001 /app/.venv /app/.venv COPY --chown=10001:10001 alembic.ini ./ COPY --chown=10001:10001 migrations ./migrations COPY --chown=10001:10001 app ./app # `playwright install --with-deps` pulls Chromium *and* the system libraries # it needs. --only-shell installs the headless shell only (no headed browser, # no ffmpeg), which is all the FK scraper uses. This single line is why this # variant is big, and the whole reason it is opt-in. RUN python -m compileall -q /app/app /app/migrations \ && playwright install --with-deps --only-shell chromium \ && rm -rf /var/lib/apt/lists/* /root/.cache \ && groupadd -g 10001 app \ && useradd -u 10001 -g app -M -d /app app \ && mkdir -p /data \ && chown app:app /data \ && chmod -R a+rX /opt/playwright VOLUME ["/data"] USER 10001:10001 EXPOSE 8000 HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \ CMD ["python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2).status == 200 else 1)"] # Chromium needs writable scratch space; with --read-only add # --tmpfs /tmp --tmpfs /dev/shm:size=256m CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--no-access-log"] # =========================================================================== # Stage 3 — dependency build (musl / Alpine), for the default slim runtime # =========================================================================== FROM ghcr.io/astral-sh/uv:python3.14-alpine AS deps-musl ENV UV_COMPILE_BYTECODE=1 \ UV_LINK_MODE=copy \ UV_PYTHON_DOWNLOADS=never \ UV_PROJECT_ENVIRONMENT=/app/.venv # No compiler needed: every dependency in uv.lock except playwright publishes # a cp314 musllinux aarch64/x86_64 wheel today (argon2-cffi, greenlet, uvloop, # httptools, pydantic-core all do). If a future dependency bump breaks that, # add `RUN apk add --no-cache build-base libffi-dev` here — it is builder-only # and never reaches the final image. WORKDIR /build # Manifests first: this layer stays cached until the dependency set actually # changes, so source edits never re-resolve or re-compile the tree. COPY pyproject.toml uv.lock ./ # --no-install-project: the app is plain source we COPY in below, so the venv # holds third-party dependencies only. --no-dev drops pytest/ruff/httpx. # # --no-install-package playwright: playwright ships NO musllinux wheel at all # (manylinux/macOS/win only), so on Alpine it is not merely undesirable, it is # uninstallable. Dropping it is safe *only* because the import is lazy — # app/scrapers/fk_scraper.py does `from playwright.async_api import # async_playwright` inside the function that drives the browser, not at module # scope, so `app.main` imports cleanly without it. The scraper is disabled via # APP_SCRAPER_ENABLED=false in the runtime stage; use the `scraper` target if # you need it. Keep that import lazy, or this stage breaks at boot. RUN uv sync --locked --no-dev --no-install-project --no-install-package playwright # =========================================================================== # Stage 4 — DEFAULT runtime: Alpine, no build toolchain, no Chromium # =========================================================================== FROM python:3.14-alpine AS runtime # Pinned by tag, not digest, on purpose: a tag pin keeps rebuilds picking up # Alpine/CPython security patches, which matters more here than bit-for-bit # reproducibility — and the *Python* dependency set is already byte-pinned by # uv.lock (`uv sync --locked`). Pin the digest in the deployment pipeline if # full supply-chain immutability outranks patching. ENV PYTHONUNBUFFERED=1 \ PYTHONDONTWRITEBYTECODE=1 \ PATH="/app/.venv/bin:$PATH" \ # SQLite lives on the mounted volume, never in an image layer. APP_SQLITE_PATH=/data/foraldraledig.db \ # No Chromium in this image — see "WHY TWO RUNTIMES" above. APP_SCRAPER_ENABLED=false WORKDIR /app # --chown on the COPY itself: a later `chown -R /app` would rewrite every # file's metadata and so duplicate the entire 89 MB venv into a second layer # (it cost ~86 MB before this was fixed). COPY --from=deps-musl --chown=10001:10001 /app/.venv /app/.venv # Source last — it changes most often, so it is the only layer that rebuilds. # alembic.ini and migrations/ must sit in WORKDIR: app/db/connection.py loads # `Config("alembic.ini")` by a path relative to the process CWD. COPY --chown=10001:10001 alembic.ini ./ COPY --chown=10001:10001 migrations ./migrations COPY --chown=10001:10001 app ./app # Pre-compile so a read-only root filesystem never needs to write .pyc files. RUN python -m compileall -q /app/app /app/migrations \ && addgroup -g 10001 -S app \ && adduser -u 10001 -S -G app -h /app app \ && mkdir -p /data \ && chown app:app /data # SQLite database + its WAL/SHM sidecars. Mount a named volume or host path # here; without one the data dies with the container. # # Read-only root filesystem (verified working) — run with: # --read-only --tmpfs /tmp -v :/data # Nothing outside /data is written at runtime: bytecode is pre-compiled above # and PYTHONDONTWRITEBYTECODE is set. VOLUME ["/data"] USER 10001:10001 EXPOSE 8000 # /health is defined in app/main.py. urllib keeps this dependency-free — no # curl or wget needs to exist in the image. HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \ CMD ["python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2).status == 200 else 1)"] # Exec form => uvicorn is PID 1 and receives SIGTERM/SIGINT directly, so it # runs its own graceful shutdown (lifespan closes the DB connection) rather # than being SIGKILLed after the stop timeout. No init shim needed: a single # uvicorn process spawns no children and has nothing to reap. # # MIGRATIONS: the app lifespan already calls `alembic upgrade head` itself on # SQLite (app/main.py -> app/db/connection.py:run_migrations), so the schema is # applied on boot. That is forward-only `upgrade`, never `downgrade`, so no # destructive DDL runs implicitly. On Postgres (APP_DATABASE_TYPE=postgres) the # lifespan does NOT migrate — run it as a one-shot before rollout: # podman run --rm -e APP_DATABASE_TYPE=postgres ... alembic upgrade head # To take the boot migration off the hot path on SQLite too, run that same # one-shot and treat the auto-migrate as a no-op safety net. CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--no-access-log"]