Fork Guide
This guide takes you from a blank Azure subscription to a running FoundryGate deployment. Budget two hours if you’re comfortable with Azure; longer if this is your first time with Bicep or APIM.
Prerequisites
- Azure CLI (
az) ≥ 2.60 - Bicep CLI (
az bicep install) - .NET 10 SDK
- GitHub CLI (
gh) ≥ 2.40 - An existing Azure API Management instance
- An existing Azure AI Foundry account with at least one model deployment
-
Fork and clone the repo
Terminal window gh repo fork kolatts/foundry-gate --clonecd foundry-gate -
Register an Entra App
Terminal window az ad app create \--display-name "FoundryGate" \--sign-in-audience AzureADMyOrgNote the
appId— you’ll need it asentraClientIdin the Bicep params.Add the Admin app role in the Azure portal under your app registration → App roles → Create:
- Display name:
FoundryGate Admin - Value:
FoundryGate.Admin - Allowed member types: Users/Groups
Graph API permissions are granted later, not here. The Entra sync calls Microsoft Graph as a user-assigned managed identity (created by the Bicep deploy) — there is no client secret. After the first infra deploy, grant the Graph application roles
Application.Read.All,User.Read.AllandGroupMember.ReadBasic.Allas app-role assignments on the Microsoft Graph service principal to bothid-foundrygate-api-{env}andid-foundrygate-func-{env}. Both, because the nightlyEntraSyncFunctioncalls Graph as the Functions identity — grant only the API’s and every nightly run fails withAuthorization_RequestDeniedwhile the on-demand sync in the UI works fine, which is a confusing way to find out. The Owner Setup Runbook has the loop that does both; #120 has a PowerShell equivalent. Then setEntra__Enabled=trueon both hosts. - Display name:
-
Configure APIM
In your APIM instance:
a. Create a product named
foundrygate(or your preferred name — record it asapimProductId).b. Add an API that backends to your Foundry endpoint:
- Backend URL:
https://{your-foundry-name}.openai.azure.com/ - URL scheme: HTTPS
- Add this API to the
foundrygateproduct.
c. Apply the token metric policy on the API (All operations):
<policies><inbound><base /><llm-emit-token-metricnamespace="foundrygate"output-type="SubscriptionId" /></inbound></policies>d. Apply the token limit policy (optional but recommended for rate safety):
<azure-openai-token-limittokens-per-minute="500000"counter-key="@(context.Subscription.Id)"estimate-prompt-tokens="true" />e. Grant APIM access to Foundry:
Terminal window az role assignment create \--role "Cognitive Services User" \--assignee <apim-managed-identity-principal-id> \--scope <foundry-resource-id> - Backend URL:
-
Set Bicep parameters
Copy
infra/parameters/dev.bicepparamand fill in your values:param location = 'eastus2'param environmentName = 'dev'param entraClientId = '<your-app-registration-client-id>'param entraTenantId = '<your-tenant-id>'param apimResourceId = '/subscriptions/.../Microsoft.ApiManagement/service/...'param apimProductId = 'foundrygate'param foundryResourceId = '/subscriptions/.../Microsoft.CognitiveServices/accounts/...' -
Create the GitHub Environments and the CI identity (OIDC)
The workflows deploy through four GitHub Environments. Create them in your fork (repo Settings → Environments, or with
gh api -X PUT repos/<you>/foundry-gate/environments/<name>):Environment Protection devnone — deploys automatically on merge productionrequired reviewer; deploys from protected branches only dev-destroyrequired reviewer + 5 min wait prod-destroyrequired reviewers + 30 min wait; protected branches only Then one app registration for GitHub Actions with a federated credential per environment — no client secret anywhere:
Terminal window az ad app create --display-name "foundrygate-github-ci" --sign-in-audience AzureADMyOrg# for each of dev, production, dev-destroy, prod-destroy:az ad app federated-credential create --id <appObjectId> --parameters '{"name": "foundrygate-dev","issuer": "https://token.actions.githubusercontent.com","subject": "repo:YOUR_ORG/foundry-gate:environment:dev","audiences": ["api://AzureADTokenExchange"]}'Its service principal needs Owner on the subscription (the template is subscription-scope and creates role assignments),
AcrPushon the registry once it exists, and membership of the SQL Entra admin group (Azure SQL is Entra-only — there is no password). -
Add the GitHub Environment variables
There are no secrets. The workflows read a handful of identifiers as Environment variables and resolve every resource name from the infra deployment’s outputs:
Variable dev production destroy envs AZURE_CLIENT_ID<appId><appId><appId>AZURE_TENANT_ID<tenantId><tenantId><tenantId>AZURE_SUBSCRIPTION_ID<subscriptionId><subscriptionId><subscriptionId>FG_ENTRA_API_CLIENT_IDAPI app registration (step 2) same — FG_ENTRA_WEB_CLIENT_IDBlazor SPA app registration same — FG_SQL_ADMIN_GROUP_OBJECT_ID/_NAME— your production SQL admin group — Until
AZURE_CLIENT_IDis set the deploy workflows fail with a message pointing here rather than an Entra error, and PR jobs (what-if, UI preview) skip. Full list and rationale: CI/CD reference. -
Deploy the whole stack once (day 0)
Actions → Deploy All → Run workflow →
environment: dev, tick create-model-deployments. This is the only run that creates the Claude deployments — they are create-once under ARM, so every later run leaves the box unticked. OpenAI deployments need no flag: ARM reconciles them on every run. The chain is infra → database (dacpac + seed) → API → Functions and UI in parallel → post-deploy tests → a summary with every URL. Expect 20–30 minutes; APIM v2 provisioning is most of it.To do the same by hand, see Infrastructure → Deploying.
-
From now on, merge to main
A merge that touches anything deployable runs the same
deploy-all.ymlchain against dev — one workflow, in order, every time, so a change shared between the API and the Functions can never deploy half of itself. PRs still get a Bicep what-if comment forinfra/**changes; docs go to GitHub Pages from their own workflow. To redeploy one component, dispatchAPI Deploy,Functions Deploy,UI DeployorInfra Deploy. Production is Deploy All withenvironment: production, behind the reviewer gate. -
Verify
- Navigate to the Static Web App URL (from
infraoutput or Azure portal). - Sign in with an Entra account.
- You should land on
/mewith a freshQuotaAllocationand a provisioned APIM key. - Assign yourself the
FoundryGate.Adminapp role in Entra and refresh — the admin nav appears.
- Navigate to the Static Web App URL (from
Troubleshooting
APIM returns 401 on first AI call
Check that the foundrygate product is published and your APIM subscription is in active state. Run GET /keys/me to confirm the key was provisioned.
POST /users/sync or POST /groups/{id}/sync-entra returns 503
Entra__Enabled is still false on the API — the response says so, and names the Graph roles. Turn it on once the roles below are granted. (It is a 503 rather than a 400 because nothing is wrong with the request: the host has not been configured for the feature.)
POST /users/sync fails with 500 (Authorization_RequestDenied in the API logs)
The API’s managed identity (id-foundrygate-api-{env}) is missing one of the Graph application roles Application.Read.All, User.Read.All, GroupMember.ReadBasic.All. Grant them as app-role assignments on the Microsoft Graph service principal (Owner Setup Runbook §6); no restart is needed. If the on-demand sync works but the nightly one fails the same way, it is the other identity — id-foundrygate-func-{env} needs the same three roles.
POST /users/sync returns skippedGroupAssignmentCount > 0 and never deactivates anyone
Group assignments are expanded to their members (#121), so this count is 0 on a healthy tenant. Non-zero means the API could not read one of the assigned groups — usually a missing GroupMember.ReadBasic.All, sometimes a group that has been deleted. The run saw only part of your developer population, so it deliberately deactivates nobody; the group is named in the API log and in the users.synced audit row. Fix the role (or unassign the deleted group) and re-run.
Container App shows “no healthy upstream”
The API is still starting or the migration hasn’t run. Check the Container App log stream and verify the SQL connection string Key Vault reference resolved correctly.