Bicep with CI/CD Pipelines
CI/CD stands for Continuous Integration and Continuous Deployment. A CI/CD pipeline automates the process of validating, testing, and deploying code every time a change is pushed to a repository. Connecting Bicep to a CI/CD pipeline means every infrastructure change goes through automatic validation, preview, approval, and deployment — without a human running commands manually.
Think of a CI/CD pipeline like a factory assembly line. Raw materials (code changes) enter one end. At each station, a machine performs a specific task — inspect, test, assemble, package. The finished product (deployed infrastructure) exits the other end. No human touches the line for every unit. The line runs the same process every time, consistently and without error.
Why CI/CD for Bicep?
- Consistency – Every deployment follows the same steps. No forgotten validation or skipped what-if checks.
- Auditability – Every deployment ties to a specific Git commit. Finding who changed what and when becomes trivial.
- Safety – Pipelines run validation and what-if before deploying. Bad templates never reach production.
- Speed – Merging a pull request triggers deployment automatically. No waiting for someone to run a command.
- Environment Promotion – Code automatically moves from dev to staging to production through defined pipeline stages.
Authentication – How Pipelines Connect to Azure
A CI/CD pipeline needs permission to deploy to Azure. The most secure and modern method is Workload Identity Federation (also called OIDC). This method creates a trust relationship between GitHub or Azure DevOps and an Azure Service Principal — no secrets or passwords are stored in the pipeline.
Authentication Flow with OIDC
GitHub Actions / Azure DevOps
│
│ "I am pipeline X from repo Y"
▼
Azure Active Directory
│ Verifies the identity claim
│ against the registered federation
▼
Issues a short-lived access token
│
▼
Pipeline deploys to Azure
using the temporary token
(No stored secrets needed)
Alternative – Service Principal with Client Secret
For teams not yet using OIDC, a Service Principal with a client secret stored as a pipeline secret works as an alternative. This approach is less secure but widely used.
# Create a Service Principal and capture the credentials az ad sp create-for-rbac \ --name "bicep-pipeline-sp" \ --role Contributor \ --scopes /subscriptions/<subscriptionId> \ --json-auth
The output JSON becomes a secret stored in GitHub (as AZURE_CREDENTIALS) or Azure DevOps (as a service connection).
Pipeline 1 – GitHub Actions
GitHub Actions uses YAML workflow files stored inside .github/workflows/ in the repository. Each workflow file defines triggers, jobs, and steps.
Repository Structure for Bicep + GitHub Actions
project/ │ ├── .github/ │ └── workflows/ │ └── deploy-bicep.yml ← Pipeline definition │ ├── main.bicep ← Main template ├── modules/ │ ├── storage.bicep │ └── appservice.bicep │ ├── dev.bicepparam ← Dev parameters └── prod.bicepparam ← Prod parameters
GitHub Actions Workflow – Full Example
# .github/workflows/deploy-bicep.yml
name: Deploy Bicep Infrastructure
# Trigger on push to main branch or pull request
on:
push:
branches:
- main
pull_request:
branches:
- main
# Permissions required for OIDC authentication
permissions:
id-token: write
contents: read
env:
RESOURCE_GROUP: rg-myapp-prod
LOCATION: eastus
jobs:
# ── Job 1: Validate the Bicep file ──────────────────────────────────
validate:
name: Validate Bicep Template
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Login to Azure (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: Validate Bicep Template
uses: azure/cli@v2
with:
azcliversion: latest
inlineScript: |
az deployment group validate \
--resource-group ${{ env.RESOURCE_GROUP }} \
--template-file main.bicep \
--parameters prod.bicepparam
# ── Job 2: What-If Preview ──────────────────────────────────────────
preview:
name: Preview Changes (What-If)
runs-on: ubuntu-latest
needs: validate
if: github.event_name == 'pull_request'
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Login to Azure (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
uses: azure/cli@v2
with:
azcliversion: latest
inlineScript: |
az deployment group create \
--resource-group ${{ env.RESOURCE_GROUP }} \
--template-file main.bicep \
--parameters prod.bicepparam \
--what-if \
--what-if-result-format FullResourcePayloads
# ── Job 3: Deploy to Production ─────────────────────────────────────
deploy:
name: Deploy to Production
runs-on: ubuntu-latest
needs: validate
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment: production # requires manual approval if configured
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Login to Azure (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 Bicep Template
uses: azure/cli@v2
with:
azcliversion: latest
inlineScript: |
az deployment group create \
--resource-group ${{ env.RESOURCE_GROUP }} \
--template-file main.bicep \
--parameters prod.bicepparam \
--name "deploy-${{ github.run_number }}"
- name: Show Deployment Outputs
uses: azure/cli@v2
with:
azcliversion: latest
inlineScript: |
az deployment group show \
--resource-group ${{ env.RESOURCE_GROUP }} \
--name "deploy-${{ github.run_number }}" \
--query "properties.outputs" \
--output json
GitHub Actions – Pipeline Execution Flow
Pull Request Opened
│
▼
[validate job]
az deployment group validate
│ Pass
▼
[preview job]
az deployment group create --what-if
│ Results shown in PR comments
▼
PR Reviewed and Merged to main
│
▼
[deploy job]
az deployment group create
│
▼
Azure Resources Created / Updated
Required GitHub Secrets
| Secret Name | Value |
|---|---|
| AZURE_CLIENT_ID | Application (client) ID of the Service Principal |
| AZURE_TENANT_ID | Azure AD tenant ID |
| AZURE_SUBSCRIPTION_ID | Azure subscription ID to deploy into |
Pipeline 2 – Azure DevOps
Azure DevOps Pipelines use YAML files stored in the repository. Azure DevOps integrates natively with Azure through Service Connections, making authentication straightforward.
Azure DevOps Pipeline – Full Example
# azure-pipelines.yml
trigger:
branches:
include:
- main
pr:
branches:
include:
- main
variables:
resourceGroup: 'rg-myapp-prod'
location: 'eastus'
templateFile: 'main.bicep'
parameterFile: 'prod.bicepparam'
serviceConnection: 'azure-prod-service-connection'
stages:
# ── Stage 1: Validate ──────────────────────────────────────────────────
- stage: Validate
displayName: Validate Bicep Template
jobs:
- job: ValidateTemplate
displayName: Run az deployment validate
pool:
vmImage: ubuntu-latest
steps:
- task: AzureCLI@2
displayName: Validate Bicep
inputs:
azureSubscription: $(serviceConnection)
scriptType: bash
scriptLocation: inlineScript
inlineScript: |
az deployment group validate \
--resource-group $(resourceGroup) \
--template-file $(templateFile) \
--parameters $(parameterFile)
# ── Stage 2: What-If Preview ───────────────────────────────────────────
- stage: Preview
displayName: What-If Preview
dependsOn: Validate
condition: succeeded()
jobs:
- job: WhatIfPreview
displayName: Show What-If Changes
pool:
vmImage: ubuntu-latest
steps:
- task: AzureCLI@2
displayName: Run What-If
inputs:
azureSubscription: $(serviceConnection)
scriptType: bash
scriptLocation: inlineScript
inlineScript: |
az deployment group create \
--resource-group $(resourceGroup) \
--template-file $(templateFile) \
--parameters $(parameterFile) \
--what-if
# ── Stage 3: Deploy ────────────────────────────────────────────────────
- stage: Deploy
displayName: Deploy to Production
dependsOn: Preview
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
jobs:
- deployment: DeployInfrastructure
displayName: Deploy Bicep to Azure
environment: production # triggers approval gate if configured
pool:
vmImage: ubuntu-latest
strategy:
runOnce:
deploy:
steps:
- checkout: self
- task: AzureCLI@2
displayName: Deploy Bicep Template
inputs:
azureSubscription: $(serviceConnection)
scriptType: bash
scriptLocation: inlineScript
inlineScript: |
az deployment group create \
--resource-group $(resourceGroup) \
--template-file $(templateFile) \
--parameters $(parameterFile) \
--name "deploy-$(Build.BuildNumber)"
- task: AzureCLI@2
displayName: Show Deployment Outputs
inputs:
azureSubscription: $(serviceConnection)
scriptType: bash
scriptLocation: inlineScript
inlineScript: |
az deployment group show \
--resource-group $(resourceGroup) \
--name "deploy-$(Build.BuildNumber)" \
--query "properties.outputs" \
--output json
Multi-Environment Pipeline – Dev to Staging to Production
Production deployments should always pass through lower environments first. The pipeline below deploys to dev, then staging, then production — each stage waiting for the previous to succeed.
Branch Strategy and Pipeline Stages
feature-branch ──► PR ──► dev branch
│
├──[validate]──[what-if]──[deploy to dev]
│
merge ──► staging branch
│
├──[validate]──[what-if]──[deploy to staging]
│
manual approval ──► main branch
│
└──[validate]──[what-if]──[deploy to prod]
GitHub Actions Multi-Environment Example
# .github/workflows/multi-env-deploy.yml
name: Multi-Environment Bicep Deployment
on:
push:
branches: [dev, staging, main]
permissions:
id-token: write
contents: read
jobs:
# Deploy to Development
deploy-dev:
if: github.ref == 'refs/heads/dev'
runs-on: ubuntu-latest
environment: development
steps:
- uses: actions/checkout@v4
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- uses: azure/cli@v2
with:
azcliversion: latest
inlineScript: |
az deployment group create \
--resource-group rg-myapp-dev \
--template-file main.bicep \
--parameters dev.bicepparam
# Deploy to Staging
deploy-staging:
if: github.ref == 'refs/heads/staging'
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- uses: azure/cli@v2
with:
azcliversion: latest
inlineScript: |
az deployment group create \
--resource-group rg-myapp-staging \
--template-file main.bicep \
--parameters staging.bicepparam
# Deploy to Production (requires manual approval)
deploy-prod:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production # manual approval gate configured here
steps:
- uses: actions/checkout@v4
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- uses: azure/cli@v2
with:
azcliversion: latest
inlineScript: |
az deployment group create \
--resource-group rg-myapp-prod \
--template-file main.bicep \
--parameters prod.bicepparam
Pipeline Best Practices for Bicep
| Practice | Why It Matters |
|---|---|
| Always run validate before deploy | Catches syntax and parameter errors early |
| Run what-if on pull requests | Reviewers see exactly what will change before approving |
| Use OIDC instead of stored secrets | No credentials to rotate or accidentally expose |
| Name every deployment with a build number | Deployment history in Azure ties to specific pipeline runs |
| Require manual approval for production | Human check before infrastructure changes hit production |
| Store parameter files in the repo | Environment configuration is version-controlled and auditable |
| Never store secrets in parameter files | Secrets belong in Azure Key Vault, referenced by the template |
Summary
CI/CD pipelines automate Bicep deployments from code commit to Azure resource. GitHub Actions and Azure DevOps both support Bicep natively through Azure CLI tasks. OIDC authentication eliminates stored credentials. A well-structured pipeline runs validation, then what-if preview, then deployment — ensuring every change is verified before it touches any environment. Multi-environment pipelines promote changes from development through staging to production with approval gates protecting critical environments.
