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

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)