Skip to content

Infrastructure Reference

Everything FoundryGate needs in Azure is one subscription-scope Bicep template, infra/main.bicep, deployed with az deployment sub create. It has two halves:

  • Gateway data plane — API Management v2, the Azure AI Foundry accounts and their model deployments, the backend pools, quota-tier products and policies, Log Analytics and Application Insights. Always deployed. Documented in Architecture → Feasibility and the policy files under infra/policies/.
  • Control plane — the FoundryGate app that manages the gateway: the API on Container Apps, the Blazor UI on Static Web Apps, background jobs on Functions Flex Consumption, Azure SQL, Key Vault, a container registry, two managed identities and the role assignments between them. Deployed only when deployControlPlane = true (dev.bicepparam and prod.bicepparam set it; test.bicepparam is gateway-only).

Every resource lands in one resource group per environment, rg-foundrygate-{env}, and carries the tags workload=foundrygate, environment, managed-by=bicep, repo, an fg-role (gateway | foundry | monitoring | control-plane) and, on control-plane resources, an fg-component (api | sql | keyvault | registry | functions | storage | ui). Cost and inventory queries filter on those.

Deploying

Terminal window
# Gateway only (what the team validated live first):
az deployment sub create --location eastus2 \
--template-file infra/main.bicep --parameters infra/parameters/test.bicepparam
# Full stack, dev:
az deployment sub create --location eastus2 --name foundrygate-dev \
--template-file infra/main.bicep --parameters infra/parameters/dev.bicepparam
# Preview any change without touching anything:
az deployment sub what-if --location eastus2 \
--template-file infra/main.bicep --parameters infra/parameters/dev.bicepparam

Two invariants for re-runs:

  1. createAnthropicModelDeployments = false after day 0. Anthropic (Claude) model deployments are create-once under ARM — re-PUTing an existing one drives it to Failed. The parameter files ship with false; override to true on the command line for the very first deployment of a new environment only.

    OpenAI-format deployments are not covered by the flag. They are a genuine upsert: re-PUTing one with the same model, SKU and capacity leaves provisioningState = Succeeded and createdAt untouched (verified against a live account, 2026-09-05). So ARM reconciles them on every run, which is what stops a missing OpenAI deployment from leaving the gateway answering 404 DeploymentNotFound on every model with nothing owning the fix (#259). Claude deployment lifecycle after day 0 still belongs to the control plane, not Bicep.

  2. Pass the current API image — always. The Container App’s image is a parameter (apiContainerImage), and the param files read it from the FG_API_IMAGE environment variable with no default, so build-params fails loudly rather than silently swapping the API for a placeholder page. On the bootstrap run the registry is created by the same deployment and holds nothing yet, so pass the public placeholder explicitly: FG_API_IMAGE=mcr.microsoft.com/k8se/quickstart:latest. The Container App module recognises it and switches to port 80 with /health-only probes (that image listens on :80 and serves only / and /health — verified under docker). Every later infra run sets FG_API_IMAGE to the tag currently running (the api-deploy workflow’s az containerapp update --image is what moves it forward):

    Terminal window
    FG_API_IMAGE=$(az containerapp show -n ca-foundrygate-api-dev -g rg-foundrygate-dev \
    --query properties.template.containers[0].image -o tsv)

    The containerAppIsBootstrapImage output reports which mode the app is in.

Prod additionally requires FG_SQL_ADMIN_GROUP_OBJECT_ID and FG_SQL_ADMIN_GROUP_NAME (a dedicated production SQL admin group — with Entra-only auth, group membership is the access model, so prod deliberately fails to build until that group exists rather than pointing at the dev group; see #109).

Local gateway test cycle

The gateway data plane is meant to be stood up, proved and torn down often — APIM StandardV2 is the only meaningful idle cost (~$0.28/hr), and nothing else in the gateway half bills while it sits there. scripts/cycle/ automates the whole loop:

Terminal window
pwsh scripts/cycle/cycle.ps1 -Subscription "<subscription name>"

Roughly 35–55 minutes, unattended. It deploys the template with the standard tier shrunk to demo size, issues three developer keys against tier products, runs the enforcement matrix, drives a real Codex CLI session into both walls, reconciles the token counts out of Log Analytics with UsageBySubscription.kql, tears the gateway down and writes a markdown evidence report to validation/.

The two demo tiers have deliberately opposite shapes, because no single tier can demonstrate both meters:

Demo tierMonthlyTPMProves
standard40 00012 000the 429 wall — a tight per-minute cap
power60 000100 000the 403 wall — a small budget with room to spend it

The TPM floor is not arbitrary. codex exec spends ~9–10K tokens on its system prompt before doing anything, so a tier whose per-minute cap sits below one agent turn deadlocks the harness: it 429s, retries, keeps the bucket empty, gives up without finishing, and therefore never spends its monthly budget at all — the 403 becomes unreachable from behind the 429. Verified live across 25 consecutive execs at both 8 000 and 12 000 TPM (#237). These are the demo tiers only; the shipped defaults in infra/main.bicep are unaffected.

Individual stages (up, subscriptions, smoke, codex-test, measure, down, status) can be run on their own; they share a gitignored state file under scripts/cycle/.state/.

Teardown defaults to KeepFoundry, which deletes everything in the resource group except the Foundry accounts and the telemetry stores (Log Analytics workspace, Application Insights), and purges the soft-deleted APIM service. Each survivor earns its place: Anthropic deployments are create-once per account, so destroying the accounts spends the next spin-up’s only Claude create attempt; Log Analytics ingestion lags the traffic by longer than a cycle takes, so deleting the workspace destroys the measurement evidence minutes before it arrives. Neither bills at rest — APIM, the one real idle cost, always goes. The APIM purge matters too: deleting the service only soft-deletes it and the name stays reserved, so without it the next spin-up fails with ServiceAlreadyExistsInSoftDeletedState. -Mode Full deletes and purges everything including the Foundry accounts, and is labelled with that risk.

Running the same checks against a deployed environment

Everything above describes the cycle standing up its own throwaway gateway. The same scripts also attach to an environment that is already deployed — dev and prod, which CI owns:

Terminal window
pwsh scripts/cycle/cycle.ps1 -Subscription "<subscription name>" -Environment dev -CleanupSubscriptions

Nothing is deployed and nothing is torn down. up.ps1 reads the addresses out of the existing foundrygate-{env} subscription deployment’s outputs, and reads each tier’s real limits off the live product policy — the deployment outputs carry monthlyTokenQuota but not tpm, which exists only inside the rendered llm-token-limit element.

For dev and prod this mode is not optional. Attach is implied, -Teardown is forced to None, and down.ps1 refuses them outright: those environments are re-deployed by Deploy All on every merge to main and hold the control plane — SQL, the Container App, the Static Web App, Key Vault. Destroying one is the infra-destroy.yml workflow, which requires a typed confirmation and a GitHub environment approval.

The fixture keys are named fgcycle-* and subscriptions.ps1 -Cleanup removes exactly those, by prefix. That matters on a shared gateway: the same APIM service also carries the real developer keys the control plane issues as foundrygate-{UserId}.

The monthly 403 wall is out of reach there, and is recorded as such. It is arithmetic: exhausting a tier’s budget takes monthlyTokenQuota / tpm minutes of continuous full-rate traffic, so the demo tiers’ 40 000 behind 12 000 TPM is about three minutes while the shipped standard tier’s 5 000 000 behind 20 000 TPM is over four hours — and the counter is keyed on the APIM subscription with no way to reset it, so burning it would leave that tier spent for everyone on it until the month rolls over. The quota checks therefore report SKIP by design with that arithmetic, and a check in their place observes x-fg-remaining-quota decrementing: the wall is unreachable, the meter is demonstrably live. The per-minute 429 wall is reached normally.

One thing to watch on any environment: a 429 is only the developer’s own meter when x-fg-remaining-tpm is 0 on the refusal. A 429 with headroom still on that header is the backing Foundry deployment saturating, which means its capacity is below the tier’s TPM cap and the developer’s budget can never bind (#260).

The capacity ceiling

A tier is well-formed between two bounds. The floor above is one agent turn. The ceiling is a resource the tier does not mention — the deployment behind each alias:

sum(capacity of the deployments an alias routes to) x 1000 >= TPM of every tier that reaches it

A pooled deployment exists in every Foundry region, so its capacity multiplies by the region count; a primary-only one does not. Below the ceiling, every throttle a developer sees is the shared deployment rather than their own allocation, x-fg-remaining-tpm is permanently misleading, and one developer’s traffic throttles another’s even though the meters are correctly isolated per subscription.

The deploy computes the shortfalls from the parameters actually in use and emits one ::warning:: per offending (tier, alias) — reported, never fatal, because capacity is a human decision and for Claude a create-once one (#205). The shipped defaults are checked offline too, for OpenAI aliases, by InfraModelDeploymentTests.

The agent-facing runbook, including the Anthropic create-once rules and the failure-mode table, is .claude/skills/gateway-cycle/SKILL.md. Committed evidence from real runs lives in validation/, one file per run — {date}-gateway-cycle.md for a full spin-up/spin-down cycle, {date}-dev-gateway.md for an attached run against dev.

Naming convention

{env} is environmentName (test, dev, prod); {suffix} is nameSuffix, needed only where the name is a global DNS label. The CLI ip setup command and the deploy workflows resolve resources from these patterns — changing one is a contract change.

ResourceNameNotes
Resource grouprg-foundrygate-{env}
Log Analytics workspacelog-foundrygate-{env}shared by gateway and control plane
Application Insightsappi-foundrygate-{env}
API Managementapim-foundrygate-{env}-{suffix}v2 tier
Foundry accountfg{env}-{suffix}-{region}one per foundryRegions entry, e.g. fgdev-e7k2-eus2
Azure SQL serversql-foundrygate-{env}-{suffix}FQDN <name>.database.windows.net
Azure SQL databasesqldb-foundrygate-{env}
Key Vaultkv-fg-{env}-{suffix}24-char limit forces the short form
Container registrycrfoundrygate{env}{suffix}alphanumeric only
Container Apps environmentcae-foundrygate-{env}
API container appca-foundrygate-api-{env}
API identityid-foundrygate-api-{env}user-assigned
Functions identityid-foundrygate-func-{env}user-assigned
Function Appfunc-foundrygate-{env}-{suffix}Flex Consumption
Functions planasp-foundrygate-func-{env}FC1
Functions storagestfg{env}{suffix}3–24 lowercase alphanumeric; shared-key access off
Static Web Appstapp-foundrygate-{env}

Modules

ModuleCreates
monitoring.bicepLog Analytics (62-day retention — two full quota months) + App Insights
foundry.bicepone AIServices account + chained model deployments; keys disabled
foundry-rbac.bicepAPIM identity → Cognitive Services User on each account
apim.bicepAPIM v2, App Insights logger, GatewayLlmLogs diagnostic (resource-specific — see below)
ai-gateway.bicepbackends, priority pool + breakers, front-door APIs, tier products, alias named values
control-plane.biceporchestrates everything below; owns the control-plane naming
managed-identities.bicepthe two user-assigned identities
key-vault.bicepRBAC-mode vault, soft delete, optional purge protection, optional fg-apim-key-encryption RSA-3072 key
sql.bicepEntra-only SQL server, AllowAllWindowsAzureIps firewall rule, database (zone-redundant in prod)
container-registry.bicepACR (Basic dev / Standard prod), admin user disabled
storage-account.bicepFunctions storage (LRS dev / ZRS prod), shared keys off, function-deployments container
static-web-app.bicepSWA (Free/Standard), provider: Custom
control-plane-rbac.bicepall role assignments below
container-app.bicepContainer Apps environment (logs via diagnostic setting — no workspace key) + the API app; bootstrap-image detection (port 80, /health-only probes)
function-app.bicepFlex Consumption plan + Function App, identity-based storage

The gateway diagnostic setting must be resource-specific

apim.bicep sets logAnalyticsDestinationType: 'Dedicated' on the diagnostic setting, and that line is load-bearing. Without it Azure Monitor sends GatewayLogs and GatewayLlmLogs to the legacy catch-all AzureDiagnostics table instead of the resource-specific ApiManagementGatewayLlmLog and ApiManagementGatewayLogs tables — and those two are exactly what src/FoundryGate.Functions/Kql/UsageBySubscription.kql joins.

The failure is silent in every direction: the diagnostic setting reports enabled, rows really do arrive promptly, and the reconciliation query is valid and runs without error against a table that has no rows. Reconciliation simply returns nothing, forever (#244). If per-developer totals ever come back empty, check the destination type before suspecting ingestion lag:

search * | summarize Rows = count() by $table
AzureDiagnostics | summarize Rows = count() by Category

A fork that enables the gateway’s GenAI diagnostic setting by hand needs the same property.

Control-plane parameters

ParameterdevprodPurpose
deployControlPlanetruetruedefault false (gateway only)
appEnvironmentqaprodASPNETCORE_ENVIRONMENT — lowercase qa/prod; local is docker-only
sqlAdminGroupObjectId / sqlAdminGroupNameSG_FOUNDRYGATE_SQL_ADMINS$FG_SQL_ADMIN_GROUP_OBJECT_ID / $FG_SQL_ADMIN_GROUP_NAME (required)SQL server administrator (Entra-only auth; no SQL login exists)
sqlLocationcentralusset it explicitlyregion for the SQL logical server, defaulting to location. Dev overrides it because eastus2 and eastus are both closed to new Azure SQL servers on this subscription — see below
sqlDatabaseSkuGP_S_Gen5 ×1 — serverless, 60-min auto-pauseGP_Gen5_2, provisionedserverless is derived from the SKU name (GP_S_*)
sqlBackupStorageRedundancyLocalGeo
sqlZoneRedundantfalsetruesurvives the loss of one availability zone without a restore; adds ~60% to the SQL compute meter (see Cost & capacity)
entraTenantIdtenanttenantAzureAd__TenantId
entraApiClientId$FG_ENTRA_API_CLIENT_IDsameAzureAd__ClientId; zero GUID until the app registration exists
entraApiAudienceapi://{clientId}sameAzureAd__Audience; the API accepts the bare client id too, because v2 tokens carry that as aud (#102)
apiContainerImage$FG_API_IMAGE (required)samesee re-run invariant 2
containerAppMinReplicas / MaxReplicas1 / 21 / 3min 1 — the admin API is the UI’s only backend
containerAppCpu / containerAppMemory0.25 / 0.5Gi0.5 / 1.0Gionly the Consumption profile pairs are valid (0.25/0.5Gi, 0.5/1.0Gi, 0.75/1.5Gi, 1.0/2.0Gi, …)
containerAppsZoneRedundantfalsefalseplumbed but not enabled: ARM rejects it without a VNet-integrated environment, so it waits on private networking (#196)
functionsStorageSkuStandard_LRSStandard_ZRSFunctions host state + the Flex deployment container
containerRegistrySkuBasicStandard100 GB included storage and higher throughput vs Basic’s 10 GB; Premium only adds geo-replication and private link
staticWebAppSku / staticWebAppLocationFree / eastus2Standard / eastus2SWA is only offered in a handful of regions
functionsRuntimeVersion10.010.0.NET isolated worker
keyVaultPurgeProtectionfalsetrueirreversible once on
keyVaultSoftDeleteRetentionInDays790
createKeyEncryptionKeytruetrueRSA key for wrapping APIM subscription keys at rest

Nothing in a parameter file is a secret: SQL is Entra-only, storage is identity-based, registry pulls are identity-based, and the App Insights connection string is a module output. Entra object ids and client ids are identifiers, not credentials.

Why sqlLocation exists

Azure closes individual regions to new Azure SQL logical servers as a capacity action, and there is no way to see it coming: it is not a quota you can read, not a SKU availability query, not anything az sql server list-usages reports. The only signal is attempting a create and getting ProvisioningDisabled / RegionDoesNotAllowProvisioning.

Probed on the dev subscription, 2026-09-05:

RegionNew SQL server
eastus2refused
eastusrefused
centraluscreated

Both the primary region and its obvious neighbour were shut, which is why dev’s SQL lives in centralus while everything else stays in eastus2. Existing servers in a closed region keep working — only creation is blocked, and it can reopen (or close) without notice. The cross-region hop between the Container App and SQL is a real latency cost, accepted for dev.

Set sqlLocation explicitly for production rather than letting it inherit location, so a prod day-0 does not discover this the way dev did (#241). The alternative is a support request (Issue type Service and subscription limits) to reopen the primary region — worth it when co-location matters, not worth blocking a first deploy on.

Role assignments

All scoped to the individual resource, never the resource group; names are guid(scope, principal, role) so re-runs are idempotent.

IdentityRoleScopeWhy
APIKey Vault Secrets UserKey Vault@KeyVault() reference resolution at startup
APIKey Vault Crypto UserKey Vaultwrap/unwrap APIM subscription keys with fg-apim-key-encryption
APIAcrPullregistrypull the API image
APIAPI Management Service ContributorAPIM instancecreate/rotate/delete developer subscriptions, move between tier products, edit fg-model-map-* named values
APICognitive Services Contributoreach Foundry accountmodel deployment lifecycle
APILog Analytics Readerworkspaceusage queries for the admin dashboard
FunctionsKey Vault Secrets UserKey Vault
FunctionsLog Analytics Readerworkspacereconciliation reads ApiManagementGatewayLlmLog
FunctionsAPI Management Service ContributorAPIM instancere-scope a developer’s subscription when the monthly reset resolves a new tier — see the note below
FunctionsStorage Blob Data Ownerstoragehost state + Flex deployment container + the scheduled jobs’ lock leases (foundrygate-locks: one blob per job)
FunctionsMicrosoft Graph Application.Read.All, User.Read.All, GroupMember.ReadBasic.AllGraph service principalthe nightly directory sync (#151) — not Bicep-able, see Graph application roles below
FunctionsStorage Queue Data Contributorstoragequeue triggers/outputs
FunctionsStorage Table Data Contributorstoragetable bindings
APIM (system-assigned)Cognitive Services Usereach Foundry accountgateway → model backends, no account keys

Why the Functions identity can write to APIM

Quota tiers are APIM products, so “this developer’s monthly budget changed” means “move their subscription to another product”. Almost every such move happens in the API, on somebody’s request — including a change to the system-wide DefaultMonthlyTokenQuota, which since #204 re-resolves its default-tier users in the same transaction as the edit. What is left over lands on the scheduled monthly reset: a developer with no earlier allocation has no known previous tier, and anything a re-resolution missed surfaces there first. Until #194 the Functions host could only log a warning that the database and the gateway had diverged, and they stayed diverged until the API next touched that user.

Giving the Functions identity API Management Service Contributor closes that, and it is a real widening of what a scheduled job can do: Azure offers no narrower built-in for “re-scope a subscription”, so the same role that permits the move also permits creating, deleting and regenerating developer subscriptions and editing the gateway’s named values. Two things bound the risk rather than remove it — the Functions host carries no key protection (it cannot read or write a developer’s key: IKeyProtector stays in the API), and every move it makes writes a system-attributed key.tier-changed audit row alongside the run’s own quota.monthly-reset row, so the trail names every subscription a job touched.

A fork that would rather keep the job read-only can delete the functionsApimContributor resource from modules/control-plane-rbac.bicep. The reset then records each refused move at Error with the developer’s identity, counts it into its own audit row, and leaves that developer on the tier the gateway is still enforcing — loud and reconcilable rather than silently divergent, which is the honest failure mode for a missing role.

Not expressible in Bicep. Two of these the deploy pipeline now does itself through the foundrygate CLI; the rest are operator steps, with the exact commands in the Owner Setup Runbook and the remaining production work tracked in #109:

StepWho does it
Contained database users for id-foundrygate-api-{env} and id-foundrygate-func-{env} — CREATE USER ... WITH SID = <client id>, TYPE = E + db_datareader/db_datawriter, never db_ddladmin (the dacpac owns the schema)Automated: foundrygate db grant-identities --env {env} --api-identity-client-id <guid> --functions-identity-client-id <guid>, run by _deploy-database.yml after seeding. Idempotent T-SQL (IF NOT EXISTS / IS_ROLEMEMBER guards); identity names default from the naming convention (--api-identity / --functions-identity override). The client ids are required — see Why the client ids, not FROM EXTERNAL PROVIDER below. --dry-run prints the T-SQL. (#106)
Runner / developer firewall rules on the SQL serverAutomated: foundrygate ip setup --env {env} and ip cleanup — see Firewall model. (#96)
The CI/OIDC principal must be a member of the SQL Entra admin group or the dacpac deploy, the seeders and db grant-identities cannot connect (there is no password fallback by design). It also needs SQL Server Contributor (or Contributor) on the resource group for the firewall-rule writes.Operator (#109)
Runtime creation of Claude deployments needs Marketplace/SaaS permissions beyond Cognitive Services ContributorOperator (#107)
Microsoft Graph application roles for the Entra sync on both the API and Functions identities’ service principals — no client secret; details belowOperator (#110, #120)

Why the client ids, not FROM EXTERNAL PROVIDER

The obvious way to give a managed identity a database user is CREATE USER [id-foundrygate-api-{env}] FROM EXTERNAL PROVIDER, and it is the wrong way here. That statement asks Azure SQL to resolve the identity’s name in Entra, which requires the logical server’s own managed identity to hold the Entra Directory Readers role. modules/sql.bicep gives the server no identity at all, so on FoundryGate’s infrastructure as deployed the statement fails — and granting Directory Readers is a tenant-level, privileged change no deploy should assume.

So db grant-identities takes the identities’ client ids (the deployment outputs apiIdentityClientId / functionsIdentityClientId) and creates each user WITH SID = <client id as varbinary(16)>, TYPE = E. No directory lookup happens, nothing beyond SQL Entra admin membership is needed, and the result is the same contained user.

  • _deploy-database.yml requires the two client-id inputs and fails the grant step with an actionable message when they are empty — it never silently falls back.
  • A fork whose SQL server does have a managed identity with Directory Readers can set the workflow input allow-external-provider: true (CLI: --allow-external-provider) to take the name-resolution path deliberately.

A related invariant sits on the other side of the pipeline: db deploy excludes both Users and RoleMembership from the DacFx comparison, so a --drop-objects deploy removes neither these users nor their db_datareader/db_datawriter memberships.

Keeping the checked-in schema in sync: db compare

FoundryGate.Data’s EF entities are the schema source of truth; FoundryGate.Database’s dbo/Tables/*.sql are hand-authored to match them, and SchemaParityTests (Predeployment, cross-platform) is the CI alarm when they drift. foundrygate db compare [--connection-string <cs>] [--apply] [--check] is the Windows-only developer convenience for the other side of that loop — actually regenerating the .sql files instead of just failing a test:

  • Source = a live SQL Server database (default: the local docker instance foundrygate local setup keeps current via EnsureCreated against the current EF model); target = FoundryGate.Database’s .sqlproj. Only table shape is compared — DacFx’s own logins, permissions, and every other non-table object type are excluded, so the command can never propose touching anything outside dbo/Tables.
  • With no flags, or with --check: prints the differences and exits non-zero if any exist (usable as a local pre-commit gate). No differences means no files are ever touched.
  • --apply additionally regenerates the affected table files via DacFx’s own PublishChangesToProject — the same engine SqlPackage/SSDT’s schema-compare tooling uses, which is why the checked-in files already look like its output — then exits 0 on success.
  • Not a CI gate: SchemaParityTests remains the cross-platform backstop. On macOS/Linux, db compare fails fast with a message pointing at that test instead.

(#103, deferred from #100)

The Graph roles go on both control-plane identities’ service principals — id-foundrygate-api-{env} and id-foundrygate-func-{env} — as app-role assignments on the Microsoft Graph service principal (appId 00000003-0000-0000-c000-000000000000); az ad app permission does not apply to managed identities, and no separate admin-consent step is needed. The Functions identity needs the same three because the nightly EntraSyncFunction (#151) runs the same reconciliation the API’s POST /users/sync and POST /groups/sync-entra do, calling Graph as its own identity. Grant them to the Api identity only and Entra__Enabled=true gives you a job that fails every night with Authorization_RequestDenied. Least privilege per the Graph reference for each call:

Graph app roleUsed for
Application.Read.AllGET /servicePrincipals(appId='{clientId}') and GET /servicePrincipals/{id}/appRoleAssignedTo — who is assigned to FoundryGate
User.Read.AllGET /users?$filter=id in (...)&$select=id,displayName,mail,userPrincipalName,employeeId and GET /users/{id}
GroupMember.ReadBasic.AllGET /groups/{id}/members / transitiveMembers with $select=id — group sync (#41) and expanding group-principal app-role assignments to their members during user sync (#121). Only id is selected, so this stays the least-privileged role the Graph reference lists for both calls; GroupMember.Read.All is not needed

Verification runbook and a PowerShell grant snippet: #120. Locally the Azure CLI login is used instead, so the developer’s own delegated Graph access applies.

Pre-deployment script

FoundryGate.Database/Scripts/PreDeployment.sql is declared <PreDeploy> in the .sqlproj (and removed from the SDK’s **/*.sql Build glob, which would otherwise try to parse DML as schema). DacFx embeds it in the dacpac as predeploy.sql and runs it inside the deployment transaction, before any schema change — which is what lets it clear data that a new constraint would otherwise reject. db deploy goes through DacServices, so it honours the script exactly as a SqlPackage publish would; nothing extra is wired in the CLI or the workflow.

Two rules for anything added here:

  • Idempotent, and safe at any earlier schema version. The script runs on every deploy, not only the one that first needs it — including against a brand-new empty database. Guard each block on the objects it touches (IF OBJECT_ID(...) IS NOT NULL), and write the change so a second run is a no-op.
  • It is data repair, not schema. Schema belongs in dbo/Tables/*.sql, which the EF model is the source of truth for.

The one block it currently carries closes duplicate pending quota increase requests per (UserId, PeriodYear, PeriodMonth), keeping the newest, before IX_QuotaIncreaseRequests_PendingPerUserPeriod is created (#147). Until that filtered unique index existed the rule was enforced only by a read-then-write check that two concurrent submissions could both pass, so a fork that has been running may already hold exactly the rows the index forbids — and because the dacpac step is part of the automatic deploy chain, a failed CREATE UNIQUE INDEX would take the whole deployment down rather than just the index. The losers become Rejected with ReviewNotes = 'Superseded by a later request (pre-deploy dedupe)' and no reviewer, the same shape a lapsed request gets from the #159 sweep.

Verified against SQL Server 2022 in docker: a database seeded with two pending rows for one user and period and the index dropped deploys cleanly, printing Pre-deploy: closed 1 duplicate pending quota increase request(s) … and then creating the index; a third deploy is silent, because there is nothing left to close.

One operational note about filtered indexes. A filtered index makes SET QUOTED_IDENTIFIER ON mandatory for DML on that table. EF Core and SqlClient set it by default and DacFx sets it for its own scripts, so application code and deploys are unaffected — but a hand-run sqlcmd -Q against QuotaIncreaseRequests needs it stated explicitly or the write fails with Msg 1934.

What the hosts are told

Set as Container App environment variables and Function App settings; keys are ASP.NET Core configuration paths (__ = section separator). None of them is a secret.

This table is the provenance view: which Azure resource each value comes from. For what each setting means, which host reads it, and what happens when it is absent, the Configuration Reference is the single per-host table. A predeployment test (GatewayInfraBindingTests) reads control-plane.bicep and fails if the Gateway__* names here drift from what the control plane binds.

KeyValue
ASPNETCORE_ENVIRONMENT (API) / AZURE_FUNCTIONS_ENVIRONMENT + DOTNET_ENVIRONMENT (Functions)qa or prod
AZURE_CLIENT_IDthe host’s user-assigned identity client id — selects it in AppTokenCredential
Azure__KeyVaultUrlhttps://kv-fg-{env}-{suffix}.vault.azure.net/
ConnectionStrings__FoundryGateServer=tcp:<fqdn>,1433;Database=sqldb-foundrygate-{env};Authentication=Active Directory Default;Encrypt=True;...
AzureAd__Instance / TenantId / ClientId / Audiencebearer-token validation
Cors__AllowedOrigins__0 (API)https://<static web app hostname>
OpenTelemetry__Enabled / OpenTelemetry__ConnectionStringtrue / App Insights connection string
Gateway__SubscriptionId, Gateway__ResourceGroup, Gateway__ApimName, Gateway__ApimGatewayUrl, Gateway__KeyEncryptionKeyUri, Gateway__FoundryAccountNames__{i}gateway addressing for the APIM key service, Foundry deployment service and reconciliation (#108)
Gateway__LogAnalyticsWorkspaceId / Gateway__LogAnalyticsWorkspaceResourceIdthe workspace GUID (customerId — what LogsQueryClient.QueryWorkspaceAsync and /v1/workspaces/{id}/query mean by “workspace id”) / the ARM resource id (QueryResourceAsync, management plane)
Gateway__Tiers__{i}__ProductId / __DisplayName / __MonthlyTokenQuotathe quota tier table, projected from the same quotaTiers parameter that creates the APIM products and renders their llm-token-limit policies (#201)
Entra__ApplicationClientIdthe app registration whose service principal carries the developer assignments, on both hosts (the Functions worker binds no AzureAd section). Entra__Enabled is deliberately unset — turning directory sync on is an owner step, because it needs Graph application roles no Bicep can grant
Gateway__ModelAliases__{i}__Tier / __Alias / __DeploymentName / __Providerthe model alias map, flattened from productModelAliases (#153)
AzureWebJobsStorage__accountName / __credential=managedidentity / __clientId (Functions)identity-based host storage — also where the scheduled jobs take their lock leases, so they needed no setting of their own
APPLICATIONINSIGHTS_CONNECTION_STRING (Functions)host telemetry

Every value in the Gateway section is set on both hosts from one shared block in the Bicep, so the API and the Functions host can never be told about different gateways — and since #201 that includes the quota tier table, which comes from the very quotaTiers parameter the APIM products are created from. A fork that overrides quotaTiers at deploy time therefore has nothing further to edit: the caps the gateway enforces and the caps the control plane validates budgets against are the same array. (A local host, which has no gateway, reads the table from each project’s appsettings.local.json instead.)

Outputs contract

Read with az deployment sub show --name <deployment> --query properties.outputs. The deploy workflows consume these rather than re-deriving names. Control-plane outputs are empty strings when deployControlPlane = false.

OutputUsed by
resourceGroupNameevery workflow
apimGatewayUrl, apimName, apimPrincipalId, anthropicApiUrl, openaiApiUrl, productIds, defaultProductIdCLI setup docs, control plane
logAnalyticsWorkspaceId (ARM id), logAnalyticsWorkspaceCustomerId (GUID), logAnalyticsWorkspaceName, appInsightsConnectionStringmonitoring, reconciliation
foundryAccountNamescontrol plane
controlPlaneDeployed, containerAppIsBootstrapImageworkflow branching (the api-deploy workflow’s first push replaces the placeholder)
sqlServerName, sqlServerFqdn, sqlDatabaseName, sqlEntraConnectionString, sqlAdminGroupName_deploy-database.yml (sql-server-name, sql-database-name, sql-resource-group → CLI ip setup / ip cleanup / db grant-identities)
keyVaultName, keyVaultUri, keyEncryptionKeyUriout-of-band secret management
containerRegistryName, containerRegistryLoginServerapi-deploy (docker push, image tag)
containerAppsEnvironmentName, containerAppName, containerAppFqdnapi-deploy (az containerapp update), postdeployment tests
functionAppName, functionAppHostname, functionsStorageAccountNamefunctions-deploy
staticWebAppName, staticWebAppHostnameui-deploy (deployment token lookup), CORS, Entra redirect URI
apiIdentityName / ClientId / PrincipalId, functionsIdentityName / ClientId / PrincipalId_deploy-database.yml (api-identity-name / functions-identity-name, and required: api-identity-client-id / functions-identity-client-id → CLI db grant-identities, which creates the contained users WITH SID — see Why the client ids), Graph permission grants
modelAliasRows, quotaTierRowswhat the control plane was actually handed as Gateway__ModelAliases__* / Gateway__Tiers__* — readable after a deploy without opening the app settings blade, so a fork that overrode productModelAliases or quotaTiers can confirm the override landed

Health probes and serverless auto-pause

The API exposes /health (hermetic liveness) and /health/ready (adds an AppDbContext connectivity check). The Container App wires them as:

ProbePathWhy
Startup/health/ready (30 × 5 s)a wrong connection string or identity fails the deploy right there; the window covers a paused serverless database resuming
Liveness/health (30 s)restart only on a hung process
Readiness/health (15 s)deliberately not /health/ready: with minReplicas: 1 a DB-touching readiness probe would open a SQL connection every 15 s and the dev serverless database would never reach its 60-minute auto-pause

The trade-off is explicit: for a single-replica admin API, “database down” surfacing as 500s instead of 503s is not worth an always-on vCore in dev. The dev budget line on Cost & Capacity (serverless, auto-pause) depends on this stance — and on periodic Functions jobs keeping their cadence above the pause delay. In the bootstrap-image mode all three probes use /health on port 80.

Firewall model for Azure SQL

Public endpoint, Entra-only authentication, and two kinds of firewall rule:

  • AllowAllWindowsAzureIps (0.0.0.0–0.0.0.0) — declared in Bicep; lets Container Apps and Functions connect without a VNet.
  • Runner and developer IP rules — created at deploy time by foundrygate ip setup --env {env} (#96) against sql-foundrygate-{env}-{suffix} in rg-foundrygate-{env}. Deliberately not declared in Bicep: an incremental deployment leaves undeclared child resources alone, so a re-run never wipes a rule the pipeline just added.

ip setup detects the caller’s public IPv4 address (api.ipify.org, then ifconfig.me; --ip overrides), finds the server by listing rg-foundrygate-{env} for the single sql-foundrygate-{env}-* server (--server / --resource-group override — the pipeline passes both from its inputs), and creates or updates a single-address rule. It is idempotent: a rule that already allows the address is left alone. Rule names tell you who made them:

RuleMade byLifetime
AllowAllWindowsAzureIpsBiceppermanent
gha-{run id}-{yyyyMMddHHmm}a GitHub Actions runner (GITHUB_ACTIONS=true); the UTC minute is in the name because ARM keeps no creation time on a firewall ruleremoved by foundrygate ip cleanup --env {env} --older-than {hours}, which _deploy-database.yml runs if: always() at the end: this run’s own gha-{run id}-* rules go unconditionally, other gha-* rules once they are older than the threshold (default 2 h) or carry no timestamp. Developer and hand-made rules are never candidates. --dry-run previews.
fg-dev-{machine}-{user}a developer running foundrygate ip setup --env dev locally (Azure CLI credential)until removed by hand

Both commands authenticate with the same AppTokenCredential chain as the API (Azure CLI locally; on a runner the azure/login@v2 session, with AZURE_SUBSCRIPTION_ID selecting the subscription), and a firewall-rule write is a plain ARM operation — SQL Server Contributor on the resource group is enough; SQL Entra admin membership is only needed to connect to the database afterwards. Names such as production are accepted for --env and mapped to the Bicep environmentName (prod) before any resource name is derived.

Private endpoints (spec §11 for prod) are a later hardening step and would replace the first rule, not the second.