Deploying on Fly
This is the canonical runbook for a production deploy of Trademark Sentinel to Fly.io. Working through it from top to bottom should produce a healthy app reachable at https://app.trademarksentinel.app, a marketing site at https://trademarksentinel.app, and a docs site at https://docs.trademarksentinel.app.
The runbook also covers staging (trademarksentinel-app-staging) — staging deploys are automated via .github/workflows/deploy-staging.yml, so once the Fly app is provisioned and FLY_API_TOKEN is set as a GitHub Actions secret, every push to main redeploys staging automatically.
Production is intentionally manual until the staging environment has soaked for a week — see §9 Production deploy.
Topology
Three sibling Fly apps + one Fly Postgres app, all in lhr:
| Fly app | Hostname | Source | Runtime |
|---|---|---|---|
trademarksentinel-app | app.trademarksentinel.app | app/ (Wasp) | Node container, 2 machines |
trademarksentinel-app-staging | staging.trademarksentinel.app | app/ (Wasp) | Node container, 1 machine |
trademarksentinel-marketing | trademarksentinel.app (apex) + www. redirect | blog/dist/ | nginx:alpine, 1 machine |
trademarksentinel-docs | docs.trademarksentinel.app | docs/dist/ | nginx:alpine, 1 machine |
trademarksentinel-db | — (internal) | Fly Postgres | managed by wasp deploy fly create-db |
Both trademarksentinel-app machines are identical builds — pg-boss handles work distribution via Postgres advisory locks, so there is no app/worker role split. Postgres is shared between staging and the deferred prod (single-instance MVP); split into two Postgres apps before going past first-week soak if you want stronger isolation.
DNS is Cloudflare DNS-only (grey cloud) for all hostnames so Fly terminates TLS at its anycast edge. Full record list in §6 DNS + TLS.
1. Prerequisites
Before running any flyctl or wasp deploy command, make sure you have:
-
Worked through Prerequisites — Node 24, Wasp 0.23, Docker,
flyctl. -
Worked through Environment variables so you understand the dev shape of every var. The production variant differs only in real keys (live Stripe, live Brevo SMTP, etc.) — see §3 Secrets.
-
A Fly.io account with billing attached. The default machine sizes in this runbook (1× shared-cpu-1x 256MB for static, 2× shared-cpu-1x 512MB for the app, 1× shared-cpu-1x 256MB Postgres) sit just above Fly’s free allowance; expect a small monthly bill.
-
Authenticated
flyctlfor the account that will own the apps:Terminal window flyctl auth loginflyctl auth whoami # confirms the active org -
Cloned the repo locally and checked out the commit you intend to deploy.
wasp deploy flybuilds from your local source tree, not from git.
2. Postgres
Provision a managed Postgres app on Fly in lhr. Wasp wraps the underlying flyctl postgres create and writes the connection string into the app’s DATABASE_URL secret automatically:
cd appwasp deploy fly create-db lhrThe first run creates trademarksentinel-db and prints the cluster’s app name + master credentials. Save the credentials in your password manager — Fly does not surface them again.
To recover the connection string later, run:
flyctl secrets list -a trademarksentinel-app | grep DATABASE_URL# or, to dump from Postgres directly:flyctl postgres connect -a trademarksentinel-dbFor staging, repeat the same wasp deploy fly create-db step against the staging app once you’ve run §4 App deploy for staging, or attach the production cluster as a second database (flyctl postgres attach -a trademarksentinel-db trademarksentinel-app-staging) — single-cluster, multi-database is fine for first-week soak.
3. Secrets
The full secret inventory the production app needs. Set these before the first wasp deploy fly deploy so the server can boot — the env validators in app/src/env.ts and per-feature app/src/**/env.ts files refuse to start with missing or empty required vars.
Required
| Secret | Source | Notes |
|---|---|---|
DATABASE_URL | Set automatically by wasp deploy fly create-db | Don’t override unless you’re moving to an external Postgres. |
JWT_SECRET | Generate locally: openssl rand -base64 48 | Wasp’s session-signing secret. Required by Wasp 0.23 itself — not validated in app/src/env.ts because Wasp’s internal env layer enforces it before user schemas run. Rotating logs out every active session. |
WIPO_MODE | Literal value live | Switches app/src/sources/registry.ts from the offline mock adapter to the live global trademark database backend. Anything other than mock selects live mode, but set explicitly to live per the WS#12 contract for clarity in flyctl secrets list. The env-var name is retained as WIPO_MODE for internal adapter routing. |
STRIPE_API_KEY | Stripe → Developers → API keys | Live mode sk_live_.... The dev-mode sk_test_... value will not work for real customers. |
STRIPE_WEBHOOK_SECRET | Stripe → Developers → Webhooks | Create a webhook pointing at https://app.trademarksentinel.app/payments-webhook first, then copy its signing secret. |
STRIPE_PRICE_ID_SOLO | Stripe → Products | Recurring price ID for the Solo tier. |
STRIPE_PRICE_ID_TEAM | Stripe → Products | Recurring price ID for the Team tier. |
STRIPE_PRICE_ID_ENTERPRISE | Stripe → Products | Recurring price ID for the Enterprise tier. |
SMTP_HOST | Brevo: smtp-relay.brevo.com | Wasp’s SMTP provider points at this host. Set together with the other SMTP_* vars or none of them. |
SMTP_PORT | Brevo: 587 (STARTTLS) | — |
SMTP_USERNAME | Brevo → Senders, Domains & Dedicated IPs → SMTP & API → SMTP | The SMTP login email shown alongside the key. |
SMTP_PASSWORD | Same Brevo screen | The xkeysib-... SMTP key. Verify the sender domain (Senders → Domains) first or Brevo will reject sends. |
ADMIN_EMAILS | — | Comma-separated. Members of this list are granted admin on signup. |
Conditionally required
Only set these if the corresponding feature is wired in app/main.wasp / app/src/env.ts. Leaving them unset on production is fine if the feature is disabled.
| Secret | Required when | Notes |
|---|---|---|
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET | Google auth uncommented in main.wasp | Use the production OAuth client; the dev one’s redirect URLs won’t match app.trademarksentinel.app. |
OPENAI_API_KEY | Demo AI app retained | Drop the demoAiAppEnvSchema import from app/src/env.ts to remove. |
AWS_S3_IAM_ACCESS_KEY, AWS_S3_IAM_SECRET_KEY, AWS_S3_FILES_BUCKET, AWS_S3_REGION | File uploads retained | Drop fileUploadEnvSchema to remove. |
PLAUSIBLE_API_KEY, PLAUSIBLE_SITE_ID, PLAUSIBLE_BASE_URL | Plausible analytics enabled | Drop plausibleEnvSchema to remove. |
GOOGLE_ANALYTICS_CLIENT_EMAIL, GOOGLE_ANALYTICS_PRIVATE_KEY, GOOGLE_ANALYTICS_PROPERTY_ID | GA4 enabled | Drop googleAnalyticsEnvSchema to remove. |
Pushing secrets to Fly
cd appwasp deploy fly cmd --context server secrets set \ JWT_SECRET="$(openssl rand -base64 48)" \ WIPO_MODE=live \ STRIPE_API_KEY=sk_live_... \ STRIPE_WEBHOOK_SECRET=whsec_... \ STRIPE_PRICE_ID_SOLO=price_... \ STRIPE_PRICE_ID_TEAM=price_... \ STRIPE_PRICE_ID_ENTERPRISE=price_... \ SMTP_HOST=smtp-relay.brevo.com \ SMTP_PORT=587 \ SMTP_USERNAME=ops@trademarksentinel.app \ SMTP_PASSWORD=xkeysib-... \ ADMIN_EMAILS=ops@trademarksentinel.appwasp deploy fly cmd --context server secrets … is a thin wrapper around flyctl secrets set -a trademarksentinel-app … that picks up the app name from app/fly.toml. Use the raw flyctl form when targeting a different app (e.g. staging):
flyctl secrets set -a trademarksentinel-app-staging \ STRIPE_API_KEY=sk_test_... \ ...FLY_API_TOKEN — set in two places
The deploy token has two distinct consumers and must be saved in both. Skipping either breaks half the deploy surface.
flyctl tokens create deploy --name "trademark-sentinel-deploy" --expiry 8760hTake the resulting token (printed once — you cannot retrieve it later) and save it to:
-
GitHub Actions repo secret — for the staging-deploy CI workflow. Settings → Secrets and variables → Actions → New repository secret →
FLY_API_TOKEN. The.github/workflows/deploy-staging.ymlworkflow reads this directly via${{ secrets.FLY_API_TOKEN }}. -
Paperclip company secrets — for adapter-driven manual deploys (e.g. when an agent runs
wasp deploy fly deployoutside CI). Save via the board-gated secrets API:Terminal window curl -X POST "https://paperclip.ing/api/companies/4636971c-e7f4-4cff-9b0f-cff307acc262/secrets" \-H "Authorization: Bearer <board-jwt>" \-H "Content-Type: application/json" \-d '{"name":"FLY_API_TOKEN","value":"<token>"}'The
4636971c-...company id is fixed for this project; the JWT must be a board user’s, since the secrets endpoint rejects agent JWTs ("Board access required").
Rotate annually — the same fresh token replaces both stores.
Scope the token to a single app if your security model allows (flyctl tokens create deploy --app trademarksentinel-app-staging), or leave it org-scoped if a single token must cover both staging and production.
4. App deploy
First-time setup creates the Fly app and writes app/fly.toml:
cd appwasp deploy fly setup trademarksentinel-app lhrWasp’s setup step provisions the Fly app, generates the fly.toml, and points the build at the Wasp-managed Dockerfile. The customisations in app/fly.toml (primary_region = "lhr", /api/health HTTP service health-check, auto_stop_machines = "off", min_machines_running = 1, [[vm]] memory = "512mb") are committed in this repo — wasp deploy fly setup will preserve them on subsequent runs.
Push the secrets from §3, then deploy:
wasp deploy fly deployThe first deploy spins one machine. To run two identical machines (so a single-machine restart doesn’t drop in-flight pg-boss work):
flyctl scale count 2 -a trademarksentinel-appSubsequent code deploys are just wasp deploy fly deploy — Fly rolls one machine at a time and waits for /api/health to return 200 before moving on. The /api/health route returns 200 with { "status": "ok" } when the Postgres connection round-trips a User.findFirst probe, 503 with a truncated reason otherwise. pg-boss shares the same DB connection so the single query covers both DB and queue reachability.
Staging variant
Repeat the same flow against trademarksentinel-app-staging:
cd appwasp deploy fly setup trademarksentinel-app-staging lhrflyctl secrets set -a trademarksentinel-app-staging \ STRIPE_API_KEY=sk_test_... \ ...wasp deploy fly deploy --remote-build--remote-build runs the Docker build on Fly’s builders rather than your laptop — required for the GitHub Actions runner (no local Docker daemon) and recommended for the manual staging deploy too. After the first manual deploy, every push to main redeploys staging automatically via .github/workflows/deploy-staging.yml.
Staging stays at count=1 — no scale-out — so any flake there immediately fails the /api/health smoke and surfaces in CI.
5. Static apps (marketing + docs)
The marketing site (blog/) and docs site (docs/) deploy as separate Fly apps. They share the nginx:alpine runtime; each has its own Dockerfile, fly.toml, and nginx.conf checked into the repo.
# Marketingcd blogflyctl launch --copy-config --no-deploy --name trademarksentinel-marketing --region lhrflyctl deploy --remote-build
# Docscd ../docsflyctl launch --copy-config --no-deploy --name trademarksentinel-docs --region lhrflyctl deploy --remote-buildflyctl launch --copy-config --no-deploy reuses the committed fly.toml and skips the interactive wizard. After the first launch, deploys are just flyctl deploy --remote-build from each directory.
The Dockerfiles run npm ci && npm run build to produce dist/, then copy dist/ and nginx.conf into nginx:alpine and serve on port 8080. CI’s existing Astro build smoke (.github/workflows/ci.yml) exercises the same npm run build paths — if CI is green, the Fly build will produce identical output.
6. DNS + TLS
DNS lives on Cloudflare in DNS-only mode (grey cloud, no proxying). Fly terminates TLS at its anycast edge for every hostname — there is no certificate management on the Cloudflare side.
Resolve each hostname’s anycast IPs first:
flyctl ips list -a trademarksentinel-marketing # apex (and www → 301)flyctl ips list -a trademarksentinel-app # app.flyctl ips list -a trademarksentinel-app-staging # staging.flyctl ips list -a trademarksentinel-docs # docs.Each app has one shared IPv4 and one shared IPv6 (use dedicated IPs only if you need an apex A/AAAA that doesn’t collide with another app). For the records, add to the trademarksentinel.app zone in Cloudflare:
| Type | Name | Target | TTL | Proxy |
|---|---|---|---|---|
A | @ | <marketing IPv4> | Auto | DNS-only |
AAAA | @ | <marketing IPv6> | Auto | DNS-only |
CNAME | www | trademarksentinel-marketing.fly.dev | Auto | DNS-only |
CNAME | app | trademarksentinel-app.fly.dev | Auto | DNS-only |
CNAME | staging | trademarksentinel-app-staging.fly.dev | Auto | DNS-only |
CNAME | docs | trademarksentinel-docs.fly.dev | Auto | DNS-only |
Then issue TLS certs for each hostname — Fly handles ACME via Let’s Encrypt automatically once it sees the DNS record:
flyctl certs add trademarksentinel.app -a trademarksentinel-marketingflyctl certs add www.trademarksentinel.app -a trademarksentinel-marketingflyctl certs add app.trademarksentinel.app -a trademarksentinel-appflyctl certs add staging.trademarksentinel.app -a trademarksentinel-app-stagingflyctl certs add docs.trademarksentinel.app -a trademarksentinel-docsflyctl certs check <hostname> polls until the cert is issued (typically a minute or two). The marketing app’s nginx.conf 301s www. to the apex.
7. Smoke
After a deploy, verify the surface end-to-end:
# 1. Health probe — covered by Fly's own machine health check, but also# exposed externally for monitoring.curl --fail https://app.trademarksentinel.app/api/health# expects: {"status":"ok"}
# 2. Marketing + docs — nginx serves /, returns 200.curl --fail -I https://trademarksentinel.app/curl --fail -I https://docs.trademarksentinel.app/
# 3. Sign-up flow — open in a browser:# https://app.trademarksentinel.app/signup# Confirm verification email arrives (Brevo verified-sender domain must match `defaultFrom.email`).
# 4. Scan-cycle smoke — once signed up, add a watch from the dashboard# and confirm a `scanWatchTick` job runs in the next cycle (default 5min).flyctl logs -a trademarksentinel-app | grep scanWatchTickFor staging, replace the app. hostname with staging. in the health probe; the rest of the flow is identical (use Stripe test cards on staging, not live cards).
8. Rollback
Fly retains every release image until the per-app retention limit (default 5). To roll back the app to a previous release:
flyctl releases list -a trademarksentinel-app# v23 pending ❌ unhealthy# v22 succeeded# v21 succeeded# ...
# Re-deploy the previous image — note the `image` field of v22:flyctl releases list -a trademarksentinel-app --json | \ python3 -c "import json,sys; print([r for r in json.load(sys.stdin) if r['version']==22][0]['imageRef']['repository']+'@'+ [r for r in json.load(sys.stdin) if r['version']==22][0]['imageRef']['digest'])"
flyctl deploy --image <prev-image-ref> -a trademarksentinel-appOr, more concisely, scale the bad release down and let Fly promote the previous one:
flyctl machine list -a trademarksentinel-appflyctl machine destroy <bad-machine-id> --force -a trademarksentinel-app# Fly will reschedule onto the previous healthy image.Database migrations are not auto-rolled-back by either path. If a migration broke the deploy:
- Roll the app back per the recipe above to restore service.
- Reverse the migration manually with
flyctl postgres connect -a trademarksentinel-dband an explicitDROP COLUMN/ALTER TABLEetc. - File a follow-up issue to add a forward fix migration.
There is no automatic rollback in .github/workflows/deploy-staging.yml — a failed staging deploy fails the workflow run and pages whoever owns the merged PR. Manual rollback uses the same recipe above with -a trademarksentinel-app-staging.
9. Production deploy
Production deploy stays manual for at least the first week of staging soak. Trigger it via workflow_dispatch (once a deploy-prod.yml lands — currently out of scope for this runbook) or run the same flow as §4 App deploy directly:
cd appwasp deploy fly deployPromote production to automated CI deploys after one week of staging green-builds + zero Sev-2+ alerts. Update this section when that happens.
Operational checklist
A useful end-to-end sanity sweep (run once per week or after any infra change):
-
flyctl status -a trademarksentinel-appreports both machines healthy. -
flyctl status -a trademarksentinel-app-stagingreports the single machine healthy. -
flyctl certs check <hostname>isIssuedfor all five hostnames. -
https://app.trademarksentinel.app/api/healthreturns 200 from an external monitor (e.g. UptimeRobot). - Most recent
deploy-staging.ymlrun is green onmain. - Stripe webhook endpoint is reachable from Stripe’s test-pinger (Stripe → Developers → Webhooks → “Send test webhook”).
- Brevo sender domain still passes SPF + DKIM (Brevo → Senders, Domains & Dedicated IPs → Domains).
See also
wasp deploy flyreference — what eachwasp deploy flysubcommand actually does.- Fly.io machines — when you need to drop below
wasp deploy flyto debug a stuck machine. - open-saas Fly deployment guide — the upstream guide we forked from. Points of divergence are the
lhrregion pin, thecount=2scale, and the sibling-app topology.