Variables and secrets — naming and placement¶
Naming-Convention names resources. This page names values — the variables, secrets, workspace settings and app settings that resources are configured with — and decides where each one is stored.
Two questions, in this order:
- Placement. Which of the four stores holds this value? Placement is a disclosure-boundary decision: it fixes who can read the value, what has to happen to rotate it, and which logs it can leak into. Get it wrong and no amount of naming discipline helps.
- Naming. What is it called in that store? Naming is a legibility decision, and it is cheap only at creation — a name already set in HCP Terraform, GitHub or an app setting cannot be changed without a coordinated change across every consumer.
Required-Inputs is the inventory — what exists, who consumes it, whether it is provisioned. This page is the rule that decides where the next entry goes and what it is called. The two are meant to be read together: CHECKLIST records the fact, this page justifies it.
This page contains no values, and must not acquire any. It uses CHECKLIST's placeholder format —
X= letter,0= number,!= special character; GUIDs as00000000-0000-0000-0000-000000000000. That includes subscription, tenant and client IDs, which are identifiers rather than credentials but are still not published.
The four stores¶
In precedence order. When more than one store could hold a value, the earlier one wins.
| # | Store | Holds | Read by | Written by |
|---|---|---|---|---|
| 1 | Azure Key Vault kv-site-prod-cus-01 |
Runtime application secrets | Function App managed identity, via @Microsoft.KeyVault(SecretUri=…) app settings or src/lib/key-vault.js |
A human, out-of-band, during a seeding window |
| 2 | HCP Terraform workspace hcw-azure |
What Terraform needs to authenticate and to plan | The run environment and the azurerm / cloudflare providers |
An operator in the workspace UI |
| 3 | GitHub Actions variables | Non-sensitive CI/CD configuration | ${{ vars.* }} in workflows |
gh variable set, or the repository settings UI |
| 4 | GitHub Actions secrets | Last resort — credentials to systems that offer no federation | ${{ secrets.* }} in workflows |
gh secret set |
The order is not arbitrary. It runs from narrowest reader set and strongest authentication to widest and weakest:
- Key Vault values are readable only by a managed identity that must reach the
vault over the Functions integration subnet —
network_acls.default_actionisDenyand the only VNet rule is that subnet. There is no credential to steal and no static reader. - HCP Terraform workspace values are readable by anything that can start a run in that workspace, which is a small, named group.
- GitHub variables are readable by anyone who can read the repository — this one is public — and by any workflow, including ones added in a future PR.
- GitHub secrets are readable by any workflow with the right trigger. Masking is best-effort log redaction, not access control: a workflow that can read a secret can print it anywhere GitHub is not looking.
Three outcomes that are not stores¶
A value that lands on one of these is correctly placed nowhere, and the record of that decision is the artefact.
| Outcome | Meaning | Examples in this repository |
|---|---|---|
| Derived | Terraform computes it from a resource it already manages, or a workflow fetches it after OIDC login. Nobody types it anywhere | COSMOS_ENDPOINT, STORAGE_BLOB_ENDPOINT, STORAGE_ACCOUNT_NAME, AZURE_OPENAI_ENDPOINT — all set from resource attributes in infra/main.tf |
| Deliberately absent | The value must not exist. Provisioning it changes behaviour for the worse | COSMOS_KEY, AZURE_OPENAI_KEY, STORAGE_ACCOUNT_KEY, STORAGE_CONNECTION_STRING, COSMOS_CONNECTION_STRING |
| Generated in place | The value is created on the host that consumes it and never moves | LABS_AGENT_CERT_PATH — the agent's private key is generated on the VPS, root-owned, 0600, and only the public certificate is uploaded |
"Deliberately absent" is a placement outcome, not a gap, and it needs to be
written down as firmly as a real placement — otherwise the next person to see
an unset name provisions it to silence a linter. COSMOS_KEY is the worked
example: connectCosmos() uses a key only when the value is non-empty and
otherwise falls through to DefaultAzureCredential. An unset GitHub secret
interpolates to the empty string, so the workflow already takes the Entra path.
Setting it would switch the client to key authentication against an account
where cosmos_local_auth_disabled = true — key auth is off, so the "fix"
converts a working path into a rejected one. Two of the retired names
(STORAGE_ACCOUNT_KEY, STORAGE_CONNECTION_STRING) are additionally defended
by a test asserting the module cannot read them again, which is what
"deliberately absent" looks like when you want it to survive contact with a
future contributor.
There is no fifth store¶
All workflows use GitHub-hosted runners. The repository has no Container Apps runner job, runner image, Docker Hub dependency, or runner-specific secrets.
Placement — the decision procedure¶
Ask these in order. The first question that answers yes decides the store;
stop there.
Q1 — Should this value exist at all?
If the platform offers an identity-based path to the same capability, the
credential must not be created. Cosmos with local_auth_disabled, Azure OpenAI
with DefaultAzureCredential, Storage with user-delegation SAS, and Cosmos
change-feed bindings with __credential = "managedidentity" all remove a secret
rather than store one.
→ Deliberately absent. Record the name, the reason, and what breaks if
someone provisions it.
Q2 — Is the value generated on the machine that consumes it, and does it never need to move? Private keys generated on their host are the case. Upload the public half only. → Generated in place. No store; record the file path and its permissions.
Q3 — Can Terraform compute it from a resource it manages, or can a workflow
fetch it at run time after OIDC login?
Endpoints, hostnames, vault URIs, account names, connection targets. A value
that can be derived must not be stored, because a stored copy is a second
source of truth that goes stale silently — and it goes stale precisely when the
resource is replaced, which is when you are least able to notice.
→ Derived. It appears as a resource attribute, a Terraform output, or an
az lookup in the workflow.
Q4 — Is it read at run time by application code, and is it sensitive?
→ Store 1, Azure Key Vault. The Function App gets it either as an app
setting holding a @Microsoft.KeyVault(SecretUri=…) reference (single-line
values) or by calling getSecret() at run time (multi-line values — see below).
Terraform creates the vault, the RBAC and the reference; a human seeds the
value.
Q5 — Does Terraform need it to authenticate, or as a plan input?
→ Store 2, HCP Terraform workspace. Authentication values are
Environment variables; configuration inputs are Terraform variables. The
distinction is mechanical, not stylistic: an Environment variable is exported
into the run's process environment where the provider's own credential chain
reads it; a Terraform variable is bound to a variable block by exact name and
is invisible to anything that is not that block.
Q6 — Does a GitHub workflow need it, and is it non-sensitive? Identifiers, hostnames, resource group names, public URLs, feature flags, runner selection. → Store 3, GitHub Actions variables.
Q7 — Is it still here? Then it is a credential to a system that offers GitHub no federation path. → Store 4, GitHub Actions secrets, and the CHECKLIST entry must say which system, why federation is unavailable, and what the rotation trigger is. An entry without that justification is a defect, not an inventory item.
The hard cases¶
A value that is not secret but is needed at build time by the frontend¶
Store 3, always. Never store 4.
Vite inlines every VITE_* variable into the bundle at build time. The value
ships to the browser and is readable with view-source. Storing it as a GitHub
secret therefore buys exactly nothing in confidentiality, and costs two real
things:
- Log legibility. GitHub masks a secret's value everywhere it appears in
workflow output. A masked API base URL turns build logs, npm error messages
and failed-request diagnostics into
***, which is precisely the information you need when the build is failing. If the value is a common substring, it mangles unrelated lines too. - A false expectation. The next reader sees
secrets.and concludes the value is confidential. It is in the bundle. Now there is a documented disagreement between the storage mechanism and the truth, and the mechanism usually wins the argument.
This was exactly the AZURE_FUNCTIONS_URL defect (CHECKLIST §7): a public API
base URL referenced as ${{ secrets.AZURE_FUNCTIONS_URL }} and fed straight
into VITE_AZURE_FUNCTIONS_URL. Fixed 2026-08-18 while the value was still
unset everywhere: the workflow now reads ${{ vars.FUNCTIONS_URL }} — a
repository variable alongside the VITE_* entries five lines below it, with
the provider prefix dropped per the naming rule.
The rule: if the value ends up in a public artefact — a JS bundle, a container image layer, an HTML page, a public DNS record — it is not a secret, whatever it looks like.
A value needed by both Terraform and a GitHub workflow¶
One writer, and everything else derives. Terraform owns the value; the workflow obtains it. In order of preference:
- The workflow fetches it at run time after
azure/loginwith OIDC —az functionapp show,az staticwebapp secrets list, and so on. Nothing is stored, nothing goes stale. - The Terraform output feeds a GitHub variable, and the two names mirror
each other exactly, per the IaC Repository Standard: output
app_hostname→ variableAPP_HOSTNAME; outputclient_id→ variableCLIENT_ID. The GitHub copy is a cache, not a source; when the resource is replaced, the cache is stale and the CHECKLIST entry is what tells the next person which side to trust.
What must not happen is the same value being typed independently into two
stores. COSMOS_ENDPOINT currently exists as a runtime app setting derived from
the resource and as a hand-set GitHub secret holding the same string. One of
those is authoritative and the other is a copy; the inventory has to say which.
Subscription, tenant and client IDs¶
Store 3, GitHub Actions variables — which is what the repository already
does (vars.CLIENT_ID, vars.TENANT_ID, vars.SUBSCRIPTION_ID).
Under workload identity federation these are identifiers, not credentials.
Possession of a client ID grants nothing. The trust decision is made by the
federated identity credential, which pins issuer + subject + audience —
https://token.actions.githubusercontent.com, repo:<org>/<repo>:ref:… or
repo:<org>/<repo>:environment:production, and
api://AzureADTokenExchange. A token that does not match all three is refused
with AADSTS70021 no matter who is holding the client ID.
Note the deliberate asymmetry with Terraform, where the same values are marked
sensitive:
| Mechanism | What it is | Why it is set the way it is |
|---|---|---|
Terraform sensitive = true |
A log-hygiene flag — redacts the value from plan output and CI logs | Keeps subscription IDs out of run logs that get pasted into issues |
GitHub secrets |
A storage-and-masking mechanism with a different reader set from vars |
Not warranted: nothing is protected, and masking a GUID that appears in error messages hurts diagnosis |
The two do not have to agree, and treating sensitive as a synonym for "must be
a GitHub secret" is how identifiers end up in the last-resort column.
Why OIDC federation means zero long-lived cloud credentials in GitHub¶
Both cloud handshakes in this estate are federated, and neither has a secret:
| Handshake | Identity | Trust anchor | Created by |
|---|---|---|---|
| HCP Terraform → Azure | id-plat-terraform-prod-cus-01 |
TFC_AZURE_PROVIDER_AUTH=true plus federated credentials tfc-plan / tfc-apply, issuer https://app.terraform.io |
scripts/bootstrap-terraform-oidc.ps1, once, outside Terraform state |
| GitHub Actions → Azure | the github_deploy user-assigned identity |
Federated credentials on issuer https://token.actions.githubusercontent.com, subject-pinned to the deploy ref AND to the production environment, each in both the name and immutable-ID forms — four credentials |
infra/oidc.tf |
| GitHub Copilot → Azure (read-only) | the github_copilot_review user-assigned identity |
The same issuer, pinned to …:environment:copilot only, both forms — two credentials, no ref subject, so no branch-triggered workflow can assume it. Reader on the four workload groups and nothing else; the Azure MCP Server runs --read-only with fourteen hand-picked tools on top |
infra/oidc.tf |
| GitHub Actions → Azure (read-only) | the github_reader user-assigned identity |
The same issuer, but pinned to the deploy ref only, in both forms — two credentials, no environment subject. That asymmetry is load-bearing: a reader workflow that declares environment: presents …:environment:<name> instead of …:ref:<ref> and is refused with AADSTS700213, which is why verify-alert-state.yml and monitor-functions-registered.yml each carry a header saying not to add one |
infra/oidc.tf |
Neither mints anything longer-lived than a per-run token. So the correct count of Azure credentials in GitHub Actions secrets is zero, and that is a falsifiable property of the repository rather than an aspiration — any Azure client secret, storage key, Cosmos key or deployment token appearing in store 4 is a defect by definition, because a federated path to the same capability already exists and is already wired.
What that implies for store 4: it may only hold credentials to systems that are
not Azure and offer no federation from GitHub. Today that is HCP
Terraform and Firebase — two external systems, each with a named reason.
AZURE_STATIC_WEB_APPS_API_TOKEN fails this test on its first word.
Secrets that must never transit Terraform state, and why Key Vault is seeded out-of-band¶
Terraform state is a plaintext record of every value the configuration
touched. sensitive = true redacts console output; it does not encrypt state.
Two consequences:
- Never manage a secret value with
azurerm_key_vault_secret. The value would be written into state, and state lives in HCP Terraform — a third-party SaaS. That widens the disclosure boundary from "Azure RBAC on one vault" to "Azure RBAC + everyone who can read state + every plan diff + every run log". - Never read a secret back with
data.azurerm_key_vault_secret. The read value lands in state too, so using Key Vault as Terraform's secret source moves the secret into the place you were trying to keep it out of. This is whycloudflare_api_tokenis an HCP Terraform workspace variable rather than a vault lookup: as a provider argument it is never persisted to state, and a data-source read would be.
The vault's network posture makes the same point independently. network_acls
is default_action = "Deny" with one VNet rule, for the Functions integration
subnet. HCP Terraform's runners are neither in that VNet nor a trusted Azure
service, so the Key Vault Secrets Officer assignment on the Terraform executor
cannot actually write a secret from a run. The seeding path is the one the
configuration is built for: populate admin_ip_rules, apply, run
az keyvault secret set as a human, empty admin_ip_rules, apply again. Empty
is the correct steady state.
The division of labour is therefore fixed: Terraform owns the vault, the RBAC,
the network rules, and the @Microsoft.KeyVault(…) references. A human owns the
values. Rotation does not require a Terraform run at all.
Since 2026-08-29 a human owns them through the admin portal. Admin →
Platform → API Keys lists every declared secret in sections, with a light each,
and writes a pasted value straight to the vault. The app is inside the
integration subnet the vault already admits, so nothing has to open a firewall.
It holds a custom role with one data action — setSecret — so it can create a
new version and cannot read, delete or purge; the page has no read path to
render a value through even if someone added one. az keyvault secret set from
a desktop, through scripts/cutover/06-seed-secret.ps1, remains the break-glass
route for when the app itself is the thing that is broken.
Multi-line and oversized secrets¶
There are none left, and the run-time read path was deleted with them
(2026-08-29). This section used to describe two: the GCP service-account JSON
(~2.3 KB, multi-line) and GITHUB-APP-PRIVATE-KEY (an RSA PEM). App settings
are visible in the portal and in az webapp config appsettings list, and
multi-line values survive that round trip badly, so both were read at run time
through functions/src/lib/key-vault.js — a vault SDK client, a KEY_VAULT_URI
setting and a data-plane RBAC grant, serving two callers.
Then one of them stopped being multi-line and the other turned out to have no
caller at all. GCP pricing reads the Cloud Billing Catalog API, which Google
documents as API-key authenticated: a single string, now the app setting
GCP_BILLING_API_KEY holding an ordinary vault reference. GITHUB-APP-PRIVATE-KEY
is read by nothing in the ported code. key-vault.js had zero call sites after
the first change, so it is gone, and with it @azure/keyvault-secrets,
google-auth-library and KEY_VAULT_URI.
The rule that remains is simpler than the one it replaces: every secret is an
app setting holding a @Microsoft.KeyVault(SecretUri=…) reference, resolved by
the host before the process starts. No exceptions, which is what makes
app-settings-secrets.test.js's allowlist empty. A value too large or too
multi-line for an app setting is a reason to reconsider the value — as it was
here, twice — before it is a reason to reintroduce a run-time vault client.
Placement matrix — every value in CHECKLIST, classified¶
Store 1 — Azure Key Vault¶
Seeded by hand; referenced from infra/main.tf app settings as
@Microsoft.KeyVault(SecretUri=…).
| Value | CHECKLIST | Why store 1 |
|---|---|---|
CF-ORIGIN-SECRET |
§3 | Runtime shared secret proving a request came via Cloudflare; the anonymous submission path depends on it |
CLIENT-IP-SALT |
§3 | Runtime salt for quota keys; rotating it resets live counters, so rotation must not need a Terraform run |
AWS-ACCESS-KEY-ID, AWS-SECRET-ACCESS-KEY |
not inventoried | Third-party static credentials — AWS offers the Function App no federation here. Scope the IAM policy to pricing:GetProducts only |
GEMINI-API-KEY |
§4 | Public Generative Language API, NOT Vertex — Vertex needs GCP ADC the Function App cannot hold. First in provider preference order since 2026-08-23. Unseeded resolves to the literal @Microsoft.KeyVault(...), which the router reads as no key, so it falls through to OpenAI |
ANTHROPIC-API-KEY, OPENAI-API-KEY, PERPLEXITY-API-KEY, REPLICATE-API-KEY |
§4, partially | Third-party SaaS keys. Distinct from Azure OpenAI, which is keyless. PERPLEXITY and REPLICATE are referenced as app settings but no longer reachable through the AI router — it implements Gemini, OpenAI and Anthropic only |
FIRECRAWL-API-KEY, LINKIE-API-KEY, YOUTUBE-API-KEY |
not inventoried | Third-party SaaS keys |
PUBLER-API-KEY, PUBLER-WORKSPACE-ID, KLAVIYO-PRIVATE-KEY, KLAVIYO-LIST-ID |
not inventoried | The two *-ID values are identifiers rather than credentials, but they travel with their key and splitting them across stores buys nothing |
TELEGRAM-BOT-TOKEN, TELEGRAM-CHAT-ID |
not inventoried | As above |
GITHUB-APP-INSTALLATION-ID, HOSTINGER-API-TOKEN |
not inventoried | Site rebuild trigger and VPS control |
GCP-BILLING-API-KEY |
not inventoried | Cloud Billing Catalog API key for the public GCP price list — Google's documented auth for it. Replaced a ~2.3 KB service-account JSON on 2026-08-29 |
GITHUB-APP-PRIVATE-KEY |
not inventoried | Multi-line PEM. Not referenced by main.tf and read by nothing — it has no app setting and no seeding path, deliberately |
infra/main.tf declares 21 @Microsoft.KeyVault references and no
run-time reads. CHECKLIST §1–§8 inventories a handful of them. That gap is
recorded below rather than papered over.
Store 2 — HCP Terraform workspace¶
Environment variables — how the run authenticates. All four names are dictated by HashiCorp and Microsoft and are contractual.
| Value | CHECKLIST | Kind | Why store 2 |
|---|---|---|---|
TFC_AZURE_PROVIDER_AUTH |
§8 | Environment | Switches the workspace to dynamic provider credentials; absent, no OIDC token is minted at all |
TFC_AZURE_RUN_CLIENT_ID |
§8 | Environment | The identity HCP Terraform assumes. Distinct from §7 CLIENT_ID, which is the GitHub Actions identity |
ARM_TENANT_ID |
§8 | Environment | Read by the azurerm credential chain, not by a variable block |
ARM_SUBSCRIPTION_ID |
§8 | Environment | Provider fallback only — providers.tf sets subscription_id explicitly on all four provider blocks, and the explicit value wins |
Terraform variables — plan inputs, bound by exact name to
infra/variables.tf. These are the ones with no default:
| Value | Why store 2 | sensitive |
|---|---|---|
subscription_app, subscription_mgmt, subscription_conn |
Provider targets. No defaults on purpose: a wrong guess deploys the workload into a platform subscription, so an unset value must fail the plan. There is deliberately no subscription_ident — the Identity zone is empty, so an alias for it would be an unused declaration and one more value that has to be right before a plan can run |
yes (log hygiene) |
entra_tenant_id |
Feeds the ENTRA_TENANT_ID app setting and JWT validation |
yes (log hygiene) |
entra_api_audience |
Validated as the JWT aud. Empty silently disables audience validation, hence the non-empty validation block |
no |
cloudflare_api_token |
A genuine credential, and the reason it is here rather than in Key Vault: as a provider argument it never enters state, whereas a vault data-source read would put it there | yes |
cloudflare_zone_id |
Identifier | no |
budget_alert_email |
Notification target | no |
admin_ip_rules, cosmos_admin_ip_rules, functions_storage_admin_ip_rules |
Populated only for a seeding or inspection window; empty is the steady state | no |
Most of the rest of infra/variables.tf has a default and needs no workspace
entry. Four are the exception, and they are the ones an operator actually
reaches for. Each carries the safe value as its default, so the unsafe or
armed value is a deliberate workspace edit and the default is the rollback:
| Value | Default | Why it is a workspace entry |
|---|---|---|
schedulers_master_enabled |
false |
Master kill switch for all 18 timers — it is what FEATURE_FLAG_SCHEDULERS is set from. A hardcoded literal until 2026-08-24, which meant no timer could be armed without a code change and nothing said so |
enabled_timers |
[] |
Which timers are armed, by flag suffix. Arming one needs both this and the master switch. An unrecognised name fails the plan rather than silently arming nothing |
availability_test_enabled |
false |
Runs the /api/health availability test. Off until the Cloudflare side is settled: Bot Fight Mode serves datacenter clients a 403, so arming it first would create a permanently-firing alert |
availability_probe_alert_enabled |
false |
The alert on the Cloudflare Worker reachability probe (ADR 0024). It fires on missing probe successes, so flipping it before the Worker is deployed and observed writing success == 1 rows creates a rule that fires immediately and permanently |
storage_shared_access_key_enabled |
false |
Shared-key auth on the content and Functions host accounts. False is the intended posture — both consumers use Entra — and true is the one-variable rollback if a key path turns out to be needed |
Everything else there is a default nobody is expected to override.
Store 3 — GitHub Actions variables¶
| Value | CHECKLIST | Why store 3 |
|---|---|---|
CLIENT_ID |
§7 | Identifier under WIF; grants nothing without a matching federated subject |
READER_CLIENT_ID, COPILOT_REVIEW_CLIENT_ID |
§7 | Identifiers for the read-only identities (github_reader; github_copilot_review, which Copilot code review signs in as) — same reasoning |
COPILOT_REVIEW_APP_ID |
— | App ID of the read-only GitHub App Copilot code review reads GitHub with. An identifier, exactly like MANIFEST_APP_ID; the key is in store 4 |
TENANT_ID |
§7 | Identifier |
SUBSCRIPTION_ID |
§7 | Identifier |
APP_HOSTNAME |
§7 | Public DNS name. Mirrors the function_hostname output |
RESOURCE_GROUP |
§7 | Resource group name for the T-503 firewall window. Must equal the resource_group_name Terraform value |
FUNCTIONS_STORAGE_ACCOUNT |
§7 | An account name, not a key. Three words to break the collision with the content storage account, which the standard permits |
VITE_ENTRA_CLIENT_ID, VITE_ENTRA_TENANT_ID |
§6, §7 | Public-client registration values that ship in the bundle |
VITE_SOCIAL_X_URL, VITE_SOCIAL_LINKEDIN_URL, VITE_SOCIAL_GITHUB_URL |
§6, §7 | Public URLs |
VITE_TRANSLATIONS, VITE_DEFAULT_LANGUAGE |
§6 | Feature flags in a public bundle (VITE_NEWS_ENABLE_INSIGHTS was retired with the insights panel on 2026-09-05, T-765) |
Store 4 — GitHub Actions secrets, with justification¶
Every entry states which system it authenticates to and why federation is not available. An entry that cannot answer both belongs in store 3 or nowhere.
| Value | CHECKLIST | Target system | Justification | Verdict |
|---|---|---|---|---|
GITHUB_TOKEN |
— | GitHub | Injected per-run by GitHub, scoped by permissions:, expires with the job. Not stored by us at all |
Correct, and contractual |
COPILOT_REVIEW_APP_PRIVATE_KEY (Agents store, not Actions) |
— | GitHub, read-only, this repository | Authenticates GitHub → GitHub for the MCP server Copilot code review uses, to reach the Actions, code-scanning, Dependabot and discussions toolsets the built-in per-review token cannot be given. The same shape as MANIFEST_APP_PRIVATE_KEY: a GitHub App's key, from which copilot-setup-steps.yml mints a one-hour installation token per session. The App holds eight read permissions on one repository and nothing else, so the key's ceiling is read-only — a leaked key mints nothing a leaked token would not already grant. No personal access token, classic or fine-grained, is used: those are user-bound and long-lived, and store 4 holds none |
Justified — the one stored key in the Copilot configuration, and it can only ever produce read-only, one-hour tokens |
TF_API_TOKEN |
§7 | HCP Terraform | Authenticates GitHub → Terraform, the reverse direction from §8. The HCP Terraform CLI credential has no inbound GitHub OIDC path. Use a team token, not a user token, so it survives the user leaving | Justified |
FIREBASE_SERVICE_ACCOUNT_JSON |
§7 | Google Cloud | Source-side credential for the one-shot Firestore export, for a system being decommissioned. Must be scoped read-only, and deleted the day the migration completes | Justified, with an expiry |
AZURE_STATIC_WEB_APPS_API_TOKEN |
§7 | Azure | None available — see below | Wrong store |
AZURE_FUNCTIONS_URL |
§7 | — | A public API base URL | Resolved 2026-08-18 — now vars.FUNCTIONS_URL, store 3 |
COSMOS_ENDPOINT (GitHub-side) |
§7 | — | A public hostname; with local_auth_disabled = true it grants nothing |
Wrong store |
COSMOS_KEY |
§7 | — | Must stay unset | Correctly absent |
Not in any store¶
| Value | CHECKLIST | Outcome |
|---|---|---|
COSMOS_ENDPOINT, COSMOS_DATABASE, STORAGE_ACCOUNT_NAME, STORAGE_BLOB_ENDPOINT, STORAGE_QUEUE_ENDPOINT, AZURE_OPENAI_ENDPOINT (app settings) |
§2, §4 | Derived — set from resource attributes in infra/main.tf |
NODE_ENV, REGION_NAME, WEBSITE_SITE_NAME |
§5 | Host-provided, or a literal in main.tf |
FEATURE_FLAG_SCHEDULERS and the per-timer flags |
§5 | Derived from store 2 Terraform variables — schedulers_master_enabled for the master flag, enabled_timers for the eighteen per-timer flags via local.timer_flags. Both still resolve to "false" today. Until 2026-08-24 the master flag was a literal in main.tf, as this row used to say; that is what made all 18 timers permanent no-ops regardless of enabled_timers |
ENTRA_TENANT_ID, ENTRA_API_AUDIENCE (app settings) |
§1 | Derived from store 2 Terraform variables |
STORAGE_ACCOUNT_KEY, STORAGE_CONNECTION_STRING, COSMOS_CONNECTION_STRING |
§2 | Deliberately absent; two are test-enforced |
COSMOS_KEY |
§7 | Deliberately absent |
AZURE_OPENAI_KEY |
§4 | Deliberately absent — see below |
LABS_AGENT_CERT_PATH |
§2b | Generated in place on the VPS |
LABS_AGENT_* (the rest), the LabAgent app role, lab_agents/{agentId} |
§2b | Host-local configuration and Entra/Cosmos objects, outside all four stores. The agent holds no database credential by design |
production-infra |
§7b | A GitHub Environment — a protection gate, not a value. data-migration was listed here too, described as an OIDC subject that could not be renamed without breaking login with AADSTS70021. That stopped being true on 2026-08-26 (T-524), when its two federated credentials were removed after migrate-data.yml was deleted in 59e471b; the environment itself was deleted on 2026-08-30. Nothing in Azure trusts the subject and nothing in the repository declares the environment |
Placement errors in the current configuration¶
Five, ranked by how much they mislead a reader.
1. AZURE_STATIC_WEB_APPS_API_TOKEN is a long-lived Azure credential in
store 4. By the zero-credentials rule this cannot be right: it is an Azure
deployment key, statically stored, with no expiry and no subject pinning, in the
store with the widest reader set. The workflow already authenticates to Azure
with OIDC elsewhere. Correct placement is derived — fetch the token at
deploy time with az staticwebapp secrets list after azure/login, and store
nothing. Two notes on the current state: the pinned
Azure/static-web-apps-deploy action takes only a deployment token as input, so
the fetch-then-pass shape is what removes the stored copy; and infra/outputs.tf
exposes the same token as the swa_token output, which means the value is also
in Terraform state — if the run-time fetch lands, that output has no remaining
consumer and should go with it.
2. AZURE_FUNCTIONS_URL was a public URL in store 4 — resolved 2026-08-18.
It fed VITE_AZURE_FUNCTIONS_URL, which is inlined into the public bundle, so
nothing was being protected — and masking it made every build-time API failure
read as ***. The workflow now reads ${{ vars.FUNCTIONS_URL }} (store 3,
provider prefix dropped per the naming rule). The rename was safe-now: the
value was unset everywhere when it happened. The variable itself remains to be
provisioned (CHECKLIST §7).
3. COSMOS_ENDPOINT is in store 4 on the GitHub side. An account endpoint
is a public hostname, derivable from the account name, and with
cosmos_local_auth_disabled = true it grants nothing to whoever reads it.
Correct placement is derived — heal-computed-properties.yml and
migrate-data.yml both run after azure/login, so
az cosmosdb show --query documentEndpoint removes the entry entirely. Store 3
is the acceptable fallback if a lookup is judged too slow.
4. Docker Hub runner credentials are not used. The runner-image workflow and the Container Apps runner job were removed; no Docker Hub secrets belong in this repository.
5. AZURE_OPENAI_KEY is inventoried in CHECKLIST §4 as Required: Yes,
source Key Vault. infra/main.tf decided the opposite under T-506: Azure
OpenAI is keyless, only AZURE_OPENAI_ENDPOINT is set, and
lib/openai-client.js must authenticate with DefaultAzureCredential. The
correct placement is deliberately absent, in the same category as
COSMOS_KEY. As written, the inventory instructs a future operator to create a
credential the architecture removed.
Two record-keeping gaps, which are not misplacements but do make the inventory unusable as a seeding checklist:
- The Key Vault contents are under-inventoried.
main.tfdeclares 21@Microsoft.KeyVaultreferences; CHECKLIST §1–§8 lists only a few of them. Every one of those resolves to empty until seeded, and an unseeded reference fails at first invocation in production, not at deploy — the failure mode the vault seeding runbook exists to prevent./api/healthnow reportsunresolvedSecretsas a count, which turns that class of failure into one number, but nothing alerts on it yet. The inventory gap itself closed on 2026-08-29:functions/src/lib/secret-catalog.jslists all 21 with a section, a label and a description, CI asserts it againstmain.tfpair by pair, and the portal's API Keys page renders it with a live status per secret — so the inventory this section calls missing now exists, in code, where it cannot go stale without failing a build. - There is no longer a second way in. This entry used to record that
functions/src/lib/key-vault.jshad exactly one call site —gcp.js, for the service-account JSON — and that its failure mode was different from every other secret's: an app-setting reference fails the whole app at startup, whereas that one path failed only GCP pricing, and only when that tool was invoked, so a missing value was invisible until a specific feature was used. That asymmetry is gone. The module was deleted on 2026-08-29 when its one caller moved to an API key, so every secret now has the same, louder failure mode.
Nothing in this section renames a value that is currently set. CLIENT_ID,
TENANT_ID, SUBSCRIPTION_ID, APP_HOSTNAME and RESOURCE_GROUP are set and
keep their names; everything proposed for a move is Missing today.
Naming¶
The core rules¶
Carried from the IaC Repository Standard, and unchanged by this page:
| Rule | Example |
|---|---|
| Max 2 words. A third only to break a real collision | CLIENT_ID, TENANT_ID, APP_HOSTNAME; FUNCTIONS_STORAGE_ACCOUNT vs the content account |
| Casing follows the store; the word count does not | CLIENT_ID (GitHub) ↔ client_id (Terraform output) |
| No provider prefixes. One platform, so say what the value is | client_id, not azure_deploy_client_id |
| A name that crosses stores mirrors itself | output app_hostname → variable APP_HOSTNAME |
| Apply at creation. Renaming a set value is a coordinated one-PR change across the setting and every consumer | — |
One rule this page adds: name the value, not its plumbing. COSMOS_ENDPOINT
describes what it is; MIGRATION_COSMOS_TARGET_ENDPOINT_SECRET describes how it
got there, and stops being true the moment the plumbing changes.
Per store¶
| Store | Casing | Separator | Example | Platform limits that bite |
|---|---|---|---|---|
| Terraform variable / output | lower_snake_case |
_ |
client_id, entra_api_audience |
Must be a valid HCL identifier |
| HCP Terraform, Terraform kind | Identical to the variable block name |
_ |
subscription_app |
A key that matches no variable block is silently ignored — no error, just an unset variable and a confusing plan |
| HCP Terraform, Environment kind | UPPER_SNAKE_CASE |
_ |
ARM_TENANT_ID |
Must match exactly what the tool reads; all four are contractual |
| Azure Key Vault secret | UPPER-KEBAB-CASE |
- |
CF-ORIGIN-SECRET |
1–127 characters, alphanumerics and hyphens only — no underscores, ever |
| Function App app setting | UPPER_SNAKE_CASE, matching process.env.X |
_ |
CF_ORIGIN_SECRET |
__ is a reserved hierarchy separator (COSMOS_CONNECTION__accountEndpoint) — never use it for word separation |
| GitHub Actions variable | UPPER_SNAKE_CASE |
_ |
APP_HOSTNAME |
Alphanumerics and _ only; must not start with a number; must not start with GITHUB_; names are case-insensitive |
| GitHub Actions secret | UPPER_SNAKE_CASE |
_ |
TF_API_TOKEN |
Same restrictions as variables, and a secret and a variable must not share a name |
| Container Apps job secret | lower-kebab-case |
- |
gh-app-private-key |
Lowercase alphanumerics and -; must start and end alphanumeric |
| GitHub Environment | lower-kebab-case |
- |
production |
Not a value, but load-bearing WHILE a federated credential pins it: the environment name appears verbatim in the OIDC subject, so renaming one that is pinned breaks login with AADSTS70021. production is pinned today (infra/oidc.tf). data-migration was the example here until 2026-08-30, by which point nothing pinned it and it had been deleted — an environment is immutable only for as long as a credential names it |
The Key Vault ↔ app setting transform¶
Key Vault secret names cannot contain underscores. Application code reads
process.env.CF_ORIGIN_SECRET. Both constraints are fixed, so the mapping is
mechanical and there is no per-secret decision to make:
app setting name CF_ORIGIN_SECRET
│ uppercase; underscores → hyphens
▼
Key Vault secret CF-ORIGIN-SECRET
│
▼
app setting value @Microsoft.KeyVault(SecretUri=https://<vault>.vault.azure.net/secrets/CF-ORIGIN-SECRET)
State it once and every future secret names itself. The alternative — deciding
per secret — produces CF_ORIGIN_SECRET next to cfOriginSecret next to
cf-origin-secret in the same vault, and a getSecret() call that 404s in
production while every test passes.
Omitting the version from the SecretUri is deliberate: the app picks up a
rotated value without a Terraform run, which is what makes rotation an
az keyvault secret set rather than a deployment.
Contractual names — exempt, and never renamed¶
A name is contractual when something outside this repository decides it. Renaming it does not produce a differently-named working system; it produces a broken one, usually silently.
| Name | Dictated by | What breaks if renamed |
|---|---|---|
TFC_AZURE_PROVIDER_AUTH, TFC_AZURE_RUN_CLIENT_ID |
HashiCorp | HCP Terraform never mints an OIDC token; the provider finds no credential |
ARM_TENANT_ID, ARM_SUBSCRIPTION_ID |
Microsoft / azurerm |
The provider credential chain does not see them |
ARM_CLIENT_ID, ARM_OIDC_TOKEN, ARM_USE_OIDC |
Microsoft / HashiCorp | Injected into the run environment automatically — never set these by hand |
VITE_* prefix |
Vite | Without the prefix the value is not inlined; the bundle reads undefined and the build still looks fine |
GITHUB_TOKEN |
GitHub | Injected per-run; the name is reserved and cannot be created as a secret anyway |
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY |
The AWS SDK | The SDK reads these exact names from the environment |
NODE_ENV, WEBSITE_SITE_NAME, REGION_NAME |
Node / the Functions host | Host-provided |
Any app setting read as process.env.X by shipped code |
This repository's own code | The read returns undefined, which most code treats as "feature off" rather than as an error |
GatewaySubnet, AzureFirewallSubnet, AzureBastionSubnet and the other reserved subnet names |
Azure | Covered in Naming-Convention; listed here so nobody applies the 2-word rule to them |
Note what is not contractual, because that is where the mistakes are made:
AZURE_STATIC_WEB_APPS_API_TOKEN— the action input isazure_static_web_apps_api_token; the secret name feeding it is ours, and five words long. Unset today, so it is a safe rename if it survives at all.FUNCTIONS_URL— ours. Renamed 2026-08-18 fromAZURE_FUNCTIONS_URL(three words, provider prefix) while unset everywhere — the safe-now case below, exercised.FIREBASE_SERVICE_ACCOUNT_JSON— ours, four words, and the_JSONsuffix encodes an encoding rather than a meaning.VITE_AZURE_FUNCTIONS_URL— theVITE_prefix is contractual, the rest is ours. Butfrontend/src/lib/functionsBase.jsreads it and a test enforces that it is the only reader, so this one is coordinated, not safe-now.
Which bucket a rename falls into¶
Every name sorts into exactly one:
| Bucket | Meaning | Currently |
|---|---|---|
| Safe now | Unset everywhere, or read only by code changed in the same PR | AZURE_STATIC_WEB_APPS_API_TOKEN, FIREBASE_SERVICE_ACCOUNT_JSON — both Missing. (AZURE_FUNCTIONS_URL → FUNCTIONS_URL already exercised this path, 2026-08-18) |
| Coordinated | Already set in HCP Terraform or GitHub, or already read by shipped code. Report it; never rename silently | CLIENT_ID, TENANT_ID, SUBSCRIPTION_ID, APP_HOSTNAME, RESOURCE_GROUP, VITE_AZURE_FUNCTIONS_URL |
| Contractual | Never touched | The table above |
Adding a new value — the short version¶
- Run Q1–Q7. Write the answer into CHECKLIST with the store and the reason.
- Name it for the store it landed in: 2 words, no provider prefix.
- If it is a Key Vault secret, apply the underscore→hyphen transform and add
the
@Microsoft.KeyVaultreference toapp_settingsininfra/main.tf— the reference, never the value. - If it landed in store 4, the CHECKLIST entry must name the external system and say why federation is unavailable.
- If the answer was "deliberately absent", record it anyway, with what breaks if someone provisions it. An absent value with no record gets provisioned.
Related¶
- IaC Repository Standard — the variable naming rule and the credential-free principle this page implements
- Naming Convention — resource names, and the platform limits that override them
- Deployment Runbook — §0 bootstrap, where the store 2 environment variables are first entered
- Required-Inputs — the inventory: what exists, who consumes it, whether it is provisioned
infra/variables.tf— the store 2 Terraform variables and their validationsinfra/main.tf— the Key Vault, its network ACLs and RBAC, and the app settings that reference it