$ run as a normal user# run as root (or with sudo)
Understand OIDC login
GitHub signs a token that says "repo s0sta/infra-shop, environment production". Azure has a federated credential that trusts exactly that subject, and gives an access token for an app registration (or a managed identity).
GitHub job (id-token: write) --token--> Microsoft Entra ID --checks subject--> Azure access token (1 hour)| Subject (who may log in) | Example |
|---|---|
| A branch | repo:s0sta/infra-shop:ref:refs/heads/main |
| An environment | repo:s0sta/infra-shop:environment:production |
| Pull requests | repo:s0sta/infra-shop:pull_request |
Set up the trust in Azure
Create an identity, give it a role, and add federated credentials for GitHub.
az identity create -g rg-cicd -n id-github-infra
CLIENT_ID=$(az identity show -g rg-cicd -n id-github-infra --query clientId -o tsv)
PRINCIPAL_ID=$(az identity show -g rg-cicd -n id-github-infra --query principalId -o tsv)
az role assignment create --assignee-object-id "$PRINCIPAL_ID" --assignee-principal-type ServicePrincipal \
--role Contributor --scope /subscriptions/SUB_ID/resourceGroups/rg-shop-staging
az identity federated-credential create -g rg-cicd --identity-name id-github-infra -n gh-staging \
--issuer https://token.actions.githubusercontent.com \
--subject repo:s0sta/infra-shop:environment:staging --audiences api://AzureADTokenExchange
gh variable set AZURE_CLIENT_ID --body "$CLIENT_ID"
gh variable set AZURE_TENANT_ID --body "$(az account show --query tenantId -o tsv)"
gh variable set AZURE_SUBSCRIPTION_ID --body "$(az account show --query id -o tsv)"Remember
The client ID, tenant ID and subscription ID are not secrets — store them as variables. There is no password anywhere.
Run Terraform with OIDC
azure/login logs the CLI in; Terraform's azurerm provider uses OIDC itself with ARM_USE_OIDC.
name: Terraform
on:
pull_request:
paths: ["envs/**", "modules/**"]
push:
branches: [main]
paths: ["envs/**", "modules/**"]
permissions:
id-token: write
contents: read
pull-requests: write
env:
ARM_USE_OIDC: "true"
ARM_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }}
ARM_TENANT_ID: ${{ vars.AZURE_TENANT_ID }}
ARM_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }}
jobs:
plan:
runs-on: ubuntu-latest
environment: staging
defaults: { run: { working-directory: envs/staging } }
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- run: terraform fmt -check -recursive ../..
- run: terraform init
- run: terraform validate
- run: terraform plan -out=tfplan -var-file=staging.tfvars
- uses: actions/upload-artifact@v4
with: { name: tfplan, path: envs/staging/tfplan, retention-days: 3 }
apply:
if: github.ref == 'refs/heads/main'
needs: plan
runs-on: ubuntu-latest
environment: staging
concurrency: { group: tf-staging, cancel-in-progress: false }
defaults: { run: { working-directory: envs/staging } }
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- uses: actions/download-artifact@v4
with: { name: tfplan, path: envs/staging }
- run: terraform init
- run: terraform apply tfplanDeploy an app to App Service
Log in with OIDC, then deploy the build to a slot and swap.
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
permissions: { id-token: write, contents: read }
steps:
- uses: actions/checkout@v4
- uses: azure/login@v2
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- uses: azure/webapps-deploy@v3
with:
app-name: app-shop-s0sta
slot-name: staging
package: .
- run: az webapp deployment slot swap -g rg-apps -n app-shop-s0sta --slot staging --target-slot productionAt work
Give the deploy identity rights only on the target resource group — not Contributor on the whole subscription.