Skip to main content
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:
  1. Backend: Velt’s signed container deployed as Cloud Run services in the customer’s GCP project (Terraform), with a bootstrapped workspace and API keys.
  2. 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.
  3. SDK: the Velt client SDK served from the customer’s own CDN/static host.
When finished, the customer’s app and admin surface make zero runtime requests to any Velt-owned 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 apply from 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:something is 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:
Secrets (API keys, auth tokens, the provision-cli result JSON, SA keys) go in the operator’s secret store. Record paths/references in the state file, never values.

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 with oras (anonymous, no auth needed):
The signed manifest is the trust root for everything else: its sha256 fields pin the module archive and console bundle, and 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:
  1. 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.
  2. Cloud Run regions-per-project cap, and destroy does NOT release it. The default setencrypteddata_regions = null layout 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 with Project failed to initialize in this region due to quota exceeded / Resource readiness deadline exceeded. Switching regions is never the escape route from a tombstone.
Decision rule: prefer a fresh project for any re-run within ~7 days of a destroy. If the project must be reused, keep 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:
Non-interactive fallback if ADC can’t be refreshed right now: 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)

Billing must be linked before most APIs can be enabled. Test permission first (side-effect-free), then act or escalate precisely:
Operator ask (verbatim, if the link fails): “I need billing linked to $PROJECT_ID. Either grant me roles/billing.user on 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:
Record 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):
or run a one-shot on-demand scan (no per-project API cost surprises; ~2–4 min on this image):
If the org routes all third-party images through a central scanner (ECR, Harbor, Prisma, …), hand their infosec team the image digest plus Velt’s signed SBOM; it is attached to the image as a cosign SPDX attestation and verifiable offline:
Setting expectations (show the operator): a full Node.js runtime image will never scan clean. Findings cluster as (a) Debian base-OS packages with no fix available upstream (the majority of CRITICAL/HIGH findings are this class); (b) npm/application findings, Velt’s release gate blocks on fixable CRITICAL/HIGH. Decision rule: compare your scan’s CRITICAL/HIGH findings against the release’s known-findings list, which ships inside the signed manifest as backend.knownFindings (each entry: {id, package, severity, fixAvailable}):
Findings in that list = accepted risk, proceed on operator sign-off; a CRITICAL/HIGH NOT in the list = stop and contact Velt before deploying. If 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’s backend.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.
Never run Terraform in a directory that already carries state from another install (a reused module dir, or a checked-out source tree that may hold live state for a different project). Foreign state turns a fresh install into a destroy-and-recreate plan against the other project. Always extract into a fresh MODULE_DIR; if terraform plan shows ANY destroys on a fresh project, stop , you are on the wrong state.

2.2 tfvars

Write velt.auto.tfvars (keep it in the operator’s config repo):

2.3 Static gates

If the plan fails naming a missing 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:
Identity Platform import (CONDITIONAL, key off the Phase 1.3 verify probe): if the probe returned 200 (Auth initialized), the singleton config already exists and MUST be imported (creating it fails ALREADY_EXISTS). If it returned 404 (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.
Expected retryables during pass 3: the Eventarc service-agent propagation error (wait ~5 min, re-apply) and expired-token 401s (refresh, re-apply). Both converge.

2.5 Close the cacheddata bootstrap loop

Save 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

Verify (phase gate): all services Ready=True (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.
Operational notes:
  • Mount the SA key under a dedicated path like /keys: mounting under /tmp inside the container fails oddly (and see the host-side /tmp sharing warning above, both sides of the mount have /tmp traps).
  • 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": 1 occurrence) into bootstrap-result.json before parsing. Its exact shape (schemaVersion: 1):
Move it to the operator’s secret store; record only its location in the state file. Verify (phase gate): exit 0; result JSON has non-empty workspace id, testing key, and production key; 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):
Also verify the apiKey metadata doc exists in the production store DB. Security rules get() this doc to resolve the effective access type; a missing doc makes the rules deny everything (SDK: “Documents provided are all denied”):
Then prove the mint end-to-end:
Mark 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:
Non-negotiable serving rules for ANY console host (each one, if violated, breaks the console at runtime): 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’s not_found_page (errorDocument) to index.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_BASE becomes https://<CONSOLE_SITE_ID>.web.app; the Terraform-emitted console-firebase.json already carries the SPA rewrite:
If the firebase CLI rejects its stored token and interactive 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): “Open https://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/handler AND <CONSOLE_BASE>/__/auth/handler. Save, then paste me the client id and secret.”
Feed them to Terraform and apply (enables the google.com provider on Identity Platform):
(Email-link sign-in is already enabled by 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):
Mark 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’s sdk.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:
If the operator pinned a version OTHER than 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 the lib/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 the staging/ 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_BASE becomes https://<site-id>.web.app:
Non-negotiable serving rules for ANY host (each one, if violated, breaks the SDK at runtime):

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:
Record the path as artifacts.selfHostedConfigPath in the state file. Point the app at it. proxyDomain is origin-only, and always pin version:
(Vue/vanilla: 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):
Mark 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 + pinned version, add the generated selfHosted object 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/react etc.), 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.
Diagnostic rule either way: if comments misbehave, read the SDK’s 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 .
  1. Console sign-in: open <CONSOLE_BASE>, sign in with Google as OWNER_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.)
  2. SDK loop: open the app chosen above (with the Phase 5 config, proxyDomain + version + the generated selfHosted object, and the production key), DevTools → Network, filter velt:
    • velt.js + chunks load from CDN_BASE, nothing from cdn.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.app services.
  3. 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.
  4. Zero-egress audit: across both browser sessions, the network log contains no request to any velt.dev or Velt-owned host (no cdn.velt.dev, console.velt.dev, api.velt.dev, Velt Sentry/analytics).
Verify (final gate): all four pass. Mark 6-acceptance: done.

Final report: deliver this to the operator

  • Console URL, and where velt-console-config.json lives.
  • Workspace id + where the bootstrap secrets are stored (testing key, production key, REST header pair), plus the reminder that the production key’s allowedDomains is the SDK gate (managed in the console thereafter).
  • SDK CDN base + pinned version, and the app config snippet in use, including the full generated selfHosted config object (velt-selfhosted-config.json contents 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 against release 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).