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 | deploy-ingest-box.yml, over AWS SSM (the ingestion box) |
| PDF rendering | Cloudflare Browser Rendering (or any endpoint you host) | Secrets on the Supabase project |
| Public-data sync (nightly delta, weekly records) | systemd timers on the ingestion EC2 box running the ingest/ CLI | Same box deploy (ingestion) |
| Email outbox drain | pg_cron job email-outbox-drain inside Supabase, calling process-email-outbox | A migration; needs Vault secrets project_url and email_outbox_cron_bearer per environment, matching edge secret EMAIL_OUTBOX_CRON_SECRET |
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 pull requests and on push to main only (not on every branch push — that doubled minutes with pull_request). One job, ci, runs the checks below sequentially so GitHub bills a single minute stream. Superseded runs on the same ref are cancelled. It never deploys.
Step (job ci) | Runs | Blocking |
|---|---|---|
| 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; KNOWN_FAILING ratchet in scripts/check-functions.mjs) | Yes |
| domain | npm ci + npm test in domain/ | Yes |
| ingest | npm ci + typecheck + test in ingest/ (no database) | Yes |
| map-service | npm ci + typecheck + 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 |
npm run lint is not in CI (large pre-existing eslint debt). Run it locally; the agent pre-tool gate before git commit is typecheck + tests only (conventions). db-tests stays local-only (local development). gitleaks is disabled until the org has a license.
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 where Google Street View has none (or with no Google key), and the map's street-view dive |
VITE_GOOGLE_MAPS_API_KEY | Optional; turns on Google imagery: every building photo becomes its front from Street View (Street View Static API, billed per image; the metadata lookup is free), the Street View viewer on a building's Overview (Maps Embed API), and the map's Earth / Street / Orbit views (Maps JavaScript API + Map Tiles API). Needs those APIs enabled and every app origin (https://*.compliednyc.com/*, local dev hosts) in the key's referrer allow-list |
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.tomlmarksprocess-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),RESEND_API_KEY,RESEND_WEBHOOK_SECRET,EMAIL_FROM_ADDRESS,EMAIL_FROM_NAME,EMAIL_REPLY_TO,PUBLIC_APP_URL,CORS_ALLOWED_ORIGINS,EMAIL_OUTBOX_CRON_SECRET(scoped bearer for the outbox drain / digest and invite/reset kickers — not the service-role key), and forsuggest-apartment-matchesAPARTMENT_MATCH_AI_TOKEN(a Cloudflare API token that can run Workers AI through AI Gateway; it usesCLOUDFLARE_ACCOUNT_IDtoo), withAPARTMENT_MATCH_AI_GATEWAY(defaultdefault) andAPARTMENT_MATCH_AI_MODEL(defaulttypesafe/jev) optional.SUPABASE_URL,SUPABASE_ANON_KEYandSUPABASE_SERVICE_ROLE_KEYare provided by the runtime. - The
email-outbox-draincron job (every 5 minutes) posts tocoalesce(vault 'project_url', <local stack gateway URL>) || '/functions/v1/process-email-outbox'with the scoped drain secret from Vaultemail_outbox_cron_bearer. Each environment's Vault needs both:project_url(for examplehttps://<ref>.supabase.co) andemail_outbox_cron_bearer. Withoutproject_urlthe job falls back to the local-only host and fails in production. The bearer must equal the edge secretEMAIL_OUTBOX_CRON_SECRET(a purpose-built random value — generate withopenssl rand -hex 32). It must not be the service-role key; a leak of the cron credential must not bypass RLS. To check Vault and the edge secret match without revealing either, compareencode(extensions.digest(decrypted_secret, 'sha256'), 'hex')for the Vault row with the digestsupabase secrets listshows forEMAIL_OUTBOX_CRON_SECRET.
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 reports load their webfonts from Google Fonts, so the renderer needs internet access and there is nothing to upload. 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:The-Dev-Group@<owner-id>/complied@<repo-id>:ref:refs/heads/main; check the live format with gh api repos/The-Dev-Group/complied/actions/oidc/customization/sub. Moving the repo to another owner changes the owner id, so both deploy roles' trust policies must be updated with it. 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-ingest-delta | 08:10 | ingest delta --all: the nightly event feeds, then the rollups. Does not chain an export. Fails (exit 1) when any feed's watermark was held. Log /var/log/complied/delta.log |
complied-map-export-morning | 08:20 to 09:55, every 5 minutes | export --if-stale during the sync's quiet window |
complied-map-export-daily | 14:00 | Force an export (stops complied-ingest.service, the disabled backfill loop, first if it is running, to free RAM) |
complied-ingest-records | Sundays 15:00 | ingest records: the full sync, with sweep, of the 23 record feeds and 8 enforcement event feeds. Ends with refresh_building_flags() and mark_map_stale(), so the hourly export picks it up. Log /var/log/complied/records.log |
complied-ingest-identity | Saturdays 04:00 | ingest identity: the Layer 1 Building Footprints + PLUTO load, writing only changed rows, then mark_map_stale() if anything changed. Log /var/log/complied/identity.log |
complied-map-export-hourly | Hourly | export --if-stale, a no-op when current. Log /var/log/complied/map-export.log |
complied-deploy-pending | Every 5 minutes | Switches to a release a deploy left staged, once no job is running (the ingestion box). Silent when there is nothing to do |
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, check that the ingestion box is on a commit with any new RPC the CLI needs, then merge the frontend. Architecture and configuration: map and map-service/AGENTS.md.
The ingestion box
The box (complied-ingest, EC2 i-0b76f36dbd6c9632c, us-east-1) runs the exporter and the ingest/ CLI on the timers above. It has no Elastic IP, so a stop and start changes its public address: update HostName for complied-ingest in your SSH config and check the host key. Nothing else depends on the address, because deploys use SSM.
Layout. Each deployed commit is a release folder; .env and logs live outside them.
| Path | What |
|---|---|
/opt/complied/releases/<sha>/ | domain/, ingest/ and map-service/ at that commit (git archive), with node_modules and a REVISION file |
/opt/complied/current, previous | Symlinks to the live release and the one before it. Every unit runs current/map-service/deploy/ec2/run-*.sh |
/opt/complied/target | The commit the box should be on. Differs from current while a release is staged |
/opt/complied/repo.git | Bare clone, fetched with a read-only deploy key (/home/ec2-user/.ssh/complied-deploy) |
/opt/complied/.env | Shared by every release |
/var/log/complied/ | delta.log, records.log, identity.log, map-export.log, deploy.log |
Deploys. deploy-ingest-box.yml runs after the CI workflow succeeds on main (workflow_run), and only when that commit touches domain/, ingest/ or map-service/ (except the Lambda's Dockerfile and template.yaml). Starting after CI avoids parking a runner for 15–45 minutes waiting on checks. It still waits for the Supabase integration's Supabase Preview check on the same commit (migrations) before deploying. Manual workflow_dispatch with a sha input skips the path filter (rollback / force). Then it runs deploy.sh on the box through AWS SSM Run Command (AWS-RunShellScript, as root). deploy.sh <sha>:
- fetches the commit into
repo.gitand buildsreleases/<sha>. It runsnpm ciiningest/andmap-service/only when thatpackage-lock.jsonchanged, and otherwise hardlinksnode_modulesfrom the live release. It smoke-testsingest --helpand writestarget; - switches when no job is running: it runs that release's
install.sh(units, timers, sudoers rule), movescurrentatomically, and keeps the newest 5 releases pluscurrent,previousandtarget.
Never under a running job. Every job unit runs its script through flock -s /opt/complied/deploy.lock, and the switch in step 2 takes that lock exclusively. A deploy therefore waits for running jobs, and a job that starts during a switch waits for it and then runs the new release. Building only writes a new folder, so a running sync never sees it. Each run script resolves its own release once, so a run stays on one commit from start to finish. If jobs are still running after 15 minutes (the Sunday records sync can run six hours), deploy.sh leaves the release staged and reports RESULT=queued. The workflow then ends with a warning instead of failing, and complied-deploy-pending.timer switches to the release once the box is idle. Several deploys queued behind one job collapse into the newest, because target holds a single commit.
By hand (as ec2-user over SSH, who has sudo):
sudo /opt/complied/current/map-service/deploy/ec2/deploy.sh <sha> # deploy a commit (waits up to 15 min, --wait <s> to change)
sudo /opt/complied/current/map-service/deploy/ec2/deploy.sh --rollback # back to `previous`
sudo /opt/complied/current/map-service/deploy/ec2/deploy.sh --status # current, previous, target, running jobs
cd /opt/complied/current/ingest && npx tsx src/cli.ts status # the CLI against the box's .env
Running the workflow by hand (workflow_dispatch) with a sha input deploys that commit; an earlier commit is a rollback. systemctl mask <timer> pauses a timer across deploys, because install.sh leaves masked units alone; systemctl unmask and the next deploy restore it.
Access.
- CI to AWS: the repository secret
AWS_INGEST_DEPLOY_ROLE_ARNnames the IAM rolecomplied-ingest-box-deploy. It is trusted through GitHub OIDC formainwith the same immutable-subtrust policy ascomplied-map-service-deploy(map service), and may only runssm:SendCommandwith theAWS-RunShellScriptdocument on this instance, plusssm:GetCommandInvocation. Without the secret the workflow skips with a warning. - The box to SSM: the instance profile
complied-ingest-ec2carriesAmazonSSMManagedInstanceCorenext to the exporter's S3 policy. Check that the box is managed withaws ssm describe-instance-information --region us-east-1. - The box to GitHub: a read-only deploy key on
The-Dev-Group/compliedtitledcomplied-ingest-box, whose private half is/home/ec2-user/.ssh/complied-deployon the box.github.com's host keys are inec2-user'sknown_hosts.
A box release keeps working against a newer database as long as no migration removed something it calls, so a release that is staged for a few hours does no harm.
Monitoring. Each scheduled job pings a Healthchecks.io check: /start before the work, then its exit status, with what the run added to its log (up to 100 KB) as the ping body. A non-zero status, or no ping within the grace period, alerts. The wrappers share hc-ping.sh and address each check by slug under the project's ping key, https://hc-ping.com/$HEALTHCHECKS_PING_KEY/<slug>. HEALTHCHECKS_PING_KEY lives in /opt/complied/.env, never in the repository; with it unset the jobs run without pinging. A failed ping never fails a job.
| Check (slug) | Job | Schedule (cron, UTC) | Grace |
|---|---|---|---|
complied-ingest-delta | run-delta-daily.sh. Non-zero when a feed's watermark was held | 10 8 * * * | 6 h |
complied-ingest-records | run-records-weekly.sh | 0 15 * * 0 | 6 h |
complied-ingest-identity | run-identity-weekly.sh | 0 4 * * 6 | 4 h |
complied-map-export-daily | run-map-export-daily.sh (the forced export) | 0 14 * * * | 1 h |
The hourly and morning --if-stale exports are not monitored: most of their runs do nothing. The wrappers pass their job's exit status through, so a failed run also shows as a failed unit in systemctl --failed. No other unit depends on these results.
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.