Deployment
!!! warning "Migrated to Google Cloud Run, 2026-08-26"
This page still documents the **Fly.io** deployment, which no longer serves
the application. `fprs-portal` and `fprs-app` now run redirect stubs that 302
to their GCP equivalents; `fprs-mcp` and `fprs-engine` are scaled to zero.
Production is Cloud Run in project `fp-impact-model` (`europe-west1`).
Infrastructure — including every service's environment and secret references —
is declared in `infra/` and applied with `terraform apply`. Image rollouts run
through the per-service `Deploy *` workflows (thin callers of
`.github/workflows/deploy-service.yml`).
The Fly configuration is kept deliberately: rebuilding the real portal from
`main` is the rollback path. Do not delete the Fly apps or the `deploy-*.yml`
workflows yet.
GitHub Actions
Deployments run via workflow dispatch:
| Workflow | App | Config |
|---|---|---|
(workflow removed) flyApp.toml |
fprs-app |
flyApp.toml |
(workflow removed) flyEngine.toml |
fprs-engine |
flyEngine.toml |
(workflow removed) flyMcp.toml |
fprs-mcp |
flyMCP.toml |
(workflow removed) flyPortal.toml |
fprs-portal |
flyPortal.toml |
Open Actions, select the workflow, then Run workflow.
Required GitHub secrets:
- FLY_API_TOKEN — use flyctl auth token (user-level). Deploy tokens (flyctl tokens create deploy) are app-specific and will cause "unauthorized" for other apps.
- AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_ENDPOINT_URL_S3, AWS_BUCKET — pushed to Fly.io for app and engine deploys (not required for MCP-only deploy).
- GITHUB_DEPLOY_TOKEN (optional) — for app/engine builds; falls back to GITHUB_TOKEN if unset
Manual deployment
The app can be deployed to fly.io manually using the following steps:
-
Install the flyctl CLI tool from fly.io
-
Log in to fly.io:
-
Source environment to get GITHUB_TOKEN
-
Deploy the engine first, then the app:
-
Scale the app:
-
Set secrets.
For GitHub Actions deployments, set repository secrets in GitHub. For manual Fly deployments, set runtime secrets on the Fly apps.
GitHub Actions secrets
Add the repo secrets with GitHub CLI:
# Source local env first
set -a; source .env; set +a
gh secret set FLY_API_TOKEN --body "$(flyctl auth token)"
gh secret set AWS_ACCESS_KEY_ID --body "$AWS_ACCESS_KEY_ID"
gh secret set AWS_SECRET_ACCESS_KEY --body "$AWS_SECRET_ACCESS_KEY"
gh secret set AWS_ENDPOINT_URL_S3 --body "$AWS_ENDPOINT_URL_S3"
gh secret set AWS_BUCKET --body "$AWS_BUCKET"
gh secret set GITHUB_DEPLOY_TOKEN --body "$GITHUB_TOKEN"
# Or set individually (gh will prompt for each value)
gh secret set FLY_API_TOKEN
gh secret set AWS_ACCESS_KEY_ID
gh secret set AWS_SECRET_ACCESS_KEY
gh secret set AWS_ENDPOINT_URL_S3
gh secret set AWS_BUCKET
gh secret set GITHUB_DEPLOY_TOKEN
These GitHub Actions secrets are consumed by the per-service .github/workflows/deploy-*.yml workflows, which push the runtime secrets to Fly during deploy.
Fly runtime secrets
For manual deploys, set the secrets directly on Fly:
set -a; source .env; set +a
flyctl secrets set AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID -a fprs-app
flyctl secrets set AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY -a fprs-app
flyctl secrets set AWS_ENDPOINT_URL_S3=$AWS_ENDPOINT_URL_S3 -a fprs-app
flyctl secrets set AWS_BUCKET=$AWS_BUCKET -a fprs-app
flyctl secrets set GITHUB_TOKEN=$GITHUB_TOKEN -a fprs-app
fly secrets set FPRS_GSHEETS_CREDENTIALS="$(cat keys/gcpserviceaccount.json)" -a fprs-app
flyctl secrets set AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID -a fprs-engine
flyctl secrets set AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY -a fprs-engine
flyctl secrets set AWS_ENDPOINT_URL_S3=$AWS_ENDPOINT_URL_S3 -a fprs-engine
flyctl secrets set AWS_BUCKET=$AWS_BUCKET -a fprs-engine
flyctl secrets set GITHUB_TOKEN=$GITHUB_TOKEN -a fprs-engine
fly secrets set FPRS_GSHEETS_CREDENTIALS="$(cat keys/gcpserviceaccount.json)" -a fprs-engine
flyctl secrets set AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID -a fprs-mcp
flyctl secrets set AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY -a fprs-mcp
flyctl secrets set AWS_ENDPOINT_URL_S3=$AWS_ENDPOINT_URL_S3 -a fprs-mcp
flyctl secrets set AWS_BUCKET=$AWS_BUCKET -a fprs-mcp
fly secrets set FPRS_GSHEETS_CREDENTIALS="$(cat keys/gcpserviceaccount.json)" -a fprs-mcp
# Portal — values come from portal/.env (see "Portal" section below for what each one is).
# No FPRS_ENGINE_BEARER: the engine is flycast-only, so the private 6PN already
# restricts callers to Fly apps in the same org. Bearer would be redundant.
set -a; source portal/.env; set +a
flyctl secrets set ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY_PROD" -a fprs-portal
flyctl secrets set DATABASE_URL="$DATABASE_URL" -a fprs-portal
flyctl secrets set AUTH_SECRET="$AUTH_SECRET" -a fprs-portal
flyctl secrets set MAILERSEND_API_KEY="$MAILERSEND_API_KEY" -a fprs-portal
flyctl secrets set AUTH_ALLOWED_EMAILS="$AUTH_ALLOWED_EMAILS" -a fprs-portal
flyctl secrets set AUTH_ALLOWED_DOMAINS="$AUTH_ALLOWED_DOMAINS" -a fprs-portal
flyctl secrets set KNOWLEDGE_DRIVE_FOLDER_ID="$KNOWLEDGE_DRIVE_FOLDER_ID" -a fprs-portal
flyctl secrets set GOOGLE_SERVICE_ACCOUNT_JSON="$(cat keys/gcpserviceaccount.json)" -a fprs-portal
# Multi-tenant: BYOK encryption key (keep stable) + platform super-admins.
flyctl secrets set BYOK_ENCRYPTION_KEY="$BYOK_ENCRYPTION_KEY" -a fprs-portal
flyctl secrets set FPRS_SUPERADMIN_EMAILS="$FPRS_SUPERADMIN_EMAILS" -a fprs-portal
flyApp.toml now sets the non-secret runtime config needed for remote execution on Fly:
FPRS_WORKER=remoteFPRS_WORKER_URL=http://fprs-engine.flycast:8080
That means a direct deploy like
will still point the app at fprs-engine over Flycast unless you explicitly override those env vars.
MCP server + artifact API (fprs-mcp)
The fprs-mcp Fly app runs the unified fprspy-serve — one process exposing both the Streamable HTTP MCP (chat assistant tools) and the artifact read API (portal-facing envelopes at /catalog, /jobs/{id}/artifacts, …). The portal hits the same host for both. See DockerfileMCP and flyMCP.toml.
Both sides read completed-job data directly from the job store (FPRS_STORAGE_TYPE), so fprs-mcp has no runtime dependency on fprs-app and the two can be deployed in either order.
Create the Fly app (once)
If fprs-mcp does not exist yet:
Use the same org as fprs-app / fprs-engine.
Deploy
From the repository root:
No GITHUB_TOKEN build arg is required (Python-only image).
Autoscale / Machines
flyMCP.toml uses auto_stop_machines = "suspend" and min_machines_running = 0: machines can scale to zero when idle (cold start on the next request); auto_start_machines = true starts them on traffic. [http_service.concurrency] sets request-based soft_limit / hard_limit for proxy load balancing and autostart/stop behavior.
To keep one machine always warm (no cold start), set min_machines_running = 1. To run multiple Machines: fly scale count 2 -a fprs-mcp.
Secrets (optional)
| Secret / env | When |
|---|---|
FPRS_GSHEETS_CREDENTIALS |
Google Sheets MCP tools; full JSON body of the service account key |
FPRS_GSHEETS_CREATE_FOLDER_ID |
Creating new spreadsheets: Folder ID from https://drive.google.com/drive/folders/<THIS_ID>. The folder must sit on a Google Workspace Shared drive (Team Drive), not in a user’s My Drive — sharing a My Drive folder with the service account is not enough and still yields 403 storage quota. Add the service account as Content manager (or Manager) on that shared drive. Omit if you only use target_url on an existing sheet. Fly secret or [env] in flyMCP.toml. Optional bypass of the preflight check: FPRS_GSHEETS_SKIP_SHARED_DRIVE_FOLDER_CHECK=1 (not recommended). |
FPRS_STORAGE_TYPE |
s3 so the MCP job tools and artifact API read the shared Tigris store. Already set in flyMCP.toml [env]; unset falls back to an empty local store |
Service accounts do not get meaningful personal Drive quota; write_to_gsheets without target_url used to fail with a 403 storage quota error unless the new file is created under a folder that has space (typically a Shared drive folder shared with the service account).
From the repository root (key file keys/gcpserviceaccount.json; the keys/ directory is gitignored—keep the file local, never commit it):
fly secrets set FPRS_GSHEETS_CREDENTIALS="$(cat keys/gcpserviceaccount.json)" -a fprs-mcp
fly secrets set FPRS_GSHEETS_CREATE_FOLDER_ID="$(cat keys/fprs_gsheets_folder_id)" -a fprs-mcp
Updating secrets restarts Machines but does not rebuild the container image. After you change MCP Python code (for example Sheets folder_id handling), run fly deploy -c flyMCP.toml so production runs that code.
Verify
- URL:
https://fprs-mcp.fly.dev/mcp(HTTPS; use Claude → Settings → Connectors for remote MCP, notclaude_desktop_config.json). - CLI:
fly status -a fprs-mcp,fly logs -a fprs-mcp
Portal (fprs-portal)
The Next.js portal (portal/, DockerfilePortal, flyPortal.toml) is the AI-assisted model builder + compute launcher. It calls fprs-engine over flycast (private 6PN — engine has no public ingress) and fprs-mcp over flycast as well, while MCP keeps its public URL for the Claude Desktop extension.
Create the Fly app (once)
Use the same org as the rest of the stack so flycast routing works.
Deploy
From the repository root:
The Dockerfile produces a Next.js 15 standalone build (Node 20 alpine). No GITHUB_TOKEN build arg is required.
Env (non-secret, in flyPortal.toml)
| Var | Value |
|---|---|
FPRS_ENGINE_URL |
http://fprs-engine.flycast:8080 (flycast — no public engine ingress) |
MCP_URL |
https://fprs-mcp.fly.dev/mcp (public — flycast was tried but flyMCP.toml has force_https = true, which makes the proxy 308-redirect flycast HTTP traffic to HTTPS on a hostname that doesn't terminate TLS, causing ECONNRESET. Set force_https = false on the MCP to take the private path.) |
FPRSAPP_URL |
https://fprs-app.fly.dev (public, for result deeplinks) |
ANTHROPIC_MODEL |
claude-sonnet-4-6 |
AUTH_FROM_EMAIL |
noreply@lucenceltd.com (sent via MailerSend; lucenceltd.com is the verified sender domain) |
AUTH_ALLOWED_DOMAINS |
founderspledge.com |
Secrets
Set on the Fly app (use the production Anthropic key; rotate the dev key if it ever leaks into a session):
fly secrets set ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY_PROD" -a fprs-portal
fly secrets set DATABASE_URL="<neon connection string with sslmode=require>" -a fprs-portal
fly secrets set AUTH_SECRET="$(openssl rand -base64 32)" -a fprs-portal
fly secrets set MAILERSEND_API_KEY="<mailersend api key>" -a fprs-portal
# Optional explicit allowlist (comma-separated; overrides the domain check)
fly secrets set AUTH_ALLOWED_EMAILS="hubert.thieriot@gmail.com" -a fprs-portal
# Drive-backed knowledge layer (general prompts, case studies, domain context).
# Folder must contain general/, case_studies/, domains/ subfolders and be
# shared (Viewer is enough — Content Manager if you also want the bot to
# update files) with the service account in keys/gcpserviceaccount.json.
fly secrets set KNOWLEDGE_DRIVE_FOLDER_ID="<drive folder id>" -a fprs-portal
fly secrets set GOOGLE_SERVICE_ACCOUNT_JSON="$(cat keys/gcpserviceaccount.json)" -a fprs-portal
# Multi-tenant (orgs/quotas/admin). BYOK_ENCRYPTION_KEY encrypts per-org
# bring-your-own Anthropic keys at rest — generate once and keep it STABLE
# (changing it makes stored org keys unreadable). FPRS_SUPERADMIN_EMAILS lists
# the emails that always have /admin access.
fly secrets set BYOK_ENCRYPTION_KEY="$(openssl rand -base64 32)" -a fprs-portal
fly secrets set FPRS_SUPERADMIN_EMAILS="you@founderspledge.com" -a fprs-portal
No FPRS_ENGINE_BEARER is set: the engine is flycast-only (see flyEngine.toml), so the private 6PN restricts callers to Fly apps in the same org. The bearer filter in the engine accepts unauthed requests when the var is unset, which is the desired state.
Database schema
The schema applies itself idempotently on first sign-in (every DDL in portal/src/db/migrate.ts is IF NOT EXISTS-guarded; portal/src/db/boot.ts is called from the Auth.js signIn callback). No separate migration step is required for deploy. To run it manually instead:
fly ssh console -a fprs-portal -C "node -e \"require('./src/db/migrate').ensureSchema().then(()=>process.exit(0))\""
Email delivery
Magic-link emails are sent via MailerSend's REST API (https://api.mailersend.com/v1/email). The sender domain (lucenceltd.com) is verified in the MailerSend account, so emails deliver to any allowlisted address. To swap providers or sender domain, update AUTH_FROM_EMAIL in flyPortal.toml and the sendVerificationRequest body in portal/src/auth/config.ts.
Flycast-only engine
flyEngine.toml was changed to flycast-only — no public [[services.ports]] block. After re-deploying the engine, https://fprs-engine.fly.dev:8080 is no longer reachable from the public internet; only Fly apps in the same org can hit http://fprs-engine.flycast:8080. No FPRS_ENGINE_BEARER is set on either side, since the 6PN-only attack surface already restricts callers to org peers.
Verify
- URL:
https://fprs-portal.fly.dev - CLI:
fly status -a fprs-portal,fly logs -a fprs-portal - Sign in flow: visit
/sign-in, request magic link, check inbox (delivered via MailerSend fromnoreply@lucenceltd.com).
fprs-app runtime layout
fprs-app now runs three processes in one Fly app:
nginxlistens on public port3838- Shiny listens on internal port
3839 - the MCP companion API (Plumber: schema, job input, results, insights) listens on internal port
8000
nginx routes:
/mcp-api/*to the Plumber API- all other paths to Shiny
The app will be available at https://fprs-app.fly.dev/
Local Docker Deployment
To run the app locally using Docker:
-
Build the Docker image:
-
Run the container:
The app will be available at http://localhost:3838