Deployment
Where each part of Complied runs, what deploys it, and the checks to make before touching production. For engineers who merge to main or operate the environment. No secret values appear here; secrets live in the platform dashboards and repository secrets named below.
What runs where
| Part | Runs on | Deployed by |
|---|---|---|
Web app (src/) | Cloudflare Pages project complied | Cloudflare's Git integration, automatically on push to main |
Docs site (website/) | Cloudflare Pages project complied-docs, at docs.compliednyc.com | Cloudflare's Git integration, automatically on push to main |
| Database, auth, storage | Supabase production project (ref xtdawebsatjlqkmmtexk) | Supabase GitHub integration applies migrations on merge to main |
Edge functions (supabase/functions/) | Same Supabase project | deploy-functions.yml |
Map query service (map-service/) | AWS Lambda complied-map-query (us-east-2) behind a Function URL, snapshots in S3 | deploy-map-service.yml |
| Map exporter and CLI ingestion | The ingestion EC2 box | map-service/deploy/ec2/install.sh by hand |
| PDF rendering | Cloudflare Browser Rendering (or any endpoint you host) | Secrets on the Supabase project |
| Nightly public-data sync | pg_cron inside Supabase, calling the sync-orchestrator function | A migration (ingestion) |
Cloudflare Pages hosts the frontend and auto-deploys on push to main. The working cadence is: build and tests green, commit, push. The production Supabase project is separate from whatever the local CLI has linked (supabase/.temp/linked-project.json); always confirm the linked project ref before running supabase db push or functions deploy against anything meant for production.
CI
ci.yml runs on every push and pull request on any branch. It never deploys.
| Job | Runs | Blocking |
|---|---|---|
lint | npm run lint | No (continue-on-error) |
typecheck | npx tsc --noEmit | Yes |
build | npm run build | Yes |
test | npm test (root vitest: src/** and supabase/functions/**) | Yes |
functions | npm run check:functions (Deno type-check of every function; functions on the KNOWN_FAILING ratchet in scripts/check-functions.mjs are exempt) | Yes |
map-service | npm run typecheck and npm test in map-service/ on Node 22 | Yes |
guardrails | scripts/ci/guardrails.sh: a change that adds a SECURITY DEFINER function without a paired REVOKE ... FROM anon/PUBLIC fails; USING (true) in a new migration and un-gated verify_jwt = false functions only warn | Yes for the SECURITY DEFINER check |
secret-scan | gitleaks | No (advisory) |
The domain package's own suite (cd domain && npm test) and db-tests are not run by CI; run them locally (local development).
Web app
Cloudflare Pages builds the repository root with npm run build and serves dist/. Production build-time variables are set on the complied Pages project:
| Variable | Notes |
|---|---|
VITE_SUPABASE_URL, VITE_SUPABASE_ANON_KEY | Required; the build's client throws at import time without them. Publishable values only. |
VITE_MAP_SERVICE_URL | Required for /map. A production build has no fallback; unset means the map fails loudly. |
VITE_MAPBOX_PUBLIC_TOKEN | Map canvas |
VITE_MAPILLARY_TOKEN | Optional; street-level photos on an opened building |
Tenants are served on subdomains (<tenant>.compliednyc.com); the tenant is read from the host. Edge-function CORS allows the apex, tenant subdomains and local dev hosts by pattern; any other origin, a Pages preview URL for example, must be named in the CORS_ALLOWED_ORIGINS function secret rather than matched by a wildcard.
Database migrations
The Supabase GitHub integration applies supabase/migrations/ to production when a change merges to main, so merging a migration is a production database change. Before opening a PR:
- Rebase on
mainand confirm your migration's timestamp sorts last (ls supabase/migrations/*.sql | tail -3). - Run it locally with
npx supabase migration up --local. - If it adds a
SECURITY DEFINERfunction, pair it with aREVOKE EXECUTE ... FROM anonin the same change (security).
Production writes by hand (supabase db push, direct SQL) are for the owner to run, not for automation or agents; the pre-tool hook blocks them (conventions). Two production tables, buildings and public_events, additionally refuse deletes, truncates and drops at the database level.
Edge functions
deploy-functions.yml runs on push to main when supabase/functions/**, domain/src/** or src/auth/permissionKeys.ts change (the functions import those by relative path), and on manual dispatch. It runs the Deno type-check job first and deploys only if it passes, then runs supabase functions deploy --project-ref xtdawebsatjlqkmmtexk for every function. It needs the repository secret SUPABASE_ACCESS_TOKEN.
Function behaviour that depends on configuration, not code:
supabase/config.tomlmarkssync-orchestrator,process-email-outbox,process-notification-digest,handle-email-suppression,handle-email-unsubscribeandrequest-password-resetasverify_jwt = false. Each performs its own authorization (security). All other functions keep the platform's JWT check.- Secrets are set per project with
npx supabase secrets set NAME=valueand read withDeno.env.get. The set the functions use:CLOUDFLARE_ACCOUNT_ID,CLOUDFLARE_API_TOKEN(orBROWSER_RENDER_ENDPOINTandBROWSER_RENDER_TOKEN),CRON_SECRET,NYC_OPEN_DATA_TOKEN,RESEND_API_KEY,RESEND_WEBHOOK_SECRET,EMAIL_FROM_ADDRESS,EMAIL_FROM_NAME,EMAIL_REPLY_TO,PUBLIC_APP_URL,CORS_ALLOWED_ORIGINS.SUPABASE_URL,SUPABASE_ANON_KEYandSUPABASE_SERVICE_ROLE_KEYare provided by the runtime. - The nightly cron job sends
CRON_SECRET, read from Supabase Vault (secret namesync_orchestrator_cron_secret), so the Vault value and the function secret must match.
A manual deploy of one function (npx supabase functions deploy <name>) is gated by the pre-tool hook, which requires a passing production build first.
PDF rendering
Set the Cloudflare credentials on the production project and leave BROWSER_RENDER_ENDPOINT unset:
npx supabase secrets set CLOUDFLARE_ACCOUNT_ID=... CLOUDFLARE_API_TOKEN=...
The token needs Browser Rendering: Edit (listed in Cloudflare's picker as "Browser Run"). The public report-fonts bucket must be populated in that project (node scripts/upload-report-fonts.mjs, with SEED_CONFIRM=<project-ref> for a non-local target). Workers Paid is recommended because the free plan's one-request-per-10-seconds limit slows concurrent reports; the function retries a 429 within a 90 s budget. To keep report data in-house, run any headless-Chrome service and set BROWSER_RENDER_ENDPOINT. Details and debugging: PDF reports.
Map service
Production layout: the ingestion EC2 box exports Parquet snapshots to S3; a Lambda function loads the latest snapshot on cold start, polls latest.json, and serves the query API through a Function URL. Region us-east-2, CloudFormation stack complied-map.
Code deploys. On push to main touching map-service/**, deploy-map-service.yml runs typecheck and tests, builds the arm64 image, pushes it to ECR tagged with the commit, runs aws lambda update-function-code on complied-map-query, waits for the update, and calls /health. AWS access uses the repository secret AWS_DEPLOY_ROLE_ARN, an IAM role assumed through GitHub's OIDC provider (id-token: write), with permission only for ECR push and updating that one function. This repo uses GitHub's immutable subject claim, so the role's trust policy sub must match repo:Martial-Geek@<owner-id>/complied@<repo-id>:ref:refs/heads/main; check the live format with gh api repos/Martial-Geek/complied/actions/oidc/customization/sub. A deploy-only IAM user (AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY) is accepted as an alternative; with neither, the workflow runs the tests and skips the deploy with a warning.
Infrastructure changes. The workflow never touches the stack. A change to template.yaml (memory, environment, alarms) needs a manual sam deploy with the current ImageUri. The snapshot bucket is created once by an account admin (the deploy user is denied s3:CreateBucket); the stack takes its name as a parameter. CORS is handled only in the app through MAP_ALLOWED_ORIGINS; leave Function URL CORS unset, because duplicate Access-Control-Allow-Origin headers break browsers. Step-by-step first-time setup (image build, bucket, sam deploy, smoke test): map-service/deploy/README.md.
Exporter and schedulers. Versioned under map-service/deploy/ec2/ and installed with install.sh, with MAP_SNAPSHOT_ROOT=s3://<bucket>/map in /opt/complied/.env and no static AWS keys (the instance role carries the stack's exporter policy):
| Timer | When (UTC) | What |
|---|---|---|
complied-map-export-daily | 14:00 | Force an export; pauses backfill to free RAM |
complied-map-export-hourly | Hourly | export --if-stale, a no-op when current |
complied-map-export-morning | 08:20 to 09:55, every 5 minutes | export --if-stale during the sync's quiet window |
complied-ingest-delta | 08:10 | Delta ingestion only; does not chain an export |
The exporter's Postgres login should be a dedicated read-only role. Pass its password through PGPASSWORD or a secret store, never in a URL that ps can show.
Ordering when the map's contract changes. The frontend calls VITE_MAP_SERVICE_URL with no production fallback, so a frontend that needs a new service behaviour must not reach main first. Apply any migrations, let deploy-map-service.yml ship the service and confirm /health shows a snapshot, run an export if the snapshot schema changed, redeploy sync-orchestrator if it needs a new RPC, then merge the frontend. Architecture and configuration: map and map-service/AGENTS.md.
Docs site
The documentation site is a separate Cloudflare Pages project from the app, so a docs-only change ships without touching the app.
| Setting | Value |
|---|---|
| Project | complied-docs |
| Domain | https://docs.compliednyc.com |
| Production branch | main |
| Root directory | website |
| Build command | npm ci && npm run build |
| Output directory | build |
| Node version | 20 |
Content is canonical markdown under docs/ plus the root REQUIREMENTS.md; website/scripts/sync-docs.mjs copies it into a gitignored website/docs/ at build time, rewrites out-of-tree and source-code links to GitHub, and the build doubles as the link checker. Never author inside website/docs/.
A pull request that only changes docs/** or website/** publishes docs on merge and leaves the app project alone. Non-main branches get Pages preview builds. Manual deploy:
cd website && npm ci && npm run build
npx wrangler pages deploy build --project-name=complied-docs --branch=<name>
Do not point the app's Pages project at website/. More: website/README.md.
Pre-deploy checklist
npm run buildandnpm run testpass locally;npm run check:functionspasses if you touched a function.- The linked Supabase project ref is the one you mean (
cat supabase/.temp/linked-project.json). - Any new migration sorts last and is idempotent where it needs to be.
- A migration that adds a table has RLS enabled with permission-key policies (security).
- Any new secret is set on the target project before the code that reads it ships.