Skip to content

Deployment

The production stack and the CI-driven deploy flow. Provisioning decisions live in the repo’s /decisions/ ADRs.

Piece Host Notes
Backend API FastAPI Cloud (fastapi deploy) decisions/2026-07-04-backend-fastapi-cloud.md
App database Neon (serverless Postgres) app data
Auth Supabase (identity / JWT only) app data stays in Neon
Dashboard (frontend/) Cloudflare Pages TanStack Start
Admin (admin/) Cloudflare Pages (separate project) plain Vite SPA
Landing (landing/) Cloudflare Pages Vite SPA
Docs (docs-site/) Cloudflare Pages Astro Starlight (this site)
CV worker On-site (store), points at the deployed API not cloud-hosted
  1. Neon — create a project + database and grab the connection string. The app uses asyncpg, so the URL is postgresql+asyncpg://USER:PASS@HOST/DB. For asyncpg + Neon, append SSL with ...?ssl=require (asyncpg uses ssl, not sslmode). Prefer the pooled endpoint for the running app; migrations can use the direct endpoint.
  2. Supabase — reuse the P0 auth project: SUPABASE_URL, SUPABASE_SECRET_KEY (backend), SUPABASE_PUBLISHABLE_KEY (frontends). Add the prod Cloudflare callback URLs under Auth → URL Configuration.
  3. FastAPI Cloudfastapi login (installs / uses fastapi-cloud-cli; add it with cd backend && uv add --dev fastapi-cloud-cli if fastapi deploy isn’t found).

First deploy (manual — creates the app and prints its ID)

Section titled “First deploy (manual — creates the app and prints its ID)”
Terminal window
cd backend
fastapi login
fastapi deploy # discovers app.main:app; creates the app, prints APP_ID + URL

fastapi run (from backend/) auto-discovers app.main:app. If discovery fails, add a fastapi_cloud.toml / entrypoint per the FastAPI Cloud docs.

Terminal window
cd backend
fastapi cloud env set DATABASE_URL "postgresql+asyncpg://…neon…?ssl=require"
fastapi cloud env set SUPABASE_URL "https://<proj>.supabase.co"
fastapi cloud env set SUPABASE_SECRET_KEY "<supabase service key>"
fastapi cloud env set WORKER_API_KEY "<long random secret>"
fastapi cloud env set SUPERADMIN_EMAILS "you@example.com"
fastapi cloud env set FRONTEND_BASE_URL "https://<dashboard>.pages.dev"

Confirm CORS in backend/app/main.py allows the Cloudflare origins (dashboard + admin).

FastAPI Cloud has no release hook, so migrations run in GitHub Actions before the deploy. The workflow has two jobs:

  • test — on every push/PR touching backend/**: spins up a postgres:16 service on localhost:5433/retailpulse_test (matching conftest.py’s hardcoded TEST_URL), runs uv sync then uv run pytest -q. DATABASE_URL is set in the job env because app_settings.database_url is required at import.
  • deploymain only, after test passes and only when the repo variable ENABLE_DEPLOY=true: uv run alembic upgrade head against the Neon prod DB, then uv run --with fastapi-cloud-cli fastapi deploy. Migrations run before deploy.

backend/alembic/env.py reads app_settings.database_url, so the migrate step only needs DATABASE_URL in its env.

GitHub secrets required (Settings → Secrets and variables → Actions):

Name Kind Value
PROD_DATABASE_URL secret Neon postgresql+asyncpg://…?ssl=require (migration endpoint)
FASTAPI_CLOUD_TOKEN secret FastAPI Cloud dashboard → deploy tokens
FASTAPI_CLOUD_APP_ID secret printed by the first manual fastapi deploy
ENABLE_DEPLOY variable set true to opt the deploy job in

Manual migrate — .github/workflows/migrate.yml

Section titled “Manual migrate — .github/workflows/migrate.yml”

A workflow_dispatch-only job that runs alembic upgrade head against PROD_DATABASE_URL from GitHub’s network. Useful when your local network can’t reach Neon on 5432. Trigger from the Actions tab → “Migrate (manual)” → Run workflow, or gh workflow run migrate.yml.

Each JS app is its own Cloudflare Pages project, deployed by its own workflow. All four use the same auth pattern: the CLOUDFLARE_API_TOKEN secret plus the CLOUDFLARE_ACCOUNT_ID variable, with bunx wrangler pages deploy dist.

App Workflow Pages project Test gate Deploy opt-in var
Dashboard (frontend/) frontend.yml metrica-dashboard bun run test ENABLE_FRONTEND_DEPLOY=true
Admin (admin/) admin.yml metrica-admin bun run test ENABLE_ADMIN_DEPLOY=true
Landing (landing/) landing.yml metrica-landing none always on main
Docs (docs-site/) docs.yml metrica-docs none always on main

Each workflow triggers on pushes to main that touch its app directory (or its own workflow file) and on workflow_dispatch. The deploy steps are:

Terminal window
bun install
bun run build
# idempotent: create the Pages project if it doesn't exist yet
bunx wrangler pages project create <project> --production-branch=main || true
bunx wrangler pages deploy dist --project-name=<project> --branch=main

frontend.yml and admin.yml gate the deploy job behind their test job and the ENABLE_*_DEPLOY variable. landing.yml and docs.yml are deploy-only — no test gate (nothing to test).

Build-time env (Cloudflare / Actions variables)

Section titled “Build-time env (Cloudflare / Actions variables)”

The dashboard and admin builds read VITE_* values from repo variables:

  • Dashboard: VITE_SUPABASE_URL, VITE_SUPABASE_PUBLISHABLE_KEY, VITE_API_URL (the FastAPI Cloud backend URL).
  • Admin: the above plus VITE_FRONTEND_URL (the deployed dashboard URL).

Add both deployed origins to Supabase Auth → URL Configuration (/auth/callback) and to the backend CORS allowlist.

Name Where Value
DATABASE_URL FastAPI Cloud env + GH PROD_DATABASE_URL Neon postgresql+asyncpg://…?ssl=require
SUPABASE_URL backend + both frontends Supabase project URL
SUPABASE_SECRET_KEY backend only Supabase service key (never in a frontend)
SUPABASE_PUBLISHABLE_KEY both frontends Supabase anon / publishable key
WORKER_API_KEY backend + on-site worker long random string
SUPERADMIN_EMAILS backend your email(s), comma-separated
FASTAPI_CLOUD_TOKEN GH secret FastAPI Cloud deploy token
FASTAPI_CLOUD_APP_ID GH secret from first fastapi deploy
PROD_DATABASE_URL GH secret Neon URL for the CI migrate step
CLOUDFLARE_API_TOKEN GH secret Cloudflare Pages deploy token
CLOUDFLARE_ACCOUNT_ID GH variable Cloudflare account id
VITE_API_URL frontends deployed backend URL
VITE_FRONTEND_URL admin deployed dashboard URL
  1. Backend /docs loads at the FastAPI Cloud URL.
  2. Log in on the deployed dashboard (OTP + Google) → see your store.
  3. Admin: provision a store, invite an owner, add a camera.
  4. Point the on-site worker at the deployed API (--api-url, WORKER_API_KEY) → events + heartbeat land → camera shows live in /health.
  • Confirm fastapi run discovers app.main:app from backend/ (else add entrypoint config).
  • Decide Neon pooled vs direct endpoint for the app’s runtime connection (pooler recommended for scale-to-zero cold starts).