LumiBaseDocs

Deployment Integrations (Vercel / Netlify)

Trigger, monitor, and debug deployments on external hosting providers (Vercel, Netlify, or a generic HTTP deploy hook) directly inside LumiBase — no jumping to the provider dashboard.

Tables: deployment_targets, deployments (packages/database/src/schema/deployments.ts) · Service: apps/cms/src/services/deployment/ · Mounted at /api/v1/deployments.

1. Goal & model

Each deployment target configures one connection to one project on one provider, scoped to a site. From that target you trigger builds and watch their status without leaving Studio.

  • Trigger — kick off a build/deploy manually, or automatically when content changes.
  • Monitor — track status (queued → building → ready/error) in near real time.
  • Debug — view build logs, error messages, branch/commit, and a link to the provider deployment.

One target = one (site, provider, project). One deployment = one build/deploy run on that target, with a LumiBase-normalized status and log excerpt.

  • Edge-native: provider adapters use only fetch (REST) — they run on both Cloudflare Workers and Docker through the shared runtime.
  • Multi-tenant: every deployment_targets / deployments row carries site_id, and every query is scoped by it.

2. Providers

ProviderproviderTriggerStatus
VercelvercelDeploy Hook / REST POST /deploymentsPolled + inbound webhook
NetlifynetlifyBuild Hook / RESTPolled + inbound webhook
Generic HTTPhttpAny deploy-hook URL (POST)Polled where the endpoint exposes status

Adapters live in apps/cms/src/services/deployment/providers/. Each provider maps its own states onto the normalized status enum: queued | building | ready | error | canceled.

3. Token security

Provider API tokens are never stored in plaintext. On create, the token is encrypted via the runtime KeyProvider (ADR-002) and stored as token_ciphertext + token_key_id. It is decrypted only at call time to talk to the provider, and is never returned by the API — the DeploymentTargetResource returned to clients omits the token entirely.

4. Status sync

Two complementary paths keep a deployment's status current:

  • Status poller — a 30s cron (apps/cms/src/services/deployment/status-poller.ts, registered in serve.ts) sweeps every non-terminal deployment and syncs it from the provider. Each sync is a guarded conditional update (only flips queued/building) and a single provider error never aborts the sweep — re-running is a no-op.
  • Inbound webhook — POST /api/v1/deployments/webhook/:provider (public, pre-auth) receives provider-pushed status events. It is intentionally outside the authenticated surface because the provider authenticates by signing the request body, not a bearer token; it still runs withTenant + withDb. The signature is verified for real over the raw body via Web Crypto: Vercel uses HMAC-SHA1 (x-vercel-signature), Netlify a JWS/HS256 (x-webhook-signature), compared in constant time. For Netlify the signature check alone is not enough — the body is not part of a JWS signing input — so the payload must also bind to these bytes: its sha256 claim is compared against the digest of the raw body (a payload that is the raw body is also accepted). Without that, an authentic JWS captured from one notification would authenticate a different body. A signature of the wrong length, an undecodable payload or a body tampered after signing all return 401, never a 500. The shared secret is read from the per-site setting deployment.webhook.<provider> (value { "secret": "…" }) — never from a request header. If no secret is configured, every webhook request is rejected (401 INVALID_SIGNATURE); status still syncs via the poller.

5. REST API

All routes are under /api/v1/deployments, on the authenticated + tenant-scoped surface (withAuth).

Method & pathPurpose
GET /targetsList configured targets (token omitted)
POST /targetsCreate a target (token encrypted on write)
PATCH /targets/:idUpdate a target
DELETE /targets/:idDelete a target
POST /targets/:id/deployTrigger a build/deploy
GET /List deployments (filter by targetId / status)
GET /:idGet one deployment
GET /:id/logsFetch build logs
POST /:id/refreshForce a status sync from the provider

SDK (@lumibase/sdk): client.deployments.targets.{list,create,update,delete,deploy} and client.deployments.{list,get,logs,refresh}, typed with DeploymentTargetResource / DeploymentResource.

5a. Trigger rate limit

Every accepted trigger starts a provider-billed build, so POST /targets/:id/deploy is capped per target on top of the admin gate (apps/cms/src/services/deployment/trigger-rate-limit.ts):

TierBudget
Burst5 triggers / 60 s
Sustained30 triggers / 3600 s
  • Scope — budget keys carry both site_id and the target id (rl:deploy:<tier>:<siteId>:<targetId>), so two targets never share a budget and one site can never exhaust another's.
  • Applies to every trigger source — manual, flow (auto) and agent triggers all consume the same per-target budget, because the limit protects the tenant's provider account regardless of who asked. Flow auto-deploys normally stay far below it thanks to coalesceWindowMs; a coalesced trigger reuses an existing build and does not spend budget (the check runs just before the outbound provider call).
  • Exceeded — the request is rejected with 429 and { "errors": [{ "code": "RATE_LIMITED", "retryAfterSeconds": <n> }] } plus a Retry-After header. No deployments row is created for a rejected trigger; the attempt is audited as deployment.trigger.rate_limited.
  • Fail-open — if the rate limiter backend is unreachable the trigger is allowed; a limiter outage must not take deploys down. The admin gate and the status='active' target check still apply.

6. Auto-deploy via Flows

The "deploy automatically when content changes" path is wired through the Flows engine, not a bespoke trigger. Two operations are registered on the flow runtime (apps/cms/src/services/flow-service.ts):

OperationOptionsEffect
deploy:triggertargetId (required), branch?, reason?Triggers a deploy via the shared DeploymentService (same path, guards and audit as the manual API). Records triggerSource='auto' and links the flow runId for provenance.
deploy:statusdeploymentId (required)Syncs and returns one deployment's status so a flow can branch on it.

Wire an auto-deploy by creating a Flow with triggerType: 'event' (e.g. an item publish in a collection) whose graph contains a deploy:trigger node pointing at a target. Both operations are runtime-bound: db, siteId, the KeyProvider (keys) and runId are supplied by the flow run environment (routes/flows.ts), so a deploy launched from a flow reuses the encrypted token and SSRF/audit guards exactly like the manual trigger. Missing environment or a missing targetId/deploymentId fails closed with a clear error before any provider call.

7. AI skills & HITL

Four governed skills let the AI Copilot operate deployments (packages/ai-skills/src/skills.ts, handlers in ai-harness.ts):

SkillCapabilityRisk
listDeploymentTargets / listDeployments / getDeploymentStatusdeployments:readsafe
triggerDeploymentdeployments:writedangerous → HITL before_execute below autopilot

triggerDeployment triggers an outward-facing side effect (a build on an external host), so it is classified dangerous: below autopilot autonomy it is routed through human approval (ai_approvals) instead of executing directly.

Like the flow operations above, all four skills are runtime-bound: their handler builds a site-scoped DeploymentService from db, siteId and the KeyProvider (keys), so every harness construction that can execute skills must pass keys — the request paths (routes/ai.ts chat + approval execution, routes/mcp.ts) take it from c.get('runtime').keys, and the queued agent-runs worker (execution: 'async') takes it from its worker deps. Without it the skill fails closed with DEPLOYMENTS_NOT_CONFIGURED before any provider call. A source-scan tripwire (apps/cms/src/__tests__/ai-harness-keys-context.test.ts) fails CI if a new construction site omits it.

8. Studio

A Settings → Deployments page (apps/studio/src/modules/settings/deployments-page.tsx) lists targets and recent deployments, with controls to add a target, trigger a deploy, refresh status, and view logs. It lives at /settings/deployments (and /<adminPath>/settings/deployments on instances with a custom admin path), reachable from the Integrations group of the settings sidebar.

9. Setup notes

  • Capabilities: adds deployments:read / deployments:write — grant them to the admin role on upgrade. (No default agent role carries deployments:write, so the triggerDeployment skill only runs for callers explicitly granted it — deploy is never auto-available to ordinary agents.)
  • No seed: targets are created by an admin after setup; there are no defaults.
  • RLS: lumibase_deployment_targets and lumibase_deployments are site-isolated via packages/database/migrations/rls-policies.sql (per project convention, not in the table migration).
  • Inbound webhooks (optional): to receive provider-pushed status, set the per-site setting deployment.webhook.<provider> to { "secret": "<shared secret>" } and configure the same secret on the provider's webhook. Without it, status syncs via the 30s poller only.
  • Tables lumibase_deployment_targets and lumibase_deployments are part of the consolidated 0000_lumibase_init schema migration.
  • See the spec under .kiro/specs/deployment-integrations/ for the full requirements and design.
Last modified: 26/09/2026