Deployment
The production stack and the CI-driven deploy flow. Provisioning decisions live
in the repo’s /decisions/ ADRs.
Production stack
Section titled “Production stack”| 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 |
0. One-time provisioning
Section titled “0. One-time provisioning”- 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 usesssl, notsslmode). Prefer the pooled endpoint for the running app; migrations can use the direct endpoint. - 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. - FastAPI Cloud —
fastapi login(installs / usesfastapi-cloud-cli; add it withcd backend && uv add --dev fastapi-cloud-cliiffastapi deployisn’t found).
1. Backend → FastAPI Cloud
Section titled “1. Backend → FastAPI Cloud”First deploy (manual — creates the app and prints its ID)
Section titled “First deploy (manual — creates the app and prints its ID)”cd backendfastapi loginfastapi deploy # discovers app.main:app; creates the app, prints APP_ID + URLfastapi run (from backend/) auto-discovers app.main:app. If discovery
fails, add a fastapi_cloud.toml / entrypoint per the FastAPI Cloud docs.
Set backend env (production values)
Section titled “Set backend env (production values)”cd backendfastapi 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).
CI/CD — .github/workflows/backend.yml
Section titled “CI/CD — .github/workflows/backend.yml”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 touchingbackend/**: spins up apostgres:16service onlocalhost:5433/retailpulse_test(matchingconftest.py’s hardcodedTEST_URL), runsuv syncthenuv run pytest -q.DATABASE_URLis set in the job env becauseapp_settings.database_urlis required at import.deploy—mainonly, aftertestpasses and only when the repo variableENABLE_DEPLOY=true:uv run alembic upgrade headagainst the Neon prod DB, thenuv 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.
2. Frontends → Cloudflare Pages
Section titled “2. Frontends → Cloudflare Pages”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:
bun installbun run build# idempotent: create the Pages project if it doesn't exist yetbunx wrangler pages project create <project> --production-branch=main || truebunx wrangler pages deploy dist --project-name=<project> --branch=mainfrontend.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.
Secrets / variables checklist
Section titled “Secrets / variables checklist”| 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 |
Smoke test after deploy
Section titled “Smoke test after deploy”- Backend
/docsloads at the FastAPI Cloud URL. - Log in on the deployed dashboard (OTP + Google) → see your store.
- Admin: provision a store, invite an owner, add a camera.
- Point the on-site worker at the deployed API (
--api-url,WORKER_API_KEY) → events + heartbeat land → camera shows live in/health.
Open items
Section titled “Open items”- Confirm
fastapi rundiscoversapp.main:appfrombackend/(else add entrypoint config). - Decide Neon pooled vs direct endpoint for the app’s runtime connection (pooler recommended for scale-to-zero cold starts).
Related
Section titled “Related”- Local development — run the same services on your machine
- Operations — migrations and incident runbooks
- Testing — the CI test jobs in detail