> ## Documentation Index
> Fetch the complete documentation index at: https://velt.dev/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Install Guide

> Copy-paste instructions for the AI coding agent that installs Velt full self-hosting in your Azure subscription.

Your AI coding agent runs the install. This page holds the instructions it follows, so you don't need to read them yourself.

1. Check the prerequisites and decide your inputs in [Get Started on Azure](/docs/self-hosting/full/azure/overview).
2. Copy the instructions below with the copy button.
3. Paste them into your agent along with the prompt from Get Started, then stay available for the steps that need a person.

<Note>
  The instructions mention companion runbooks (Validation, Upgrade, REST API, SAML and SCIM, Backup and Restore) that are not published on this site yet. Ask Velt for them.
</Note>

## Agent instructions

````markdown Agent instructions (Azure) expandable theme={null}
# Velt Full Self-Hosting — Install Guide (Azure)

> **Doc set.** On AWS, use the [Install Guide (AWS)](https://docs.velt.dev/self-hosting/full/aws/install) instead.
> After install: the Validation runbook (acceptance evidence) and the Upgrade Guide; the REST API this deployment
> can serve is off by default (Phase 4.1 turns it on). Ask Velt for these runbooks.
> 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 — every step is a plain command.

**Outcome:** the Velt portable backend running entirely inside the customer's own
Azure subscription:

1. **Substrate** — an AKS cluster, Azure Database for PostgreSQL Flexible Server (private,
   customer-managed key), Key Vault, a Blob storage account, managed identities and an
   Application Gateway ingress, created by Velt's signed Terraform module.
2. **Application** — Velt's signed container images (gateway, workers, migrate) and the
   in-cluster Valkey cache, installed from Velt's signed Helm chart, pulled only from the
   customer's own container registry.
3. **Console** — the Velt admin console served from the same ingress, signing in through the
   customer's own identity provider (Entra ID or any OIDC provider).
4. **SDK** — the Velt client SDK bundle served from the same ingress, so the customer's pages
   download nothing from Velt.
5. **A first workspace** — an API key, an organization and an admin user seeded into the
   deployment's own database.

When finished, the customer's app, the console and the backend make **zero runtime requests
to any Velt-owned host**. Velt learns which version runs only from the receipt file the
operator chooses to send.

**Cloud scope:** Azure commercial (`AzureCloud`) and Azure Government (`AzureUSGovernment`).
Every command below is the same on both; the few values that differ are called out in boxes
marked **Azure Government**.

---

## To the agent executing this — read this first

- **Execute phases in order.** Each phase ends with a **Verify** block carrying a
  *do not continue until* condition; do not proceed until it passes.
- **Maintain the state file** (`velt-selfhost-azure-state.json`, contract below) after
  every step. If your session is interrupted, a fresh session resumes by reading this
  guide plus the state file — never by memory.
- **Never run against any subscription other than `$SUBSCRIPTION_ID`.** Every resource
  is prefixed by `DEPLOYMENT_NAME`; do not touch resources outside the deployment's
  resource group.
- **All versions come from the release** (Phase 0). The images, the chart, the Terraform
  module and the tools bundle are one release, pinned by version at the top of Phase 0.
  Never substitute a different version for one component.
- **Verify before you use.** Every artifact — manifest, tools bundle, module, each image,
  the chart — is verified with `cosign` against Velt's release identity before anything
  is extracted, imported or installed. A verification failure is a **stop**, never a
  retry: an artifact that does not verify is not a Velt release.
- **Human-in-the-loop moments are known in advance.** Show the operator the
  "When you'll be needed" table before starting; when a moment arrives, hand them the
  *exact* command or portal URL — never a vague ask.
- **Retryable errors (expected — retry with backoff, do NOT report as failures):**
  - (a) `403 … Caller is not authorized … Assignment: (not found)` on the first Key Vault
    key/secret create — the deployer's Key Vault Administrator grant is still propagating
    (1–2 min). Wait 90 s and run `terraform apply` again (it is idempotent).
  - (b) `Provider produced inconsistent result after apply: Root object was present, but
    now absent` (VNet / Key Vault / identity), or `ParentResourceNotFound: SOA` on the
    private DNS zone — Azure eventual consistency in a fresh region. Re-run the apply. If
    a resource is in Terraform state but 404s in Azure, `terraform state rm <address>`,
    then apply again.
  - (c) an `az` call failing with an expired token mid-run — `az login` again and re-run
    the command (Terraform state and `az` are idempotent).
  - (d) `az acr import` or an image pull failing with `connection refused` — transient;
    retry the import.
  - (e) the first `terraform apply` ending with a **404 on `azurerm_monitor_diagnostic_setting.appgw`**
    ("the Application Gateway was not found") after every other resource was created — the ingress
    add-on creates the gateway asynchronously, and it can crash-loop on
    `ApplicationGatewayInsufficientPermissionOnSubnet` for a minute or two while its subnet grant
    propagates. Restart it and apply again (Phase 1.3 names the exact commands).
- **NON-retryable — needs an operator decision (Phase 0 and Troubleshooting):**
  `LocationIsOfferRestricted` (PostgreSQL Flexible Server not permitted in this region for
  this subscription) and `The VM size … is not allowed in your subscription in location`
  — both are subscription-quota facts, resolved by changing region or SKU, never by retry.
  The preflight in Phase 0 exists so you learn both BEFORE the apply.
- **Long waits are normal.** A full run is ~60–90 min of wall clock: the AKS + Postgres
  apply is ~12–15 min; Helm pod startup ~2–3 min; the App Gateway's first program ~3–6 min.
  Tell the operator when a long wait starts so the run does not look hung.
- **Shell quirks (bash/zsh):** in zsh, `$VAR:x` is a parameter modifier — write `${VAR}`
  whenever a colon or word character follows. A pipeline's exit code reflects only its
  last command — check the command you care about. `timeout` may be absent on macOS.
- If a command fails and is not retryable, check **Troubleshooting** at the end before
  escalating to the operator.
- **Never print a secret.** The Postgres passwords, the DSNs, the ES256 private key and the
  workspace auth token go into the operator's secret store; the state file and your report
  carry **paths and names**, never values.

### State file contract — `velt-selfhost-azure-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:

```jsonc
{
  "guideVersion": 4,
  "release": { /* the full manifest fetched in Phase 0.1, including its tierB block.
                  manifestVerified: true when the manifest's cosign signature verified AND
                  the tierB block was present; false when the version had to come from
                  the chart's tag list instead (a weaker provenance claim — the final
                  report must say which one you got). */ },
  "inputs": {
    "subscriptionId": "", "tenantId": "", "cloud": "AzureCloud",
    "location": "", "resourceGroup": "velt-portable", "deploymentName": "velt",
    "publicHost": "",
    "consoleAuthIssuer": "", "consoleAuthClientId": "",
    "acrName": "",
    "aksAdminGroupObjectId": "", "operatorEgressCidr": "",
    "apiKey": "", "orgId": "", "adminUserIdHash": "", "consoleAdminEmail": "",
    "allowedDomains": [], "privateCommentsEnabled": false,
    "webhookV2Delivery": "", "redis": "in-chart|external", "alertEmailAddresses": []
  },
  "versions": {
    "portableVersion": "", "consoleVersion": "", "sdkVersion": "", "schemaHead": ""
  },
  "phases": {
    "0-preflight": "pending|done",
    "1-substrate": "pending|in-progress|done",
    "2-images": "…", "3-keys-secrets": "…", "4-helm": "…",
    "5-ingress-dns-tls": "…", "6-smoke": "…", "7-acceptance": "…"
  },
  "artifacts": {
    "toolsDir": "./velt-portable-tools",
    "preflightReport": "",          // written by portable-install-preflight.sh; "0-preflight" is done ONLY on exit 0
    "moduleDir": "./terraform/azure",
    "valuesFile": "",               // values-<env>.yaml
    "receiptPath": "",              // receipt.json from portable-receipt.sh (final report)
    "wsEndpoint": "", "apiEndpoint": "", "consoleUrl": "",
    "keyVaultId": "", "workloadIdentityClientId": "", "postgresSubnetCidr": "",
    "appGatewayIp": "", "alertActionGroupId": "",
    "jwtPrivateKeyLocation": "",    // SECRET — a path or a vault reference, never inline
    "jwtPublicKeySecretName": "velt-jwt-es256",
    "jwtPrivateKeySecretName": "velt-jwt-es256-private",
    "dsnSecretName": "velt-postgres-dsn", "dsnAdminSecretName": "velt-postgres-dsn-admin",
    "appDbPasswordSecretName": "velt-app-db",
    "tlsPath": "customer-secret|key-vault|cert-manager", "tlsSecretName": "velt-portable-tls",
    "imageDigests": {}              // from cosign / az acr: what the infosec sign-off covered
  },
  "humanSteps": {
    "azLogin": "pending|done", "roleGrantConfirmed": "pending|done",
    "regionConfirmed": "pending|done", "entraAppRegistration": "pending|done|n/a",
    "infosecScanSignOff": "pending|done|waived", "dnsRecord": "pending|done",
    "certificate": "pending|done", "signInTest": "pending|done",
    "acceptanceApp": "pending|done|stand-in"
  },
  "log": [ { "ts": "…", "phase": "…", "note": "…" } ]
}
```

---

## Inputs and human-in-the-loop moments

Collect all of these **once, up front** — a run that stops halfway to ask for a
subscription id is a run the operator has to babysit.

### Inputs to collect from the operator (all up front, once)

| Input (placeholder used below) | Meaning | Example |
|---|---|---|
| `<subscription-id>` → `SUBSCRIPTION_ID` | Azure subscription to deploy into | `0000…` |
| `<tenant-id>` → `TENANT_ID` | Entra tenant id (the console's OIDC issuer is built from it) | `0000…` |
| `<cloud>` → `CLOUD` | `AzureCloud` (commercial) or `AzureUSGovernment` | `AzureCloud` |
| `<region>` → `LOCATION` | Azure region. MUST permit PostgreSQL Flexible Server for this subscription (Phase 0.5 checks). Hosts BOTH AKS and Postgres — single VNet, single region. | `canadacentral` |
| `<resource-group>` → `RESOURCE_GROUP` | Resource group name | `velt-portable` |
| `<deployment-name>` → `DEPLOYMENT_NAME` | 3–16 character slug that prefixes every Azure resource name. **One-shot for 90 days:** a torn-down deployment's Key Vault stays soft-deleted under this name (the preflight's P10 refuses it) — suffix it (`acmevelt01`) rather than reuse it | `acmevelt01` |
| `<public-host>` → `PUBLIC_HOST` | Public hostname the ingress serves. Builds the WS and API endpoints, the ingress host and the certificate name — keep them equal. Needs a DNS record (human moment #6), or use the free Azure DNS label in Phase 5.2. | `velt.acme.com` |
| `<console-auth-issuer>` → `CONSOLE_AUTH_ISSUER` | Console OIDC issuer. Entra commercial: `https://login.microsoftonline.com/<tenant-id>/v2.0`; Entra Government: `https://login.microsoftonline.us/<tenant-id>/v2.0`; or any OIDC provider's issuer | … |
| `<console-client-id>` → `CONSOLE_AUTH_CLIENT_ID` | The console app registration's client id (human moment #4 if you cannot create app registrations) | … |
| `<acr-name>` → `ACR_NAME` | Azure Container Registry that will hold the verified Velt images. Globally unique, alphanumeric, 5–50 chars. | `acmeveltacr` |
| `<aks-admin-group-object-id>` | Object id of the Entra security group whose members administer the cluster (REQUIRED — the cluster has no local accounts; a fresh tenant has none — Phase 0.3 creates one) | `0000…` |
| `<operator-egress-cidr>` | The public IP range the operator runs `kubectl` from, as a CIDR (REQUIRED unless the operator chooses a private API server). Find it with `curl -4 https://api.ipify.org`; a home or VPN address changes, so give a range (`/24`) or expect to update `api_server_authorized_ip_ranges` later (the Operator notes at the end) | `203.0.113.7/32` |
| `<api-key>`, `<org-id>`, `<admin-user-id-hash>` | The first workspace's API key, first organization id and the first admin user's id hash — strings the operator chooses; the SDK and REST calls later use these exact values | `acme-prod`, `acme`, `u-1` |
| `<console-admin-email>` | Email of the first console administrator — must equal the `email` (or Entra UPN) claim their IdP sign-in carries | `ops@acme.com` |
| `<allowed-domains>` | The domains the customer's pages are served from, comma separated — identify refuses a workspace that has none | `acme.com,app.acme.com` |
| `<app-origin>` | The browser origin (scheme + host [+ port]) of those pages, for CORS — list every origin, comma separated | `https://app.acme.com` |
| `<ops-email>` | The address Let's Encrypt (or your ACME CA) notifies about certificate expiry — only on the cert-manager TLS path (Phase 5.3); the console admin email is fine | `ops@acme.com` |
| `<api-auth-token>` | The first API key's auth token for the REST API (`x-velt-auth-token`): a value **you generate** (at least 24 characters, letters and digits) and store in the operator's secret store before Phase 4.5 seeds it | `openssl rand -base64 24 \| tr -d '/+='` |
| `<attachment-origins>` | **Optional — comment attachments.** The browser origins whose users attach files to comments; the storage account answers CORS for exactly these. Usually the same as `<app-origin>`. `https://` only (`http://localhost` / `http://127.0.0.1` for development), no path, no `*`. Leave empty to install without attachments (the SDK then hides the attachment button). | `https://app.acme.com` |
| Private comments? | Whether the customer uses Velt private comments (seeds `isPrivateCommentsEnabled`) | yes / no |
| Webhook V2 delivery | `""` (no advanced webhooks yet), `svix` (their own Svix account — needs a token) or `direct` (deliver from inside the cluster, no third party) | `direct` |
| Console SAML sign-in + SCIM | Whether console members sign in through the customer's SAML identity provider (Okta, Entra ID, Keycloak, Ping, ADFS), with or without the Entra OIDC sign-in above, and whether that IdP provisions the console roster over SCIM. Needs a SAML app in the IdP; can be turned on after the install ("Turning a feature on later") | no |
| Redis | in-chart Valkey (default; nothing to provision) or an external managed Redis URL | in-chart |
| Alert recipients | Who is told when a metric alert fires (persistent mode): email addresses for the action group the module creates, or the resource id of your own action group (your on-call) | `ops@acme.com` |
| Vulnerability scan policy | Does the organization require scanning third-party images before deployment? (human moment #5) | yes / no |
| TLS path | customer-supplied certificate (default), Key Vault certificate, or cert-manager + ACME (needs public egress) | customer-supplied |

### Features you can turn on — decide now or later

Every optional feature is off until it is turned on, and every one can be turned on after the
install with a `helm upgrade` plus whatever outside piece it needs. Decide on purpose rather
than inherit the defaults; the how-to for each is under "After the install → Turning a feature
on later".

| Feature | Default | Asked above as | Outside the chart it needs |
|---|---|---|---|
| REST API (server-to-server) | off | `rest-api` in `enabledModules` (Phase 4.1) | nothing — the API key's auth token from Phase 4.5 |
| Comment attachments | off | `<attachment-origins>` | a CORS rule on the storage account (Terraform `attachment_cors_origins`) |
| Advanced webhooks | off | Webhook V2 delivery | `direct`: nothing; `svix`: a Secret with the customer's Svix token |
| Console SAML sign-in + SCIM | off | Console SAML sign-in + SCIM | a SAML app in the customer's identity provider |
| Self-hosted SDK bundle | off | `sdk:` block (Phase 4.1) | the `velt-sdk-static` image imported into the registry; the app's `cdnHost` pin |
| Webhook payload encryption | off | — (decide later) | a Secret holding the encryption key |
| Deployment-wide token minting | off, and off is the safe choice | — (decide later) | a Secret holding the master token; only for internal services that mint tokens across several API keys |
| Private comments | per API key | Private comments? | nothing — the console (or the seed's `--project-config`) |
| FIPS mode | on in Azure Government, off in commercial | `fips_enabled` (Phase 1.2) | **an install-time choice**: the node pools' FIPS flag replaces the nodes when changed; the application's `fips.enabled` follows it |
| Node autoscaling | on: the pool starts at `aks_node_count` and the cluster autoscaler may grow it to `aks_node_max_count` (twice the count unless set) | — (Phase 1.2) | nothing to install (it is AKS's own); the VM family's vCPU quota for the ceiling (Phase 0.5) |
| Metric alerts | on in `persistent`, off in `sandbox` | Alert recipients | an action group with receivers — the module's (`alert_email_addresses`) or yours (`alert_action_group_id`) |
| PostgreSQL high availability | off | — (decide later) | `postgres_high_availability_mode` (Phase 1.2): `ZoneRedundant` needs a region with availability zones, `SameZone` does not; about doubles the database's cost |

### When you'll be needed (show the operator this table now)

| # | Moment | What the operator does | Time |
|---|---|---|---|
| 1 | Phase 0.3 | `az login` — you hand them the device-code URL and code if the session is headless | 2 min |
| 2 | Phase 0.3 | Confirm the deploying identity holds **Owner** (or **Contributor + User Access Administrator**) on the subscription. Contributor alone cannot create the Key Vault grant the module needs. | 2 min |
| 3 | Phase 0.5 | Confirm the chosen `LOCATION` (you propose one from the region checks) | 1 min |
| 4 | Phase 0.3 | Create the Entra console app registration and supply `CONSOLE_AUTH_CLIENT_ID`, IF your identity lacks the **Application Developer** role (otherwise you create it) | 5 min |
| 5 | Phase 2.2 | **Vulnerability-scan sign-off**: if organization policy requires scanning third-party images, review the scan of their registry copy and approve (or waive scanning explicitly) | 5–15 min |
| 6 | Phase 5.2 | Add the DNS A record pointing `PUBLIC_HOST` at the Application Gateway IP (not needed with the Azure DNS label) | 2 min + propagation |
| 7 | Phase 5.3 | Supply the TLS certificate for `PUBLIC_HOST` (customer-supplied path), or approve the Key Vault / cert-manager path | 5–30 min |
| 8 | Phase 7 | Sign in to the console in a browser; run the SDK loop in their app | 10 min |
| 9 | Phase 0.3 | Approve creating the Entra security group `velt-aks-admins` (or supply an existing group's object id), IF your identity may not create groups | 2 min |
| 10 | Phase 0.5 | Raise the vCPU quota when the subscription's limits are below the footprint — a family quota at **0** usually needs a Portal request or a Microsoft support ticket (hours to days): do this on day 1, or choose the small footprint | 5 min + waiting |

Expected total wall clock: **60–90 min** plus the human moments. Expected running cost of the
deployed stack is in **After the install** at the end — tell the operator up front that a
persistent deployment is a small always-on cluster plus a managed Postgres server,
and how to idle it.

### Tooling preflight (hard requirements — the preflight script checks the versioned ones)

| Tool | Why | Check | Install (macOS Homebrew; Linux: the vendor's package) |
|---|---|---|---|
| Azure CLI ≥ 2.60 | all provisioning | `az version` | `brew install azure-cli` |
| Terraform ≥ 1.6 | the substrate | `terraform version` | `brew tap hashicorp/tap && brew install hashicorp/tap/terraform` (the plain `terraform` formula no longer exists) |
| Helm 3.17+ or 4.x | the application (both are supported; the chart is installed by its digest, which Helm supports from 3.17) | `helm version` | `brew install helm` |
| kubectl | cluster operations | `kubectl version --client` | `brew install kubectl` |
| **Azure** kubelogin | Entra sign-in to the cluster (the cluster has no local accounts) | `kubelogin convert-kubeconfig --help` works | `brew install Azure/kubelogin/kubelogin` — NOT `brew install kubelogin`, which installs a different tool of the same name (int128's OIDC plugin) that Phase 1.4 cannot use |
| cosign | signature verification of every artifact (MANDATORY) | `cosign version` | `brew install cosign` |
| oras | pulling the manifest, tools bundle and module from the release registry | `oras version` | `brew install oras` |
| Node.js ≥ 18 | the update check (`portable-check-updates.js`) in the final report | `node --version` | `brew install node@22` |
| Docker daemon | running the provision CLI from the migrate image (keygen, token) | `docker info` | Docker Desktop / OrbStack / Colima |
| psql (optional) | lets the support bundle read the schema head when the database is reachable from the workstation — on this Azure path it is NOT (the server is private), so the receipt takes the head you read in Phase 4.4 with `--schema-head` instead | `psql --version` | skip it on Azure; `brew install libpq` elsewhere |
| curl, python3, openssl, jq, dig | verification, JSON reading, password generation, DNS checks (`dig` is on every macOS; Linux: `dnsutils` / `bind-utils`; without it use the `python3` one-liner shown in Phase 5). `python3` must RUN, not just be on the PATH: every digest step and the preflight's P9 use it, and P1 refuses one that does not run (a Mac without the Command Line Tools has a stub that only offers to install them) | `python3 -c 'import json'` | `xcode-select --install` (macOS), or `brew install jq` for jq |

Auth is probed in Phase 0.3 with real calls — a listed account is not proof of a working
session.

---

## Phase 0 — Release resolution, identity, region, preflight

### 0.0 The versions — set once, used everywhere

Every command in this guide reads these variables. Set them in the shell you run the
install from, and re-export them at the start of every fresh session (record them in
`versions` in the state file).

```bash
REG=us-docker.pkg.dev/velt-sdk/velt-releases
IDENTITY='^https://github\.com/snippyly/shared-firebase-function/\.github/workflows/container-release\.yml@refs/heads/main$'
ISSUER='https://token.actions.githubusercontent.com'

PORTABLE_V=0.1.13     # the portable release: images, chart, Terraform module, tools bundle
CONSOLE_V=0.1.19      # the console bundle — its own cadence, its own tag
SDK_V=6.0.24          # the SDK bundle — its own cadence; ALSO part of the served path
```

`PORTABLE_V` is the one you confirm from the manifest in 0.1 (the worked value above is the
release this guide was written for). `CONSOLE_V` and `SDK_V` come from the manifest too when
it pins them. When it does not (every manifest published before the release pipeline began pinning the SDK
omits `sdkStatic`), take the newest version a portable release published; only Velt's release job signs these images,
so the verify in Phase 2 still holds them to the same signer:

```bash
oras repo tags "$REG/velt-console-static" | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1   # CONSOLE_V
oras repo tags "$REG/velt-sdk-static"     | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1   # SDK_V
```

Releases before `0.1.13` have no tools bundle. If the manifest pins an older
`portableVersion`, Velt support supplies the five scripts; everything else in this guide is
unchanged.

**The version numbers written into this guide are worked examples** (`0.1.13`, console
`0.1.19`, SDK `6.0.24`), not pins: the manifest you verify in 0.1 is the pin, and it is
usually newer than the examples. The registry may also hold a release newer than the one
the manifest pins (still being validated by Velt) — **a newer registry tag is not a release
you install**; the manifest is. Some steps below say "0.1.14 or newer" or "0.1.15 or newer":
they need a tools bundle, module or chart of at least that version, which the manifest's pin
tells you.

### 0.1 Resolve and VERIFY the release manifest

The umbrella manifest is the trust root. Its `tierB` block pins the portable release — every
artifact's reference and digest.

```bash
# MANDATORY — resolve the tag ONCE to its digest, verify that digest, then pull that digest.
# Only deploy a release that verifies.
# (The manifest is published by a different workflow than the images, so its identity differs.)
MANIFEST_DIGEST=$(oras manifest fetch --descriptor "$REG/velt-selfhost-manifest:latest" \
  | python3 -c "import json,sys;print(json.load(sys.stdin)['digest'])")
cosign verify \
  --certificate-identity-regexp '^https://github\.com/snippyly/shared-firebase-function/\.github/workflows/selfhost-umbrella-publish\.yml@refs/heads/main$' \
  --certificate-oidc-issuer "$ISSUER" "$REG/velt-selfhost-manifest@$MANIFEST_DIGEST" \
  && oras pull -o . "$REG/velt-selfhost-manifest@$MANIFEST_DIGEST"    # writes manifest.json

# Read the portable release pins.
python3 -c "import json;m=json.load(open('manifest.json'));print(json.dumps(m.get('tierB','<<ABSENT>>'),indent=2))"
```

**Verify and fetch by one digest.** The registry is asked what a tag names every time a command
uses the tag, so a verification by tag followed by a pull by tag gets two answers, and only the
first was checked. Every step below that pulls or imports an artifact resolves the tag once
(`oras manifest fetch --descriptor`), verifies that digest, and pulls or imports that same
digest — the Helm chart included (Phase 4.3). Do not put the tag back in place of the digest.

When the block is present it pins `portableVersion`, `helmChart`, `module` (the Terraform
substrate blueprint), `tools` (the operator tools bundle — present from `0.1.13`) and
`images` (gateway / workers / migrate plus the optional `consoleStatic` and `sdkStatic`),
each by reference and cosign-verified digest. Set `PORTABLE_V` to `tierB.portableVersion`
and, when present, `CONSOLE_V` / `SDK_V` from the `consoleStatic` / `sdkStatic` tags. Use the
pinned references verbatim.

**A `tierB` block with no `tools` entry predates `0.1.13`** — the manifest is republished only
when Velt publishes a new umbrella, so it can lag the newest portable release. Treat it exactly
like an absent block: take the version from the chart's tag list below, record
`release.manifestVerified: false`, and tell the operator the manifest is behind (Velt republishes
it; the install does not wait for that). Do not install a version whose tools bundle does not
exist, and do not mix the manifest's older image pins with a newer chart.

**`tierB` is an optional block and may be absent** (a release without the portable deployment).
Do not assume either way — read it. When it is absent, or present without `tools`, the portable
version comes from the chart's own tag list in the release registry:

```bash
oras repo tags "$REG/velt-portable" | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1   # the newest portable release (signature tags sort last — filter them out)
```

Set `PORTABLE_V` to that value and record `release.manifestVerified: false` in the state
file — a chart tag is a weaker provenance claim than a verified manifest block, and the final
report must say which one the deployment got. Every artifact is still cosign-verified
individually below, so what is lost is the manifest's cross-component pin, not signing.

Store the whole manifest in the state file.

### 0.2 Pull and VERIFY the tools bundle

The five operator scripts this guide runs — the install preflight, the version receipt, the
support bundle, the upgrade script and the update check — ship as a signed artifact of the
same release. Pull them now, before anything else runs, so every later step uses the
release's own copies:

```bash
TOOLS_DIGEST=$(oras manifest fetch --descriptor "$REG/velt-portable-tools:$PORTABLE_V" \
  | python3 -c "import json,sys;print(json.load(sys.stdin)['digest'])")
cosign verify --certificate-identity-regexp "$IDENTITY" --certificate-oidc-issuer "$ISSUER" "$REG/velt-portable-tools@$TOOLS_DIGEST" \
  && oras pull "$REG/velt-portable-tools@$TOOLS_DIGEST" && tar xzf "velt-portable-tools-$PORTABLE_V.tar.gz"
ls velt-portable-tools/
# portable-install-preflight.sh  portable-receipt.sh  portable-support-bundle.sh
# portable-upgrade.sh  portable-check-updates.js  README.md  VERSION
cat velt-portable-tools/VERSION      # must equal $PORTABLE_V
```

The archive unpacks to `./velt-portable-tools/`; the commands below call the scripts from
there. If `cosign verify` fails, STOP: do not extract the archive.

### 0.3 Sign in to Azure; identity and permissions

```bash
# Azure Government ONLY — before az login:
#   az cloud set --name AzureUSGovernment
# Do not know the tenant id yet? Sign in once without it and read it:  az login ; az account list -o table
az login --tenant "$TENANT_ID"    # headless: add --use-device-code  (hand the operator the URL + code)
az account set --subscription "$SUBSCRIPTION_ID"
az account show --query '{sub:id, name:name, tenant:tenantId, cloud:environmentName, user:user.name}' -o table
az ad signed-in-user show --query userPrincipalName -o tsv   # the REAL identity: a guest prints …#EXT#@…, which `az account show` hides
export ARM_SUBSCRIPTION_ID="$SUBSCRIPTION_ID"      # Terraform reads this
```

If device-code sign-in is refused with `AADSTS530035` (the tenant's security defaults or a
Conditional Access policy block the device-code flow), use the interactive browser sign-in
(`az login --tenant "$TENANT_ID"` without `--use-device-code`) from a machine with a browser,
or ask the tenant admin to allow device code for this user. **Check `user` in the output:** a
cached session for another account is common on a shared machine, and a guest account
(`…#EXT#@…`) holds roles under its guest identity, not its home one.

**The deploying identity needs more than Contributor** — **Owner**, or **Contributor +
User Access Administrator**, on the subscription. Two reasons, both hit on the very first
apply otherwise:

- The Key Vault is RBAC-authorized, and the module writes the customer-managed keys and the
  Postgres password *into* it — a data-plane operation that subscription Owner alone does
  not cover. The module therefore grants the deployer **Key Vault Administrator** on the
  vault itself. **Creating that grant needs `Microsoft.Authorization/roleAssignments/write`,
  which Contributor lacks.**
- **Do not pre-create that Key Vault Administrator grant by hand.** On a cold apply the
  module creates it; a grant that already exists makes the apply fail with
  `RoleAssignmentExists`.

```bash
# What roles does this identity hold on the subscription — directly, through a group, or
# inherited from a management group? (preflight P3 asks the same way)
az role assignment list --assignee "$(az ad signed-in-user show --query id -o tsv)" \
  --scope "/subscriptions/$SUBSCRIPTION_ID" --include-groups --include-inherited \
  --query '[].roleDefinitionName' -o tsv
```

Without `--include-groups --include-inherited` the list shows only direct assignments at that
exact scope, and a correctly privileged identity (Owner through a group, or on the management
group) is wrongly told to stop.

Two Entra questions decide human moments #4 and #9 below (may this identity create an app
registration? a security group?). Ask them with two Graph calls instead of guessing:

```bash
# Directory roles this identity holds (Global Administrator, Application Developer, Groups Administrator …):
az rest --method GET --url "https://graph.microsoft.com/v1.0/me/transitiveMemberOf/microsoft.graph.directoryRole" \
  --query "value[].displayName" -o tsv
# Tenant-wide defaults when no role applies: may ordinary users create app registrations / security groups?
az rest --method GET --url "https://graph.microsoft.com/v1.0/policies/authorizationPolicy" \
  --query "{appRegistrations:defaultUserRolePermissions.allowedToCreateApps, securityGroups:defaultUserRolePermissions.allowedToCreateSecurityGroups}" -o json
```

Either a role or a `true` default means you create the object yourself; otherwise it is the
operator's moment.

If the output lacks `Owner` and lacks the pair `Contributor` + `User Access Administrator`,
this is human moment #2: hand the operator the role to grant and stop until it is granted.

**The console app registration.** The console signs in through an OIDC app registration in
the operator's tenant. If your identity holds the Entra **Application Developer** role (or
higher), create it; otherwise this is human moment #4 — give the operator these exact
settings: a single-page application, redirect URI `https://<public-host>/signup`, the
`openid profile email` scopes, and ask for the application (client) id.

```bash
# Only if you may create app registrations in this tenant:
CONSOLE_AUTH_CLIENT_ID=$(az ad app create --display-name "velt-console-$DEPLOYMENT_NAME" \
  --sign-in-audience AzureADMyOrg --query appId -o tsv)
# The SPA redirect URI goes in with a Graph PATCH: `az ad app update --set spa.redirectUris=…` fails
# on a fresh app ("Couldn't find 'spa' in ''") on Azure CLI 2.90, and leaves the app without its URI.
APP_OBJECT_ID=$(az ad app show --id "$CONSOLE_AUTH_CLIENT_ID" --query id -o tsv)
az rest --method PATCH --url "https://graph.microsoft.com/v1.0/applications/$APP_OBJECT_ID" \
  --headers 'Content-Type=application/json' \
  --body '{"spa":{"redirectUris":["https://'"$PUBLIC_HOST"'/signup"]}}'
az ad app show --id "$CONSOLE_AUTH_CLIENT_ID" --query spa.redirectUris -o tsv    # must print the URI — do not continue until it does
echo "$CONSOLE_AUTH_CLIENT_ID"
```

The console issuer for Entra is `https://login.microsoftonline.com/<tenant-id>/v2.0`
(Government: `https://login.microsoftonline.us/<tenant-id>/v2.0`). Any other OIDC provider
works the same way: its issuer URL and a client id for a single-page app with that redirect
URI. The issuer must be reachable **from the users' browsers**, not from the cluster.

**The AKS admin group.** The cluster is created with Entra ID as its identity provider and no
local accounts, so `aks_admin_group_object_ids` (Phase 1.2) must name an Entra security group
whose members administer the cluster. A fresh tenant has no groups. Find one, or create it:

```bash
az ad group list --query "[].{name:displayName,id:id}" -o table      # any existing group to use?
# None suitable: create one and add the operator (needs the Groups Administrator or Global
# Administrator role, or the tenant setting that lets users create security groups):
AKS_ADMIN_GROUP_OBJECT_ID=$(az ad group create --display-name "velt-aks-admins" \
  --mail-nickname "velt-aks-admins" --description "Administers the Velt AKS cluster" \
  --query id -o tsv)
az ad group member add --group "$AKS_ADMIN_GROUP_OBJECT_ID" \
  --member-id "$(az ad signed-in-user show --query id -o tsv)"
echo "$AKS_ADMIN_GROUP_OBJECT_ID"
```

If your identity may not create groups, this is human moment #9: ask the operator for an
existing group's object id, or for the group to be created with the operator as a member.
Record the id as `<aks-admin-group-object-id>`.

### 0.4 Register the resource providers (once per subscription)

```bash
for NS in Microsoft.ContainerService Microsoft.DBforPostgreSQL Microsoft.KeyVault \
          Microsoft.Storage Microsoft.Network Microsoft.ManagedIdentity \
          Microsoft.Compute Microsoft.ContainerRegistry; do
  az provider register --namespace "$NS"
done
# Registration is asynchronous — all eight must read Registered before the apply:
for NS in Microsoft.ContainerService Microsoft.DBforPostgreSQL Microsoft.KeyVault \
          Microsoft.Storage Microsoft.Network Microsoft.ManagedIdentity \
          Microsoft.Compute Microsoft.ContainerRegistry; do
  printf '%-32s %s\n' "$NS" "$(az provider show --namespace "$NS" --query registrationState -o tsv)"
done
```

### 0.5 Region and quota — do this BEFORE the apply, not after it fails

PostgreSQL Flexible Server is gated per subscription **and** per region. An apply can bring
up the VNet, Key Vault, AKS and storage and then fail only on Postgres with
`LocationIsOfferRestricted` — roughly twelve minutes in. Postgres must share AKS's region
(private DNS zone + delegated subnet live in one VNet), so a restricted region means moving
the whole stack. Check first.

**Quota first, on day 1.** The module's persistent-mode default footprint is 3 nodes ×
`Standard_D4ds_v5` (4 vCPU each): it needs **Total Regional vCPUs ≥ 16** (12 + one node of
surge for a rolling upgrade) and **Standard DDSv5 Family vCPUs ≥ 16** in the region. A fresh
pay-as-you-go subscription commonly has a regional limit of **10** and several families at
**0**; a family at 0 usually cannot be raised self-service and needs a Portal request or a
Microsoft support ticket, which takes hours to days. Read the two numbers now (step 2), and
either file the request today (step 2b) or take the small footprint, which fits a 10-vCPU
subscription with no request.

**Scan several regions at once.** Each `az vm list-skus` call downloads the region's whole
SKU catalogue and takes one to two minutes with no output — one call per region, cached in a
file, answers every question below. On a fresh subscription it is common that Postgres is
restricted in one region, the default size is `NotAvailableForSubscription` in another and
the small size is not offered in a third, so run the loop over your candidates first and
pick the region where every line is green:

```bash
for LOC in westus3 centralus eastus2 westus2; do
  SKUS="skus-$LOC.json"; [[ -s "$SKUS" ]] || az vm list-skus -l "$LOC" --resource-type virtualMachines -o json > "$SKUS"   # 1–2 min each, once
  PG=$(az postgres flexible-server list-skus -l "$LOC" -o json 2>/dev/null | python3 -c 'import json,sys; d=json.load(sys.stdin)[0]; print("ALLOWED" if [f["status"] for f in d["supportedFeatures"] if f["name"]=="OfferRestricted"]==["Disabled"] else "RESTRICTED")' 2>/dev/null || echo "RESTRICTED")
  for SIZE in Standard_D4ds_v5 Standard_D2ds_v4; do
    FAM=$(python3 -c "import json,sys; s=[x for x in json.load(open('$SKUS')) if x['name']=='$SIZE']; print(s[0]['family'] if s else '')")
    RESTR=$(python3 -c "import json,sys; s=[x for x in json.load(open('$SKUS')) if x['name']=='$SIZE']; print(','.join(r['reasonCode'] for r in s[0].get('restrictions',[])) if s else 'NOT-OFFERED')")
    QUOTA=$([[ -n "$FAM" ]] && az vm list-usage -l "$LOC" --query "[?name.value=='$FAM'].[currentValue,limit] | [0]" -o tsv 2>/dev/null | tr '\t' '/' || echo "n/a")
    echo "$LOC  postgres=$PG  $SIZE  family=${FAM:-none}  restrictions=${RESTR:-none}  familyQuota(used/limit)=${QUOTA:-n/a}"
  done
done
```

A size whose `family` prints `none` is not offered there — the single-region commands below
then print nothing for the family line, which is the same answer, not a pass.

**1. Is Postgres offered to this subscription here?** One call, nothing to clean up:

```bash
az postgres flexible-server list-skus -l "$LOCATION" -o json | python3 -c '
import json,sys
d=json.load(sys.stdin)[0]
r=[f["status"] for f in d["supportedFeatures"] if f["name"]=="OfferRestricted"]
print("POSTGRES: " + ("ALLOWED" if r==["Disabled"] else "RESTRICTED (pick another region)"))
skus={s["name"] for e in d["supportedServerEditions"] for s in e["supportedServerSkus"]}
print("GP_Standard_D2ds_v5 (the module default): " + ("offered" if "Standard_D2ds_v5" in skus else "NOT offered — set postgres_sku"))'
```

**2. Compute quota for the AKS nodes.** The module's persistent-mode default is
**3 nodes × `Standard_D4ds_v5` (4 vCPU each)** — 12 vCPUs, plus one node of headroom for a
rolling upgrade surge (16). The cluster autoscaler may grow the pool to `aks_node_max_count`
(6 by default), so the ceiling with its surge is 28 vCPUs: the preflight refuses below 16 and
warns below 28, naming the value to request (`--max-nodes` if you set `aks_node_max_count`).
Read two numbers:

```bash
AKS_NODE_VM_SIZE=Standard_D4ds_v5          # change here if you override aks_node_vm_size below
AKS_VCPUS_PER_NODE=4
# The SKU's family (the quota is per family), then exactly the two lines that matter:
FAMILY=$(az vm list-skus -l "$LOCATION" --size "$AKS_NODE_VM_SIZE" --query "[?name=='$AKS_NODE_VM_SIZE'].family | [0]" -o tsv)
az vm list-usage -l "$LOCATION" \
  --query "[?name.value=='cores' || name.value=='$FAMILY'].{quota:name.localizedValue, used:currentValue, limit:limit}" -o table
# Need: cores limit - used >= 3 × $AKS_VCPUS_PER_NODE + $AKS_VCPUS_PER_NODE (surge) = 16 for the default,
#       and the family limit >= the same. A family limit of 0 = that size is unavailable to you here.
# Room to scale: 6 × $AKS_VCPUS_PER_NODE + $AKS_VCPUS_PER_NODE = 28 lets the autoscaler reach its default ceiling.
```

Then confirm the size is offered AND unrestricted — the two look alike when read carelessly
(an empty restriction list also comes back for a size the region does not offer at all):

```bash
az vm list-skus -l "$LOCATION" --size "$AKS_NODE_VM_SIZE" \
  --query "[?name=='$AKS_NODE_VM_SIZE'].{size:name, restrictions:restrictions[].reasonCode}" -o json
# []                                  = NOT offered in this region — pick another size
# [{"size": …, "restrictions": []}]   = offered and usable
# ["NotAvailableForSubscription"]     = offered to others, restricted for you — pick another size
```

**2b. When the numbers do not fit, two ways out.**

*The small footprint — no request, fits a 10-vCPU subscription.* Any 2-vCPU **x86**
general-purpose size whose family has quota (the images are linux/amd64; ARM `_p*` sizes will
not run them): `Standard_D2ds_v4`, `Standard_D2ads_v7`, `Standard_D2s_v6` and their siblings.
3 nodes × 2 vCPU = 6, plus 2 for surge = 8. Each node has 8 GiB; the application's requests at
the floor are about 1 CPU and 3 GiB, so this is a comfortable evaluation and small-team
posture. Set in Phase 1.2:

```hcl
aks_node_vm_size = "Standard_D2ds_v4"      # 2 vCPU x86; check its family quota the same way (Standard DDSv4 Family)
```

and pass `--vm-size Standard_D2ds_v4 --vcpus-per-node 2` to the preflight in 0.6. Move to the
default size later with a plan that changes only the node pool.

*The quota request — for the default size.* Self-service first; a family at 0 is usually
refused self-service and needs the Portal or a support ticket:

```bash
az extension add --name quota --upgrade -o none
SCOPE="/subscriptions/$SUBSCRIPTION_ID/providers/Microsoft.Compute/locations/$LOCATION"
az quota create --scope "$SCOPE" --resource-name cores   --resource-type dedicated --limit-object value=16
az quota create --scope "$SCOPE" --resource-name "$FAMILY" --resource-type dedicated --limit-object value=16
az quota request status list --scope "$SCOPE" -o table      # Succeeded, InProgress, or Failed
```

`Failed` on a family: Portal → **Quotas → Compute**, the region, the family row → **Request
adjustment**; if that is refused too, **Help + support → New support request → Service and
subscription limits (quotas) → Compute-VM (cores-vCPUs)**, naming the region, the family and
16. Tell the operator this is human moment #10 and can take days; do not wait inside the
install — take the small footprint, or stop here and resume when it is granted.

Where only confidential sizes are offered, `Standard_DC2s_v3` is an x86 fallback — but its
family (`Standard DCSv3 Family`) is often at 0 too; read its quota exactly as above before
relying on it. Being on pay-as-you-go does not mean quota is unlimited — it means the
restrictions are different ones.

**3. Fallback — the empirical probe.** The capability list is almost always right; when it
disagrees with reality (an apply still fails on Postgres), find a permitted region by
creating a tiny throwaway server in each candidate and keeping the one that reaches
`Ready`. Everything it creates lives in one resource group that the last line deletes:

```bash
az group create -n velt-pg-probe -l eastus -o none
for R in eastus westus2 westus3 southcentralus canadacentral northcentralus; do
  az postgres flexible-server create -g velt-pg-probe -n "probe-$R" -l "$R" \
    --tier Burstable --sku-name Standard_B1ms --storage-size 32 --version 16 \
    --admin-user probeadmin --admin-password "Pr0beX9z-$R-Q" \
    --public-access None --yes >/tmp/probe-$R.log 2>&1 &
done; wait
grep -l 'OfferRestricted' /tmp/probe-*.log          # the restricted regions
az group delete -n velt-pg-probe --yes --no-wait
```

(The preflight below runs this probe for you with `--probe-postgres`.) Human moment #3:
propose the region to the operator and record `regionConfirmed: done`.

> **Azure Government.** The same checks apply; regions are `usgovvirginia`, `usgovarizona`,
> `usgovtexas`. The default Postgres SKU and node size must be offered there — read the
> same two outputs.

### 0.6 Run the install preflight

The release's own preflight runs the checks above, and more, in order — cheapest and safest
first — and **stops at the first failure**. It mutates nothing, except the optional
`--probe-postgres`, which creates and deletes the one probe resource group shown above.

```bash
./velt-portable-tools/portable-install-preflight.sh \
  --location "$LOCATION" --subscription-id "$SUBSCRIPTION_ID" \
  --acr "$ACR_NAME" --manifest-ref "$REG/velt-selfhost-manifest:latest" \
  --portable-version "$PORTABLE_V" --manifest-file ./manifest.json \
  --deployment-name "$DEPLOYMENT_NAME" \
  --vm-size "$AKS_NODE_VM_SIZE" --nodes 3 --vcpus-per-node "$AKS_VCPUS_PER_NODE" \
  --report ./preflight-report.json          # add --probe-postgres to run the empirical probe too
echo "exit $?"
```

`--portable-version`, `--manifest-file` and `--deployment-name` need a tools bundle of
**0.1.14 or newer** (they carry P9's manifest-pin comparison and P10's name check). A `0.1.13`
bundle answers `unknown argument` — drop those three flags there; the checks they add are the
ones you did by hand in 0.1 and the inputs table.

`--manifest-ref` takes the tag, as written: P9 resolves it and verifies that digest itself (a
bundle of 0.1.15 or older reads a `…@sha256:` reference as a tag and refuses it). A newer bundle
also checks that `./manifest.json` is the manifest it verified, and refuses when `latest` has
moved since 0.1 — repeat 0.1, then run the preflight again.

Exit `0` = every check passed. Exit `1` = a check failed and the report names it. Exit `2`
= usage error. There is no flag to continue past a failure. Record the report path in
`artifacts.preflightReport`.

| id | What it checks | When it fails |
|---|---|---|
| P1 | `az` ≥ 2.60, `terraform` ≥ 1.6, `helm` ≥ 3.17, `node` ≥ 18, a `python3` that runs, `kubectl`, `cosign`, `oras` and **Azure's** `kubelogin` on PATH (the other tool of that name is refused by name) | install or upgrade the named tool (the tooling table) |
| P2 | the `az` session's subscription equals `--subscription-id` | `az login` / `az account set` |
| P3 | the deployer holds **Owner**, or **Contributor + User Access Administrator**, on the subscription — directly, through a group, or inherited | human moment #2 (0.3) |
| P4 | the eight resource providers are `Registered` | re-run the loop in 0.4 and wait |
| P5 | PostgreSQL Flexible Server is offered in the region; with `--probe-postgres` also the empirical probe | pick another region (0.5) — Postgres must share AKS's region |
| P6 | the AKS node VM size has no restrictions in the region for this subscription | set `aks_node_vm_size` to a size the region offers (0.5) |
| P7 | the vCPU quota of that size's family leaves room for `--nodes × --vcpus-per-node` plus one surge node; a WARN (not a failure) when the autoscaler's ceiling (`--max-nodes`, twice `--nodes` unless given, plus the surge) does not fit, naming the value to request | request a quota increase, or a smaller size |
| P8 | `--acr`: the registry exists in this subscription, or its name is available | pick a globally unique alphanumeric name |
| P9 | `--manifest-ref`: the manifest's digest verifies under cosign with the release identity; with `--portable-version` and the pulled `manifest.json`, its `tierB` pin is compared with the release you install (equal: fine; older or without `tools`: the 0.1 fallback applies, `manifestVerified: false`; newer: refused — install the pinned one) | STOP on a signature failure — a manifest that does not verify is not a release; on "newer", set `PORTABLE_V` to the pinned version |
| P10 | `--deployment-name`: the fixed names derived from it are free — Key Vault `<deployment-name>-kv` is neither live nor **soft-deleted** in this subscription, and the storage account name `<deployment-name>blob` is available | pick another `DEPLOYMENT_NAME`, or recover and adopt the old vault (Troubleshooting: "soft-deleted Key Vault") |

P8, P9 and P10 report `skip` (never `pass`) when their input is not supplied — always supply
all three. P10 is the one a REINSTALL trips on: a persistent-mode Key Vault keeps its name for
90 days after a teardown and cannot be purged, and without this check the apply fails at the vault
about twelve minutes in, after the network, cluster identities and Key Vault grant are built.

**Verify (phase gate):** the manifest verified and is stored; `velt-portable-tools/VERSION`
equals `PORTABLE_V`; `az account show` names `SUBSCRIPTION_ID`; the role check printed
`Owner` or the Contributor + User Access Administrator pair; all eight providers read
`Registered`; the preflight exited `0`; every input in the table is recorded; the operator
acknowledged the human-moments table. **Do not continue until the preflight has exited 0.**
Mark `0-preflight: done`.

---

## Phase 1 — The substrate (Terraform)

### 1.1 Pull and VERIFY the Terraform module

The Azure substrate ships as a signed OCI artifact of the same release:

```bash
MODULE_DIGEST=$(oras manifest fetch --descriptor "$REG/velt-portable-module:$PORTABLE_V" \
  | python3 -c "import json,sys;print(json.load(sys.stdin)['digest'])")
cosign verify --certificate-identity-regexp "$IDENTITY" \
  --certificate-oidc-issuer "$ISSUER" "$REG/velt-portable-module@$MODULE_DIGEST" \
  && oras pull "$REG/velt-portable-module@$MODULE_DIGEST" \
  && tar xzf "velt-portable-module-$PORTABLE_V.tar.gz"
ls terraform/azure/                 # the *.tf files and .terraform.lock.hcl — nothing else
```

The archive unpacks to `./terraform/azure/` — the commands below work verbatim from the
directory you extracted it in. It holds the `*.tf` files and `.terraform.lock.hcl`, which
pins the provider versions this release was tested with; no state, tfvars or plan file can
ride along.

**Terraform versions and platforms.** The module plans on Terraform 1.6 and every later
release. The lock file carries provider hashes for five platforms: `linux_amd64`,
`linux_arm64`, `darwin_amd64`, `darwin_arm64` and `windows_amd64`. On any other platform a
plain `terraform init` adds your platform's hash to the lock file; if you run
`init -lockfile=readonly`, add yours first:

```bash
terraform -chdir=terraform/azure providers lock -platform=<os>_<arch>
```

### 1.2 Write `terraform/azure/terraform.tfvars`

This file holds identifiers; keep it out of any shared repository. Every value comes from
the inputs table:

```hcl
mode                = "persistent"          # the customer posture: stable names, 90-day retention, 3 × D4ds_v5 nodes
cloud               = "AzureCloud"          # or "AzureUSGovernment"
location            = "<region>"            # confirmed in Phase 0.5
resource_group_name = "<resource-group>"
deployment_name     = "<deployment-name>"   # 3-16 chars; prefixes every resource
# postgres_sku      = "GP_Standard_D2ds_v5" # the default; change only if 0.5 showed it is not offered
# aks_node_vm_size  = "Standard_D4ds_v5"    # the default; override with an x86 size the region offers (0.5)
# aks_node_vm_size  = "Standard_D2ds_v4"    # the small footprint (6 vCPU + 2 surge) when the quota is 10 (0.5, step 2b)
# aks_node_count    = 3                     # the default in persistent mode
# aks_node_max_count = 6                    # the cluster autoscaler's ceiling: twice aks_node_count unless set;
                                            # equal to aks_node_count keeps a fixed pool

# Metric alerts (on in persistent): six alerts on Postgres, the nodes and the Application Gateway notify ONE action
# group — the one the module creates with these receivers, or yours (then no addresses here).
alert_email_addresses = ["<ops-email>"]
# alert_action_group_id = "/subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.Insights/actionGroups/<name>"

# PostgreSQL: the point-in-time restore window (7..35 days) and optional high availability (about doubles its cost).
# postgres_backup_retention_days  = 14   # unset keeps the server's own window (7 on a new server)
# postgres_high_availability_mode = "ZoneRedundant"   # or "SameZone" where the region has no availability zones

console_auth_issuer    = "<console-auth-issuer>"     # e.g. https://login.microsoftonline.com/<tenant-id>/v2.0
console_auth_client_id = "<console-client-id>"
public_host            = "<public-host>"
acr_name               = "<acr-name>"

# Who operates the cluster, and from where. Persistent mode REQUIRES the first value and
# ONE of the next two: the cluster is created with Entra ID as its identity provider and
# local accounts disabled, so a plan that names no admins or no reachable API server is refused.
aks_admin_group_object_ids      = ["<aks-admin-group-object-id>"]
api_server_authorized_ip_ranges = ["<operator-egress-cidr>"]   # OR: private_cluster_enabled = true
# private_cluster_enabled       = true      # a private API server, reachable from inside the VNet only
# aks_network_policy            = "cilium"  # default: cilium in AzureCloud, azure in AzureUSGovernment — changing it later REPLACES the cluster
# fips_enabled                  = true      # default: true in AzureUSGovernment, false in AzureCloud

# Comment attachments (optional; module 0.1.14 or newer). Browsers upload and download the files straight to the
# storage account with a short-lived signed URL, so the account must answer CORS for the app's origins. Empty (the
# default) = no CORS rule, and attachments stay off for browsers. Each entry is an origin: https://host[:port], no
# path, no "*". A 0.1.13 module has no such variable: either install without attachments, or add the rule by hand
# after the apply — `az storage cors add --services b --methods PUT GET HEAD OPTIONS --origins <origin> \
# --allowed-headers 'content-type,x-ms-*' --exposed-headers 'content-length,content-type,etag,last-modified,x-ms-*' \
# --max-age 3600 --account-name <storage account>` — and remove it from this file before the next apply on 0.1.14+.
# attachment_cors_origins       = ["<attachment-origins>"]
```

> **Azure Government.** `cloud = "AzureUSGovernment"`, a Government region, the
> `login.microsoftonline.us` issuer. `fips_enabled` and the Azure network-policy engine
> are the defaults there; set `fips.enabled: true` in the chart values (Phase 4) to match
> the `fips_enabled` output.

### 1.3 Apply

Tell the operator: **this takes 12–15 minutes**.

```bash
terraform -chdir=terraform/azure init
terraform -chdir=terraform/azure plan -out tf.plan
terraform -chdir=terraform/azure apply tf.plan
```

It creates: the resource group · a VNet with the AKS subnet, the Application Gateway subnet
and a delegated Postgres subnet · a private DNS zone and link · Key Vault (purge protection
on — the storage customer-managed key requires it) with the deployer's Key Vault
Administrator grant and two CMK keys · the Postgres administrator password as a Key Vault
secret · the **AKS cluster** (Entra ID RBAC, workload identity, OIDC issuer, the Application
Gateway ingress add-on, the network-policy engine, the cluster autoscaler on the node pool
between `aks_node_count` and `aks_node_max_count`) · **PostgreSQL Flexible Server 16**
(private, CMK, storage auto-grow, point-in-time restore — 7 days unless `postgres_backup_retention_days` says otherwise) · a storage account (CMK) with the `attachments` container · three
user-assigned identities (workload / Postgres CMK / storage CMK) with their role assignments
· the federated identity credential · **a Log Analytics workspace and five diagnostic
settings** (AKS, Postgres, Key Vault, Blob and the Application Gateway send their audit logs
there; `enable_diagnostics = false` in the tfvars opts out — the ingestion is a recurring
cost, see "After the install") · and the file `terraform/azure/out/velt-console-config.json`
(the console's runtime config, spliced into the chart values in Phase 4) · in persistent mode,
**an action group and six metric alerts** (Postgres storage and CPU, node CPU and memory, the
Application Gateway's unhealthy backends and 5xx answers; `enable_alerts = false` opts out).

Two things the plan prints that are not problems: a `Warning: Argument is deprecated` from
the module's own code (a provider deprecation, harmless on this provider range), and, on a
SECOND apply, two "changes" to attributes Azure defaulted after the first (the node pool's
`upgrade_settings`, a `Microsoft.Storage` service endpoint on the Postgres subnet) — apply
them; nothing is replaced.

Expected on a first apply and **retryable** (see the list at the top): the Key Vault 403
while the grant propagates — wait 90 s, apply again; `Provider produced inconsistent result
after apply` / `ParentResourceNotFound: SOA` — apply again; a **404 on
`azurerm_monitor_diagnostic_setting.appgw`** as the LAST resource (module 0.1.13 / 0.1.14) —
the ingress add-on creates the Application Gateway itself and may be crash-looping on
`ApplicationGatewayInsufficientPermissionOnSubnet` while its subnet grant propagates. A
0.1.15-or-newer module waits for that gateway itself before creating the setting: the apply
prints `appgw_ready: waiting for the ingress add-on to create the Application Gateway (n/90)`
every 15 s, for up to about 22 minutes, and the normal case ends in `appgw_ready: … is
Succeeded`. If it stops at the ceiling instead, the add-on has backed off past its own
retry — run the recipe below and apply again (the same recipe closes the 404 on the older
modules):

```bash
az aks get-credentials -g "$RESOURCE_GROUP" -n "${DEPLOYMENT_NAME}-aks" --overwrite-existing && kubelogin convert-kubeconfig -l azurecli
kubectl -n kube-system rollout restart deploy/ingress-appgw-deployment
kubectl -n kube-system rollout status deploy/ingress-appgw-deployment --timeout=5m
az network application-gateway list -g "MC_${RESOURCE_GROUP}_${DEPLOYMENT_NAME}-aks_${LOCATION}" --query "[].provisioningState" -o tsv   # Succeeded, ~90 s later
terraform -chdir=terraform/azure plan -out tf.plan && terraform -chdir=terraform/azure apply tf.plan   # completes the one resource
```
 a Postgres `zone can only be
changed when exchanged with the standby zone` on a *second* apply does not occur on this
module (it ignores zone drift) — if you see it, you are not on the release's module.

**Not retryable:** `LocationIsOfferRestricted` (region) and `The VM size … is not allowed`
(SKU) — Phase 0.5 again, then re-plan. **Changing the network plugin or policy engine
replaces the cluster** — Terraform says so at plan time (`# forces replacement` on
`network_profile`); never apply such a plan over a cluster holding data without a
maintenance window.

### 1.4 Sign in to the cluster, read the outputs

```bash
az aks get-credentials -g "$RESOURCE_GROUP" -n "${DEPLOYMENT_NAME}-aks" --overwrite-existing
kubelogin convert-kubeconfig -l azurecli       # Entra sign-in through your az session; no local accounts exist
kubectl get nodes -L kubernetes.io/arch -L kubernetes.io/os   # 3 nodes Ready, ARCH amd64, OS linux (`-o wide` prints no architecture column)

terraform -chdir=terraform/azure output
# ws_endpoint, api_endpoint, ingress_class_name, key_vault_id, workload_identity_client_id,
# blob_endpoint, blob_container, postgres_subnet_cidr, fips_enabled, aks_oidc_issuer, app_gateway_id,
# alert_action_group_id
```

Record `wsEndpoint`, `apiEndpoint`, `keyVaultId`, `workloadIdentityClientId`,
`postgresSubnetCidr` and the `fips_enabled` value in the state file — Phase 4's values file
reads all of them. If you set `attachment_cors_origins`, record `blobEndpoint` and
`blobContainer` too (the `storage:` block in Phase 4.1).

**Verify (phase gate):** `terraform apply` ended with `Apply complete!`; `kubectl get nodes`
shows every node `Ready` on `amd64`; `terraform output` prints all the outputs above
non-empty; `terraform/azure/out/velt-console-config.json` exists and parses. **Do not
continue until `kubectl get nodes` works through Entra** — every later phase needs it.
Mark `1-substrate: done`.

---

## Phase 2 — Images: verify, import, scan

Velt's release registry is public-read, but your cluster must not pull from it at runtime:
verify every image against the release identity, then import it into your own registry.
The chart is pointed at your registry only.

### 2.1 Verify, then import

```bash
# 1. Your registry, attached to the cluster for pull auth (first time only).
az acr create -g "$RESOURCE_GROUP" -n "$ACR_NAME" --sku Basic -o none
az aks update -g "$RESOURCE_GROUP" -n "${DEPLOYMENT_NAME}-aks" --attach-acr "$ACR_NAME"

# 2. VERIFY FIRST — an unverified image must never reach your registry. Resolve each tag once,
#    verify that digest, and import THAT digest (`--image` keeps the tag your values pull).
for IMG in velt-gateway velt-workers velt-migrate; do
  DIGEST=$(oras manifest fetch --descriptor "$REG/$IMG:$PORTABLE_V" \
    | python3 -c "import json,sys;print(json.load(sys.stdin)['digest'])")
  cosign verify --certificate-identity-regexp "$IDENTITY" \
    --certificate-oidc-issuer "$ISSUER" "$REG/$IMG@$DIGEST" \
    && az acr import -n "$ACR_NAME" --source "$REG/$IMG@$DIGEST" --image "$IMG:$PORTABLE_V"
done

# 3. The console SPA. Its tag is the CONSOLE version, not $PORTABLE_V.
CONSOLE_DIGEST=$(oras manifest fetch --descriptor "$REG/velt-console-static:$CONSOLE_V" \
  | python3 -c "import json,sys;print(json.load(sys.stdin)['digest'])")
cosign verify --certificate-identity-regexp "$IDENTITY" \
  --certificate-oidc-issuer "$ISSUER" "$REG/velt-console-static@$CONSOLE_DIGEST" \
  && az acr import -n "$ACR_NAME" --source "$REG/velt-console-static@$CONSOLE_DIGEST" \
    --image "velt-console-static:$CONSOLE_V"

# 4. The SDK bundle host. Its tag is the SDK version. REQUIRED for a deployment whose pages
#    must not download JavaScript from Velt (see the bundle axis, Phase 4.1 and 7.1).
SDK_DIGEST=$(oras manifest fetch --descriptor "$REG/velt-sdk-static:$SDK_V" \
  | python3 -c "import json,sys;print(json.load(sys.stdin)['digest'])")
cosign verify --certificate-identity-regexp "$IDENTITY" \
  --certificate-oidc-issuer "$ISSUER" "$REG/velt-sdk-static@$SDK_DIGEST" \
  && az acr import -n "$ACR_NAME" --source "$REG/velt-sdk-static@$SDK_DIGEST" --image "velt-sdk-static:$SDK_V"

# 5. Valkey (the Redis-compatible cache). The chart's in-chart cache would otherwise pull
#    valkey/valkey:8 from DOCKER HUB — the one image that does not come from your registry.
#    Import it so the cluster needs no public egress at runtime.
#    (Skip only if you set redis.externalUrl to a managed Redis — that disables the in-chart cache.)
az acr import -n "$ACR_NAME" --source docker.io/valkey/valkey:8 --image valkey:8
```

Valkey is an upstream image, so there is no Velt signature to verify — pin it by digest if
your policy requires provenance, or use a managed Redis and skip it.

**Import by the verified digest, never by the tag.** `az acr import --source <repo>:<tag>` asks
Velt's registry for the tag again, so what it copies is not necessarily what `cosign verify`
checked; `--source <repo>@sha256:…` copies exactly the manifest that verified, under the tag
`--image` names. Record each image's digest for the report — each must be the digest that
verified above:

```bash
for IMG in velt-gateway velt-workers velt-migrate; do
  az acr repository show -n "$ACR_NAME" --image "$IMG:$PORTABLE_V" --query digest -o tsv
done
```

### 2.2 Vulnerability scan + infosec sign-off (human moment #5) — BEFORE Phase 3

**Ask the operator whether their organization requires a vulnerability scan of third-party
images before deployment.** Most enterprises do, and a finding disputed mid-deploy costs more
than one found here. Scan **their** registry copy, not Velt's.

```bash
# Azure-native: Microsoft Defender for Containers assesses ACR images on push.
az security pricing create -n ContainerRegistry --tier Standard      # if not already on
az acr repository show-tags -n "$ACR_NAME" --repository velt-gateway  # then read findings in Defender for Cloud
# Or one-shot, no subscription change, against the registry copies — EVERY image you imported, not only the gateway:
az acr login -n "$ACR_NAME"
for IMG in velt-gateway:$PORTABLE_V velt-workers:$PORTABLE_V velt-migrate:$PORTABLE_V velt-console-static:$CONSOLE_V velt-sdk-static:$SDK_V valkey:8; do
  trivy image --severity CRITICAL,HIGH --ignore-unfixed "$ACR_NAME.azurecr.io/$IMG"
done
```

**Findings on `valkey:8`** are expected sooner or later: it is the upstream Valkey image the
guide tells you to import, not a Velt build, and Velt's release process does not scan it. The
operator decides: accept them (pin the imported digest in `redis.image`, Phase 4.1, so the
accepted image is the one that runs), re-import a newer `valkey:8` and rescan, or use a
managed Redis instead (`redis.externalUrl`). Record the decision with the sign-off.

Every Velt image carries a **signed SPDX SBOM** as a cosign attestation, so a central
scanner can be handed the digest and verify offline:

```bash
cosign verify-attestation --type spdxjson \
  --certificate-identity-regexp "$IDENTITY" --certificate-oidc-issuer "$ISSUER" \
  "$REG/velt-gateway:$PORTABLE_V" | python3 -c "import json,sys,base64; \
  print(json.loads(base64.b64decode(json.load(sys.stdin)['payload']))['predicate']['name'])"
```

Velt's release process fails on any FIXABLE critical or high finding in every published
image, so a clean scan is expected — the point of this step is the operator's **sign-off**,
not the discovery. Record `infosecScanSignOff: done` (or `waived`) and the digests.

**Verify (phase gate):** `cosign verify` printed a verified result for every image you
imported, and each imported digest is the one that verified; `az acr repository list -n "$ACR_NAME"` lists `velt-gateway`, `velt-workers`,
`velt-migrate`, `velt-console-static`, `velt-sdk-static` and `valkey` (unless external
Redis); the sign-off is recorded. **Do not continue with an image that did not verify.**
Mark `2-images: done`.

---

## Phase 3 — Keys and secrets

The chart hands secrets to the pods through `envFrom: secretRef`, so **the key inside each
Kubernetes Secret must be the environment-variable name the image reads** — a filename-style
key like `jwt-public-key.pem` never reaches the process. The exact key names are below;
do not rename them.

### 3.1 Generate the deployment's ES256 signing keypair

The provision CLI ships inside every release image. Run it from the **migrate** image
(the one whose normal shape is a short-lived process) — no source checkout, nothing to
install. Three details of the `docker run` are load-bearing:

- `--entrypoint node` — the CLI is invoked as a node script, bypassing the image's
  server entrypoint;
- `-u $(id -u):$(id -g)` — the image runs as a non-root uid that cannot write a directory
  you own on a Linux host, so the container runs as YOUR uid;
- `-v $PWD/keys:/keys` with `--out-dir /keys` — the keys are written to the **mount**;
  written anywhere else they stay inside the `--rm` container and vanish with it.

```bash
mkdir -p keys
# dist/portable-runtime/src/provision-cli.js is where the image keeps its compiled CLI. A shell FUNCTION, not a
# variable: zsh (the macOS default) does not word-split an unquoted $PCLI, so `$PCLI keygen …` fails there with
# "no such file or directory: docker run …". The function works in bash and zsh alike.
pcli() { docker run --rm -u "$(id -u):$(id -g)" --entrypoint node -v "$PWD/keys:/keys" \
  "$ACR_NAME.azurecr.io/velt-migrate:$PORTABLE_V" dist/portable-runtime/src/provision-cli.js "$@"; }
KEYS_OUT=/keys
# On an arm64 host (Apple Silicon), add --platform linux/amd64 after --rm: the images are amd64-only.

pcli keygen --out-dir "$KEYS_OUT" --secret-name velt-jwt-es256 --namespace velt
ls -l keys/        # jwt-private.pem (mode 600, owned by you), jwt-public.pem, jwt-public-key-secret.yaml
```

If `ls keys/` is empty, or keygen fails with `EACCES`, see Troubleshooting (a root-owned
`./keys` from an earlier run, or a missing `-u`).

### 3.2 Create the namespace and the secrets

```bash
kubectl create namespace velt

# 1. The JWT PUBLIC key — key MUST be VELT_JWT_PUBLIC_KEY (every pod verifies tokens with it).
kubectl -n velt create secret generic velt-jwt-es256 \
  --from-file=VELT_JWT_PUBLIC_KEY=./keys/jwt-public.pem

# 2. The JWT PRIVATE key — key MUST be VELT_JWT_PRIVATE_KEY. Required whenever identify or
#    the console is enabled (both are, below): identify signs the tokens it mints with it.
#    Only the gateway mounts it.
kubectl -n velt create secret generic velt-jwt-es256-private \
  --from-file=VELT_JWT_PRIVATE_KEY=./keys/jwt-private.pem
```

**Two database connection strings, three secrets.** The application is not the database
administrator:

- `velt-postgres-dsn-admin` — the server administrator (`veltadmin`, password in Key Vault).
  **The migrate Job alone uses it**: it applies the schema and, on its first run, CREATES
  the application role with data privileges only.
- `velt-postgres-dsn` — the application role (`velt_app`). The gateway and workers use it.
- `velt-app-db` — the application role's password, handed to the migrate Job so it can
  create or update that role. The same value goes into the application DSN.

Both DSN secrets use the key `VELT_PG_DSN`; the password secret uses `VELT_APP_DB_PASSWORD`.

```bash
KV_NAME="${DEPLOYMENT_NAME}-kv"                                   # persistent mode: no random suffix
PGPASS=$(az keyvault secret show --vault-name "$KV_NAME" --name "${DEPLOYMENT_NAME}-pg-password" --query value -o tsv)
APP_PW=$(openssl rand -base64 24 | tr -d '/+=')                   # letters and digits only: it is URL-embedded below
PG_HOST="${DEPLOYMENT_NAME}-pg.postgres.database.azure.com"       # Azure Government: ${DEPLOYMENT_NAME}-pg.postgres.database.usgovcloudapi.net

kubectl -n velt create secret generic velt-postgres-dsn-admin --from-literal=VELT_PG_DSN=\
"postgres://veltadmin:${PGPASS}@${PG_HOST}:5432/postgres?sslmode=require"
kubectl -n velt create secret generic velt-app-db --from-literal=VELT_APP_DB_PASSWORD="${APP_PW}"
kubectl -n velt create secret generic velt-postgres-dsn --from-literal=VELT_PG_DSN=\
"postgres://velt_app:${APP_PW}@${PG_HOST}:5432/postgres?sslmode=require"
unset PGPASS APP_PW
```

The `velt_app` role does not exist until the migrate hook has run once (Phase 4) — that is
fine: the gateway and workers start only after the hook succeeds.

### 3.3 Put the private key away

`./keys/jwt-private.pem` is the deployment's signing key. Once both JWT secrets exist, the
cluster no longer needs the file. Keep ONE copy outside the cluster — you will mint a smoke
token from it in Phase 6, and Velt support may ask you to mint one later:

```bash
az keyvault secret set --vault-name "$KV_NAME" --name "${DEPLOYMENT_NAME}-jwt-private-key" \
  --file ./keys/jwt-private.pem -o none
rm -r keys
```

Record `artifacts.jwtPrivateKeyLocation` as the Key Vault secret name (never the value).
Never commit the file anywhere.

**Verify (phase gate):**

```bash
kubectl -n velt get secret velt-jwt-es256 velt-jwt-es256-private velt-postgres-dsn velt-postgres-dsn-admin velt-app-db
kubectl -n velt get secret velt-jwt-es256 -o jsonpath='{.data}' | python3 -c "import json,sys;print(sorted(json.load(sys.stdin).keys()))"
# ['VELT_JWT_PUBLIC_KEY']  — the KEY name, exactly
```

All five secrets exist; the key names are `VELT_JWT_PUBLIC_KEY`, `VELT_JWT_PRIVATE_KEY`,
`VELT_PG_DSN` (both DSN secrets) and `VELT_APP_DB_PASSWORD`; the private key is in Key Vault
and `./keys` is gone. **Do not continue until the key-name check prints exactly
`VELT_JWT_PUBLIC_KEY`** — a wrong key name is a gateway that cannot start. Mark
`3-keys-secrets: done`.

---

## Phase 4 — Install the application (Helm)

### 4.1 The values file — complete

Write `values-<env>.yaml`. Everything the chart needs is here; no image in this file comes
from anywhere but your own registry. Replace every `<placeholder>` from the inputs table and
the Phase 1 outputs.

```yaml
image:
  registry: <acr-name>.azurecr.io    # NO trailing slash — the chart adds the /
  tag: "0.1.13"                      # $PORTABLE_V — also what the gateway's /version reports

serviceAccount:
  annotations:
    azure.workload.identity/client-id: "<workload_identity_client_id output>"
  podLabels:
    azure.workload.identity/use: "true"

ingress:
  host: <public-host>                                        # the public_host you applied
  className: azure-application-gateway                       # the ingress_class_name output
  timeoutAnnotationKey: appgw.ingress.kubernetes.io/request-timeout   # carries the WS idle timeout to App Gateway
  tls:
    secretName: velt-portable-tls     # the customer-supplied certificate (Phase 5.3). Leave "" for the
                                      # Key Vault path, and for the FIRST install on the cert-manager path.

secrets:
  postgresDsnSecretName: velt-postgres-dsn                # the APPLICATION role (Phase 3)
  migratePostgresDsnSecretName: velt-postgres-dsn-admin   # the administrator — migrate Job only
  appDbPasswordSecretName: velt-app-db                    # the migrate Job creates/updates velt_app with it
  jwtEs256SecretName: velt-jwt-es256
  # svixSecretName: velt-svix        # ONLY with webhookV2Delivery "" or "svix": your own Svix token, key VELT_SVIX_API_KEY

# REQUIRED whenever the console is enabled. `console.enabled` only deploys the SPA; without
# this block the gateway answers 501 on /console/session — the console loads, redirects to
# the IdP, comes back, and cannot sign anyone in.
# ⚠ Spelling: oidcIssuer / oidcClientId. Helm accepts `issuer:` / `clientId:` SILENTLY and
# leaves the real values empty. The chart's own render check can only fire once enabled is true.
consoleAuth:
  enabled: true
  oidcIssuer: <console-auth-issuer>          # character for character the issuer of
                                             # <console-auth-issuer>/.well-known/openid-configuration — see 4.2
  oidcClientId: <console-client-id>
  jwtPrivateKeySecretName: velt-jwt-es256-private
  # preferredUsernameFallback: true          # Entra: see 4.2 — only if your chart lists this key

# REQUIRED for the SDK to mint self-hosted tokens. `allowedOrigins` is OPTIONAL: what identify
# requires is each workspace's allowed DOMAINS (seeded in 4.5). If you DO set allowedOrigins it
# narrows further, refuses "*" by design, and every origin listed must also be admitted by
# corsAllowedOrigins or the gateway refuses to boot.
identify:
  enabled: true
  wsEndpoint: wss://<public-host>/ws
  apiEndpoint: https://<public-host>/api
  # allowedOrigins: "https://<app-origin>"

# CORS — the browser origins of the customer's pages, exact (scheme + host [+ port]). EMPTY
# means browsers are blocked. A malformed entry is dropped with a warning in the gateway log.
corsAllowedOrigins: "https://<public-host>,https://<app-origin>"

# Webhook V2 delivery — ONE choice per deployment. "" or "svix": deliver through your own Svix
# (then set secrets.svixSecretName). "direct": no Svix — the workers sign each event with Svix's
# own scheme (svix-id / svix-timestamp / svix-signature, a whsec_ secret your receiver verifies
# with the unchanged svix package) and POST it from inside the cluster to the endpoints you
# register through the console or the REST API. Nothing leaves to a third party. "direct"
# beside secrets.svixSecretName FAILS the render on purpose. Direct mode sends once and never
# retries; each attempt is a ledger row kept webhookV2DeliveryRetentionDays (7).
webhookV2Delivery: ""                     # "" | "svix" | "direct"
# webhookV2DeliveryRetentionDays: 7       # direct mode only; 0 keeps the ledger forever

# NetworkPolicy — enforced because Phase 1 created the cluster with a network-policy engine.
# Ingress restriction is ON (Redis only from the gateway and workers; workers and migrate
# accept nothing). Egress restriction is opt-in: list the Postgres subnet and everything the
# deployment calls out to — Blob, Entra / your IdP, Svix (not in direct mode), your email
# provider, your webhook receivers and permission provider. Leave `restricted: false` until
# you have that list; a policy that silently blocks a webhook is the failure you least want
# to debug.
networkPolicy:
  enabled: true
  egress:
    restricted: false
    postgresCidrs: ["<postgres_subnet_cidr output>"]     # e.g. ["10.10.16.0/24"]
    allowCidrs: []                                       # the list above, once restricted: true

# FIPS mode for the APPLICATION. The node pools' FIPS is the substrate's switch (the
# fips_enabled output); the image ships the OpenSSL FIPS provider and refuses to start when
# this says true and FIPS is not active. Set it to match the output.
fips:
  enabled: false                          # true on Azure Government / when fips_enabled is true

redis:
  # Imported in Phase 2. Without this the chart pulls valkey/valkey:8 from Docker Hub.
  image: <acr-name>.azurecr.io/valkey:8
  # ALTERNATIVE: drop `image` and use a managed Redis instead; this disables the in-chart cache:
  # externalUrl: rediss://<name>.redis.cache.windows.net:6380
  # The in-chart Redis ALWAYS requires a password. Nothing to set — the chart creates
  # velt-redis-auth with a random one, keeps it across upgrades, and hands it to the pods.

# THE BUNDLE AXIS — not covered by `strict`, and the most consequential block on this page
# for a deployment that must not download JavaScript from Velt. The SDK's loader picks its
# bundle URL BEFORE velt.js exists, from {staging, develop, version, proxyDomain |
# proxyConfig.cdnHost}; `selfHosted` / `strict` are not loader inputs. Unpinned, it resolves
# https://cdn.velt.dev/lib/sdk@<v>/velt.js. This block serves the bundle from YOUR ingress at
# /lib/sdk@<version>/; the app-side pin (Phase 7.1) is what makes it used. Both halves or neither.
sdk:
  enabled: true
  version: "6.0.24"               # $SDK_V — this is part of the served PATH
  tag: "6.0.24"                   # the image tag; defaults to sdk.version
  retainedVersions: []            # on a LATER SDK upgrade: the old version(s) to keep serving while apps repoint
                                  # (chart 0.1.16+; one image serves one version — see the Upgrade Guide, Phase U5)

console:
  enabled: true
  tag: "<console-version>"        # $CONSOLE_V from the manifest (0.1 — the examples in this guide are older than the manifest)
  config: {}                      # REPLACED by the splice in 4.2 — never leave it {} with enabled: true

# WHICH MODULES this deployment serves — the one list the console, the SDK and the gateway all
# read (chart 0.1.15 or newer; on 0.1.13 / 0.1.14 set `restApi.enabled: true` instead). `core` is the SDK
# data plane and is always on. Add `rest-api` for the customer REST API:
# a token-authenticated surface on the public ingress
# (`/api/v2/...`) that a deployment whose integrations all go through the SDK never needs; its
# credential is the API key's auth token you seed in Phase 4.5. Without `rest-api`, `/api/v2/...`
# answers a bare 404 and the console hides the REST features. No other module id is served on
# this deployment — listing one only makes the console show features that answer `unimplemented`.
enabledModules:
  - core
  # - rest-api

# Comment attachments (module and chart 0.1.14 or newer) — ONLY if you set attachment_cors_origins
# in Phase 1.2 (or added the CORS rule by hand on a 0.1.13 module); otherwise leave the whole block
# out (the attachment calls then answer `unimplemented` and the SDK hides the button). The gateway signs short-lived upload / download URLs for the storage account and
# the workers delete a deleted document's files; both use the workload identity above.
# storage:
#   blobEndpoint: "<blob_endpoint output>"             # https://<account>.blob.core.windows.net/ (.usgovcloudapi.net on Government)
#   container: "<blob_container output>"               # attachments
#   tokenEndpoint: "https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/token"   # Government: login.microsoftonline.us
#   clientId: "<workload_identity_client_id output>"
#   tenantId: "<tenant-id>"
#   # federatedTokenFile and sasTtlSecondsMax (3600, the longest a signed URL lives) keep their defaults

# Numbers of a million or more — restApi.maxBodyBytes, maxSnapshotBytes, dataRate.*, and any
# value under `env:` — may be written bare; the chart renders every numeric entry as its digits.
```

Defaults the file above overrides on purpose: `redis.image` defaults to Docker Hub,
`console.enabled` and `sdk.enabled` default to `false`, `networkPolicy` and `fips` to the
posture shown. A deployment that takes the defaults gets an egress dependency it did not ask
for, no console and no SDK host.

### 4.2 Splice the console config, check the issuer

Terraform wrote the console's runtime config in Phase 1. Replace the `console:` block's
`config: {}` with it rather than retyping it:

```bash
# Remove the placeholder console block from values-<env>.yaml first (the last three lines of the
# `console:` block above), then append the generated one. JSON is valid YAML.
python3 - "$CONSOLE_V" <<'EOF' >> values-<env>.yaml
import json, sys
cfg = json.load(open('terraform/azure/out/velt-console-config.json'))
print('console:')
print('  enabled: true')
print(f'  tag: "{sys.argv[1]}"')
print('  config:', json.dumps(cfg))
EOF
# Or with yq:  yq -o yaml '{"console":{"config": .}}' terraform/azure/out/velt-console-config.json
```

The generated config carries `enabledModules: ["core"]` from the module's own variable. Leave
it: the chart appends `rest-api` to the console's copy when your values turn the REST API on
(Phase 4.1), so the console shows what the gateway serves.

`auth.issuer` in that config must be reachable **from the browser**, not from the cluster —
the SPA fetches `{issuer}/.well-known/openid-configuration` to start the OIDC
authorization-code + PKCE flow. Set `console.enabled: false` if you are not deploying the
console; never `true` with an empty `config` — the chart fails the render on purpose,
because the SPA's fallback for a missing config is to boot on build defaults: a blank console
with no error.

**Two Entra checks before the install** (either one wrong is a console that cannot sign in):

```bash
# 1. The issuer, character for character. Both lines must print the same text; if they differ,
#    use the SECOND one as consoleAuth.oidcIssuer AND as auth.issuer in the console config.
echo "$CONSOLE_AUTH_ISSUER"
curl -s "${CONSOLE_AUTH_ISSUER%/}/.well-known/openid-configuration" | jq -r '.issuer'

# 2. Does this release's chart carry the preferred_username fallback? An Entra member with no
#    `mail` attribute has no `email` claim and signs in by UPN; releases that gate that fallback
#    need the key set to true, or that member gets 401. The chart is resolved to its digest and verified first, as in 4.3.
CHART_DIGEST=$(oras manifest fetch --descriptor "$REG/velt-portable:$PORTABLE_V" \
  | python3 -c "import json,sys;print(json.load(sys.stdin)['digest'])")
cosign verify --certificate-identity-regexp "$IDENTITY" --certificate-oidc-issuer "$ISSUER" "$REG/velt-portable@$CHART_DIGEST" \
  && helm show values "oci://$REG/velt-portable@$CHART_DIGEST" | grep -c preferredUsernameFallback
# 1 or more → add `preferredUsernameFallback: true` under consoleAuth in values-<env>.yaml.
# 0        → the key does not exist on this release; the fallback is always on. Leave it out.
```

### 4.3 Verify the chart, then install

Tell the operator: **the install takes 3–5 minutes** (the schema migration runs first, then
the pods start).

```bash
CHART_DIGEST=$(oras manifest fetch --descriptor "$REG/velt-portable:$PORTABLE_V" \
  | python3 -c "import json,sys;print(json.load(sys.stdin)['digest'])")
cosign verify --certificate-identity-regexp "$IDENTITY" \
  --certificate-oidc-issuer "$ISSUER" "$REG/velt-portable@$CHART_DIGEST" \
  && helm install velt "oci://$REG/velt-portable@$CHART_DIGEST" -n velt -f values-<env>.yaml --timeout 8m
```

The chart is installed by the digest that verified, like every other artifact. Do not add
`--version`: Helm 3.17 ignores it beside a digest, and newer Helm refuses the pair when the tag has
moved. Every later `helm upgrade` in this guide re-applies `$CHART_DIGEST`; in a new shell, set and
verify it again by running this block up to the `cosign verify` (everything before `&& helm install`).

The release is named `velt` in namespace `velt`; every resource is `velt-<component>`
(`velt-gateway`, `velt-workers`, `velt-migrate`, `velt-redis`, `velt-console`, `velt-sdk`),
and that pair (`--release velt --namespace velt`) is what the receipt, support bundle and
upgrade commands take.

The **migrate** Job runs as a pre-install hook and applies every migration the release
carries, in order, against your Postgres before the application pods start; a migration
failure aborts the install loudly. Then the gateway, workers, Valkey, console and SDK pods
come up.

### 4.4 Verify the migrate hook actually applied the schema

A `Completed` migrate pod is necessary and NOT sufficient — read the **migration ledger**.
The runner commits each version's DDL and its `schema_migrations` row in one transaction, so
a recorded version means that version fully applied; a table count cannot tell you that.

```bash
kubectl -n velt get pods | grep migrate           # Completed, not Error
kubectl -n velt logs job/velt-migrate | tail -20

# The schema head this chart applies is published ON the chart:
helm show chart "oci://$REG/velt-portable@$CHART_DIGEST" | grep schema-head
#   velt.dev/schema-head: "126"      ← an example: use the number YOUR chart prints

# The ledger's highest version must equal that number. Run the pod DETACHED, wait for it, then read its
# log: an attached `kubectl run --rm -i` can race the short-lived container and print NOTHING, which
# looks like an empty ledger (it is not — run again if you see only "try pressing enter").
kubectl -n velt run pgcheck --restart=Never --image=postgres:16-alpine \
  --overrides='{"spec":{"containers":[{"name":"pgcheck","image":"postgres:16-alpine",
    "envFrom":[{"secretRef":{"name":"velt-postgres-dsn"}}],
    "command":["sh","-c","psql \"$VELT_PG_DSN\" -tAc \"select version, id, applied_at from schema_migrations order by version\""]}]}}'
kubectl -n velt wait pod/pgcheck --for=jsonpath='{.status.phase}'=Succeeded --timeout=120s
kubectl -n velt logs pgcheck | tee ledger.txt
kubectl -n velt delete pod pgcheck
SCHEMA_HEAD=$(awk -F'|' '{print $1}' ledger.txt | sort -n | tail -1); echo "ledger head: $SCHEMA_HEAD"   # the final report's receipt takes it
```

The ledger carries one row per migration the release ships; **gaps in the numbering are
normal** (a version number reserved and never used is skipped). What must hold is
`max(version)` = the chart's `velt.dev/schema-head`. A chart with no such annotation is a
release older than `0.1.13`; then compare against the head Velt support names for that
release. The runner also announces **version skew** on its own — a database carrying versions
this image does not know about (a rollback mid-flight) is reported, never silently
re-applied; reconcile the image tag against the database before continuing if the log says so.

(`postgres:16-alpine` is pulled from Docker Hub for this one-off check; air-gapped clusters
import it into the registry first, like Valkey, and use that reference.)

### 4.5 Seed the first workspace (in-cluster)

The database is private, so run the provision CLI **inside the cluster**, from the migrate
image, with the DSN from the same secret the gateway uses — there is no second copy of it.
It is idempotent: re-running upserts, never duplicates.

Two credentials come out of this step, for two different ladders of the REST API — do not
confuse them:

- the **API key's auth token** (`x-velt-api-key` + `x-velt-auth-token`): what the REST API doc
  calls "that apiKey's auth token", and what the console shows as the API key's **Auth Token**. You
  choose it (`<api-auth-token>` in the inputs table) and the seed stores it hashed;
- the **workspace auth token** (`x-velt-workspace-id` + `x-velt-workspace-auth-token`): the
  deployment-scoped credential of the `/v2/workspace/*` family. The seed GENERATES it and
  prints it once — the console shows it as **Workspace Auth Token**.

A request on one ladder with the other ladder's token is refused with `403 Invalid Auth token.`.

```bash
# Inline JSON for --project-config and --identify-config is fine: neither carries a secret. The inner
# quotes are backslash-escaped because each value is spliced into the --overrides JSON string below.
PRIVATE_COMMENTS='{\"isPrivateCommentsEnabled\":true}'     # or '{}' when the customer does not use private comments
# The identify configuration needs BOTH flags — a config without them is refused:
#   requireAutoOrgUser  true:  a user who identifies into an organization is added to it (the hosted default for a
#                              new API key); false: add users to organizations through the REST API first
#   requireJwtToken     false: identify accepts a user without a token from your server (the hosted default); true:
#                              only users your server signs (needs your verification key as customerJwt)
IDENTIFY_CONFIG='{\"allowedOrigins\":[\"<app-origin>\"],\"requireAutoOrgUser\":true,\"requireJwtToken\":false}'  # every browser origin of the app, comma separated inside the array
API_AUTH_TOKEN=$(az keyvault secret show --vault-name "$KV_NAME" --name "${DEPLOYMENT_NAME}-api-auth-token" --query value -o tsv)   # <api-auth-token>, stored before this step
kubectl -n velt run velt-provision --restart=Never \
  --image="$ACR_NAME.azurecr.io/velt-migrate:$PORTABLE_V" \
  --overrides='{"spec":{"containers":[{"name":"velt-provision",
    "image":"'"$ACR_NAME"'.azurecr.io/velt-migrate:'"$PORTABLE_V"'",
    "envFrom":[{"secretRef":{"name":"velt-postgres-dsn"}}],
    "command":["node","dist/portable-runtime/src/provision-cli.js","seed",
      "--api-key","<api-key>","--org-id","<org-id>","--user-id-hash","<admin-user-id-hash>",
      "--console-admin-email","<console-admin-email>",
      "--allowed-domains","<allowed-domains>",
      "--identify-config","'"$IDENTIFY_CONFIG"'",
      "--auth-token","'"$API_AUTH_TOKEN"'",
      "--project-config","'"$PRIVATE_COMMENTS"'"]}]}}'
# The log holds the generated WORKSPACE auth token (one bare line, printed once) next to a node `pg`
# SSL-mode warning: capture it straight into the secret store, then delete the pod so the log is gone.
# ONE command: a failed seed, or a line that is not a token, stores nothing and keeps the pod so you
# can read its log (a re-seed prints no token and ends here too — expected; delete the pod).
kubectl -n velt wait pod/velt-provision --for=jsonpath='{.status.phase}'=Succeeded --timeout=120s \
  && ( umask 077; kubectl -n velt logs velt-provision | grep -vE '^\[|warning|Warning' | tail -1 > seed-workspace-token.txt ) \
  && grep -qE '^[A-Za-z0-9_-]{32,}$' seed-workspace-token.txt \
  && az keyvault secret set --vault-name "$KV_NAME" --name "${DEPLOYMENT_NAME}-workspace-auth-token" --file seed-workspace-token.txt -o none \
  && kubectl -n velt delete pod velt-provision \
  || echo "NOT stored — read: kubectl -n velt logs velt-provision; fix, then kubectl -n velt delete pod velt-provision"
rm -f seed-workspace-token.txt; unset API_AUTH_TOKEN
```

The pod runs detached and is read from its log on purpose: an attached `kubectl run --rm -i`
can race the container and lose the output, and a re-seed never prints the workspace token
again (it is stored hashed). If the log shows `workspace auth token already provisioned`, the
first run's token is the one in the secret store; re-seeding is otherwise idempotent.

**`--identify-config` matters beyond CORS:** a workspace seeded without one has no identify
configuration, and adding a second workspace in the console then fails with `cannot create a
second live apiKey … until every existing apiKey has a valid identify config` until the first
has one (Troubleshooting). Pass the app's origins here and the console is consistent from the
start.

What this seeds and why each flag matters:

- the API key row — without it the authorization evaluator denies everything;
- the first organization and the first admin user (`--user-id-hash` is the hash of the user
  id your app will identify with);
- `--console-admin-email` — console members are matched by email; this is who can sign in;
- `--allowed-domains` — identify refuses a workspace that has none; add more later through
  the console or the REST API;
- `--identify-config` — the workspace's identify configuration (its allowed browser origins);
  the console requires one before a second workspace can be added;
- `--auth-token` — the API key's auth token for the REST API (`x-velt-auth-token`); without it
  the REST API has no credential until one is created in the console;
- `--project-config` with `isPrivateCommentsEnabled: true` — **required when the customer
  uses private comments**: the unseeded default is `false`, under which the SDK's viewer
  does not build visibility tokens and a comment restricted to its own author disappears
  from the author's view.

### 4.6 One port, two paths

The gateway serves WebSocket, REST and health on **one** port (8080). The chart probes
`/readyz` and `/healthz` on it and the Service maps both published ports (`ws:8080`,
`api:8081`) to it, so the ingress `/ws` vs `/api` split still works. A pod that hangs at
`0/1` with `connection refused` on its readiness probe is running a chart other than this
release's.

**Verify (phase gate):**

```bash
helm -n velt status velt | grep STATUS        # deployed
kubectl -n velt get pods                      # velt-migrate-* Completed; gateway, workers, redis, console, sdk Running and Ready
```

`helm status` is `deployed`; the migrate pod is `Completed`; the ledger's `max(version)`
equals the chart's `velt.dev/schema-head`; the seed printed its completion and the auth token
was captured; every application pod is `Running` and Ready. **Do not continue until the
ledger head equals the chart annotation** — a half-applied schema surfaces as runtime errors
much later. Record `versions.schemaHead`. Mark `4-helm: done`.

---

## Phase 5 — Ingress, DNS and TLS (Application Gateway)

Phase 1 enabled the AKS **Application Gateway ingress add-on** in its own subnet. It programs
an Application Gateway from the chart's `Ingress` object; TLS terminates at the App Gateway,
and the gateway Service stays ClusterIP — there is no plaintext side door. Do this in THIS
order: the App Gateway's public IP does not exist until the add-on applies, and the
certificate's name must equal `PUBLIC_HOST`.

### 5.1 Read the Application Gateway IP; the first-sync restart

```bash
NODE_RG=$(az aks show -n "${DEPLOYMENT_NAME}-aks" -g "$RESOURCE_GROUP" --query nodeResourceGroup -o tsv)
az network application-gateway list -g "$NODE_RG" --query '[].{name:name, state:operationalState, prov:provisioningState}' -o table
az network public-ip list -g "$NODE_RG" --query "[?contains(name,'appgw')].ipAddress" -o tsv
kubectl -n velt get ingress                   # ADDRESS must show that IP
```

**Expected on a freshly created cluster:** the add-on reconciles while the App Gateway is
still `Stopped`/warming — the ingress shows an EMPTY ADDRESS and `:443` answers nothing, with
the add-on pod logging `Reset IP ... is in stopped state`. Once the table above reads
`Running` / `Succeeded`, force one reconcile and allow 3–6 minutes of
`provisioningState: Updating`:

```bash
kubectl -n kube-system rollout restart deploy/ingress-appgw-deployment
kubectl -n velt get ingress -w                # ADDRESS appears, typically within a minute
```

If the add-on pod is in `CrashLoopBackOff` naming `ApplicationGatewayInsufficientPermissionOnSubnet`,
it is waiting on a role assignment the module created after the cluster; its own retry picks
it up as RBAC propagates — restart it with the same command rather than waiting out the
backoff ceiling.

Record `artifacts.appGatewayIp`.

### 5.2 DNS (human moment #6)

Create an **A record** for `PUBLIC_HOST` pointing at that IP. `PUBLIC_HOST` is the single
input that builds the WS/API endpoints, the ingress host AND the certificate's name — keep
them equal.

**No domain? Azure gives you a real one.** Every public IP takes a DNS name label, published
in public DNS as `<label>.<region>.cloudapp.azure.com` — no registrar, no zone. It is real
public DNS, so ACME works against it and browsers trust the result. If the operator chose
this, `PUBLIC_HOST` was `<label>.<region>.cloudapp.azure.com` from Phase 0 on:

```bash
LABEL=<label>
az rest --method GET --url "https://management.azure.com/subscriptions/$SUBSCRIPTION_ID/providers/Microsoft.Network/locations/$LOCATION/CheckDnsNameAvailability?domainNameLabel=$LABEL&api-version=2023-09-01" --query available
# Azure Government: https://management.usgovcloudapi.net/… and the host is <label>.<region>.cloudapp.usgovcloudapi.net
az network public-ip update -g "$NODE_RG" -n "${DEPLOYMENT_NAME}-appgw-appgwpip" --dns-name "$LABEL"
```

```bash
dig +short "$PUBLIC_HOST"                     # the App Gateway IP — wait for propagation before 5.3
# without dig: python3 -c 'import socket,sys; print(socket.gethostbyname(sys.argv[1]))' "$PUBLIC_HOST"
```

### 5.3 The certificate — pick ONE path (human moment #7)

- **Default — customer-supplied Kubernetes TLS secret** (works offline, no renewal
  dependency on the cluster). Obtain a certificate with `CN=<public-host>` from the
  operator's CA, then:

  ```bash
  kubectl -n velt create secret tls velt-portable-tls --cert=host.crt --key=host.key
  ```

  `ingress.tls.secretName: velt-portable-tls` is already in the values file. If you
  installed before the secret existed, run the `helm upgrade` at the end of this step so the
  ingress picks it up.

- **Azure — Key Vault certificate** (the certificate never leaves Key Vault): set
  `ingress.tls.secretName: ""` and add
  `ingress.annotations: { "appgw.ingress.kubernetes.io/appgw-ssl-certificate": "<kv-cert-name>" }`
  to the values file, with the certificate installed on the App Gateway from Key Vault per
  Azure's documentation; then the `helm upgrade` below.

- **cert-manager + Let's Encrypt** — needs public egress and a public host, so not for a
  sealed deployment, but the common shape for a customer with a public hostname. The ACME
  challenge needs the `:80` listener, and an ingress naming a TLS secret that does not
  exist yet is a dangling reference, so: install (or upgrade) FIRST with
  `--set ingress.tls.secretName=`, issue, then attach.

  ```bash
  helm upgrade velt "oci://$REG/velt-portable@$CHART_DIGEST" -n velt -f values-<env>.yaml \
    --set ingress.tls.secretName=
  helm repo add jetstack https://charts.jetstack.io --force-update
  helm install cert-manager jetstack/cert-manager -n cert-manager --create-namespace --set crds.enabled=true
  kubectl apply -f - <<EOF
  apiVersion: cert-manager.io/v1
  kind: ClusterIssuer
  metadata: {name: letsencrypt-prod}
  spec:
    acme:
      server: https://acme-v02.api.letsencrypt.org/directory
      email: <ops-email>            # from the inputs table (the console admin email is fine)
      privateKeySecretRef: {name: letsencrypt-prod-account-key}
      solvers:
        - http01: {ingress: {ingressClassName: azure-application-gateway}}
  EOF
  kubectl apply -f - <<EOF
  apiVersion: cert-manager.io/v1
  kind: Certificate
  metadata: {name: velt-portable-tls, namespace: velt}
  spec:
    secretName: velt-portable-tls
    issuerRef: {name: letsencrypt-prod, kind: ClusterIssuer}
    commonName: <public-host>
    dnsNames: [<public-host>]
  EOF
  kubectl -n velt wait --for=condition=Ready certificate/velt-portable-tls --timeout=10m
  ```

  The first challenge attempt reports `wrong status code '502'` for 30–60 s while the App
  Gateway syncs the solver ingress; that is not a failure — cert-manager retries and it
  clears. Issuance takes about a minute.

Then attach the certificate — re-run the install's values without any override:

```bash
helm upgrade velt "oci://$REG/velt-portable@$CHART_DIGEST" -n velt -f values-<env>.yaml
```

### 5.4 The `:80` listener and ACME renewal

Once the ingress carries `tls.secretName`, the add-on programs an HTTPS listener **only** —
`:80` stops answering entirely (cleartext requests to `/` and `/api/…` time out). That is
the posture you want. Note the consequence if you obtained the certificate with **ACME
HTTP-01**: renewal needs `:80` back. Either re-open it for the renewal window, switch
cert-manager to a DNS-01 solver, or supply the certificate out of band (the default path,
which has no renewal dependency at all).

**Verify (phase gate):**

```bash
dig +short "$PUBLIC_HOST"                                                  # the App Gateway IP (or the python3 one-liner from 5.2)
curl -sI "https://$PUBLIC_HOST/" | head -1                                 # HTTP/… 200 (the console)
curl -sI "https://$PUBLIC_HOST/lib/sdk@$SDK_V/velt.js"           | head -1  # 200
curl -sI "https://$PUBLIC_HOST/lib/sdk@$SDK_V/velt-portable.js"  | head -1  # 200 — a sibling chunk, proving the WHOLE directory is served
curl -s  "https://$PUBLIC_HOST/velt-console-config.json" | jq .backend     # "portable"
```

The host resolves; TLS handshakes with a certificate for `PUBLIC_HOST` (no `-k`); the four
URLs answer as shown. **Do not continue until the two `/lib/sdk@…` URLs answer 200** —
the SDK loads lazily from that directory and a partial mirror fails inside the product, not
at page load. Mark `5-ingress-dns-tls: done`.

---

## Phase 6 — Smoke

### 6.1 Pod-direct (liveness only)

Bypasses the ingress, so it proves the pods, not TLS or the path split:

```bash
kubectl -n velt get pods                               # migrate Completed; everything else Running and Ready
kubectl -n velt port-forward svc/velt-gateway 8080:8080 &
for i in 1 2 3 4 5 6 7 8 9 10; do curl -sf http://127.0.0.1:8080/healthz >/dev/null 2>&1 && break; sleep 1; done   # the forward takes a few seconds
curl -sf http://127.0.0.1:8080/healthz ; echo           # ok
curl -s  http://127.0.0.1:8080/readyz  ; echo           # ready  (pings Postgres and Redis)
curl -s  http://127.0.0.1:8080/version | jq .portableVersion   # "0.1.13" — what RUNS, read pod-direct only (never routed by the ingress)
kill %1
```

### 6.2 Real-ingress smoke — both legs through the public host

With the ingress up, prove that BOTH paths route through the App Gateway to a real handler.
A **404** here means the `/api` prefix is not reaching the gateway, or DNS/certificate is
wrong — NOT that the product works. First mint a token with the private key you put away in
Phase 3.3:

```bash
mkdir -p keys
az keyvault secret download --vault-name "$KV_NAME" --name "${DEPLOYMENT_NAME}-jwt-private-key" \
  --file ./keys/jwt-private.pem
TOKEN=$(pcli token --private-key /keys/jwt-private.pem \
  --api-key "<api-key>" --user-id-hash "<admin-user-id-hash>" --ttl-seconds 900)
rm -r keys

# /api → the REST plane. Expect 200, 400 or 401 (a REAL handler response), NEVER 404.
curl -si "https://<public_host>/api/operations/read" \
  -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"feature":"comment","scope":"doc","resourceId":"a1"}' | head -1
# /ws → the WebSocket upgrade. Expect HTTP/1.1 101 Switching Protocols.
curl -si "https://<public_host>/ws" \
  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
  -H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' | head -1
unset TOKEN
```

(`pcli` is the Phase 3.1 `docker run` function; `<public_host>` is `PUBLIC_HOST`.) The
ingress routes EXACTLY `/ws`, `/api`, `/lib` and `/` — root paths such as `/identify` are
unrouted by design and 502 at the App Gateway, which is why the SDK's backend URL in Phase
7.1 ends in `/api`.

### 6.3 Comment attachments (only if you set the `storage:` block — module and chart 0.1.14 or newer)

Skip this step when the deployment runs without attachments. Otherwise prove the whole path
through the public host — the same calls the SDK makes — with a fresh token (mint it as in
6.2):

```bash
mkdir -p keys
az keyvault secret download --vault-name "$KV_NAME" --name "${DEPLOYMENT_NAME}-jwt-private-key" \
  --file ./keys/jwt-private.pem
TOKEN=$(pcli token --private-key /keys/jwt-private.pem \
  --api-key "<api-key>" --user-id-hash "<admin-user-id-hash>" --ttl-seconds 900)
rm -r keys
API="https://<public_host>/api/operations"
DOC="install-check-attachments-$(date +%s)"
H=(-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json')

# 1. a document to attach to (the SDK creates documents the same way)
curl -s "$API/setDocumentMetadata" "${H[@]}" \
  -d "{\"organizationId\":\"<org-id>\",\"documentId\":\"$DOC\",\"metadata\":{}}"; echo     # {"ok":true}
# 2. an upload URL
UP=$(curl -s "$API/getUploadUrl" "${H[@]}" \
  -d "{\"organizationId\":\"<org-id>\",\"documentId\":\"$DOC\",\"contentType\":\"text/plain\"}")
echo "$UP" | jq -c '{ok, method: .data.method, headers: .data.headers}'
# {"ok":true,"method":"PUT","headers":{"x-ms-blob-type":"BlockBlob","Content-Type":"text/plain"}}
# 3. the browser's PUT, with exactly those headers, straight to your storage account
curl -s -o /dev/null -w '%{http_code}\n' -X PUT "$(echo "$UP" | jq -r .data.url)" \
  -H 'x-ms-blob-type: BlockBlob' -H 'Content-Type: text/plain' --data-binary 'attachment check'   # 201
# 4. a download URL, then the GET
ID=$(echo "$UP" | jq -r .data.attachmentId)
BODY="{\"organizationId\":\"<org-id>\",\"documentId\":\"$DOC\",\"attachmentId\":\"$ID\"}"
DOWN=$(curl -s "$API/getDownloadUrl" "${H[@]}" -d "$BODY")
curl -s "$(echo "$DOWN" | jq -r .data.url)"; echo                                                 # attachment check
# 5. the browser's CORS preflight, from one of your app origins
ORIGIN="<attachment-origins>"     # if you listed several, keep ONE of them here
curl -s -o /dev/null -D - -X OPTIONS "$(echo "$UP" | jq -r .data.url)" -H "Origin: $ORIGIN" \
  -H 'Access-Control-Request-Method: PUT' -H 'Access-Control-Request-Headers: content-type,x-ms-blob-type' \
  | grep -i '^access-control-allow-origin'                                                     # $ORIGIN
# 6. delete it, then the download answers not-found
curl -s "$API/deleteAttachment" "${H[@]}" -d "$BODY"; echo                                       # {"ok":true,"data":{"success":true}}
curl -s "$API/getDownloadUrl" "${H[@]}" -d "$BODY"; echo                                         # {"ok":false,"code":"not-found",…}
unset TOKEN UP DOWN
```

`{"ok":false,"code":"unimplemented"}` at step 2 means the `storage:` block did not reach the
gateway (its startup log line says `storage: disabled`); a 403 at step 3 or 4, or no
`access-control-allow-origin` at step 5, is in **Troubleshooting**.

**Verify (phase gate):** `/healthz` prints `ok` and `/readyz` prints `ready` pod-direct;
`/version` reports `PORTABLE_V`; through the public host, `/api/operations/read` answers a
non-404 status and `/ws` answers `101 Switching Protocols`; with attachments on, every 6.3 step
prints its expected value. **Do not continue on a 404 from either leg.** Mark `6-smoke: done`.

---

## Phase 7 — End-to-end acceptance (the payoff)

Human moment #8 — 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. A real app also exercises
what a synthetic one cannot: CSP headers, the framework wrapper (`@veltdev/react` …), and
the app's own auth flow. If they have none, build a minimal page yourself, say plainly that
it is a stand-in, and record that the loop should be re-proven inside their app when one
exists (`acceptanceApp: stand-in`).

### 7.1 The app's SDK config — both axes

```js
initVelt('<api-key>', {
  // BUNDLE axis — where the JavaScript is downloaded FROM. Omit either line and the loader
  // goes to cdn.velt.dev even with strict: true (the two axes are independent).
  version: '6.0.24',                                   // MUST equal values sdk.version ($SDK_V)
  proxyConfig: { cdnHost: 'https://<public-host>' },   // serves /lib/sdk@<version>/…
  // RUNTIME axis — where the SDK TALKS to.
  selfHosted: {
    enablePortableBackend: true,
    portableBackendUrl: 'https://<public-host>/api',   // the /api base — the SDK appends /identify to it
    strict: true,                                      // the whole air-gap switch: without it the SDK still calls Velt hosts
  },
})
```

**Three silent traps on the bundle axis:**

- **Mirror the whole directory.** `velt.js` is only the eager part; dozens of sibling
  chunks are imported lazily *relative to the served path*. A `velt.js`-only mirror fails
  the portable backend with no error you would recognise. The `velt-sdk-static` image
  carries the whole package — use it rather than hand-copying files (Phase 5's two
  `/lib/sdk@…` checks prove it).
- **`staging: true` / `develop: true` beat the pin.** Both are checked before the
  `cdnHost` pin, and both point at a Velt host — either flag downloads from Velt no matter
  what `cdnHost` says. Never set them on a self-hosted page.
- **`version` defaults to the wrapper's own hardcoded version**, not `latest`. Pin it
  explicitly and keep it equal to `values sdk.version`, or the path 404s.
- **One image serves one version.** When you later move `sdk.version`, the old `/lib/sdk@<old>/`
  path is gone the moment the new pod is up, unless `<old>` is listed in `sdk.retainedVersions`
  (chart 0.1.16+). Apps repoint on their own deploy, so an SDK upgrade retains the old version
  until they have — the Upgrade Guide's Phase U5 gives the order.

### 7.2 The four checks

1. **Console sign-in.** Open `https://<public-host>`, sign in as the seeded console admin
   email. Lands on the dashboard. (Members are matched by email — the address passed to
   `--console-admin-email` in Phase 4.5.)
2. **SDK loop.** Open the app with the 7.1 config. DevTools → Network, filter `velt`: the
   bundle loads from **your** host; `window.Velt.version` equals the pinned version;
   creating a comment renders and the calls go to **your** ingress.
3. **Cross-surface proof.** The comment from step 2 is visible in the console — SDK → your
   backend → your console, one loop, no Velt infrastructure. The console's comment detail
   pane shows the annotation id, not the text or author — match on the id the SDK shows for
   the comment. The console session lives in the operator's browser; when you (the agent)
   must verify this alone and the REST API is enabled, read the comment through it instead —
   the same proof from the other door:

   ```bash
   curl -sS "https://$PUBLIC_HOST/api/v2/commentannotations/get" -H 'content-type: application/json' \
     -H "x-velt-api-key: <api-key>" -H "x-velt-auth-token: $API_AUTH_TOKEN" \
     -d '{"data":{"organizationId":"<org-id>","documentId":"<the document the comment was made on>"}}' | jq '.result.data | length'   # ≥ 1
   ```
4. **Zero-egress audit.** Across both sessions the network log contains **no request to any
   Velt-owned host** — no `cdn.velt.dev`, `api.velt.dev`, `staging.velt.dev`, no Velt
   analytics.

Step 4 is the one that fails for a reason people find surprising: `strict: true` silences
the SDK's runtime calls, and `proxyConfig.cdnHost` decides where the bundle is downloaded
from — `strict` cannot reach the second, because that choice is made before `velt.js`
exists. A deployment with a perfectly sealed gateway and no `cdnHost` pin passes steps 1–3
and fails step 4 on the very first request in the log. Set both, or state plainly in the
report that the deployment is backend-sealed and CDN-dependent.

**With attachments on,** add a fifth: attach a file to a comment in the app, reload, open it.
DevTools shows the upload and the download going to **your** storage account
(`<account>.blob.core.windows.net`, or `.usgovcloudapi.net`) and the signed-URL requests to your
ingress — nothing to a Velt host.

**Verify (final gate):** all four pass (five with attachments on). Mark `7-acceptance: done`.

---

## Final report — deliver this to the operator

Produce the report as one document. It is the artifact the customer's app team, their
security team and Velt support will each need.

1. **Endpoints:** `PUBLIC_HOST`, the API endpoint (`https://<public-host>/api`), the WS
   endpoint (`wss://<public-host>/ws`), the console URL (`https://<public-host>`).
2. **The app's config snippet, in full** — the `initVelt` object from 7.1 including
   `version`, `proxyConfig.cdnHost` and the `selfHosted` block. No secrets in it; this is
   the thing the app team integrates.
3. **Workspace:** the API key, the organization id, the seeded admin email, the allowed
   domains, and WHERE the workspace auth token and the ES256 private key are stored
   (names and paths, never values).
4. **The version receipt.** Run the release's receipt script. It opens a port-forward to the
   gateway's pod-direct `/version`, reads what RUNS — the image digests, the schema head,
   the gateway's signed attestation — and closes it:

   ```bash
   # The schema head: the database is private, so the workstation cannot read it — pass the head you read in Phase 4.4
   ./velt-portable-tools/portable-receipt.sh --release velt --namespace velt --schema-head "$SCHEMA_HEAD" --out receipt.json
   ```

   (`--schema-head` needs a tools bundle of 0.1.15 or newer; a 0.1.13 / 0.1.14 receipt records
   `schemaHead: could not be read` — write the Phase 4.4 number into the report by hand.)

   Record `artifacts.receiptPath` and **send `receipt.json` to Velt with this report**. The
   deployment never calls home; this file is how Velt knows which version you run.
5. **The update check** — from the workstation, never the cluster:

   ```bash
   node ./velt-portable-tools/portable-check-updates.js --receipt receipt.json
   # exit 0 up to date · 10 an update exists and this version is supported · 20 OUTSIDE the support window
   ```

   It reads the public release index with `oras`, names newer releases, and says whether the
   installed version is inside Velt's support window. Include its output. (The `0.1.13` and
   `0.1.14` tools bundles crash here with `Cannot find module './lib/selfhost-compat-matrix'` —
   they shipped without the module; run the check from a 0.1.15-or-newer bundle, which works
   against any installed version.)
6. **Versions deployed:** `portableVersion`, the chart version, the schema head, the
   console bundle, the SDK, the Terraform module — and whether they came from a VERIFIED
   manifest `tierB` block or from the chart's tag list (Phase 0.1). Say which.
7. **Infosec:** the scan sign-off from Phase 2.2 and the image digests.
8. **TLS path chosen**, and — if ACME HTTP-01 — the `:80` renewal note from 5.4.
9. **Anything skipped or degraded** — console not enabled, SDK not self-hosted (so pages
   load from `cdn.velt.dev`), egress not restricted, webhook delivery not chosen,
   acceptance run on a stand-in page. A gap the operator knows about is a decision; one
   they discover later is a defect.
    Also under this item: whether the **REST API** module is on (`rest-api` in
    `enabledModules`, Phase 4.1) and, if so, where the API key's auth token is stored.
10. **Cost and idle** — the table below, with the numbers for their SKUs.
11. **Operations:** the node pool's floor and ceiling (`aks_node_count`, `aks_node_max_count`);
    the action group the alerts notify and who is on it; the backup window
    (`postgres_backup_retention_days`) and the date of the last restore rehearsal
    (Backup and Restore).

---

## After the install

### What this costs to run, and how to idle it

A persistent deployment is a small always-on cluster plus a managed database: three
general-purpose AKS nodes, a General Purpose PostgreSQL Flexible Server, an Application
Gateway, Key Vault, a storage account and a Basic container registry. Read the exact figures
for the chosen SKUs and region from the Azure pricing calculator; the nodes and the
database dominate. As an order of magnitude (US list prices, October 2026, one US region;
your rates differ):

| Footprint | Running, per month | Idle (compute stopped), per month |
|---|---|---|
| default — 3 × `Standard_D4ds_v5`, Postgres GP `D2ds_v5`, App Gateway v2 | about $800–850 | about $200–250 |
| small — 3 × `Standard_D2ds_v4`, same database and gateway | about $550–650 | about $200–250 |

The idle figure is what keeps billing when the cluster and the database are stopped: the
Application Gateway (about $150 unless you stop it too, below), the OS disks, Postgres
storage, Log Analytics ingestion (`enable_diagnostics`), Key Vault and the registry. Between evaluation windows, stop the compute and keep the data —
disks, Key Vault, Blob and the Postgres storage persist, so the deployment restarts where
it left off:

```bash
az aks stop -g "$RESOURCE_GROUP" -n "${DEPLOYMENT_NAME}-aks"
az postgres flexible-server stop -g "$RESOURCE_GROUP" -n "${DEPLOYMENT_NAME}-pg"
# Azure restarts a stopped Flexible Server after 30 days — re-stop it if the window is longer.
# The Application Gateway bills whether or not the cluster runs (its fixed charge is the
# largest idle cost); stop it too. It lives in the cluster's NODE resource group:
NODE_RG=$(az aks show -g "$RESOURCE_GROUP" -n "${DEPLOYMENT_NAME}-aks" --query nodeResourceGroup -o tsv)
APPGW=$(az network application-gateway list -g "$NODE_RG" --query "[0].name" -o tsv)
az network application-gateway stop -g "$NODE_RG" -n "$APPGW"
# To resume (the gateway first; its public IP and DNS label are kept while it is stopped):
az network application-gateway start -g "$NODE_RG" -n "$APPGW"
az postgres flexible-server start -g "$RESOURCE_GROUP" -n "${DEPLOYMENT_NAME}-pg"
az aks start -g "$RESOURCE_GROUP" -n "${DEPLOYMENT_NAME}-aks"
```

What keeps billing while idled this way: the public IP, the nodes' OS disks, the Postgres
storage, Key Vault and the storage account — a few tens of dollars a month at the default
sizing. Stopping only the cluster and the database leaves the Application Gateway running,
which on its own is well over a hundred dollars a month.

`az aks stop` stops the cluster autoscaler with it, so nothing adds nodes back while the
cluster is stopped; `az aks start` restores the pool and the autoscaler resumes. While the
database is stopped its alerts see no data and stay quiet. After a restore adopted under
`postgres_server_name` (the Backup and Restore runbook), use that name in the two
`flexible-server` commands above, not `${DEPLOYMENT_NAME}-pg`.

The cluster autoscaler never removes the node running the cache pod (Valkey): the chart marks
that pod `safe-to-evict: "false"`, because every gateway is unready while it moves. Other
nodes it drains wait for the chart's disruption budgets.

### Alerts, and what they do not cover

In persistent mode six metric alerts notify the action group (`alert_action_group_id`), and
each alert's description says what to look at. Azure sends an "added to an action group" email
to each address in `alert_email_addresses`; an action group with no receivers notifies nobody.

| Alert | Fires when | First look |
|---|---|---|
| `<deployment-name>-pg-storage` | Postgres storage is over 85% for 15 minutes | auto-grow raises the disk before it fills, but never shrinks it: find what is growing |
| `<deployment-name>-pg-cpu` | Postgres averages over 80% CPU for 15 minutes | slow queries, then a larger `postgres_sku` |
| `<deployment-name>-aks-node-cpu` | the nodes average over 80% CPU for 15 minutes | is the pool at `aks_node_max_count`? raise it (and the family quota) or the VM size |
| `<deployment-name>-aks-node-memory` | the nodes' working set averages over 85% for 15 minutes | the same; the in-chart Redis must fit on one node |
| `<deployment-name>-appgw-unhealthy-hosts` | a backend was unhealthy at some point in the last five minutes — a rollout or a scale-down can cause a brief one; it matters when it keeps firing | `kubectl -n velt get pods`, then the pod's logs |
| `<deployment-name>-appgw-5xx` | the Application Gateway answers more than 30 requests with a 5xx in 15 minutes | a 502 is no healthy backend; a 500 is in the gateway's logs. The gateway's 501 for an endpoint this deployment does not offer counts too (its log line's route key is `not-ported`) |

Send a test notification to every receiver once, from the portal: **Monitor → Alerts →
Action groups → `<deployment-name>-alerts` → Test action group**. Two signals have no
platform metric: pods the autoscaler could not place, and the in-chart Redis volume filling —
check them on a schedule:

```bash
kubectl get pods -A --field-selector=status.phase=Pending       # at the ceiling, or out of quota
kubectl -n velt exec statefulset/velt-redis -- df -h /data       # the cache's volume; grow the PVC before it fills
```

### Backups and restore

PostgreSQL keeps automated backups for `postgres_backup_retention_days` when you set it (7 to 35
days); unset, the module leaves the server's own window alone (7 days on a new server, or what
you set in the portal), and restores to any second in that window. The Backup and Restore
runbook has what each backup covers, the restore procedures, and the rehearsal to run before
going live.

### Teardown

To remove the deployment entirely: `terraform -chdir=terraform/azure destroy`. The
registry was created outside Terraform (Phase 2.1), so destroy fails on it the first time —
delete the registry (`az acr delete -g "$RESOURCE_GROUP" -n "$ACR_NAME" --yes`), then run
destroy again. Check for `Destroy complete!` in the output and `az group list` afterwards
rather than trusting the exit code, or a registry silently keeps billing. Key Vault is
purge-protected (the storage customer-managed key requires it): it soft-deletes and ages out
after the retention period — a zero-cost tombstone, expected, not a leak. A new deployment
with the same `DEPLOYMENT_NAME` inside that window fails at the vault: preflight P10 catches it
before the apply; use a different name, or recover and adopt the old vault (Troubleshooting:
"soft-deleted Key Vault").

### Upgrades

**Do not improvise an upgrade.** Upgrades have their own document and their own script —
Upgrade Guide, executed by the release's
`./velt-portable-tools/portable-upgrade.sh`:

```bash
./velt-portable-tools/portable-upgrade.sh --target "$PORTABLE_V" --release velt --namespace velt
```

The one-line version: verify the new chart and all images, carry your values forward,
`helm upgrade` (migrations run as a pre-upgrade hook, so the gateway rolls only after they
succeed), then **assert the schema actually moved** before trusting the rollout. Rolling
back moves the code back, not the data.

### Turning a feature on later

Each feature below is a change to `values-<env>.yaml` plus one `helm upgrade`, with the outside
piece named in its row. Make the outside piece first, then upgrade, then run the check. Keep
the whole values file in one place and apply it in full — never `--set` a single key you do not
also write into the file, or the next upgrade carries the old value forward. `$CHART_DIGEST` is the
installed chart's digest, resolved and verified in Phase 4.3; in a new shell, run that block again
up to the `cosign verify` (everything before `&& helm install`).

```bash
helm upgrade velt "oci://$REG/velt-portable@$CHART_DIGEST" -n velt -f values-<env>.yaml
```

| Feature | Values to set | Outside piece, first | Check |
|---|---|---|---|
| **REST API** | add `rest-api` to `enabledModules` (Phase 4.1) | an auth token on the API key: seeded in Phase 4.5 with `--auth-token`, or created in the console under the key's App Configuration | `curl -sS https://<public-host>/api/v2/organizations/get -H 'content-type: application/json' -H "x-velt-api-key: <api-key>" -H "x-velt-auth-token: <token>" -d '{"data":{"organizationIds":["<org-id>"]}}'` → 200 (REST API) |
| **Comment attachments** | the `storage:` block from Phase 4.1, from the Terraform outputs | `attachment_cors_origins` in the tfvars (Phase 1.2) and `terraform apply` — the storage account, container and identity roles already exist from the install; a 0.1.13 module takes the manual `az storage cors add` in Phase 1.2 instead | the five steps of Phase 6.3 through the public host; the gateway's startup log reads `storage: enabled` |
| **Advanced webhooks — direct** | `webhookV2Delivery: direct` | nothing (the workers sign and POST from inside the cluster; private receiver addresses are allowed) | in the console, enable advanced webhooks on the API key and add an endpoint; the receiver gets a signed test event |
| **Advanced webhooks — Svix** | `webhookV2Delivery: ""` and `secrets.svixSecretName: velt-svix` | `kubectl -n velt create secret generic velt-svix --from-literal=VELT_SVIX_API_KEY=<the customer's Svix token>`; outbound HTTPS to `api.svix.com`, and the receiver reachable from Svix | the same console steps; the endpoint appears in the Svix application |
| **Console SAML sign-in + SCIM** | `consoleAuth.saml.enabled: true` plus the IdP fields (or `idpMetadataUrl`) — the `consoleAuth:` block in the SAML runbook | a SAML app in the identity provider pointing at `https://<public-host>/api/console/saml/acs`, audience `https://<public-host>/api/console/saml/metadata`; a Secret for the IdP certificate(s) | a real sign-in through the IdP lands on the console dashboard; with SCIM on, a user deactivated in the IdP is gone from the console roster |
| **Self-hosted SDK bundle** | `sdk.enabled: true`, `sdk.version` / `sdk.tag` (Phase 4.1) | `az acr import` of `velt-sdk-static:<version>` (Phase 2.1); the app pins `version` and `proxyConfig.cdnHost` (Phase 7.1) | `curl -sI https://<public-host>/lib/sdk@<version>/velt.js` and `…/velt-portable.js` → 200 |
| **Webhook payload encryption** | `secrets.webhookEncryptionSecretName: velt-webhook-key` | `kubectl -n velt create secret generic velt-webhook-key --from-literal=VELT_WEBHOOK_ENCRYPTION_KEY="$(openssl rand -base64 32)"` | enable `encryptData` on a webhook in the console; the next event arrives encrypted. Without the secret, a webhook with `encryptData` on fails loudly rather than sending plaintext |
| **Deployment-wide token minting** | `secrets.masterAuthTokenSecretName: velt-master-auth` | `kubectl -n velt create secret generic velt-master-auth --from-literal=VELT_MASTER_AUTH_TOKEN="$(openssl rand -hex 32)"`; keep the value in the secret store | `POST https://<public-host>/api/auth/token` with that token mints for any API key on the deployment. Leave this off unless internal services mint across several API keys — off is the safe choice, and a revoked API key stays revoked either way |
| **Private comments** | no chart change | the console: turn on private comments for the API key (or re-run the seed with `--project-config '{"isPrivateCommentsEnabled":true}'`) | a comment restricted to its author stays visible to the author |
| **FIPS mode** | `fips.enabled: true` | `fips_enabled = true` in the tfvars and `terraform apply` — **this replaces the node pools**; plan a maintenance window, and treat it as an install-time choice where you can | the gateway's startup log names the FIPS provider; a pod started without it refuses to run |

Turning a feature **off** is the reverse: remove the values and upgrade. Data a feature wrote
stays (attachments in the storage account, webhook ledger rows, SAML members on the roster)
until you remove it yourself.

### Before a support conversation: the support bundle

```bash
./velt-portable-tools/portable-support-bundle.sh --release velt --namespace velt --out .
```

Send the archive. It carries facts about the DEPLOYMENT — chart version, the image digests
the pods actually run, pod phases and restarts, the schema head, environment-variable
NAMES, and `helm get values` filtered by a fail-closed allowlist — and **no secret value,
no log line, no user data**. Add `--include-logs` only when asked; every log line then
passes a redaction filter (bearer tokens, JWTs, DSNs, PEM blocks). It never reads a
Kubernetes Secret.

### Acceptance evidence

The Validation runbook is the checklist that
says "correctly installed" with evidence — provenance, FIPS posture, network policy, the
schema head, the REST door. Run it once after this guide; attach it to the report.

---

## Troubleshooting (Azure-specific)

| Symptom | Cause | Action |
|---|---|---|
| an attachment call answers `{"ok":false,"code":"unimplemented"}` (HTTP 501) | the chart has no `storage:` block, so attachments are off | fill the block (Phase 4.1) and upgrade; the gateway log's startup line then reads `storage: enabled …` |
| the browser's attachment upload fails with a CORS error, or the PUT answers 403 `CORS not enabled or no matching rule found` | the storage account answers CORS only for `attachment_cors_origins`, and the page's origin is not in it | add the origin (scheme + host + port, no path) to `attachment_cors_origins`, `terraform apply` |
| the PUT or GET answers 403 `AuthorizationPermissionMismatch` / `AuthenticationFailed`, or `getUploadUrl` answers `failed-precondition` and the gateway log names the delegation-key endpoint | the workload identity's `Storage Blob Delegator` / `Storage Blob Data Contributor` grants are still propagating (a few minutes after the first apply), or `storage.clientId` / `storage.tenantId` do not match the workload identity | wait five minutes and retry; compare `storage.clientId` with the `workload_identity_client_id` output |
| `getDownloadUrl` answers `not-found` right after an upload | the PUT did not complete (the browser closed, or it was refused) | upload again; the attachment id is new each time |
| `403 … Caller is not authorized … Assignment: (not found)` on a Key Vault key/secret create during the first apply | the deployer's Key Vault Administrator grant is still propagating | wait 90 s, `terraform apply` again (retryable) |
| `RoleAssignmentExists` on the Key Vault grant | a Key Vault Administrator grant was pre-created by hand | delete the hand-made grant; the module creates its own |
| preflight P10 fails, or the apply fails creating the Key Vault with `VaultAlreadyExists` / `ConflictError` naming a **soft-deleted** vault | a previous deployment with this `DEPLOYMENT_NAME` was torn down; its vault is soft-deleted (90 days in persistent mode) and purge-protected, so the name is reserved | **Simplest:** pick another `DEPLOYMENT_NAME` and start Phase 1 again. **To keep the name:** the failed apply has already created the resource group, so recover the vault into it and let the module adopt it — `az keyvault recover --name "${DEPLOYMENT_NAME}-kv" --location "$LOCATION"`; then `terraform -chdir=terraform/azure import azurerm_key_vault.this "/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RESOURCE_GROUP/providers/Microsoft.KeyVault/vaults/${DEPLOYMENT_NAME}-kv"`; the two keys and the secret came back with it, so import those too: `terraform -chdir=terraform/azure import azurerm_key_vault_key.postgres_cmk "$(az keyvault key show --vault-name "${DEPLOYMENT_NAME}-kv" --name "${DEPLOYMENT_NAME}-pg-cmk" --query key.kid -o tsv)"`, the same for `azurerm_key_vault_key.storage_cmk` with `${DEPLOYMENT_NAME}-blob-cmk`, and `terraform -chdir=terraform/azure import azurerm_key_vault_secret.postgres_password "$(az keyvault secret show --vault-name "${DEPLOYMENT_NAME}-kv" --name "${DEPLOYMENT_NAME}-pg-password" --query id -o tsv)"`; then `terraform apply` again — the plan updates the password secret to a new value (the new server uses it) and leaves the keys as they are |
| `AuthorizationFailed` creating a role assignment | the deployer is Contributor without User Access Administrator | human moment #2: Owner, or Contributor + User Access Administrator |
| `LocationIsOfferRestricted` on Postgres | the region is not permitted for this subscription | pick a region the Phase 0.5 check passes; or request a quota increase; Postgres must share AKS's region |
| `The VM size … is not allowed in your subscription in location` | the SKU is not offered to this subscription in this region | set `aks_node_vm_size` to an x86 size `az vm list-skus` shows unrestricted |
| AKS create fails on quota | the family or regional vCPU quota is short | request an increase, or a smaller size — read `az vm list-usage` first (Phase 0.5) |
| `Provider produced inconsistent result after apply` / `ParentResourceNotFound: SOA` | Azure eventual consistency in a fresh region | apply again; `terraform state rm` a phantom resource, then apply |
| Postgres `zone can only be changed when exchanged with the standby zone` on a second apply | a module that does not ignore zone drift | use the release's module (it ignores the zone); do not hand-edit the cluster |
| Persistent-mode plan refused: no admins / no reachable API server | `aks_admin_group_object_ids` empty, or neither `api_server_authorized_ip_ranges` nor `private_cluster_enabled` set | fill the tfvars block "who operates the cluster" (Phase 1.2) |
| `kubectl` fails after `get-credentials`; `--admin` is refused | the cluster has no local accounts; your session is not through Entra | `kubelogin convert-kubeconfig -l azurecli`; be a member of the admin group |
| Plan shows `# forces replacement` on `network_profile` | the network plugin or policy engine changed | do NOT apply over a cluster holding data without a maintenance window — every pod is recreated and the in-chart Redis volume does not follow |
| `cosign verify` fails on any artifact | the artifact is not what Velt published, or the identity/issuer strings were altered | STOP; re-check `IDENTITY` / `ISSUER` exactly as written in Phase 0.0; never import or install an unverified artifact |
| `az acr import` → `connection refused` / transient failure | registry propagation | retry the import (retryable) |
| keygen: `EACCES: permission denied, open '/keys/jwt-private.pem'` | a root-owned `./keys` left by an earlier run, or `-u $(id -u):$(id -g)` missing from the `docker run` | `sudo rm -r keys`, then re-run Phase 3.1 as written (`mkdir -p keys`, `-u`, `KEYS_OUT=/keys`) |
| keygen exits 0 and `./keys` is EMPTY | `--out-dir` pointed inside the container instead of the mount | re-run Phase 3.1 as written (`KEYS_OUT=/keys`, the mount) |
| `docker run` fails to pull: `no matching manifest for linux/arm64` | an arm64 host; the images are amd64-only | add `--platform linux/amd64` to the `docker run` |
| gateway pod `CrashLoopBackOff`, log names `VELT_JWT_PUBLIC_KEY` / `VELT_PG_DSN` / `VELT_JWT_PRIVATE_KEY` missing | a secret's KEY is not the env-var name | recreate the secret with the exact key names in Phase 3.2 |
| gateway refuses to start: identify enabled, private key missing | `velt-jwt-es256-private` absent or wrongly keyed | create it (Phase 3.2, key `VELT_JWT_PRIVATE_KEY`) |
| gateway refuses to boot naming `allowedOrigins` / `corsAllowedOrigins` | an `identify.allowedOrigins` entry is not admitted by `corsAllowedOrigins`, or is `*` / malformed | list exact origins in both, or drop `allowedOrigins` |
| `helm install` fails the render naming `console.config` | `console.enabled: true` with an empty config | splice the Terraform-emitted config (Phase 4.2), or set `console.enabled: false` |
| `helm install` fails the render on `webhookV2Delivery` | `direct` set beside `secrets.svixSecretName` | one delivery mode: drop the Svix secret, or set `""` / `svix` |
| migrate Job `Error`; `helm install` aborts | a migration failed | `kubectl -n velt logs job/velt-migrate`; do not retry blindly — send the support bundle |
| migrate pod `Completed` but the ledger head < `velt.dev/schema-head` | the hook ran against a different database, or a rollback left the ledger behind | compare the DSN secrets' host; re-run `helm upgrade` with the same values; read the ledger again |
| migrate hook fails on `ALTER ROLE` / `NOSUPERUSER` on managed Postgres | a release older than `0.1.13` re-applying the application role | upgrade to `0.1.13` or later (the Upgrade Guide) |
| gateway pod stuck `0/1`, readiness `connection refused` | a chart other than this release's probing a second port | install the release's chart (single-port probe) |
| console loads, redirects to the IdP, comes back, cannot sign in; gateway answers 501 on `/console/session` | `consoleAuth` missing, or spelled `issuer:` / `clientId:` | set `consoleAuth.enabled`, `oidcIssuer`, `oidcClientId` exactly (Phase 4.1) |
| every console sign-in answers 503 | `consoleAuth.oidcIssuer` differs from the discovery document's `issuer` | the issuer check in Phase 4.2 — use the discovery document's text |
| an Entra member gets 401 at console sign-in; gateway logs `preferred_username fallback is off` | the member has no `email` claim and the release turns the UPN fallback off by default | `consoleAuth.preferredUsernameFallback: true` (Phase 4.2), `helm upgrade` |
| console sign-in redirect fails at the IdP (`redirect_uri` mismatch) | the app registration lacks `https://<public-host>/signup` as an SPA redirect URI | add it (Phase 0.3) |
| console boots blank, no error | `velt-console-config.json` missing or invalid at the web root | `curl https://<public-host>/velt-console-config.json`; splice the config (Phase 4.2) |
| `kubectl get ingress` ADDRESS stays empty; `:443` answers nothing | the App Gateway was still `Stopped` when the add-on first reconciled | once `operationalState: Running`, `kubectl -n kube-system rollout restart deploy/ingress-appgw-deployment` (Phase 5.1) |
| add-on pod `CrashLoopBackOff`: `ApplicationGatewayInsufficientPermissionOnSubnet` | the subnet-join role assignment lands after the cluster exists | restart the add-on pod; RBAC propagation clears it |
| cert-manager challenge: `wrong status code '502'` | the App Gateway is syncing the solver ingress | wait 30–60 s; cert-manager retries |
| `:80` no longer answers after TLS | the add-on programs an HTTPS-only listener once `tls.secretName` is set | expected; for ACME renewals see Phase 5.4 |
| `https://<public-host>/api/...` → 404 | the `/api` prefix is not reaching the gateway, or DNS/certificate is wrong | the ingress must route `/api`; re-check 5.1–5.3; `kubectl -n velt describe ingress` |
| `https://<public-host>/identify` → 502 | root paths are unrouted by design | the SDK's `portableBackendUrl` must end in `/api` (Phase 7.1) |
| `/lib/sdk@<v>/velt.js` 200 but a Velt feature silently fails | the directory is partially mirrored, or `version` in the app ≠ `sdk.version` | use the `velt-sdk-static` image; pin `version` equal to `sdk.version` |
| after an SDK upgrade, `/lib/sdk@<old>/velt.js` → 404 and apps pinned to `<old>` stop loading the SDK | one image serves one version; `sdk.version` moved and `<old>` was not retained | add `<old>` to `sdk.retainedVersions` (chart 0.1.16+) and `helm upgrade`; keep it there until every app has repointed (Upgrade Guide, Phase U5) |
| the new sdk pod stays `Init:ImagePullBackOff` after adding a retained version; the previous pod still serves | a listed version's `velt-sdk-static:<v>` image is not in your registry; the rolling update keeps the old pod up and the new version is never served | import that image (Phase 2.1) or remove the entry; every entry in `retainedVersions` needs its image |
| browser: CORS error on `/api/...` | `corsAllowedOrigins` empty or not an exact origin | list the page's exact origin (scheme + host [+ port]); read the gateway's startup `corsMode` line |
| identify fails: no allowed domain | the workspace was seeded without `--allowed-domains` | re-run the seed with `--allowed-domains` (idempotent), or add domains in the console |
| a comment restricted to its author disappears from the author's view | `isPrivateCommentsEnabled` not seeded | re-run the seed with `--project-config '{"isPrivateCommentsEnabled":true}'` |
| the zero-egress audit shows `cdn.velt.dev` | the bundle axis is unpinned (`proxyConfig.cdnHost` / `version` missing, or `staging`/`develop` set) | Phase 7.1; `strict` cannot fix this |
| `portable-check-updates.js` exits 20 | the installed version is outside Velt's support window | plan the upgrade (Upgrade Guide) |
| `terraform destroy` fails on the container registry | the registry is not in Terraform state | delete it with `az acr delete`, run destroy again, check for `Destroy complete!` |
| the apply's LAST resource, `azurerm_monitor_diagnostic_setting.appgw`, fails with 404 (`ResourceNotFound` on the Application Gateway) — module 0.1.13 / 0.1.14 | the ingress add-on creates the gateway itself, and it is crash-looping on the subnet permission while its grant propagates | the recipe in Phase 1.3: restart `ingress-appgw-deployment`, wait for the gateway's `provisioningState: Succeeded`, apply again |
| the apply stops at `appgw_ready: the Application Gateway did not reach Succeeded in time` (module 0.1.15+) | the add-on backed off past its retry ceiling while its subnet grant propagated | the recipe in Phase 1.3: restart `ingress-appgw-deployment`, wait for `Succeeded`, apply again (the gate re-polls) |
| `az ad app update --set spa.redirectUris=…` → `Couldn't find 'spa' in ''` | a fresh app registration has no `spa` object yet; this CLI path cannot create it | the Graph `PATCH` in Phase 0.3, then `az ad app show … --query spa.redirectUris` must print the URI |
| preflight answers `unknown argument: --portable-version` (or `--manifest-file`, `--deployment-name`) | a `0.1.13` tools bundle (those flags arrived in 0.1.14) | drop the three flags, or run a 0.1.14-or-newer bundle (Phase 0.6) |
| `portable-check-updates.js` → `Cannot find module './lib/selfhost-compat-matrix'` | the `0.1.13` / `0.1.14` tools bundles shipped without their `lib/` module | run the check from a 0.1.15-or-newer bundle (it works against any installed version) |
| `$PCLI keygen …` → `no such file or directory: docker run …` (zsh) | zsh does not word-split an unquoted variable | the `pcli()` shell function in Phase 3.1 |
| `kubectl run --rm -i …` prints nothing, or only `try pressing enter` | the attached run raced the short-lived container | run detached, `kubectl wait`, then `kubectl logs` (Phases 4.4 and 4.5 are written that way) |
| the console refuses a second workspace: `cannot create a second live apiKey … until every existing apiKey has a valid identify config` | the first workspace was seeded without `--identify-config` | re-run the seed with `--identify-config` (the Phase 4.5 value — idempotent), or set the first workspace's identify configuration in the console |
| the seed refuses: `refusing to seed an invalid identify config: requireAutoOrgUser must be a boolean` (or `requireJwtToken`) | the identify configuration lacks one of its two flags | the Phase 4.5 value, which sets both |
| `https://<public-host>/api/v2/...` → 404 while `/api/operations/read` answers 400 or 401 | the `rest-api` module is not on (the default) | add `rest-api` to `enabledModules` (Phase 4.1; `restApi.enabled: true` on a 0.1.13 / 0.1.14 chart), `helm upgrade` |
| `helm` fails the render: `enabledModules lists rest-api but restApi.enabled is false` | the two spellings of the REST answer disagree in your values | give one answer: remove `rest-api` from the list, or set `restApi.enabled: true` (or leave it empty — it follows the list) |
| pods stay `Pending` (`Insufficient cpu` / `memory`) and no node is added | the pool is at `aks_node_max_count`, or the VM family's vCPU quota is exhausted | `kubectl describe pod` names the reason; `az aks nodepool show -g "$RESOURCE_GROUP" --cluster-name "${DEPLOYMENT_NAME}-aks" -n default --query "{count:count,max:maxCount}"`; raise `aks_node_max_count` (and the quota, Phase 0.5) |
| an alert fires but nobody hears it | the module's action group has no receivers (`alert_email_addresses` empty), or the receivers' mail filters the Azure Monitor sender | add receivers and apply; the portal's "Test action group" proves delivery |
| `403 Invalid Auth token.` on `/api/v2/...` with a token the seed printed | the printed token is the WORKSPACE auth token; `x-velt-auth-token` takes the API key's auth token | use `<api-auth-token>` (seeded with `--auth-token`, or read in the console); the workspace token goes with `x-velt-workspace-id` / `x-velt-workspace-auth-token` on `/v2/workspace/*` (Phase 4.5) |

---

*This guide is evergreen: every version-specific value (image digest, chart version,
console and SDK versions) comes from the signed release manifest at run time, and the
worked pins at the top of Phase 0 are the release this edition was written for. The
canonical copy lives on the Velt docs site. (`guideVersion` in the state file tags the
state-file contract for resume, not a release.)*
````


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.