Skip to main content

CI/CD Pipeline

All production changes must go through the CI/CD pipeline. Developer machines may run apply only against personal sandbox environments.

Validations

Run the following before every commit:

terraform fmt -recursive
terraform validate
tflint

Automate both in a pre-commit hook and in CI.

Drift Detection

Schedule a regular terraform plan run (e.g. nightly) against each environment and alert on any drift. Unplanned drift is a signal of out-of-band changes that need to be reconciled.

Core Workflow

Every Terraform change follows a strict plan-then-apply flow. The plan output is saved as an artifact and the apply step uses only that artifact, ensuring what was reviewed is exactly what gets applied.

Pull Request opened


terraform fmt --check
terraform validate
tflint
terraform plan -out=tfplan ← plan posted as PR comment


PR approved & merged to main


terraform apply tfplan ← uses the saved plan artifact

GitHub Actions

The following workflow runs plan on every pull request. A merged PR to main is required before apply runs. It uses OIDC authentication (no long-lived secrets stored in GitHub).

# .github/workflows/terraform.yml
name: Terraform

on:
pull_request:
branches: [main]

permissions:
id-token: write # required for OIDC
contents: read
pull-requests: write

env:
TF_VERSION: "1.15.3"
WORKING_DIR: environments/prod

jobs:
plan:
name: Plan
runs-on: ubuntu-latest
defaults:
run:
working-directory: ${{ env.WORKING_DIR }}

steps:
- uses: actions/checkout@v4

- uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}

- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1

- name: Terraform Init
run: terraform init

- name: Terraform Format Check
run: terraform fmt -check -recursive

- name: Terraform Validate
run: terraform validate

- name: TFLint
uses: terraform-linters/setup-tflint@v4
with:
tflint_version: latest
- run: tflint --init && tflint

- name: Terraform Plan
id: plan
run: terraform plan -out=tfplan -no-color 2>&1 | tee plan.txt

- name: Post plan to PR
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const plan = fs.readFileSync('${{ env.WORKING_DIR }}/plan.txt', 'utf8');
const body = `### Terraform Plan\n\`\`\`\n${plan.slice(0, 65000)}\n\`\`\``;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body,
});

- name: Upload plan artifact
uses: actions/upload-artifact@v4
with:
name: tfplan
path: ${{ env.WORKING_DIR }}/tfplan
retention-days: 7

apply:
name: Apply
runs-on: ubuntu-latest
needs: plan
if: github.event_name == 'pull_request' && github.event.pull_request.merged == true
defaults:
run:
working-directory: ${{ env.WORKING_DIR }}

steps:
- uses: actions/checkout@v4

- uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}

- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1

- name: Terraform Init
run: terraform init

- name: Download plan artifact
uses: actions/download-artifact@v4
with:
name: tfplan
path: ${{ env.WORKING_DIR }}

- name: Terraform Apply
run: terraform apply -auto-approve tfplan

Multi-Environment

For multiple environments (dev, staging, prod), use a promotion model: changes flow through each environment in sequence with a mandatory approval gate before reaching production.

main branch push


Deploy → dev (automatic, no approval)


Deploy → staging (automatic after dev succeeds)


Manual approval ──── GitHub Environment protection rule


Deploy → prod

Each environment maps to its own workflow job with a separate working directory and IAM role. GitHub Environments provide the approval gates.

# .github/workflows/terraform-multi-env.yml
name: Terraform Multi-Environment

on:
pull_request:
branches: [main]

permissions:
id-token: write
contents: read

jobs:
deploy-dev:
name: Deploy (dev)
runs-on: ubuntu-latest
environment: dev
env:
WORKING_DIR: environments/dev
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.15.3"
- name: Configure credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.DEV_AWS_ROLE_ARN }}
aws-region: us-east-1
- run: terraform init
working-directory: ${{ env.WORKING_DIR }}
- run: terraform apply -auto-approve
working-directory: ${{ env.WORKING_DIR }}

deploy-staging:
name: Deploy (staging)
runs-on: ubuntu-latest
needs: deploy-dev
environment: staging
env:
WORKING_DIR: environments/staging
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.15.3"
- name: Configure credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.STAGING_AWS_ROLE_ARN }}
aws-region: us-east-1
- run: terraform init
working-directory: ${{ env.WORKING_DIR }}
- run: terraform apply -auto-approve
working-directory: ${{ env.WORKING_DIR }}

deploy-prod:
name: Deploy (prod)
runs-on: ubuntu-latest
needs: deploy-staging
environment: prod # ← requires manual approval in GitHub
env:
WORKING_DIR: environments/prod
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.15.3"
- name: Configure credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.PROD_AWS_ROLE_ARN }}
aws-region: us-east-1
- run: terraform init
working-directory: ${{ env.WORKING_DIR }}
- run: terraform apply -auto-approve
working-directory: ${{ env.WORKING_DIR }}

GitHub Environment configuration (set in repository Settings > Environments):

EnvironmentRequired reviewersWait timerDeployment branches
devNoneNonemain
stagingNoneNonemain
prod1+ approverOptional (e.g. 5 min)main only

OIDC Authentication

Never store cloud credentials as long-lived GitHub secrets. Use OpenID Connect (OIDC) so GitHub Actions can assume a scoped IAM role directly.

AWS

IAM role trust policy:

{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:my-org/infrastructure:*"
}
}
}

Each environment (dev, staging, prod) must have its own IAM role with the minimum permissions needed to apply that environment's Terraform code.

Azure

Create an App Registration in Entra ID and add a federated credential instead of storing a client secret.

Federated credential settings (set in App Registration > Certificates & secrets > Federated credentials):

FieldValue
Issuerhttps://token.actions.githubusercontent.com
Subject identifierrepo:my-org/infrastructure:environment:prod
Audienceapi://AzureADTokenExchange

Use environment:* to scope to a specific GitHub Environment, or ref:refs/heads/main to scope to a branch. Never use repo:my-org/infrastructure:* (too broad) for production roles.

In the workflow, authenticate with the federated credential:

- name: Configure Azure credentials (OIDC)
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

Assign the App Registration's service principal the minimum Azure RBAC role required (e.g. Contributor scoped to the target resource group, not the entire subscription). Each environment must use a separate App Registration.