Deploying Scarif One to Fly.io
Reading time: 12 minutes. Hands-on time: ~45 minutes for first deploy + DNS cutover. What you’ll have at the end: scarifone.com running on Fly.io with persistent /data, automatic CD on every push to main, Cloudflare DNS pointing at Fly’s anycast IPs, encrypted nightly backups to Backblaze B2.
This is the production-launch tutorial. Self-host customers (Sovereignty Package buyers) don’t need any of this — they run Scarif One via Docker on their own hardware per docs/install.md.
Fast path — automated provisioning
If you’ve already created accounts on each provider (Fly.io, Cloudflare, Backblaze, GitHub), use scripts/provision-production.mjs to do everything below in one command.
# 1. Copy the template + fill in the API tokens
cp .env.production.local.example .env.production.local
$EDITOR .env.production.local
# 2. Dry run — see what would happen, no changes applied
npx tsx scripts/provision-production.mjs --check
# 3. For real
npx tsx scripts/provision-production.mjs
The script is idempotent — re-running it after a partial failure picks up where it left off, and re-running after a successful run is a no-op (except for fly secrets set which Fly diffs internally).
What it does, in order:
- Creates the Fly app (if not exists)
- Provisions the 10GB persistent volume in
lhr(if not exists) - Pushes every prod env var (Stripe / Resend / Gemini / SuperAdmin / VAPID) to Fly’s encrypted secret store
- Auto-generates
SCARIF_INTERNAL_KEY+SCARIF_SUPERADMIN_TOKENif they’re blank (32-byte hex) - Creates the Backblaze B2 backup bucket with private + AES256 server-side encryption (if not exists)
- Creates a scoped Application Key on that bucket (if not exists) + writes its credentials to Fly secrets
- Sets
FLY_API_TOKENas a GitHub Actions repository secret for CD viagh secret set - Allocates a Fly IPv4/IPv6 + creates/updates Cloudflare DNS A + AAAA records pointing at them
- Requests Fly TLS certs for
scarifone.com+www.scarifone.com - Runs
fly deploy --remote-only --strategy rolling
Skip with --no-deploy if you want to provision infra without the deploy. Run only one step with --only=<step-name> (e.g. --only=cf-dns to redo just the Cloudflare records).
If you prefer to know what each step does at the API level + want full control, follow the manual walkthrough below. Either path lands you in the same place.
Prerequisites
- Fly.io account — sign up at <a href="https://fly.io/app/sign-up">fly.io</a>. The Hobby tier is fine for v1 (up to 3 small VMs free), but you’ll likely move to a paid plan once Stripe processes its first charges (~£3-£8/month for our config).
- flyctl installed locally —
curl -L https://fly.io/install.sh | sh fly auth login - Domain registered —
scarifone.comalready lives in your Cloudflare account (which is free). - Optional but recommended: Backblaze B2 account for encrypted backups. Free tier is 10GB. Set up at <a href="https://www.backblaze.com/cloud-storage">backblaze.com/cloud-storage</a>.
Step 1 — One-time Fly.io app setup
The repo already ships with fly.toml. From the project root:
# Create the app on Fly (matches the name in fly.toml).
fly launch --no-deploy --copy-config --name scarifone-app
# Provision a 10GB persistent volume in lhr (London Heathrow). Tenant
# data lives here — brand profiles, audit logs, asset libraries.
fly volumes create scarif_data --region lhr --size 10
Fly may ask whether you want a Postgres / Redis sidecar — say no to both. We’re fully file-backed so we don’t need them.
Step 2 — Set Fly secrets
Production env vars never touch git. They live in Fly’s encrypted secret store and surface to the running container as env vars.
Required to function (set BEFORE first deploy):
fly secrets set \
GEMINI_API_KEY="…" \
SCARIF_INTERNAL_KEY="$(openssl rand -hex 32)"
Required for hosted billing (Phase 48 — the marketing /pricing CTAs):
fly secrets set \
STRIPE_SECRET_KEY="sk_live_…" \
STRIPE_WEBHOOK_SECRET="whsec_…" \
STRIPE_PRICE_SOLO_MONTHLY="price_…" \
STRIPE_PRICE_SOLO_ANNUAL="price_…" \
STRIPE_PRICE_PRO_MONTHLY="price_…" \
STRIPE_PRICE_PRO_ANNUAL="price_…" \
STRIPE_PRICE_AGENCY_MONTHLY="price_…" \
STRIPE_PRICE_AGENCY_ANNUAL="price_…" \
STRIPE_PRICE_SOVEREIGNTY="price_…" \
STRIPE_AUTOMATIC_TAX="true"
Required for transactional email (Phase 52 — welcome / payment-failed / Sovereignty licence):
fly secrets set \
SCARIF_RESEND_KEY="re_…" \
SCARIF_RESEND_FROM="Scarif One <hello@scarifone.com>"
Required for the operator console (Phase 49 — /superadmin/*):
fly secrets set \
SCARIF_SUPERADMIN_TOKEN="$(openssl rand -hex 32)" \
SCARIF_SUPERADMIN_ALLOWED_IPS="<your-home-ip>,<your-office-ip>"
Recommended — encrypted off-site backups (Phase 50d — see Step 5):
fly secrets set \
SCARIF_BACKUP_S3_BUCKET="scarifone-backups" \
SCARIF_BACKUP_S3_REGION="eu-central-003" \
SCARIF_BACKUP_S3_ENDPOINT="https://s3.eu-central-003.backblazeb2.com" \
SCARIF_BACKUP_S3_ACCESS_KEY="…" \
SCARIF_BACKUP_S3_SECRET_KEY="…"
Optional — Web Push (PWA notifications):
# Generate a keypair: npx web-push generate-vapid-keys
fly secrets set \
SCARIF_VAPID_PUBLIC="…" \
SCARIF_VAPID_PRIVATE="…"
Note: SCARIF_BASE_DOMAIN, SCARIF_PUBLIC_URL, SCARIF_DATA_PATH, SCARIF_MODE, NODE_ENV, PORT, HOSTNAME, NEXT_TELEMETRY_DISABLED are already in fly.toml under [env] — no need to duplicate them as secrets.
To verify what’s set: fly secrets list.
Step 3 — First deploy
fly deploy
Fly builds the Docker image on its own builder (not your laptop), pushes it, then rolls out to a single shared-cpu-1x machine in lhr. Takes 4-6 minutes.
After the deploy completes, verify the app is live:
fly status # should show "1 machine running"
fly logs # tail logs in real time (Ctrl+C to exit)
curl -fsS https://scarifone-app.fly.dev/api/health
You should get a JSON response like:
{ "ok": true, "checks": { "dataDir": "ok", "geminiKey": "ok", "scheduler": "running" } }
If anything is error, fly logs shows what crashed.
Open https://scarifone-app.fly.dev/superadmin/setup to bootstrap your first operator account (Phase 49 — username + password ≥ 12 chars).
Step 4 — Cloudflare DNS cutover
We’re moving scarifone.com from whatever pre-launch placeholder it points at to Fly’s anycast IPs.
4a. Point Cloudflare at Fly
Get your app’s IPs:
fly ips list
You’ll see one IPv4 + one IPv6 (Fly assigns a dedicated IPv4 for free with the Hobby plan).
In Cloudflare → scarifone.com → DNS → Records:
-
Delete any existing
A/AAAArecords for@andwww. -
Add these:
Type Name Content Proxy A @ (your Fly IPv4) DNS only (grey cloud) AAAA @ (your Fly IPv6) DNS only (grey cloud) A www (your Fly IPv4) DNS only (grey cloud) Don’t enable the orange-cloud proxy yet — Fly issues its own TLS certificate and Cloudflare proxying interferes with the validation step. We turn on proxying after Fly cert validates.
4b. Tell Fly about the custom domain
fly certs add scarifone.com
fly certs add www.scarifone.com
Fly issues a Let’s Encrypt certificate in 1-3 minutes. Watch progress with:
fly certs show scarifone.com
When status flips to Issued, hit https://scarifone.com in a browser. Should serve the same Scarif One app.
4c. Optional — re-enable Cloudflare proxy
Once Fly’s cert is issued, you can flip Cloudflare back to proxied (orange cloud) for DDoS protection + free analytics. This requires Cloudflare’s SSL mode to be Full (strict) — confirm at Cloudflare → SSL/TLS → Overview.
Step 5 — Configure backups
Without this, tenant data lives only on the Fly volume. If the volume corrupts (unlikely but possible), you lose everything.
5a. Create a Backblaze B2 bucket
- Backblaze B2 → Buckets → Create a Bucket
- Name:
scarifone-backups(must be globally unique — pick a different name if taken) - Files: Private (must be private — backups contain hashed passwords + brand profiles)
- Default encryption: Server-Side Encryption with B2-managed keys
- Object Lock: Enabled — Compliance Mode, retention 7 days. This stops anyone (including you) from deleting backups before they age out — protects against ransomware that wipes backups before encrypting the live data.
5b. Create an Application Key
- Backblaze B2 → Application Keys → Add a New Application Key
- Name:
scarifone-prod-backup - Allow access to:
scarifone-backups(just this bucket) - Type of access: Read and Write
- Copy the keyID + applicationKey — you’ll never see the applicationKey again.
5c. Set Fly secrets
fly secrets set \
SCARIF_BACKUP_S3_BUCKET="scarifone-backups" \
SCARIF_BACKUP_S3_REGION="eu-central-003" \
SCARIF_BACKUP_S3_ENDPOINT="https://s3.eu-central-003.backblazeb2.com" \
SCARIF_BACKUP_S3_ACCESS_KEY="<keyID>" \
SCARIF_BACKUP_S3_SECRET_KEY="<applicationKey>"
(Region depends on which Backblaze datacentre you picked — check the bucket page. EU-Central-003 is Amsterdam, the closest EU option.)
5d. Verify
lib/scheduler.ts runs runBackup() daily at 03:00 UTC. To test before then:
# SSH into the running Fly machine
fly ssh console
# Inside the container, trigger manually:
curl -X POST -H "x-scarif-superadmin: $SCARIF_SUPERADMIN_TOKEN" \
http://localhost:3000/api/admin/backup-now
Then check Backblaze — there should be a new tarball under scarifone-backups/. Format: scarif-{timestamp}.tar.gz.
Step 6 — Continuous deploy from main
The repo already ships .github/workflows/fly-deploy.yml that runs on every push to main + tagged release.
To enable it:
- Generate a deploy token locally:
fly tokens create deploy - Add it to GitHub — repo → Settings → Secrets and variables → Actions → New repository secret:
- Name:
FLY_API_TOKEN - Value: (paste the token)
- Name:
- Push a small change to main + watch the Actions tab. The workflow:
- Builds + deploys via
flyctl deploy --remote-only --strategy rolling - Smoke-tests
/api/healthafter rollout - Logs the commit + author + tag for traceability
- Builds + deploys via
Step 7 — Day-2 operations
Tail prod logs
fly logs # real-time stream
fly logs --instance <machine-id> # specific machine if multi-instance
Get a shell inside the running container
fly ssh console
# Inside:
ls /data/tenants # see live tenant directories
cat /data/superadmin/audit.log | tail -20 # see recent operator actions
Scale up
fly scale vm shared-cpu-2x --memory 2gb
(Costs ~£10/month vs ~£3/month for the default. Bump only when CPU>70% sustained or memory>85%.)
Resize the data volume
fly volumes list # find the ID
fly volumes extend <vol-id> --size 25 # bump to 25GB
Rollback
fly releases # list past releases
fly deploy --image registry.fly.io/scarifone-app:deployment-<ID-from-list>
Bulk pause if something terrible happens
fly scale count 0 # stops the app entirely
fly scale count 1 # brings it back
Step 8 — DNS for tenant subdomains (Phase 51 — wildcard certs)
When you start onboarding hosted customers, each one gets a subdomain (acme.scarifone.com, katiewootton.scarifone.com, etc).
Option A — Cloudflare wildcard CNAME
In Cloudflare DNS:
| Type | Name | Content | Proxy |
|---|---|---|---|
| CNAME | * | scarifone-app.fly.dev | Proxied |
This routes every subdomain through Fly. Wildcard TLS comes from Cloudflare (free).
Option B — Fly wildcard cert (more control)
fly certs add "*.scarifone.com"
Fly handles TLS. Requires a single CNAME pointing * → scarifone-app.fly.dev in Cloudflare with proxy off.
Both work; Option A is the simplest.
Step 9 — Stripe webhook update
Once production is live at scarifone.com, the Stripe webhook URL needs to change.
- Stripe Dashboard → Developers → Webhooks
- Edit the webhook endpoint
- Change URL from
https://scarifone-app.fly.dev/api/billing/webhook(or whatever placeholder) →https://scarifone.com/api/billing/webhook - Save. Stripe will roll the signing secret if asked.
- Update Fly secret if the signing secret changed:
fly secrets set STRIPE_WEBHOOK_SECRET="whsec_…"
Common issues
fly deploy hangs after “Building image”
Probably a long npm install. First deploy can take 8-10 minutes. Subsequent deploys should be 3-4 minutes once the layer cache is warm.
Health check returns data dir not reachable
The volume isn’t mounted at /data. Verify:
fly ssh console
mount | grep data # should show: /dev/vdb on /data
If empty, the volume is in a different region than the machine. Re-create with fly volumes create scarif_data --region lhr and redeploy.
Cloudflare 525 / SSL handshake error
Cloudflare proxy is on but Fly hasn’t issued the cert yet. Either:
- Disable proxy temporarily (grey cloud), wait for
fly certs showto flip to Issued, re-enable - Set Cloudflare SSL mode to Flexible temporarily (allows self-signed origin) until the Fly cert validates
Stripe webhooks fail after DNS cutover
The webhook signing secret is per-endpoint. If you changed the URL Stripe rotated the secret. Pull the new one from the Stripe dashboard + fly secrets set STRIPE_WEBHOOK_SECRET=….
Operator console returns 404 from production IP
SCARIF_SUPERADMIN_ALLOWED_IPS is restricting. Either add your IP, or temporarily clear the allowlist:
fly secrets unset SCARIF_SUPERADMIN_ALLOWED_IPS
Cost estimate
Per-month at v1 scale (one VM, 10GB volume, ~50 active tenants):
- Fly shared-cpu-1x with 1GB RAM (730 hours): ~£3.20
- Fly 10GB volume: ~£1.30
- Fly dedicated IPv4: ~£1.80
- Cloudflare DNS + proxy: £0
- Backblaze B2 (10GB stored, 1GB egress): ~£0.10
Total: ~£6.40/month. Add ~£0.50/tenant beyond 50 in compute scale.
This is well below the cost of ONE Solo subscription (£29/month), so the platform pays for itself with a single paying customer.
What this tutorial doesn’t cover
- Multi-region deployment — premature for v1; Fly auto-replicates the volume only when you add a second machine. Single-region (lhr) gives every UK + EU customer <100ms latency.
- Database migration tooling — Scarif One is fully file-backed (JSON files under
/data), so there’s no DB to migrate. - Rolling out custom domains for individual tenants — Phase 51 work; Cloudflare CNAME-flattening + Fly’s
fly certs addper-tenant is the path. - Observability beyond
/api/health— Sentry / Datadog integration is on the v1.5 roadmap.
Need help? Email <a href="mailto:hello@scarifone.com">hello@scarifone.com</a>. The audit log at /superadmin/audit is also a good first place to look when something behaves unexpectedly.