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@v7 - 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@v4 - 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@v7 - 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@v4 - 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@v7
- 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.
Per GitHub’s own docs (docs-sourced, not run — no Azure tenant exists to create this in), the three setup steps are: “1. Create an Entra ID application and a service principal. 2. Add federated credentials for the Entra ID application. 3. Create GitHub secrets for storing Azure configuration.” — docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-azure. Step 2 is the federated credential itself: it binds the App Registration to a subject claim scoped to a specific repository, environment, or branch (e.g. repo:my-org/my-infra:ref:refs/heads/main), the Azure-side equivalent of the sub condition AWS’s IAM trust policy checks.
Linting the workflow itself with actionlint
Section titled “Linting the workflow itself with actionlint”The workflow YAML above is proven by running actionlint (rhysd/actionlint) against it in a one-shot container — the same tool a pre-commit hook or a separate CI job would run on every workflow change:
$ docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:latest -colorReal output against both workflows in this lesson (the plan/apply pipeline and the drift-detection job below): no output at all, and a 0 exit code — actionlint prints nothing when it finds no problems, which is itself the proof there are no undefined ${{ }} expressions, invalid runs-on values, unpinned uses: refs missing a version, or shellcheck-level issues in the embedded run: scripts.
Applying the exact plan that was reviewed, not a fresh one
Section titled “Applying the exact plan that was reviewed, not a fresh one”A subtle gap in the two-job pipeline above: the apply job runs terragrunt run --all apply --non-interactive, which computes its own plan at apply time. If anything changed between the PR being reviewed and the merge landing — a teammate’s parallel merge, a provider registry update, drift in the subscription — the plan a human approved and the plan that actually gets applied are not provably the same plan. terragrunt run --all plan --out-dir <dir> (confirmed via terragrunt run --help: --out-dir value Directory to store plan files.) closes that gap by writing one saved plan file per unit, which terragrunt run --all apply --out-dir <dir> then applies directly instead of re-planning:
$ terragrunt run --all plan --out-dir plan-output --non-interactive...Saved the plan to:.../plan-output/tfplan.tfplanTo perform exactly these actions, run the following command to apply: terraform apply ".../plan-output/tfplan.tfplan"
❯❯ Run Summary 1 units Succeeded 1
$ terragrunt run --all apply --out-dir plan-output --non-interactivelocal_file.config: Creating...local_file.config: Creation complete after 0s [id=f572d396fae9206628714fb2ce00f72e94f2258f]Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
❯❯ Run Summary 1 units Succeeded 1Real Terragrunt 1.1.0 output, proven locally against a local backend (this mechanism is Terragrunt-core, identical regardless of which cloud’s units it is driving): the second command applied the file the first command saved — no re-plan, no prompt. In CI, the plan job uploads plan-output/ as a build artifact keyed to the commit SHA, and the apply job downloads that same artifact before running apply --out-dir, so the thing that gets applied is provably the thing a reviewer read.
Gating apply with -detailed-exitcode, and a scheduled drift check
Section titled “Gating apply with -detailed-exitcode, and a scheduled drift check”terraform plan -detailed-exitcode (terraform plan -help: “Return detailed exit codes when the command exits… 0 - Succeeded, diff is empty (no changes)”) changes exit code 0 to mean no changes, 1 to mean an error, and 2 to mean changes present. A pipeline step can use that to skip an unnecessary apply entirely, or — run on a schedule instead of on push — to detect drift with no apply step at all. All three exit codes proven for real below — 1 against a real azurerm provider block (no Azure login exists in this sandbox, so it is exactly the failure a real offline azurerm plan produces), 0 and 2 against a cloud-agnostic local_file resource (-detailed-exitcode’s exit-code mechanics are Terraform-core, not provider-specific):
# 1 — a real azurerm error: no Azure CLI/OIDC session exists in this sandbox$ terraform plan -input=false -detailed-exitcodePlanning failed. Terraform encountered an error while generating this plan.╷│ Error: unable to build authorizer for Resource Manager API: could not│ configure AzureCli Authorizer: could not parse Azure CLI version: launching│ Azure CLI: exec: "az": executable file not found in $PATH││ with provider["registry.terraform.io/hashicorp/azurerm"],│ on main.tf line 10, in provider "azurerm":╵$ echo $?1
# 0 — no changes (local_file already applied once)$ terraform plan -input=false -detailed-exitcode...Terraform has compared your real infrastructure against your configurationand found no differences, so no changes are needed.$ echo $?0
# 2 — changes present (local_file content edited — forces replacement)$ terraform plan -input=false -out=tfplan -detailed-exitcode...Plan: 1 to add, 0 to change, 1 to destroy.Saved the plan to: tfplan$ echo $?2Three separate real runs, three different real exit codes. The 1 case is the honest answer to “what does a CI drift job see if Azure auth itself is broken”: a real azurerm provider, given no CLI session and no federated-credential token (this sandbox has neither), fails at provider configuration before it ever reaches a resource — the workflow below distinguishes that (1, a broken check) from real drift (2) for exactly this reason.
terraform show -json of a saved plan file
Section titled “terraform show -json of a saved plan file”The saved tfplan from the 2 case above is a binary file — terraform show -json tfplan renders it as the same documented, versioned JSON schema the-state-file.mdx uses for state (format_version, separate from state’s own version field), except resource_changes[].change.actions describes a proposed change instead of an applied one. Real output (trimmed):
{ "format_version": "1.2", "terraform_version": "1.16.4", "resource_changes": [ { "address": "local_file.config", "change": { "actions": ["delete", "create"] } } ]}["delete", "create"] is the JSON form of the -/+ forced-replacement marker from earlier in this lesson — editing content forces local_file to replace rather than update in place, and change.actions is exactly the array a policy check (policy-as-code-and-static-analysis.mdx) would inspect to flag or block that kind of replacement on a stateful azurerm resource before apply ever runs.
name: drift-detection
on: schedule: - cron: '0 6 * * *' # every day at 06:00 UTC workflow_dispatch:
permissions: id-token: write contents: read
jobs: drift: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - 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@v4 - name: Check for drift id: plan run: | set +e terragrunt run --all plan -detailed-exitcode -no-color echo "exitcode=$?" >> "$GITHUB_OUTPUT" - name: Fail if drift detected if: steps.plan.outputs.exitcode == '2' run: | echo "::error::Drift detected — real infrastructure no longer matches configuration" exit 1This job never runs apply — its only job is to fail loudly (and, in a real setup, page someone or post to a chat channel) the moment plan’s exit code says the subscription has drifted from what’s committed, catching drift on a schedule instead of only at the next deliberate apply.
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
Mental Model
Section titled “Mental Model”An infrastructure pipeline has exactly two jobs, and confusing them is the root cause of most incidents: compute a plan for a human (fast, safe, read-only against Azure — nothing changes) and execute an already-reviewed plan (the only step allowed to touch real resources, and only after both automated gates — -detailed-exitcode, policy checks — and a human gate have passed). Treat every other mechanism in this lesson as serving one of those two jobs: OIDC federated credentials exist so the execute job never needs a standing client secret; --out-dir exists so execute runs the exact bytes compute produced instead of re-deriving them; the scheduled drift job is the compute job running with no execute job behind it at all, purely to surface when reality has quietly diverged from the plan.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
| OIDC federated credentials instead of a stored client secret | No long-lived Azure AD client secret exists anywhere to leak or forget to rotate | Requires one-time federated-credential setup on the App Registration per repository/branch, and only works from a CI system that can present an OIDC token |
--out-dir plan/apply artifact reuse | Apply is provably the plan a human reviewed, not a fresh re-plan | An extra artifact-upload/download round trip between the plan job and the apply job, and the artifact must be treated as sensitive (a plan can contain values a data source resolved) |
-detailed-exitcode gating | Skips a no-op apply, and turns a scheduled plan into a drift alarm with no apply step at all | Exit code 1 (a real error) and exit code 2 (real changes) both need explicit handling — treating any non-zero exit as “changes” silently swallows real plan errors |
Manual approval environment: production gate | A named human must explicitly approve before any production resource is touched, even after every automated check passes | Adds latency to every production change, and is only as good as the reviewers actually reading the plan rather than reflexively clicking approve |
Common Misconceptions
Section titled “Common Misconceptions”- “Running
applyagain in the apply job re-produces the same plan a reviewer saw on the pull request.” Proven above: without--out-dir, the apply job computes an independent plan at apply time — if anything changed in the subscription or the module registry between review and merge, that plan can differ from the one a human actually read. - “
-detailed-exitcodereturning non-zero always means something is wrong.” It returns2for a perfectly healthy “there are changes to apply” and1only for a genuine error — treating both the same way in a script either blocks a completely normal deploy or, worse, silently ignores a real failure. - “OIDC removes the need for an Azure AD App Registration.” It removes the need for a long-lived client secret — a real App Registration with a federated credential scoped to the right repository and branch is still required and is the actual authorization boundary.
- “A scheduled drift-detection job needs an
applystep to be useful.” The job proven above never applies anything — its entire value isplan -detailed-exitcodefailing loudly on a schedule, which catches drift long before the next deliberate change would have surfaced it.
Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing an AI-generated CI/CD workflow for Terraform/Terragrunt on Azure, check for:
- A stored Azure AD
client secretused withaz login --service-principalinstead ofazure/loginwithclient-id/tenant-id/subscription-idand noclient-secretinput — the giveaway of a long-lived secret that should be a federated credential instead. - An
applyjob with noenvironment:(or equivalent required-reviewers gate) on the job — nothing stops a merged PR from applying to production unattended. terraform apply(no-input=false, no saved plan file) run directly in CI instead of applying a plan artifact produced by an earlierplanstep — this is exactly the gap the--out-dirproof above closes.- A drift-detection or CI
planstep that checksif: failure()instead of a captured-detailed-exitcodevalue —failure()cannot distinguish “there are changes” (exit2) from “plan actually errored” (exit1). - Missing
permissions: id-token: writeon a workflow that otherwise looks like it uses OIDC — without it, the token exchange has nothing to present and the job fails at theazure/loginstep, not silently.
🐛 Spot the bug
- name: Check for driftid: planrun: terragrunt run --all plan -detailed-exitcode -no-colorcontinue-on-error: true- name: Fail if drift detectedif: steps.plan.outcome == 'failure'run: exit 1Show the answer
-detailed-exitcode makes the step’s shell command exit non-zero for two different reasons: exit code 2 means real changes exist (the case this job wants to catch), but exit code 1 means the plan itself failed — a bad credential, a syntax error, a provider timeout. Both make continue-on-error: true mark steps.plan.outcome as 'failure', so this workflow cannot tell “infrastructure drifted” apart from “the drift check itself is broken,” and a broken check gets reported as drift every single day. The fix, proven above, is to capture the literal exit code with echo "exitcode=$?" >> "$GITHUB_OUTPUT" under set +e (no continue-on-error needed at all) and branch on steps.plan.outputs.exitcode == '2' specifically.