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 generateand fails the PR if that produces a new file:schema.tsmust never change without a committed migration inbackend/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: theciworkflow completing onmain— 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 trustedmaincode ever runs there — PR CI runs on GitHub-hosted runners. - Deploys never interrupt each other (
concurrency: deploy-main, no cancel-in-progress). workflow_dispatchon 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¶
- Mints a short-lived Artifact Registry token and fetches the GitHub Packages token (frontend build deps).
- Materializes
deploy/.env(mode 0600) from Secret Manager viafetch-env.sh— JWT secret, encryption key, Polis shared secret, admin password, DB password. Secrets never appear in an image layer. - Builds the images and starts the Cloud SQL Auth Proxy first, so the DB is reachable for migrations.
- Applies Polis migrations explicitly (
@swisper/polismigrate-cli). Polis owns its ownpolis.*schema and — unlike Foundry — does not migrate automatically at boot; skipping this step would abort backend boot. The step is idempotent (tracked inpolis.migrations_applied). - 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):
- Versioned Drizzle migrations from
backend/drizzle/are applied (runMigrations). - 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.
- 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
gcplogsdriver; dual logging keepsdocker compose logsworking 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.