This guide is written to be executed by an AI coding agent with shell access. Hand it the page and let it work. A human can also follow it manually, since every step is a plain command. Want the short version first? Read Get Started on GCP.
Doc set. Human orientation + concepts: Overview. Field-level contracts (manifest, profiles, configs, trust): Reference. After install, upgrades: Upgrade guide. This file is the executable install procedure. Paste it to an agent, or follow the commands yourself.Audience: an AI coding agent with shell access, working for a Velt customer. A human operator pastes this document (or its URL) to you as instructions. A human can also follow it manually, since every step is a plain command. Outcome: the complete Velt platform running entirely on the customer’s own infrastructure:
- Backend: Velt’s signed container deployed as Cloud Run services in the customer’s GCP project (Terraform), with a bootstrapped workspace and API keys.
- Console: the Velt admin console served from a static host the customer chooses (their existing host, a GCP-native stack, or Firebase Hosting), talking only to their own project.
- SDK: the Velt client SDK served from the customer’s own CDN/static host.
To the AI agent: read this first
- Execute phases in order (Phase 5 may run in parallel with Phase 4). Each phase ends with a Verify block; do not proceed until it passes.
- Maintain the state file (
velt-selfhost-state.json, contract below) after every step. If your session is interrupted at any point, a fresh session resumes by reading this guide plus the state file, never by memory. - Never run commands against any project other than
$PROJECT_ID. - All versions come from the release manifest (Phase 0). Never substitute a different component version than the manifest pins.
- Human-in-the-loop moments are known in advance. Show the operator the table in Phase 0 §“When you’ll be needed” before starting, and when a moment arrives, hand them the exact command or console URL, never a vague ask.
- Retryable errors: these are expected and must be retried with backoff, not
reported as failures, (a) Eventarc “Permission denied while using the Eventarc
Service Agent” within ~10 min of enabling the Eventarc API (wait 5 min, re-apply);
(b) 401s mid-
terraform applyfrom an expired access token (refresh auth, re-apply, Terraform state makes it idempotent); (c)webApps/service listings empty seconds after creation (propagation; wait 10 s, re-list); (d) a just-created service account returning “does not exist” on an IAM binding (wait ~15 s, retry). - Long waits are normal. The full run is ~2–4 hours of wall clock, most of it waiting on applies and index creation. Flagged inline. Tell the operator when a long wait starts so the run doesn’t look hung.
- Shell quirks: commands are written for bash/zsh. In zsh,
$VAR:somethingis parsed as a parameter modifier and silently corrupts the value, always write${VAR}when a colon (or any word character) follows. When a pipeline’s last command succeeds, earlier failures are masked; check the command you care about, not just the pipeline’s exit code. - If a command fails and it isn’t in the retryable list, check the Troubleshooting appendix before escalating to the operator.
State file contract: velt-selfhost-state.json
Keep it in the working directory (or the operator’s config repo). It is the single
source of truth for resume and for cross-phase handoffs. Shape:
Phase 0: Release resolution, inputs, preflight
0.1 Resolve the release
All release artifacts live in ONE public Artifact Registry repo, the same registry the container image is pulled from. Non-image artifacts (manifest, module archive, console bundle) are OCI artifacts fetched withoras (anonymous, no auth needed):
backend.digest pins the image (which gets its
own cosign verification in Phase 1). The manifest pins everything you will deploy:
Store the whole manifest in the state file. Verify: the manifest parses, has
schemaVersion: 2, and all fields above are non-empty. If schemaVersion is higher
than 2, re-fetch this guide from the Velt docs site before continuing.
0.2 Inputs to collect from the operator (all up front, once)
CONSOLE_HOST and SDK_HOST are explicit operator decisions. Both artifacts are
plain static files; any host meeting the phase’s serving rules works (Phase 4.2 for
the console, Phase 5 for the SDK), and most customers have a static host they’d
prefer over a new Firebase site. Present the choice; only pick for them if they say
“you choose”. The console has one extra serving requirement the SDK doesn’t: it is a
SPA, so unknown paths must fall back to index.html (Phase 4.2).
DNS feasibility check (do this NOW, not in Phase 5): if CDN_BASE or
CONSOLE_BASE uses a custom hostname (anything that isn’t *.web.app or an
already-live host), ask the operator explicitly: “Do you control the DNS zone for
<hostname>, and can you (or someone reachable now) create an A/CNAME record within
the hour?” A hostname in a zone the operator can’t touch (e.g. the company’s
production apex zone managed by another team) is the single biggest wall-clock risk
in this guide. If the answer is no or unsure,
resolve the hostname choice before starting Phase 1; a Google-managed cert cannot
go ACTIVE until the DNS record is visible.
Reused-project gate (do this NOW if PROJECT_ID has hosted a Velt deployment
before, even one that was fully terraform destroyed): a previously-deployed
project carries two invisible constraints that will otherwise fail Phase 2 hours in:
- Cloud Tasks queue-name tombstones. Deleting a queue blocks re-creating a
queue with the same name in the same region, Terraform fails with
FAILED_PRECONDITION: … existed too recently. Google documents ~7 days; observed sometimes shorter (~3 days), never assume less than 7. - Cloud Run regions-per-project cap, and destroy does NOT release it. The
default
setencrypteddata_regions = nulllayout pins 4 extra regions (europe-west1, asia-southeast1, australia-southeast1, asia-northeast1); with the home region that is 5: exactly the default Cloud Run per-project region cap, zero headroom. Region initialization survives a destroy, so switching the home region on a reused project asks for a 6th region and every service fails withProject failed to initialize in this region due to quota exceeded/Resource readiness deadline exceeded. Switching regions is never the escape route from a tombstone.
REGION identical to the previous
deployment and either wait out the tombstone window or accept that the Phase 2
queue-create step may fail until it clears. If a new home region is genuinely
required, that is a Cloud Run quota-increase request (regions per project) plus the
operator’s sign-off, not a tfvars tweak.
0.3 When you’ll be needed (show the operator this table now)
Issue the OAuth ask (moment #2) right now, at Phase 0. Every value it needs is
already known (
PROJECT_ID, CONSOLE_BASE), the exact wording is in Phase 4.3,
and the operator can create the client while you deploy. Collecting it up front turns
what is otherwise the single largest idle wait of the run (deployment done, waiting on
a human) into a parallel task. Note: the project must exist before the operator can
open the credentials page, if you are creating the project in Phase 1, issue the ask
immediately after gcloud projects create.
Everything else is automated. Expected total wall clock: 2–5 hours (≈ 15 min
Terraform applies, ≈ 10 min–2.5 h workspace bootstrap, index creation dominates and
varies run to run; the rest verification and waiting).
Expected idle cost of the deployed stack: Cloud Run scales per catalog min-instances;
Secret Manager/Artifact Registry are cents per month. Ask the operator whether this is
a production deployment (keep catalog min-instances) or an evaluation
(min_instances_override = 0 → near-$0 idle).
0.4 Tooling preflight (hard requirements: check ALL before starting)
(Install hints show Homebrew; on Linux/CI use your package manager or each tool’s
official installer; any current version works.)
Auth probes: a listed account is NOT proof of working auth, so probe with real calls:
export GOOGLE_OAUTH_ACCESS_TOKEN=$(gcloud auth print-access-token), but tokens live
~60 min and a long apply can outlive one; prefer real ADC.
Verify (phase gate): every tool present; all three auth probes pass; manifest
stored; inputs recorded in the state file; operator has acknowledged the
human-moments table. Mark 0-preflight: done.
Phase 1: GCP project bootstrap
Everything here is project-lifecycle infrastructure that the Terraform module deliberately does not model.1.1 Project + billing (human moment #1)
Operator ask (verbatim, if the link fails): “I need billing linked to$PROJECT_ID. Either grant meroles/billing.useron your billing account, or have a billing admin run:gcloud billing projects link $PROJECT_ID --billing-account=<ACCOUNT_ID>”
1.2 Firebase + APIs
1.3 Firestore, Realtime Databases, Auth, default bucket
1.4 Verify + copy the signed image
Verify against Velt’s registry, before copying (signatures attach to the digest in the source repo), then copy by digest into the customer’s own Artifact Registry:artifacts.imageRef in the state file as
<local repo path>@<digest>. Terraform gets the digest form, never the tag.
1.5 Vulnerability-scan the image copy (operator’s infosec policy)
Ask the operator whether their organization requires vulnerability scanning of third-party images before deployment (most enterprises do; the AWS equivalent is ECR scan-on-push). If yes, run it now against THEIR registry copy and get their sign-off before Phase 2; a finding dispute discovered mid-deploy is far more expensive than one found here. GCP-native flow (Artifact Analysis): either enable auto-scan-on-push in the customer project (each pushed image version is scanned automatically, ~$0.26/image):backend.knownFindings (each entry: {id, package, severity, fixAvailable}):
knownFindings is
missing or empty, fall back to the manifest’s releaseNotes URL; if that is also
unavailable, present the raw findings to the operator for judgment (unfixable
Debian base-OS packages are the expected class) and record their explicit decision.
Record the scan result reference and the operator’s decision in the state file
(humanSteps.infosecScan: done).
Verify (phase gate): Firestore ACTIVE; 5 RTDB instances ACTIVE
(curl -sf "${AUTH[@]}" https://firebasedatabase.googleapis.com/v1beta/projects/$PROJECT_ID/locations/-/instances);
Identity Platform probe outcome recorded (200 vs 404, drives the Phase 2.3 import);
default bucket name recorded; cosign Verified OK captured; pushed digest ==
manifest digest; infosec scan done + signed off (or operator explicitly waived it).
Mark 1-project-bootstrap: done.
Phase 2: Backend deployment (Terraform)
2.1 Download + verify the backend module archive
The manifest’sbackend.moduleRef is a self-contained archive: terraform/ (the
deployment blueprint; its README.md is the authoritative variable reference) and the
precompiled functions/lib/ (the deployment-profiles CLI that Terraform runs; no npm
install or build needed, only Node ≥ 20). Always use this guide from the Velt docs
site (or your current copy), not any snapshot that may ship inside an older archive.
MODULE_DIR; if terraform plan shows ANY destroys on a fresh project, stop ,
you are on the wrong state.
2.2 tfvars
Writevelt.auto.tfvars (keep it in the operator’s config repo):
2.3 Static gates
VELT_* variable or an unknown profile, that is the
the module validates fail-closed. Fix the tfvars, do not work around it.
Export these before ANY apply: the firebaserules provider calls route their quota
through the billing project, and the later apply 403s without them:
CONFIGURATION_NOT_FOUND: initializeAuth never took), SKIP the import. The module
creates the config from scratch, and importing a non-existent config fails:
2.4 Three-pass apply
Cloud Run refuses a revision that mounts a secret with no enabled version, so: containers first, seed, then services.2.5 Close the cacheddata bootstrap loop
terraform output -json service_urls > service-urls.json and record the path in
the state file (Phase 4 and the final report use it).
2.6 Backend smoke tests
gcloud run services list --project="$PROJECT_ID" | grep -c False → 0); final plan
converged (“No changes”); both smokes return the expected envelopes;
velt-console-config.json + console-firebase.json were emitted next to the module.
Mark 2-backend: done.
Phase 3: First-workspace bootstrap (provision-cli)
A freshly deployed backend has no owner. This CLI mints the first workspace, a testing API key, and the first production API key with its real per-workspace infrastructure. Run it ONCE.- Mount the SA key under a dedicated path like
/keys: mounting under/tmpinside the container fails oddly (and see the host-side/tmpsharing warning above, both sides of the mount have/tmptraps). - Expect a long “silent but busy” stretch after progress stops: composite-index
creation for the new databases runs in-process and the CLI exits only when it
drains. Wall clock is typically tens of minutes and can exceed two hours ,
index build time varies with Firestore load and is not a hang. Tell the operator
up front; do not kill it. (Poll
gcloud firestore operations list --database=<storeDbId>in another shell if you want visible progress.) - The result JSON goes to stdout, but stray log lines may leak into it, so do not
assume the captured file is pure JSON. Extract the LAST top-level JSON object
(brace-match from the final
"schemaVersion": 1occurrence) intobootstrap-result.jsonbefore parsing. Its exact shape (schemaVersion: 1):
productionStoreDb.compositeIndexesConverged and
productionStoreDb.metadataSeeded are both true when present. Still verify
the production DB’s composite indexes and apiKey metadata doc below. Treat
those checks as defense-in-depth even when the CLI reports converge success.
Confirm the production store DB’s composite-index count matches the sdktest
database (the CLI awaits and fail-louds the sdktest pass; use it as ground truth):
get() this doc to resolve the effective access type; a missing doc makes
the rules deny everything (SDK: “Documents provided are all denied”):
3-workspace: done.
Phase 4: Console
The backend apply already produced the two artifacts the console needs:velt-console-config.json (runtime config: the project’s real firebaseConfig, every
service URL in functionUrls, the sendLoginLink/aiChat endpoints) and
console-firebase.json (Hosting config with the SPA rewrite).
4.1 Fetch + verify the console bundle (pins from the manifest)
4.2 Stage + deploy to the host the operator chose (CONSOLE_HOST, Phase 0.2)
Stage first, identical for every host:
Then deploy per the operator’s choice, this was their decision; do not silently
substitute another host:
-
Operator’s existing static host / GCP-native stack (e.g. GCS bucket + external
HTTPS LB + Cloud CDN, or Cloud Run): upload
$DEPLOY_DIR/dist/with their tooling (or provision the stack in this project if they asked you to), honoring the serving rules above. On a GCS+LB setup the SPA fallback is the one that needs care: set the backend bucket’snot_found_page(errorDocument) toindex.html, and note GCS serves it with a 404 status, which browsers render fine but naive health checks flag; probe/(200) rather than a deep link. A Google-managed cert on the LB needs the operator’s DNS record and can take 15–60 min to go ACTIVE after DNS resolves. Start it early and continue Phase 5 in parallel. (If Phase 5 also provisions a GCP-native host, share ONE LB across both surfaces: one IP, one managed cert covering both hostnames, and a host-routed URL map with a backend bucket per surface, this halves cert wait and IP/DNS churn.) The/__/auth/*proxy on a GCS+LB console host (the serving rule above , GCS buckets can’t serve these paths themselves):
- Firebase Hosting site in this project (only if the operator chose it) ,
CONSOLE_BASEbecomeshttps://<CONSOLE_SITE_ID>.web.app; the Terraform-emittedconsole-firebase.jsonalready carries the SPA rewrite:
firebase login --reauth
isn’t possible, deploy non-interactively with a short-lived service account instead:
mint an SA with roles/firebasehosting.admin, export GOOGLE_APPLICATION_CREDENTIALS=<key.json>,
and run npx --yes firebase-tools deploy --only hosting --project "$PROJECT_ID" --non-interactive
(delete the SA key immediately after, same hygiene as Phase 3).
Also give velt-console-config.json a no-cache header if editing firebase.json
(config changes then take effect without redeploying the bundle).
Custom (non-web.app) console domains, one extra step: Terraform already
authorizes the velt_portal_url hostname on Identity Platform
(console_manage_identity_platform = true derives authorized_domains from it), but
verify after apply: Identity Platform → Settings → Authorized domains must list the
CONSOLE_BASE hostname, or Google sign-in will reject the origin.
4.3 Google sign-in (human moment #2: cannot be automated)
You should have issued this ask back in Phase 0 (all its values are known up front) , if so, just collect the id/secret now. If not, issue it verbatim:Operator ask (verbatim): “OpenFeed them to Terraform and apply (enables thehttps://console.cloud.google.com/apis/credentials?project=<PROJECT_ID>→ Create Credentials → OAuth client ID → Web application. Authorized JavaScript origins:<CONSOLE_BASE>. Authorized redirect URIs:https://<PROJECT_ID>.firebaseapp.com/__/auth/handlerAND<CONSOLE_BASE>/__/auth/handler. Save, then paste me the client id and secret.”
google.com provider on Identity
Platform):
console_manage_identity_platform = true,
but the magic-link email sends via Customer.io, without a real CUSTOMER_IO_API_KEY
secret version it fails at send time. Google OAuth is the primary path.)
Verify (phase gate):
4-console: done. (The sign-in itself is proven in Phase 6.)
Phase 5: SDK on the customer’s CDN
May run in parallel with Phase 4. Choose the SDK version: the manifest’ssdk.testedVersion unless the operator pins another one, anything >= sdk.minVersion
is supported. The version must equal what the app’s installed wrapper targets
(npm ls @veltdev/client @veltdev/react in the app).
The release registry carries the testedVersion’s SDK files as sdk.bundleRef (the
pristine npm tarball, sha256-pinned by the signed manifest), prefer it:
testedVersion, the registry has no
artifact for it, fetch that version from npm instead (same staging steps):
npm pack "@veltdev/sdk@$VELT_VERSION" && tar -xzf veltdev-sdk-*.tgz.
Deploy staging/ to the host the operator chose in SDK_HOST (Phase 0.2, this was
their decision; do not silently substitute another host):
- Operator’s existing CDN / static host (the common case): upload
staging/preserving thelib/sdk@<version>/layout, using their normal upload tooling (aws s3 sync,rsync,wrangler, CI job, …). If you don’t have their upload credentials, hand them thestaging/directory plus the serving-rules table below as a checklist and wait for the URL. - GCS + external HTTPS LB + Cloud CDN in this project: recipe below. Start it as EARLY as Phase 5 allows: the managed cert is the long pole (15–60 min after the DNS record is visible), and the DNS record is an operator action (hand them the IP the moment it’s reserved; feasibility was confirmed in Phase 0.2).
- Firebase Hosting site in this project (only if the operator chose it): create a
site and deploy, site id must be globally unique;
CDN_BASEbecomeshttps://<site-id>.web.app:
5.1 Assemble the app’s selfHosted config (deliverable: always produce this)
Serving the SDK from the customer’s CDN only moves the code; the SDK’s runtime
endpoints still default to Velt SaaS. The config.selfHosted object repoints every
endpoint at the customer’s own deployment, without it there is no zero-egress. Every
value is already on disk from earlier phases; generate it, don’t hand-type it:
artifacts.selfHostedConfigPath in the state file. Point the app at
it. proxyDomain is origin-only, and always pin version:
initVelt(apiKey, { proxyDomain, version, selfHosted }).) If the app has
a CSP, add CDN_BASE to script-src and remove cdn.velt.dev. Without selfHosted
the SDK code loads from the CDN but every API call still goes to Velt SaaS; the Phase 6
zero-egress audit would fail.
Verify (phase gate):
5-sdk-cdn: done.
Phase 6: End-to-end acceptance (the payoff)
Human moment #4: the operator (or you, with browser tooling) proves the full loop. First, ASK the operator: “Do you have a test app (or a dev build of your real app) we can wire the SDK into?” Do not silently spin up a synthetic page.- They have one (preferred): apply the Phase 5 changes there, swap the SDK
source to
proxyDomain+ pinnedversion, add the generatedselfHostedobject and the production key, and run the checks below in THAT app. A real app also exercises what a synthetic page can’t: CSP headers, the bundler/framework wrapper (@veltdev/reactetc.), and the app’s own auth flow. - They don’t: create a minimal test page yourself (plain HTML, SDK loaded from
CDN_BASE, the same Phase 5 config, an identified test user + test document, the comments + comment-tool components. Follow the SDK quickstart for the current init shape) and serve it on a localhost port. Tell the operator it’s a stand-in and that the loop should be re-proven inside their app when one exists.
sa error
telemetry (Cloud Run logs of velt-sa, which carries Firestore listener errors with
index-creation links) BEFORE probing backend endpoints by hand. Hand-built curl
calls to callable endpoints carry no Firebase ID token, so auth-gated endpoints
return errors like “Api key not found” that look like product bugs but aren’t
.
- Console sign-in: open
<CONSOLE_BASE>, sign in with Google asOWNER_EMAIL→ lands on the dashboard showing the bootstrapped workspace and both API keys. (Admins are matched by email; the first sign-in with a seeded email lands in the workspace.) - SDK loop: open the app chosen above (with the Phase 5 config,
proxyDomain+version+ the generatedselfHostedobject, and the production key), DevTools → Network, filtervelt:velt.js+ chunks load fromCDN_BASE, nothing fromcdn.velt.dev;window.Velt.version=== the pinned version;- create a comment → it renders; the API calls in the Network tab go to the
customer’s own
*.run.appservices.
- Cross-surface proof: the comment created in step 2 is visible in the console’s data browser (comments/documents view), SDK → customer backend → customer console, one loop, zero Velt infrastructure.
- Zero-egress audit: across both browser sessions, the network log contains no
request to any
velt.devor Velt-owned host (nocdn.velt.dev,console.velt.dev,api.velt.dev, Velt Sentry/analytics).
6-acceptance: done.
Final report: deliver this to the operator
- Console URL, and where
velt-console-config.jsonlives. - Workspace id + where the bootstrap secrets are stored (testing key, production key,
REST header pair), plus the reminder that the production key’s
allowedDomainsis the SDK gate (managed in the console thereafter). - SDK CDN base + pinned version, and the app config snippet in use, including the
full generated
selfHostedconfig object (velt-selfhosted-config.jsoncontents inline in the report; it contains no secrets: only public URLs, the web-app firebaseConfig, and the module list). This is the artifact the customer’s app team actually needs to integrate. - Deployed self-host release version (from the manifest), profile + opt-in modules, and the tfvars/state-file locations.
- Anything skipped or degraded (e.g. email-link sign-in pending a Customer.io key; optional modules not enabled), and any FAILED_PRECONDITION index warnings seen in logs (report these to Velt, Velt uses them to keep indexes complete in future releases).
Upgrades (summary)
A new self-host release = a new manifest. Diff the new manifest againstrelease in the state file; re-run only the phases whose pins changed
(backend → Phase 1.4 image copy + Phase 2 applies with the new module archive;
console → Phase 4; SDK → Phase 5 into a NEW lib/sdk@<version>/ folder, keeping the
old one for instant rollback). The full agent-executable procedure (delta
computation, compatibility fences for sdk.minVersion and console config schema,
secrets-delta seeding, Terraform state carry-over, and per-component rollback
anchors) is the companion Upgrade guide.
Troubleshooting
This guide is evergreen: every version-specific value (image digest, module sha, console/SDK versions, registry) comes from the signed release manifest at run time, so the guide text itself does not change per release. The canonical copy lives on the Velt docs site. (
guideVersion in the state file tags the state-file contract for
resume, not a release.) Release enumeration:
oras pull -o . us-docker.pkg.dev/velt-sdk/velt-releases/velt-selfhost-index:latest
(writes index.json, newest first).
