Skip to content

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 appHostnameSourceRuntime
trademarksentinel-appapp.trademarksentinel.appapp/ (Wasp)Node container, 2 machines
trademarksentinel-app-stagingstaging.trademarksentinel.appapp/ (Wasp)Node container, 1 machine
trademarksentinel-marketingtrademarksentinel.app (apex) + www. redirectblog/dist/nginx:alpine, 1 machine
trademarksentinel-docsdocs.trademarksentinel.appdocs/dist/nginx:alpine, 1 machine
trademarksentinel-db— (internal)Fly Postgresmanaged 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:

  1. Worked through Prerequisites — Node 24, Wasp 0.23, Docker, flyctl.

  2. 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.

  3. 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.

  4. Authenticated flyctl for the account that will own the apps:

    Terminal window
    flyctl auth login
    flyctl auth whoami # confirms the active org
  5. Cloned the repo locally and checked out the commit you intend to deploy. wasp deploy fly builds 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:

Terminal window
cd app
wasp deploy fly create-db lhr

The 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:

Terminal window
flyctl secrets list -a trademarksentinel-app | grep DATABASE_URL
# or, to dump from Postgres directly:
flyctl postgres connect -a trademarksentinel-db

For 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

SecretSourceNotes
DATABASE_URLSet automatically by wasp deploy fly create-dbDon’t override unless you’re moving to an external Postgres.
JWT_SECRETGenerate locally: openssl rand -base64 48Wasp’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_MODELiteral value liveSwitches 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_KEYStripe → Developers → API keysLive mode sk_live_.... The dev-mode sk_test_... value will not work for real customers.
STRIPE_WEBHOOK_SECRETStripe → Developers → WebhooksCreate a webhook pointing at https://app.trademarksentinel.app/payments-webhook first, then copy its signing secret.
STRIPE_PRICE_ID_SOLOStripe → ProductsRecurring price ID for the Solo tier.
STRIPE_PRICE_ID_TEAMStripe → ProductsRecurring price ID for the Team tier.
STRIPE_PRICE_ID_ENTERPRISEStripe → ProductsRecurring price ID for the Enterprise tier.
SMTP_HOSTBrevo: smtp-relay.brevo.comWasp’s SMTP provider points at this host. Set together with the other SMTP_* vars or none of them.
SMTP_PORTBrevo: 587 (STARTTLS)
SMTP_USERNAMEBrevo → Senders, Domains & Dedicated IPs → SMTP & API → SMTPThe SMTP login email shown alongside the key.
SMTP_PASSWORDSame Brevo screenThe xkeysib-... SMTP key. Verify the sender domain (Senders → Domains) first or Brevo will reject sends.
ADMIN_EMAILSComma-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.

SecretRequired whenNotes
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRETGoogle auth uncommented in main.waspUse the production OAuth client; the dev one’s redirect URLs won’t match app.trademarksentinel.app.
OPENAI_API_KEYDemo AI app retainedDrop 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_REGIONFile uploads retainedDrop fileUploadEnvSchema to remove.
PLAUSIBLE_API_KEY, PLAUSIBLE_SITE_ID, PLAUSIBLE_BASE_URLPlausible analytics enabledDrop plausibleEnvSchema to remove.
GOOGLE_ANALYTICS_CLIENT_EMAIL, GOOGLE_ANALYTICS_PRIVATE_KEY, GOOGLE_ANALYTICS_PROPERTY_IDGA4 enabledDrop googleAnalyticsEnvSchema to remove.

Pushing secrets to Fly

Terminal window
cd app
wasp 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.app

wasp 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):

Terminal window
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.

Terminal window
flyctl tokens create deploy --name "trademark-sentinel-deploy" --expiry 8760h

Take the resulting token (printed once — you cannot retrieve it later) and save it to:

  1. 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.yml workflow reads this directly via ${{ secrets.FLY_API_TOKEN }}.

  2. Paperclip company secrets — for adapter-driven manual deploys (e.g. when an agent runs wasp deploy fly deploy outside 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:

Terminal window
cd app
wasp deploy fly setup trademarksentinel-app lhr

Wasp’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:

Terminal window
wasp deploy fly deploy

The first deploy spins one machine. To run two identical machines (so a single-machine restart doesn’t drop in-flight pg-boss work):

Terminal window
flyctl scale count 2 -a trademarksentinel-app

Subsequent 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:

Terminal window
cd app
wasp deploy fly setup trademarksentinel-app-staging lhr
flyctl 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.

Terminal window
# Marketing
cd blog
flyctl launch --copy-config --no-deploy --name trademarksentinel-marketing --region lhr
flyctl deploy --remote-build
# Docs
cd ../docs
flyctl launch --copy-config --no-deploy --name trademarksentinel-docs --region lhr
flyctl deploy --remote-build

flyctl 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:

Terminal window
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:

TypeNameTargetTTLProxy
A@<marketing IPv4>AutoDNS-only
AAAA@<marketing IPv6>AutoDNS-only
CNAMEwwwtrademarksentinel-marketing.fly.devAutoDNS-only
CNAMEapptrademarksentinel-app.fly.devAutoDNS-only
CNAMEstagingtrademarksentinel-app-staging.fly.devAutoDNS-only
CNAMEdocstrademarksentinel-docs.fly.devAutoDNS-only

Then issue TLS certs for each hostname — Fly handles ACME via Let’s Encrypt automatically once it sees the DNS record:

Terminal window
flyctl certs add trademarksentinel.app -a trademarksentinel-marketing
flyctl certs add www.trademarksentinel.app -a trademarksentinel-marketing
flyctl certs add app.trademarksentinel.app -a trademarksentinel-app
flyctl certs add staging.trademarksentinel.app -a trademarksentinel-app-staging
flyctl certs add docs.trademarksentinel.app -a trademarksentinel-docs

flyctl 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:

Terminal window
# 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 scanWatchTick

For 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:

Terminal window
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-app

Or, more concisely, scale the bad release down and let Fly promote the previous one:

Terminal window
flyctl machine list -a trademarksentinel-app
flyctl 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:

  1. Roll the app back per the recipe above to restore service.
  2. Reverse the migration manually with flyctl postgres connect -a trademarksentinel-db and an explicit DROP COLUMN / ALTER TABLE etc.
  3. 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:

Terminal window
cd app
wasp deploy fly deploy

Promote 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-app reports both machines healthy.
  • flyctl status -a trademarksentinel-app-staging reports the single machine healthy.
  • flyctl certs check <hostname> is Issued for all five hostnames.
  • https://app.trademarksentinel.app/api/health returns 200 from an external monitor (e.g. UptimeRobot).
  • Most recent deploy-staging.yml run is green on main.
  • 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