Skip to content

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:

  1. Install the flyctl CLI tool from fly.io

  2. Log in to fly.io:

    flyctl auth login
    

  3. Source environment to get GITHUB_TOKEN

       set -a; source .env; set +a
    

  4. Deploy the engine first, then the app:

    fly deploy -c flyEngine.toml --build-arg GITHUB_TOKEN=$GITHUB_TOKEN --flycast
    fly deploy -c flyApp.toml --build-arg GITHUB_TOKEN=$GITHUB_TOKEN
    

  5. Scale the app:

    fly scale count 10
    

  6. 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=remote
  • FPRS_WORKER_URL=http://fprs-engine.flycast:8080

That means a direct deploy like

fly deploy -c flyApp.toml --build-arg GITHUB_TOKEN=$GITHUB_TOKEN

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:

flyctl apps create fprs-mcp --org <your-org>

Use the same org as fprs-app / fprs-engine.

Deploy

From the repository root:

fly deploy -c flyMCP.toml --remote-only

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, not claude_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)

flyctl apps create fprs-portal --org <your-org>

Use the same org as the rest of the stack so flycast routing works.

Deploy

From the repository root:

fly deploy -c flyPortal.toml --remote-only

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 from noreply@lucenceltd.com).

fprs-app runtime layout

fprs-app now runs three processes in one Fly app:

  • nginx listens on public port 3838
  • 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:

  1. Build the Docker image:

    docker build -f DockerfileApp -t fprsapp --build-arg GITHUB_TOKEN=$GITHUB_TOKEN .
    docker build -f DockerfileEngine -t fprsengine --build-arg GITHUB_TOKEN=$GITHUB_TOKEN .
    

  2. Run the container:

    docker run -p 3838:3838 fprsapp
    

The app will be available at http://localhost:3838