Skip to content

Part VII · Operations

Deployment

Foundry ships from a protected main branch through a two-gate CI pipeline into an auto-deployed cloud instance. Nothing reaches production without a green CI run, and the database schema is brought up to date automatically at every backend boot — the same mechanism locally and in the cloud.

The path to production

flowchart TD
  A["Commit on a task branch"] --> B["PR to a feature/** integration branch"]
  B --> C["Task gate: gate-tier tests + Biome + gitleaks + Semgrep"]
  C --> D["Merge to integration branch"]
  D --> E["PR to main"]
  E --> F["Feature gate: gate tier + extended tier + Biome + gitleaks + Semgrep"]
  F --> G["Merge to protected main"]
  G --> H["ci workflow re-runs on the main push"]
  H -- "workflow_run: ci succeeded on main" --> I["deploy workflow on the VM's self-hosted runner"]
  I --> J["deploy.sh: fetch secrets, build images, apply Polis migrations, compose up"]
  J --> K["Backend boot: Drizzle migrations + seeds (fatal on failure)"]
  K --> L["Health gate: container healthcheck polled up to 240s"]
  L --> M["Live at foundry.swisper.ai"]

CI: the two-gate model

Backend tests are split into tiers (see backend/tests/TIERS.md), and the pipeline applies them at two gates (.github/workflows/ci.yml):

Gate Trigger What runs
Task gate PR into a feature/** integration branch Fast gate tier only: typecheck, access-control gate, migration drift guard, vitest gate tier against an ephemeral Postgres (parallel, per-worker DB clones)
Feature gate PR into main Everything above plus the slow extended tier (heavy / browser / subprocess tests) — the last automated check before production
Post-merge Push to main Both tiers re-run on the merged commit
Nightly 03:00 UTC cron Extended tier re-runs on schedule

Two details worth knowing as an operator:

  • Migration drift guard — CI runs npx drizzle-kit generate and fails the PR if that produces a new file: schema.ts must never change without a committed migration in backend/drizzle/. This is what makes boot-time migrations trustworthy.
  • Frontend build/unit and Playwright e2e are deliberately not part of the required gate.

Always-on quality workflows

These run on every PR (both gates) and on main pushes, as separate workflows:

Workflow What it does Blocking?
biome.yml Formatting + import organization + lint (Biome) Format/imports: yes. Lint: informational (baseline-then-ratchet)
gitleaks.yml Secrets scan of the working tree (directory mode, allowlist in .gitleaks.toml) Yes
semgrep.yml SAST (Semgrep OSS, p/security-audit + p/typescript); also a weekly Monday cron ERROR-severity findings: yes. Full report: informational
dependabot.yml SCA: weekly grouped update PRs for backend/frontend npm + GitHub Actions; security updates raised immediately PRs, not a gate

Protected main and auto-deploy

main is the trunk and is protected — changes land only via PR through the feature gate. Deployment (.github/workflows/deploy.yml) is then automatic, with a deliberate safety chain:

  • The deploy workflow triggers on workflow_run: the ci workflow completing on main — and its job only runs when that CI run succeeded. A green main is a precondition for production: even if someone bypassed branch protection, a red CI would not deploy.
  • It runs on a self-hosted runner on the production VM itself (label foundry-vm), so it deploys with the VM's attached service account (Secret Manager, Artifact Registry, Vertex). Only trusted main code ever runs there — PR CI runs on GitHub-hosted runners.
  • Deploys never interrupt each other (concurrency: deploy-main, no cancel-in-progress).
  • workflow_dispatch on the deploy workflow is the manual redeploy / break-glass path.

The deploy job resets the VM's checkout hard to origin/main and runs deploy/deploy.sh, then enforces a health gate: it polls the backend container's own Docker healthcheck (which curls 127.0.0.1:8001/health inside the container — the port is not published on the host) every 5 s for up to 240 s. If the backend never reports healthy, the deploy fails and the last 60 backend log lines are printed into the workflow run.

What deploy.sh does on the VM

  1. Mints a short-lived Artifact Registry token and fetches the GitHub Packages token (frontend build deps).
  2. Materializes deploy/.env (mode 0600) from Secret Manager via fetch-env.sh — JWT secret, encryption key, Polis shared secret, admin password, DB password. Secrets never appear in an image layer.
  3. Builds the images and starts the Cloud SQL Auth Proxy first, so the DB is reachable for migrations.
  4. Applies Polis migrations explicitly (@swisper/polis migrate-cli). Polis owns its own polis.* schema and — unlike Foundry — does not migrate automatically at boot; skipping this step would abort backend boot. The step is idempotent (tracked in polis.migrations_applied).
  5. Brings up the full stack (docker compose -f deploy/docker-compose.cloud.yml --env-file deploy/.env up -d).

Migrations at boot

On every backend boot (local rig and cloud alike, backend/src/index.ts):

  1. Versioned Drizzle migrations from backend/drizzle/ are applied (runMigrations).
  2. Seeds and backfills run: admin user, provider seeds, factory schema definitions, factory agents. Boot seeds are INSERT-only for user-ownable fields — they never clobber your edits.
  3. Any failure in this chain is fatal: the process logs the error and exits rather than serving with a broken schema or a broken chat path. In the cloud the container restarts (restart: unless-stopped) and, if it keeps failing, the deploy health gate turns the deploy red.

There is no manual "run migrations" deploy step for Foundry's own schema — merging a PR whose migration passed the drift guard is the whole procedure. The one exception is the Polis schema, which deploy.sh migrates explicitly before the backend starts (see above).

Cloud topology (v1)

One GCP Compute Engine VM (project swisper-489418, region europe-west6) running Docker containers; Postgres is off-box on Cloud SQL:

Container Role
backend Foundry API; boots an OpenCode subprocess + in-process Polis
cloudsql-proxy Cloud SQL Auth Proxy — DB access without public IP or static certs (swisper-489418:europe-west6:foundry-pg)
frontend-build One-shot job that builds the SPA into a shared static volume
caddy TLS termination for foundry.swisper.ai, static frontend, /api/* reverse proxy to backend:8001
  • Vertex AI auth is keyless (ADC via the VM's attached service account) — no model-provider key material on the VM.
  • Container logs ship to Google Cloud Logging via the Docker gcplogs driver; dual logging keeps docker compose logs working on the VM too (see Troubleshooting for the read commands).
  • Foundry's own login (JWT) is the authentication at the edge; there is no separate outer auth gate on the Caddy layer.

Operator quick reference (on the VM)

# restart a service
docker compose -f deploy/docker-compose.cloud.yml restart backend

# rollback: stop, check out a prior commit, redeploy
docker compose -f deploy/docker-compose.cloud.yml down
git checkout <prior-commit>
bash deploy/deploy.sh

Manual redeploy without SSH: trigger the deploy workflow via workflow_dispatch in GitHub Actions.

Local rig (docker compose)

The development/UAT rig is a plain compose stack from the repo root (docker-compose.yml):

Service Host port Notes
db 5435 Postgres 15, volume-backed
backend 8010 Same boot sequence as cloud — Drizzle migrations + seeds on start
frontend 3100 Vite dev server

Secrets and tunable runtime config live in a gitignored .env. An alt-ports overlay (docker-compose.alt-ports.yml) exists so a second stack (e.g. a feature-branch worktree rig) can run beside the default one on different host ports; team rigs run their own compose projects with their own port sets. The rig is functionally identical to the cloud instance except: local Postgres instead of Cloud SQL, no Caddy/TLS, and provider auth via configured keys instead of attached-SA ADC.