deploy: combined Dockerfile — FastAPI serves the SPA in prod
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

Three-stage build:
  1. node:22-alpine — `npm ci` + `npm run build` produces frontend/dist
  2. python:3.12-slim — poetry installs backend deps into a venv
  3. python:3.12-slim — runtime, copies the venv + frontend/dist,
     sets FRONTEND_DIST=/app/frontend_dist

Backend gates the API surface on FRONTEND_DIST:

- Unset (dev / tests): routers mount at root (/networth, /scenarios,
  …). 172 tests still pass unchanged. The Vite dev server proxies
  `/api/*` → backend stripping the prefix.
- Set (prod): routers mount under `/api/*`. The SPA bundle mounts at
  `/` with html=True so React Router owns client routing for paths
  like `/scenarios`, `/what-if`. Same-origin, no CORS, one deploy.

Operational endpoints (`/healthz`, `/metrics`, `/recompute`) stay at
root in both shapes.

Existing Woodpecker pipeline picks this up unchanged — same context,
same Dockerfile path, just produces a richer image.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Viktor Barzin 2026-05-09 22:25:52 +00:00
parent cb79118da7
commit b82770b5c4
3 changed files with 73 additions and 17 deletions

22
.dockerignore Normal file
View file

@ -0,0 +1,22 @@
# Caches and build artifacts
.venv
__pycache__
*.pyc
.pytest_cache
.ruff_cache
.mypy_cache
.hypothesis
# Frontend
frontend/node_modules
frontend/dist
frontend/.npm
frontend/*.tsbuildinfo
# Git, IDE
.git
.vscode
.idea
# Dev-only docs (README.md is needed by poetry; keep it)
PLAYBOOK_VIKTOR.md

View file

@ -1,4 +1,17 @@
FROM python:3.12-slim AS builder
# ── Stage 1: build the React SPA ─────────────────────────────────────
FROM node:22-alpine AS frontend-builder
WORKDIR /frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
# ── Stage 2: install backend Python deps ─────────────────────────────
FROM python:3.12-slim AS backend-builder
ENV POETRY_VERSION=1.8.4 \
POETRY_VIRTUALENVS_IN_PROJECT=true \
@ -16,16 +29,19 @@ COPY alembic.ini ./alembic.ini
RUN poetry install --only main
# ── Stage 3: runtime ─────────────────────────────────────────────────
FROM python:3.12-slim
WORKDIR /app
RUN useradd --system --uid 10003 --home /app --shell /usr/sbin/nologin firep
COPY --from=builder --chown=firep:firep /app /app
COPY --from=backend-builder --chown=firep:firep /app /app
COPY --from=frontend-builder --chown=firep:firep /frontend/dist /app/frontend_dist
ENV PATH="/app/.venv/bin:${PATH}" \
PYTHONUNBUFFERED=1
PYTHONUNBUFFERED=1 \
FRONTEND_DIST=/app/frontend_dist
EXPOSE 8080
USER firep

View file

@ -1,14 +1,17 @@
"""FastAPI application — wires routers + middleware + lifespan.
Routers:
- /healthz, /metrics, /recompute operational
- /networth, /networth/history read NW from account_snapshot
- /scenarios/... scenario CRUD + projection
- /scenarios/{id}/life-events,
/life-events/{id} life event CRUD
- /scenarios/{id}/goals,
/goals/{id} retirement goal CRUD
- /simulate, /compare sync simulate (no DB write)
Two deployment shapes:
- **Dev / tests**: `FRONTEND_DIST` unset. API routers mount at root
(e.g. `/networth`, `/scenarios`). The Vite dev server (port 5173)
hits `/api/*` and proxies to the backend stripping the prefix.
- **Prod**: `FRONTEND_DIST=/path/to/frontend/dist` set. API routers
mount under `/api/*`; the SPA static bundle mounts at `/` with
HTML fallback so React Router can own the URL.
Operational endpoints (`/healthz`, `/metrics`, `/recompute`) stay at
root in both shapes.
Auth: write/compute paths take Bearer auth via the `require_bearer`
dependency when `API_BEARER_TOKEN` is set. Read paths skip auth so the
@ -26,10 +29,12 @@ import logging
import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
from fastapi import Depends, FastAPI, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from prometheus_fastapi_instrumentator import Instrumentator
from fire_planner.api.auth import require_bearer
@ -100,6 +105,9 @@ async def _drain_queue(app: FastAPI) -> None:
queue.task_done()
_FRONTEND_DIST = os.environ.get("FRONTEND_DIST")
_API_PREFIX = "/api" if _FRONTEND_DIST else ""
app = FastAPI(title="fire-planner", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
@ -110,11 +118,11 @@ app.add_middleware(
)
Instrumentator().instrument(app).expose(app, endpoint="/metrics")
app.include_router(networth_router)
app.include_router(scenarios_router)
app.include_router(life_events_router)
app.include_router(goals_router)
app.include_router(simulate_router)
app.include_router(networth_router, prefix=_API_PREFIX)
app.include_router(scenarios_router, prefix=_API_PREFIX)
app.include_router(life_events_router, prefix=_API_PREFIX)
app.include_router(goals_router, prefix=_API_PREFIX)
app.include_router(simulate_router, prefix=_API_PREFIX)
@app.post(
@ -134,3 +142,13 @@ async def healthz() -> dict[str, Any]:
queue = getattr(app.state, "queue", None)
depth = queue.qsize() if queue is not None else 0
return {"status": "ok", "queue_depth": depth}
# Mount the SPA last so it catches any path the API didn't claim. With
# `html=True`, requests for paths like `/scenarios` (no file at that
# path) return `index.html`, letting React Router own client routing.
if _FRONTEND_DIST and Path(_FRONTEND_DIST).is_dir():
app.mount("/", StaticFiles(directory=_FRONTEND_DIST, html=True), name="spa")
log.info("Mounted SPA from %s; API at %s/*", _FRONTEND_DIST, _API_PREFIX)
elif _FRONTEND_DIST:
log.warning("FRONTEND_DIST=%s does not exist; SPA not mounted", _FRONTEND_DIST)