CI/CD for Infrastructure
The idea in one sentence
Section titled “The idea in one sentence”A standard infrastructure pipeline runs plan automatically on every pull request for a human to review, and reserves apply for after merge to the main branch — using short-lived, OIDC-federated Azure credentials instead of a long-lived client secret stored in CI.
Plan on every pull request, apply only after merge
Section titled “Plan on every pull request, apply only after merge”The pattern that most teams converge on mirrors application CI/CD, with one important difference in what each stage actually does:
- Pull request opened or updated → run
terraform plan(orterragrunt run --all planfor a multi-unit Terragrunt setup) and post the resulting diff as a comment on the pull request. Nothing in Azure changes yet — this is a dry run whose only job is to make the proposed change visible to a reviewer before anyone approves it. - Merge to main → run
terraform apply(orterragrunt run --all apply) from the pipeline itself, not from anyone’s laptop. For production specifically, this step is frequently gated behind a manual approval — a named reviewer or team has to click “approve” in the CI system before the apply job is allowed to run, even though the plan was already reviewed at the pull-request stage.
name: terraform
on: pull_request: paths: - 'infra/**' push: branches: - main paths: - 'infra/**'
permissions: id-token: write contents: read pull-requests: write
jobs: plan: if: github.event_name == 'pull_request' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: azure/login@v3 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - uses: hashicorp/setup-terraform@v3 - run: terragrunt run --all plan -no-color | tee plan.txt - run: gh pr comment ${{ github.event.pull_request.number }} --body-file plan.txt env: GH_TOKEN: ${{ github.token }}
apply: if: github.event_name == 'push' runs-on: ubuntu-latest environment: production steps: - uses: actions/checkout@v4 - uses: azure/login@v3 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - uses: hashicorp/setup-terraform@v3 - run: terragrunt run --all apply --non-interactiveThe environment: production line is what supplies the manual approval gate — GitHub Actions holds the apply job until someone with permission on that environment approves the run. Notice that neither job ever checks out a stored client secret; both authenticate through OIDC, covered next.
Why a plan deserves sharper review than a typical code diff
Section titled “Why a plan deserves sharper review than a typical code diff”A pull request against application code is reviewed for logic, tests, and style — worst case, a bug ships and gets rolled back. An infrastructure apply can do something a code deploy cannot: destroy real, stateful Azure resources and rebuild them from scratch, sometimes because of a single one-line change.
Terraform calls this a forced replacement. Certain arguments on certain resources cannot be updated in place — changing them means the provider has no API call for “modify this attribute,” only “destroy and create a new one.” Renaming an azurerm_linux_virtual_machine, changing a storage account’s account_tier, or moving certain resources to a different location all fall into this category depending on the resource. When that happens, terraform plan marks the resource with -/+ instead of the in-place ~:
# terraform plan output — this is the line to stop and read carefully # azurerm_mssql_database.main must be replaced-/+ resource "azurerm_mssql_database" "main" { ~ name = "app-db" -> "app-db-renamed" # forces replacement sku_name = "S1" max_size_gb = 50 }A -/+ on a stateful resource like a database or a managed disk can mean real data loss or real downtime the moment someone clicks approve — the old resource is gone before the new one exists. This is exactly why reviewing an infrastructure plan cannot be a skim. A reviewer has to specifically scan for -/+ lines and ask whether that replacement is expected and safe, not just read the plan the way they would read a code diff for style and logic.
Short-lived OIDC credentials instead of a stored client secret
Section titled “Short-lived OIDC credentials instead of a stored client secret”Older pipelines stored a long-lived Azure AD application client secret as a CI secret and passed it to terraform login or the azurerm provider block on every run. That secret works for plan and apply equally well, which is exactly the problem: if it ever leaks — from a misconfigured log, a compromised dependency, or a workflow file that echoes environment variables — it keeps working until someone manually rotates or revokes it.
Current best practice replaces that secret with OIDC via federated credentials, sometimes called Workload Identity Federation for Azure AD. GitHub Actions can present a short-lived, cryptographically signed identity token to Azure AD for each workflow run. A federated credential configured on an Azure AD App Registration (or a user-assigned managed identity) trusts GitHub’s token issuer for a specific repository and branch. The azure/login action exchanges that token for temporary Azure credentials scoped to the assigned role, valid for the lifetime of the job and nothing beyond it.
permissions: id-token: write contents: read
jobs: plan: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4
- name: Log in to Azure via OIDC uses: azure/login@v3 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Terraform plan run: terraform planclient-id, tenant-id, and subscription-id are identifiers, not secrets — they only tell Azure AD which App Registration and subscription to use, and they are stored as GitHub Actions secrets purely for convenience and environment scoping, not because they are sensitive on their own. No Azure client secret ever needs to exist as a stored CI secret at all — there is nothing long-lived to leak, rotate, or forget about. permissions: id-token: write is what allows the workflow to request that identity token in the first place; without it, the OIDC exchange has nothing to present to Azure AD.
flowchart LR pr["Pull request"] -->|triggers| plan["terragrunt run --all plan"] plan -->|posted as| comment["PR comment for review"] comment -->|approved and merged| main["Merge to main"] main -->|triggers, gated by approval| apply["terragrunt run --all apply"] oidc["Azure AD federated credential"] -->|short-lived credentials| plan oidc -->|short-lived credentials| apply