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 NameValue
AZURE_CLIENT_IDApplication (client) ID of the Service Principal
AZURE_TENANT_IDAzure AD tenant ID
AZURE_SUBSCRIPTION_IDAzure 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

PracticeWhy It Matters
Always run validate before deployCatches syntax and parameter errors early
Run what-if on pull requestsReviewers see exactly what will change before approving
Use OIDC instead of stored secretsNo credentials to rotate or accidentally expose
Name every deployment with a build numberDeployment history in Azure ties to specific pipeline runs
Require manual approval for productionHuman check before infrastructure changes hit production
Store parameter files in the repoEnvironment configuration is version-controlled and auditable
Never store secrets in parameter filesSecrets 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.

Leave a Comment