Cloud Tech

How to Build a Zero-Downtime Terraform Pipeline with GitHub Actions

Problem this article addresses

Build a guarded Terraform and GitHub Actions deployment pipeline with OIDC, concurrency, saved plans, approvals, rolling changes, verification, and rollback.

Published Aug 17, 2026Victor NwokeReviewed Aug 18, 202610 min read

Technical claims are reviewed against the cited primary sources. Hands-on guides include execution or diagnostic evidence when the article makes a tested-result claim.

Terraform plus GitHub Actions cannot guarantee zero downtime.

Terraform controls infrastructure intent. Availability during a change also depends on the Azure service, application health, capacity, upgrade mode, database compatibility, traffic management, and rollback design.

What a strong pipeline can guarantee is a controlled path: one writer, short-lived identity, reviewed plans, protected approval, rolling-compatible infrastructure, post-deployment verification, and a clear stop condition.

The deployment contract

Before writing YAML, define the service's availability strategy. A VMSS might use rolling upgrades and load-balancer health probes. An App Service might use deployment slots. AKS workloads need readiness probes, disruption budgets, and a rolling deployment strategy.

The existing Azure deployment slots guide shows the application-side pattern.

Configure GitHub OIDC for Azure

GitHub's OIDC flow lets the workflow request a short-lived token instead of storing a client secret. I create a Microsoft Entra application or managed identity, add a federated credential constrained to the repository and environment, then grant the narrow Azure role required by the root.

The workflow needs:

yaml
permissions:
  contents: read
  id-token: write

id-token: write lets the job request an OIDC token. It does not itself grant Azure permission; the Azure federated credential and RBAC assignment make the trust decision.

Prevent concurrent production applies

yaml
concurrency:
  group: terraform-production
  cancel-in-progress: false

I do not cancel an in-progress apply simply because a newer commit arrived. Cancellation can interrupt the client while the cloud operation continues. Let the current deployment finish, then plan the next commit against the resulting state.

Terraform backend locking remains required. GitHub concurrency controls workflow scheduling; backend locking protects the state writer.

A production workflow skeleton

This readable example uses major action tags. For a hardened repository, pin third-party actions to reviewed full commit SHAs and use dependency update automation.

yaml
name: terraform-production

on:
  push:
    branches: [main]
    paths: ['infrastructure/production/**']
  workflow_dispatch:

permissions:
  contents: read
  id-token: write

concurrency:
  group: terraform-production
  cancel-in-progress: false

env:
  TF_IN_AUTOMATION: 'true'
  TF_INPUT: 'false'
  TF_WORKING_DIR: infrastructure/production

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    defaults:
      run:
        working-directory: ${{ env.TF_WORKING_DIR }}

    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
        with:
          terraform_wrapper: false

      - uses: azure/login@f5d393ae46f8fde4be8b75f32e3fc50e654ad0ca # v3.0.1
        with:
          client-id: ${{ vars.AZURE_CLIENT_ID }}
          tenant-id: ${{ vars.AZURE_TENANT_ID }}
          subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}

      - run: terraform fmt -check -recursive
      - run: terraform init
      - run: terraform validate
      - run: terraform test
      - run: terraform plan -out=tfplan
      - run: terraform apply -auto-approve tfplan
      - run: ./scripts/verify-production.sh

The protected production environment should restrict deployment branches and require appropriate reviewers. This skeleton creates and applies the saved plan in one approved job, avoiding a broadly shared plan artifact.

For a separate plan-approval-apply design, store the plan only in a restricted artifact path with minimal retention, bind it to the commit and root, and treat it as sensitive. I never let an apply job accept an arbitrary artifact uploaded by an untrusted workflow.

Make the plan reviewable

At minimum, surface:

  • I add/change/destroy counts
  • Every replacement and delete
  • Identity, firewall, route, encryption, and state changes
  • Provider and module upgrades
  • The commit SHA, root path, backend key, and target subscription

I do not paste an unredacted JSON plan into a public pull-request comment. Plans can contain sensitive data.

Encode rolling-compatible infrastructure

create_before_destroy can help only when two copies can coexist:

hcl
lifecycle {
  create_before_destroy = true
}

I check names, quotas, attachments, data migration, and traffic cutover. For VMSS, configure an Azure rolling upgrade strategy and enough healthy capacity. For load-balanced compute, a real health probe must remove an unready instance before traffic reaches it.

Zero-downtime schema changes require expand-and-contract application/database migrations. Terraform cannot make an incompatible database change safe by sequencing resources differently.

Verify after apply

terraform apply success proves the provider operations completed. It does not prove users can complete a request.

I run layered verification:

bash
curl --fail --retry 5 --retry-delay 10 https://example.com/healthz

Also check:

  • Backend/target health in the load balancer
  • Error rate, latency, and saturation
  • New instance readiness
  • Logs for startup and authorization failures
  • A synthetic user journey where appropriate

Define a fixed observation window and a named decision maker.

Rollback is a new forward plan

Terraform does not provide a universal rollback command. Reverting configuration and applying creates another plan against current reality.

For each service, document:

  • How traffic returns to the last healthy deployment
  • Whether old compute remains available
  • Whether data changes are backward compatible
  • Which Terraform commit represents the intended infrastructure
  • What state and cloud operations already completed

I do not restore old state merely to “roll back” resources. State restoration changes Terraform's record; it does not undo the real cloud operation.

Pipeline security checklist

  • OIDC replaces stored cloud client secrets
  • Production uses a dedicated protected environment
  • Actions are pinned to reviewed commits
  • Concurrency and backend locking prevent multiple writers
  • The applied plan is the reviewed plan
  • Plans and logs are treated as potentially sensitive
  • Rolling capacity and health checks are tested
  • Post-deployment verification reflects user experience
  • Rollback and stop conditions are documented

I use the GitHub Actions Generator to learn workflow structure, then harden the result with the controls above. Pair it with the secure CI/CD reference.

Reproducible evidence lab: GitHub Actions plan and approval

I use a disposable directory and non-production Azure subscription when the lab reaches Azure. The proof files may contain resource IDs or plan values, so inspect them before sharing.

Create the evidence workspace

bash
export TF_EVIDENCE_ROOT="${PWD}/.evidence/terraform-campaign"
export TF_EVIDENCE_SLUG="zero-downtime-terraform-github-actions-pipeline"
export TF_EVIDENCE_DIR="$TF_EVIDENCE_ROOT/$TF_EVIDENCE_SLUG"
mkdir -p "$TF_EVIDENCE_DIR/proof"
cd "$TF_EVIDENCE_DIR"
date -u +"%Y-%m-%dT%H:%M:%SZ" | tee proof/run-started-utc.txt
terraform version | tee proof/terraform-version.txt

Beginner walkthrough

  1. I create a private disposable GitHub repository and clone it locally. I do not use the website repository or a production infrastructure repository.
  2. I start a new evidence session with TF_EVIDENCE_SLUG="zero-downtime-terraform-github-actions-pipeline" and copy its main.tf into the disposable repository.
  3. In GitHub, open Settings, then Environments, select New environment, and name it production-evidence. I add a required reviewer and restrict deployment branches when the account plan supports those controls.
  4. I create the Entra application, service principal, resource group scope, Reader assignment, and federated credential with the commands below. Replace OWNER/REPOSITORY before creating the JSON.
  5. I add the three GitHub environment variables. Confirm them under the environment's Variables list. I do not add an Azure client secret.
  6. I create .github/workflows/terraform-evidence.yml with this minimum workflow. The comments record the release tag represented by each immutable commit SHA. Recheck the official releases before publishing the article:
yaml
name: Terraform evidence

on:
  workflow_dispatch:

permissions: {}

concurrency:
  group: terraform-production-evidence
  cancel-in-progress: false

jobs:
  plan:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
      - run: terraform fmt -check
      - run: terraform init -backend=false -input=false
      - run: terraform validate
      - run: terraform plan -out=tfplan -input=false
      - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: tfplan
          path: tfplan
          if-no-files-found: error

  apply:
    needs: plan
    runs-on: ubuntu-latest
    environment: production-evidence
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: azure/login@f5d393ae46f8fde4be8b75f32e3fc50e654ad0ca # v3.0.1
        with:
          client-id: ${{ vars.AZURE_CLIENT_ID }}
          tenant-id: ${{ vars.AZURE_TENANT_ID }}
          subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
      - uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
      - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: tfplan
      - run: az account show --query "{name:name,isDefault:isDefault}" --output table
      - run: terraform init -backend=false -input=false
      - run: terraform apply -input=false tfplan
      - run: test "$(terraform output -raw release)" = "evidence"
  1. Commit and push, open Actions, select the workflow, and choose Run workflow.
  2. Wait for plan to pass. The apply job should show a review wait. I capture that screen before approving it.
  3. I start a second run while the first apply is active. I capture the queued or waiting state caused by the shared concurrency group.
  4. Approve only the disposable environment, let both runs finish, and capture the job graph plus OIDC login step. I never open or capture token details.
  5. Delete the environment variables, federated credential, role assignment, resource group, service principal, and application using the cleanup commands.

If OIDC login fails, compare the federated credential subject exactly with repo:OWNER/REPOSITORY:environment:production-evidence. Case, owner, repository, and environment must match.

I create a dedicated Microsoft Entra application and service principal. Scope its role to the disposable resource group, not the subscription:

bash
export TF_GITHUB_APP_NAME="github-terraform-evidence"
export TF_GITHUB_REPO="OWNER/REPOSITORY"
export TF_GITHUB_ENVIRONMENT="production-evidence"
export TF_GITHUB_APP_ID="$(az ad app create --display-name "$TF_GITHUB_APP_NAME" --query appId -o tsv)"
export TF_GITHUB_APP_OBJECT_ID="$(az ad app show --id "$TF_GITHUB_APP_ID" --query id -o tsv)"
az ad sp create --id "$TF_GITHUB_APP_ID"
export TF_GITHUB_SP_OBJECT_ID="$(az ad sp show --id "$TF_GITHUB_APP_ID" --query id -o tsv)"
export TF_GITHUB_TENANT_ID="$(az account show --query tenantId -o tsv)"
export TF_GITHUB_SUBSCRIPTION_ID="$(az account show --query id -o tsv)"

az group create --name rg-github-terraform-evidence --location uksouth
export TF_GITHUB_SCOPE="$(az group show --name rg-github-terraform-evidence --query id -o tsv)"
az role assignment create --assignee-object-id "$TF_GITHUB_SP_OBJECT_ID" --assignee-principal-type ServicePrincipal --role Reader --scope "$TF_GITHUB_SCOPE"

I create credential.json locally and insert the repository and environment names:

json
{
  "name": "github-production-evidence",
  "issuer": "https://token.actions.githubusercontent.com",
  "subject": "repo:OWNER/REPOSITORY:environment:production-evidence",
  "description": "Disposable Terraform evidence lab",
  "audiences": ["api://AzureADTokenExchange"]
}

I create the federated credential:

bash
az ad app federated-credential create --id "$TF_GITHUB_APP_OBJECT_ID" --parameters credential.json

First create the production-evidence environment under Settings, Environments. I add a required reviewer, prevent self-review when the account plan supports it, and restrict deployment branches.

With GitHub CLI authenticated for the test repository, store identifiers as environment variables, not secrets:

bash
gh variable set AZURE_CLIENT_ID --repo "$TF_GITHUB_REPO" --env "$TF_GITHUB_ENVIRONMENT" --body "$TF_GITHUB_APP_ID"
gh variable set AZURE_TENANT_ID --repo "$TF_GITHUB_REPO" --env "$TF_GITHUB_ENVIRONMENT" --body "$TF_GITHUB_TENANT_ID"
gh variable set AZURE_SUBSCRIPTION_ID --repo "$TF_GITHUB_REPO" --env "$TF_GITHUB_ENVIRONMENT" --body "$TF_GITHUB_SUBSCRIPTION_ID"

I use the workflow from the article with id-token: write, contents: read, the environment name, and a production concurrency group. For this evidence lab, replace the environment name with production-evidence and use a local terraform_data resource. The job should perform Azure login, run az account show with IDs excluded, create and apply a saved local plan, then verify a Terraform output. It must not create an Azure workload.

The lab root can use:

hcl
terraform {
  required_version = ">= 1.10.0"
}

resource "terraform_data" "release" {
  input = "evidence"
}

output "release" {
  value = terraform_data.release.output
}

Pin third-party actions to reviewed commit SHAs before publication. The official GitHub OIDC guide shows the required id-token: write permission and Azure login exchange.

I run the workflow twice. The proof set must show:

  1. The plan job succeeded.
  2. The apply job waited for environment approval.
  3. A second run did not execute a concurrent production apply.
  4. Azure login used OIDC and no client secret exists in repository settings.

I capture the GitHub Actions job graph and approval screen with repository ownership details redacted. I do not capture the OIDC token.

Delete the test environment or its rules, then clean up Azure and Entra resources:

bash
az role assignment delete --assignee-object-id "$TF_GITHUB_SP_OBJECT_ID" --scope "$TF_GITHUB_SCOPE" --role Reader
az group delete --name rg-github-terraform-evidence --yes --no-wait
az ad app federated-credential delete --id "$TF_GITHUB_APP_OBJECT_ID" --federated-credential-id github-production-evidence
az ad sp delete --id "$TF_GITHUB_APP_ID"
az ad app delete --id "$TF_GITHUB_APP_ID"
rm -f credential.json

The honest zero-downtime claim

A trustworthy headline is not “this YAML guarantees zero downtime.” It is: “this pipeline removes avoidable deployment hazards and only deploys through an architecture designed to remain available.”

Prove that claim with failure tests: remove an instance during deployment, make one health check fail, exhaust a safe staging quota, and confirm the workflow stops without sending users to an unhealthy target.

ShareXLinkedInReddit
Was this page helpful?
Suggest an improvement