Skip to the content.

← back to home

Wires this platform to an Azure subscription: the identity it authenticates with, the permissions it needs, and the storage account that holds Terraform state. When you finish, the platform is ready for its first Azure - Provision & Reconcile Application Resources run.

Do the GitHub setup first. This guide assumes the repository is already on GitHub and its dev / staging / prod Environments exist — the federated credentials below are scoped to that repository slug and those environment names.

It assumes you have Owner rights on the target Azure subscription.

What you’ll end up with

Estimated time: 10–15 minutes the first time.


Prerequisites

Tooling on your workstation, on top of what the GitHub guide lists:

Tool Minimum version Notes
az 2.60 App Registration + RBAC + federated credentials
jq (optional) 1.6 Useful for inspecting az output

Azure access:


Step 1 — Create the Azure App Registration

# Sign in to the right tenant if you have several
az login

# Make sure you're operating against the intended subscription
az account set --subscription "<your-subscription-id-or-name>"

# Create the App Registration
az ad app create --display-name "sp-platform-eng-github"

# Capture the appId — this is the value you'll pass as `azure_client_id`
APP_ID=$(az ad app list \
  --display-name "sp-platform-eng-github" \
  --query "[0].appId" -o tsv)

# Create the matching Service Principal in your tenant
az ad sp create --id "$APP_ID"

# Capture the SP object ID — needed for RBAC role assignments
SP_OBJECT_ID=$(az ad sp show --id "$APP_ID" --query id -o tsv)

# Capture the tenant ID — you'll pass this as `azure_tenant_id`
TENANT_ID=$(az account show --query tenantId -o tsv)

# Capture the subscription ID — you'll pass this as `azure_subscription_id`
SUBSCRIPTION_ID=$(az account show --query id -o tsv)

cat <<EOF

Save these three values — you'll feed them to the workflow as inputs:

  azure_tenant_id : $TENANT_ID
  azure_subscription_id : $SUBSCRIPTION_ID
  azure_client_id : $APP_ID

  (SP object ID, only used in the next steps: $SP_OBJECT_ID)
EOF

Tip. The App Registration’s appId and the Service Principal’s objectId are different identifiers. RBAC assignments and federated credentials work with the SP. Keep both handy.


Step 2 — Configure federated credentials (OIDC)

The workflows authenticate to Azure with short-lived OIDC tokens issued by GitHub Actions. Azure validates each token against a federated credential on the App Registration. The token’s subject claim must match exactly.

You need four credentials because two different subject formats apply:

Credential Used by Subject
Branch-scoped every job in provision-infrastructure.yml without an environment: key — resolve-inputs, fmt, checkov, bootstrap-tfstate, create-app-repo, create-run-issue, configure-environments, configure-federated-credentials, observe-ci, finalize; and the standalone bootstrap-tfstate.yml repo:<org>/<repo>:ref:refs/heads/main
Environment dev every job pinned to environment: devplan, apply, the verify-infrastructure.yml reusable workflow repo:<org>/<repo>:environment:dev
Environment staging same set of jobs, pinned to environment: staging repo:<org>/<repo>:environment:staging
Environment prod same set of jobs, pinned to environment: prod repo:<org>/<repo>:environment:prod

Check your repo’s subject format first. GitHub embeds immutable IDs in the subject claim of any repository created (or renamed/transferred) after July 15, 2026: the prefix becomes repo:<org>@<owner-id>/<repo>@<repo-id> instead of repo:<org>/<repo>, and Entra matches subjects verbatim. Query the exact prefix your platform repo presents and use it in the four subjects below:

gh api "repos/$REPO/actions/oidc/customization/sub" --jq .sub_claim_prefix

The snippets below assume the legacy prefix, which is what a platform repo created before that date presents. The app repos the platform creates need no such care from you — the configure-federated-credentials job queries each new repo’s actual prefix before registering its credentials.

Create all four:

REPO="<your-org>/<repo-name>"   # e.g. deors/workshop-platform-eng

# 1. Branch-scoped credential (bootstrap jobs)
az ad app federated-credential create --id "$APP_ID" --parameters '{
  "name":     "github-main-branch",
  "issuer":   "https://token.actions.githubusercontent.com",
  "subject":  "repo:'"$REPO"':ref:refs/heads/main",
  "audiences": ["api://AzureADTokenExchange"]
}'

# 2-4. One credential per GitHub Environment
for ENV in dev staging prod; do
  az ad app federated-credential create --id "$APP_ID" --parameters '{
    "name":     "github-env-'"$ENV"'",
    "issuer":   "https://token.actions.githubusercontent.com",
    "subject":  "repo:'"$REPO"':environment:'"$ENV"'",
    "audiences": ["api://AzureADTokenExchange"]
  }'
done

# Verify
az ad app federated-credential list --id "$APP_ID" \
  --query "[].{name:name, subject:subject}" -o table

You should see exactly four rows.


Step 3 — Assign Azure RBAC roles

The Service Principal needs three roles at subscription scope. The third one — Storage Blob Data Contributor — is the easy-to-miss one: the bootstrap script creates the state storage account with allow-shared-key-access=false, so the only way the script can then create the container is via RBAC. The role must be in place before the first run.

SCOPE="/subscriptions/$SUBSCRIPTION_ID"

# Manage control-plane resources (RGs, App Service, networking, …)
az role assignment create \
  --assignee-object-id      "$SP_OBJECT_ID" \
  --assignee-principal-type ServicePrincipal \
  --role  "Contributor" \
  --scope "$SCOPE"

# Read/write state blobs (Contributor does NOT cover the data plane)
az role assignment create \
  --assignee-object-id      "$SP_OBJECT_ID" \
  --assignee-principal-type ServicePrincipal \
  --role  "Storage Blob Data Contributor" \
  --scope "$SCOPE"

# Create role assignments — needed for the webapp module's ACR pull and
# Key Vault access policy resources
az role assignment create \
  --assignee-object-id      "$SP_OBJECT_ID" \
  --assignee-principal-type ServicePrincipal \
  --role  "User Access Administrator" \
  --scope "$SCOPE"

# Verify
az role assignment list --assignee "$SP_OBJECT_ID" --scope "$SCOPE" \
  --query "[].roleDefinitionName" -o table

Expected output:

Result
-------------------------------
Contributor
Storage Blob Data Contributor
User Access Administrator

Why all three at subscription scope? During bootstrap the resource group and storage account don’t exist yet, so any role on a narrower scope wouldn’t apply. Once we evolve the platform to provision infrastructure for many apps in many subscriptions, this RBAC model will be revisited (likely a per-subscription identity rather than a single shared SP).

Allow the SP to manage its own federated credentials

After the platform provisions infrastructure for a new app, it must register three additional federated credentials on this same App Registration — one per environment, scoped to the new app repo (subjects repo:<owner>/<app>:environment:{dev,staging,prod}, or the immutable-ID variant repo:<owner>@<id>/<app>@<id>:environment:{…} that GitHub mints for repos created after July 15, 2026 — the workflow queries the new repo for whichever prefix it actually presents). Without these, deploy workflows in the new repo fail at azure/login with AADSTS700213.

The platform workflow does this automatically (see job configure-federated-credentials), but the SP needs two things to be allowed to write to its own App Registration:

  1. Self-ownership of the App Registration object (directory-level), and
  2. The Application.ReadWrite.OwnedBy application permission on Microsoft Graph, with admin consent.

Ownership alone is sufficient for user-delegated flows but not for application-only flows like the OIDC token a workflow runs under, even in your own tenant — the corporate Entra default policy denies the call with Insufficient privileges to complete the operation.

1. Add the SP as owner of its own App Registration

APP_OBJECT_ID=$(az ad app show --id "$APP_ID" --query id -o tsv)

az ad app owner add \
  --id              "$APP_OBJECT_ID" \
  --owner-object-id "$SP_OBJECT_ID"

# Verify
az ad app owner list --id "$APP_OBJECT_ID" --query "[].id" -o tsv

2. Grant Application.ReadWrite.OwnedBy on Microsoft Graph

This step requires admin consent in the tenant: a Global Administrator, Privileged Role Administrator, Cloud Application Administrator, or Application Administrator must run it (or grant consent in the portal). In a corporate tenant this typically means filing an internal request.

# Microsoft Graph's well-known appId
GRAPH_APP_ID="00000003-0000-0000-c000-000000000000"
GRAPH_SP_ID=$(az ad sp show --id "$GRAPH_APP_ID" --query id -o tsv)

# AppRoleId for Application.ReadWrite.OwnedBy on Graph
ROLE_ID=$(az ad sp show --id "$GRAPH_APP_ID" \
  --query "appRoles[?value=='Application.ReadWrite.OwnedBy'].id | [0]" -o tsv)

# Grant it (admin consent required to execute this call)
az rest --method POST \
  --uri "https://graph.microsoft.com/v1.0/servicePrincipals/${SP_OBJECT_ID}/appRoleAssignments" \
  --headers "Content-Type=application/json" \
  --body "{
    \"principalId\": \"${SP_OBJECT_ID}\",
    \"resourceId\":  \"${GRAPH_SP_ID}\",
    \"appRoleId\":   \"${ROLE_ID}\"
  }"

# Verify — should list one row with role 'Application.ReadWrite.OwnedBy'
az rest --method GET \
  --uri "https://graph.microsoft.com/v1.0/servicePrincipals/${SP_OBJECT_ID}/appRoleAssignments" \
  --query "value[].{resource:resourceDisplayName, roleId:appRoleId}" -o table

Portal alternative

Entra ID → App registrations → your app → API permissionsAdd a permission → Microsoft Graph → Application permissionsApplication.ReadWrite.OwnedByAdd. Then click Grant admin consent for <tenant>.

Why OwnedBy and not All? Application.ReadWrite.OwnedBy only lets the SP write to App Registrations where it is an owner (set in step 1 above). Application.ReadWrite.All would let it write to any App Registration in the tenant — a much wider blast radius.

Bootstrap storage account — security model

The state storage account is created with:

If your threat model requires network-level isolation, switch to a Private Endpoint and run the workflows on a self-hosted runner inside the VNet. That trade-off is intentionally out of scope for the workshop baseline.

Backend implication. Because the SA forbids shared-key auth, the azurerm backend must also be told to use Azure AD against the blob endpoint (not just for credential acquisition). The workflow sets both use_oidc=true and use_azuread_auth=true (plus ARM_USE_AZUREAD=true). Without the second flag, terraform init hits 403 KeyBasedAuthenticationNotPermitted even with a valid OIDC token.

Web App network exposure — per-environment policy

Application Web Apps follow a deliberate per-env split:

Env Private Endpoint Public endpoint Rationale
dev enabled enabled GitHub-hosted runners are not in the VNet and have no fixed egress IP. The dev environment intentionally accepts public traffic so the application repo’s CI/CD can run an HTTP smoke test against https://<webapp>.azurewebsites.net/health after each deploy.
staging enabled disabled PE-only. Mirrors the production posture so any data flowing through staging is treated with the same network sensitivity as prod.
prod enabled disabled PE-only. The only path in is from the integration subnet via the private endpoint.

The toggle is a module variable, public_network_access_enabled (default false). Dev sets it to true explicitly; staging/prod inherit the secure default. CKV_AZURE_222 (Public network access disabled) is enforced for prod and skipped for dev/staging in .checkov.nonprod.yaml so the dev exception doesn’t fail policy.

Deploy validation strategy

The application repository ships two top-level workflows that both delegate to a reusable deploy.yml:

Operators who need real HTTP smoke tests against PE-only environments should run the deploy/release workflows on a self-hosted runner inside webapp_integration_subnet (or a peered VNet). Out of scope for the workshop baseline.


Step 4 — Trigger the first run

In the GitHub UI: Actions → Azure - Provision & Reconcile Application Resources → Run workflow, and provide:

Input Required Value for the first test
app_name yes test-webapp (3–22 chars, lowercase, digits, hyphens)
environment yes dev
azure_tenant_id yes the tenant GUID captured in step 1
azure_subscription_id yes the GUID captured in step 1
azure_client_id yes the appId captured in step 1
location yes westeurope — Azure region for the tfstate storage account and every provisioned resource. No default, by design: an implicit region silently deploys to the wrong place
infra_template_repo yes the <owner>/<name> of the infrastructure template repo
infra_template_ref no (leave empty — uses the template’s default branch) git ref (tag, branch, or commit SHA) to pin the infra template
app_template_repo no the <owner>/<name> of the application template repo; leave empty to skip the app-repo phase (infra-only run)
app_template_ref no (leave empty — uses the template’s default branch) git ref (tag, branch, or commit SHA) to pin the app template
container_image no mcr.microsoft.com/appsvc/staticsite:latest
container_registry_url no (leave empty — public image)
ci_workflow_file no (leave empty — defaults to ci.yml; only used when app_template_repo is set)

What you should observe

Each row below names the job exactly as it appears in the run’s UI. Jobs marked (app phase) only appear when app_template_repo is provided.

Resolve inputs                    ✓ validated inputs, derived sttf<app><sub>
Checkov · {env}                   ✓ no findings
Terraform fmt check               ✓ formatting clean
Bootstrap tfstate storage         ✓ rg-test-webapp-tfstate + storage account + container
Plan · {env}                      ✓ terraform plan generated, artifact uploaded
Apply · {env}                     ✓ terraform apply succeeded
Verify · {env}                    ✓ control-plane assertions passed
Create application repo           ✓ <owner>/<app_name> created from template   (app phase)
Create run issue                  ✓ per-run tracking issue opened               (app phase)
Configure env · {env}             ✓ GitHub Environment + variables set          (app phase)
Federated credential · {env}      ✓ AAD subject registered on the SP            (app phase)
Observe CI in app repo            ✓ CI watched, build+test+dev-deploy succeeded (app phase, first run only)
Comment on app issue              ✓ summary posted as issue comment             (app phase)
Comment on infra issue            ✓ plan + verify results posted to infra issue
Final summary                     ✓ consolidated summary with links to issues

The exact storage account name shows up in the bootstrap-tfstate job logs as TFSTATE_STORAGE_ACCOUNT=.... The plan output (and the binary tfplan file) is attached as a workflow artifact named tfplan-test-webapp-dev, retained for 7 days. The plan is then consumed by the apply job, which provisions the resources for real, after which verify runs control-plane assertions against the live infrastructure.

When app_template_repo is provided, the run also creates the application repository from your template, configures its GitHub Environments + variables (AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID, AZURE_CLIENT_ID, AZURE_RESOURCE_GROUP, AZURE_WEBAPP_NAME, CONTAINER_REGISTRY_URL when a registry was supplied, and DEPLOY_TARGET_CLOUD=azure — the environment-scoped switch the app repo’s deploy router reads to pick its cloud, so different environments of one app can target different clouds), registers the per-env federated credentials on the platform SP, observes the auto-triggered CI in the new repo, and posts a summary comment on the per-run tracking issue. When app_template_repo is omitted, all of those steps are skipped and only the infra issue and final summary are written.


Step 5 — Configure scheduled drift detection (optional)

The detect-drift.yml workflow compares the recorded Terraform state against the live Azure resources (terraform plan -refresh-only) and opens an issue in the affected application’s infrastructure repo (<app>-infra) when it finds drift or an error. It has three entry points: a weekly schedule (Mondays at 05:23 UTC — an off-peak minute, since GitHub delays top-of-the-hour schedules the most), manual workflow_dispatch, and workflow_call (so other workflows can reuse it).

To change the cadence, edit the cron expression in detect-drift.yml. It cannot be moved to a repository variable — GitHub does not evaluate $ expressions in the on: block, so a templated cron never fires. Scheduled runs are also best-effort: GitHub delays them under load and disables them after 60 days of repository inactivity.

For each environment the workflow classifies the result from the (managed-state × resource-group) matrix:

Terraform state Resource group Result
empty / missing absent ⏭️ skipped — never provisioned
empty / missing present 🔴 drift — unmanaged/orphaned resource group
has resources absent 🔴 drift — all managed resources missing
has resources present refresh-only plan → ✅ no drift, 🔴 drift, or ⚠️ error

When drift is found the run stays green and reports via an issue; the run only goes red when the check itself errors (init/plan failure, missing directory, unreadable state), so a real problem with the sweep is visible and any workflow_call caller can react. The issue body carries the per-environment status, a follow-up comment carries the structured breakdown (added / removed / replaced / modified / tag-only), and the raw terraform plan output, binary plan and plan JSON are uploaded to the run as the drift-<app> artifact.

Manual and called runs pass their parameters as inputs. The scheduled run cannot — GitHub schedule events carry no inputs — so it reads them from repository variables, all DRIFT_-prefixed to make clear they exist solely to drive the automated sweep:

Repository variable Required Description
DRIFT_AZURE_APP_NAMES yes Application name(s) to check, comma-separated (e.g. myapp,otherapp). The workflow fans out one matrix job per app.
DRIFT_AZURE_ENVIRONMENTS no Environment(s) to check: all (default) or a comma-separated subset of dev/staging/prod.
DRIFT_AZURE_TENANT_ID yes Azure Tenant ID used for the OIDC login.
DRIFT_AZURE_SUBSCRIPTION_ID yes Azure Subscription ID hosting the apps’ resources and tfstate.
DRIFT_AZURE_CLIENT_ID yes Client ID of the platform App Registration.
DRIFT_AZURE_LOCATION yes Azure region of the resources under check (e.g. westeurope). Required by the infra template; a refresh-only plan will not run without it.

Set them under Settings → Secrets and variables → Actions → Variables, or via the CLI:

gh variable set DRIFT_AZURE_APP_NAMES       -R <org>/<platform-repo> --body "test-webapp"
gh variable set DRIFT_AZURE_ENVIRONMENTS    -R <org>/<platform-repo> --body "all"
gh variable set DRIFT_AZURE_TENANT_ID       -R <org>/<platform-repo> --body "<tenant-guid>"
gh variable set DRIFT_AZURE_SUBSCRIPTION_ID -R <org>/<platform-repo> --body "<subscription-guid>"
gh variable set DRIFT_AZURE_CLIENT_ID       -R <org>/<platform-repo> --body "<client-guid>"
gh variable set DRIFT_AZURE_LOCATION        -R <org>/<platform-repo> --body "westeurope"

The drift job authenticates with the branch-scoped federated credential (repo:<org>/<repo>:ref:refs/heads/main) already configured in step 2 — it is read-only and never binds a GitHub Environment, so no extra OIDC subject is needed. Issue creation in the <app>-infra repo uses the same GH_PAT secret configured in the GitHub setup guide.

The DRIFT_AZURE_* variables are the opt-in for the scheduled sweep — the schedule itself ships with the workflow file. If none of them are set, a scheduled run skips cleanly with a notice in its summary. If only some are set (or any other required input is missing), the run fails fast with a clear error: partial configuration is a mistake, and silence would hide it.


Troubleshooting

Azure identity/login failures (AADSTS70021, Insufficient privileges) and Terraform state problems (AuthorizationFailed, Failed to query container, Error refreshing state) are collected in Troubleshooting:

For the Azure resources themselves — App Service, networking, the template’s Checkov baselines — see the infrastructure template’s troubleshooting. —

What’s next

With the first run green end-to-end, the typical follow-up workshop topics are: