Bicep CI/CD: GitHub Actions Pipeline for Azure
Last year, our team hit three environments -- dev, staging, production -- and the "just run az deployment group create from your laptop" workflow fell apart in about two weeks. Staging got a VNet CIDR that overlapped with production because someone deployed from a stale branch. That was the week we built the pipeline.
Effort: 1-2 days for the initial pipeline, half a day per additional environment Cost: $0 for GitHub Actions (2,000 free minutes/month on public repos), ~$0.008/min on Ubuntu runners for private repos Prerequisites: Azure subscription, GitHub repository with Bicep files, Azure AD app registration with federated credentials (or a service principal if you cannot use OIDC)
What Changed in 2025
GitHub Actions for Azure got noticeably better this year. The changes that matter most for Bicep pipelines:
- OIDC federation is the default path. The
azure/login@v2action dropped the requirement forAZURE_CREDENTIALSJSON. You configure a federated identity credential on an Azure AD app registration and the runner authenticates with a short-lived token. No more client secrets rotting in GitHub Secrets. - Reusable workflows hit GA. You can define a deployment workflow once and call it from multiple repositories. For organizations managing 10+ Bicep modules, this eliminates copy-paste drift across repos.
az deployment group what-ifoutput can be posted directly as a PR comment withactions/github-script. Reviewers see exactly what will change before approving the merge.- Environment protection rules now support required reviewers, wait timers, and branch restrictions -- all native in GitHub, no third-party gates needed.
Why This Matters
Manual Bicep deployments work fine when it is one person, one subscription, one environment. The moment you scale past that, problems stack up:
- No review before deploy. Someone deploys a breaking change to a Key Vault access policy and the application goes down at 11 PM. There is no PR, no diff, no approval trail.
- Drift between environments. Dev gets parameters that staging never received. Production runs three versions behind because the last manual deploy was two sprints ago.
- Secret sprawl. Every developer has an
az loginsession with their personal credentials. One laptop gets compromised and the blast radius is every subscription they can access.
A CI/CD pipeline solves all three: changes are reviewed via PR diff, every environment gets the same template with different parameters, and authentication uses a scoped identity with no persistent secrets.
Implementation: Production Pipeline
Here is the pipeline we run. The workflow file lives at .github/workflows/deploy-infra.yml. It triggers on PR (what-if preview) and on merge to main (actual deployment).
Step 1: Configure OIDC Federation
In Azure AD, create an app registration and add a federated credential for your GitHub repository:
# Create the app registration
az ad app create --display-name "gh-actions-bicep-deployer"
# Note the appId from output, then create a service principal
az ad sp create --id <appId>
# Add federated credential for main branch deployments
az ad app federated-credential create --id <appId> --parameters '{
"name": "github-main-deploy",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:contoso/infra-bicep:ref:refs/heads/main",
"audiences": ["api://AzureADTokenExchange"]
}'
# Assign Contributor role scoped to the target resource group
az role assignment create \
--assignee <appId> \
--role "Contributor" \
--scope "/subscriptions/a1b2c3d4-5678-90ab-cdef-1234567890ab/resourceGroups/rg-platform-prod-westeurope"Add three secrets in your GitHub repository settings: AZURE_CLIENT_ID, AZURE_TENANT_ID, and AZURE_SUBSCRIPTION_ID. No client secret needed -- that is the whole point.
Step 2: The Workflow
# .github/workflows/deploy-infra.yml
name: Deploy Bicep Infrastructure
on:
pull_request:
paths:
- 'infra/**'
push:
branches:
- main
paths:
- 'infra/**'
permissions:
id-token: write # Required for OIDC
contents: read
pull-requests: write # Post what-if as PR comment
env:
RESOURCE_GROUP: rg-platform-prod-westeurope
LOCATION: westeurope
TEMPLATE: infra/main.bicep
PARAMETERS: infra/parameters/prod.bicepparam
jobs:
what-if:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
environment: preview
steps:
- uses: actions/checkout@v4
- name: Azure Login (OIDC)
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Run what-if
id: whatif
run: |
result=$(az deployment group what-if \
--resource-group ${{ env.RESOURCE_GROUP }} \
--template-file ${{ env.TEMPLATE }} \
--parameters ${{ env.PARAMETERS }} \
--no-pretty-print 2>&1)
echo "output<<EOF" >> $GITHUB_OUTPUT
echo "$result" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Post what-if to PR
uses: actions/github-script@v7
with:
script: |
const output = `### Bicep What-If Preview
\`\`\`
${{ steps.whatif.outputs.output }}
\`\`\`
*Triggered by @${{ github.actor }}*`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: output
});
deploy-staging:
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- name: Azure Login (OIDC)
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Deploy to staging
run: |
az deployment group create \
--resource-group rg-platform-staging-westeurope \
--template-file ${{ env.TEMPLATE }} \
--parameters infra/parameters/staging.bicepparam \
--name "staging-$(date +%Y%m%d-%H%M%S)"
deploy-production:
needs: deploy-staging
runs-on: ubuntu-latest
environment:
name: production
url: https://portal.azure.com
steps:
- uses: actions/checkout@v4
- name: Azure Login (OIDC)
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Deploy to production
run: |
az deployment group create \
--resource-group ${{ env.RESOURCE_GROUP }} \
--template-file ${{ env.TEMPLATE }} \
--parameters ${{ env.PARAMETERS }} \
--name "prod-$(date +%Y%m%d-%H%M%S)"Step 3: Environment Protection Rules
In GitHub repository settings under Environments, configure production with:
- Required reviewers: Add at least one team lead. No one deploys to production without a second pair of eyes.
- Wait timer: 5 minutes after approval. Enough time for someone to say "wait, actually no."
- Branch restriction: Only
main. Feature branches cannot bypass staging.
Here is what the docs do not tell you about OIDC setup: the federated credential subject field is case-sensitive and must match exactly. If your repo is Contoso/Infra-Bicep but you configured contoso/infra-bicep, the token exchange silently fails with a generic AADSTS700024 error. We spent two hours on that one.
Real-World Results
After switching from manual az deployment calls to this pipeline, here is what actually changed.
The first deployment failure we caught was a missing role assignment. The OIDC service principal had Contributor on the resource group but the Bicep template was creating a Key Vault with an access policy referencing a managed identity in a different resource group. The error in the GitHub Actions log:
ERROR: {"status":"Failed","error":{"code":"DeploymentFailed","target":
"/subscriptions/a1b2c3d4-.../resourceGroups/rg-platform-prod-westeurope/
providers/Microsoft.Resources/deployments/prod-20250210-143022",
"message":"At least one resource deployment operation failed. Please list
deployment operations for details.","details":[{"code":"Forbidden",
"message":"The client 'f8a3b1c2-...' with object id 'f8a3b1c2-...' does
not have authorization to perform action
'Microsoft.KeyVault/vaults/accessPolicies/write' over scope
'/subscriptions/a1b2c3d4-.../resourceGroups/rg-security-prod-westeurope/
providers/Microsoft.KeyVault/vaults/kv-platform-prod'."}]}}
The fix was adding a second role assignment scoped to the security resource group. In the old workflow, someone would have run az login with their Owner-level personal account and never noticed the permission gap.
Pipeline execution times (averaged over 30 runs):
| Stage | Duration |
|---|---|
| What-if preview (PR) | 1m 42s |
| Deploy staging | 3m 15s |
| Deploy production (after approval) | 3m 22s |
| Total (staging + prod) | ~7 min |
Compare that to the old manual process: SSH into a jump box, run az login, navigate to the right directory, remember which parameter file to use, run the deployment, wait, check the portal. Conservatively 20 minutes per environment, plus the cognitive overhead of doing it right.
Key Takeaways
- OIDC first, service principal secrets never. Federated credentials eliminate secret rotation entirely. The
subjectclaim must match your repo name exactly -- including capitalization. - What-if on every PR. Reviewers should see infrastructure diffs the same way they see code diffs. Post the output as a PR comment so it is impossible to miss.
- Environment gates are not optional. A 5-minute wait timer on production has saved us from at least two "oh wait" moments.
- Name your deployments. The
--name "prod-$(date +%Y%m%d-%H%M%S)"pattern makes it trivial to correlate a GitHub Actions run with an Azure deployment in the portal. - Start with one resource group, expand later. Get the OIDC plumbing working for a single RG before adding subscription-level deployments or management group scopes.
If you are already working with Terraform and wondering how it compares, we covered the foundational patterns in our Terraform Azure best practices guide. The choice between Bicep and Terraform often comes down to whether your team is Azure-only or multi-cloud -- the CI/CD patterns are remarkably similar either way.
Looking for help setting up infrastructure deployment pipelines for your Azure environment? Check out our infrastructure and DevOps consulting services -- we have built these pipelines for teams ranging from 3-person startups to enterprise platform teams.
About the author

Martin Rylko
Senior Cloud Architect & DevOps Engineer
14+ years in IT – from on-premises datacenters and Hyper-V clustering to cloud infrastructure on Microsoft Azure. I specialize in Landing Zones, IaC automation, Kubernetes and security compliance.
Frequently Asked Questions
Should I use OIDC federation or a service principal secret for GitHub Actions to Azure?▾
What deployment scope should I use for Bicep in CI/CD -- subscription or resource group?▾
How do I handle rollback when a Bicep deployment fails in the pipeline?▾
Can I run Bicep what-if in a pull request check before merging?▾
You might also like
Terraform Azure Modules: Private Registry and Testing
Build reusable Terraform modules for Azure with private registry publishing, automated testing with Terratest, and versioned module consumption in production.
ReadTerraform Azure Best Practices: Modules & CI/CD
Terraform Azure best practices for production projects. Covers remote state locking, module structure, drift detection, naming conventions, and testing.
ReadAzure Landing Zone with Bicep: Enterprise Setup
Deploy an enterprise-ready Azure Landing Zone using Bicep modules. Covers hub-spoke networking, policy governance, and CI/CD pipeline integration.
Read