Quick answer: A continuous deployment pipeline for Terraform runs fmt, validate, a security scan and plan on every pull request, posts the plan for review, and on merge to main applies that exact plan to the target environment β with a manual approval gate for production. The pipeline authenticates to the cloud with short-lived OIDC credentials, stores state in a locked remote backend, and promotes the same code through dev, staging and production.
This step-by-step guide builds that pipeline from an empty repository to a working GitHub Actions workflow that deploys to AWS. You will learn how to structure the repository, configure remote state, write the workflow, add environment promotion and approval gates, and sidestep the mistakes that make infrastructure pipelines flaky.
What “continuous deployment” means for infrastructure
With application code, CD means every merged commit reaches production automatically. With infrastructure, most teams practise a slightly softer version: every commit is automatically planned, non-production environments are automatically applied, and production apply waits for a human to approve the reviewed plan. The pipeline still does all the work; the human simply confirms that the proposed change matches the intent.
The result is that nobody runs terraform apply from a laptop against shared environments. Every change is reviewed, logged and reproducible. If you are new to the tool, read How to Write Terraform Code: A Beginner’s Guide before building a pipeline around it.
Step 1: Structure the repository
Keep reusable modules separate from the root modules that represent deployable environments:
infra/
βββ modules/
β βββ network/ # VPC, subnets, routing
β βββ web/ # ALB, ASG, security groups
βββ envs/
β βββ dev/
β β βββ main.tf # calls modules with dev sizes
β β βββ backend.tf
β β βββ terraform.tfvars
β βββ staging/
β βββ prod/
βββ .github/workflows/terraform.yml
Each envs/<name> directory is an independent root module with its own state file. Promoting a change means merging the same module version into the next environment’s main.tf, which keeps dev and prod from diverging silently.
Step 2: Configure remote state and OIDC authentication
The pipeline needs two things before it can run: a place to store state and a way to authenticate without stored keys. On AWS, create an S3 bucket for state and an IAM role that trusts GitHub’s OIDC provider. Then each environment points at its own state key:
# envs/prod/backend.tf
terraform {
required_version = ">= 1.9.0"
backend "s3" {
bucket = "tkh-tfstate-prod"
key = "web/terraform.tfstate"
region = "ap-south-1"
encrypt = true
use_lockfile = true
}
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.60"
}
}
}
provider "aws" {
region = "ap-south-1"
default_tags {
tags = { ManagedBy = "terraform", Env = "prod" }
}
}
The IAM role’s trust policy should restrict which repository and branch can assume it (for example only main for the production role). This is what makes the pipeline more secure than a developer laptop: the production role can only be used from reviewed code.
Step 3: Write the pipeline
The workflow below runs on pull requests and on pushes to main. It plans on every PR, comments the plan, and applies on merge. The environment: key ties the apply job to a GitHub Environment where you configure required reviewers.
# .github/workflows/terraform.yml
name: terraform-cd
on:
pull_request:
paths: ["infra/**"]
push:
branches: [main]
paths: ["infra/**"]
permissions:
id-token: write
contents: read
pull-requests: write
env:
TF_IN_AUTOMATION: "1"
WORKDIR: infra/envs/prod
jobs:
plan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with: { terraform_version: "1.9.5" }
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/gha-terraform-prod
aws-region: ap-south-1
- run: terraform -chdir=$WORKDIR init -input=false
- run: terraform -chdir=$WORKDIR fmt -check -recursive
- run: terraform -chdir=$WORKDIR validate
- uses: aquasecurity/trivy-action@master
with: { scan-type: config, scan-ref: infra, exit-code: "1", severity: HIGH,CRITICAL }
- run: terraform -chdir=$WORKDIR plan -input=false -out=tfplan
- run: terraform -chdir=$WORKDIR show -no-color tfplan > plan.txt
- uses: actions/upload-artifact@v4
with: { name: tfplan, path: "${{ env.WORKDIR }}/tfplan" }
- if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const plan = fs.readFileSync('plan.txt', 'utf8').slice(0, 60000);
github.rest.issues.createComment({
...context.repo, issue_number: context.issue.number,
body: '### Terraform plan\n```\n' + plan + '\n```'
});
apply:
needs: plan
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production # required reviewers configured here
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with: { terraform_version: "1.9.5" }
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/gha-terraform-prod
aws-region: ap-south-1
- uses: actions/download-artifact@v4
with: { name: tfplan, path: ${{ env.WORKDIR }} }
- run: terraform -chdir=$WORKDIR init -input=false
- run: terraform -chdir=$WORKDIR apply -input=false tfplan
Two details matter most. First, the apply job downloads the same plan artifact that reviewers saw, so what was approved is what runs. Second, the production environment pauses the job until a designated reviewer approves it in the GitHub UI.
Step 4: Promote through environments
Use a matrix or separate jobs to run dev automatically on every merge, staging after dev succeeds, and production after approval. A simple promotion table:
| Environment | Trigger | Apply | Gate |
|---|---|---|---|
| dev | Merge to main |
Automatic | Plan passed, scans passed |
| staging | dev apply succeeded | Automatic | Smoke tests on dev passed |
| prod | staging apply succeeded | After approval | Required reviewer, change window |
Add a post-apply smoke test β a curl against the load balancer output, or a Terratest suite β so a broken dev deploy stops the promotion before it reaches staging.
Step 5: Add testing and monitoring
Infrastructure pipelines need tests just like application pipelines. Terraform 1.6+ ships a native terraform test command: write .tftest.hcl files that apply a module in a sandbox account and assert on outputs. Pair it with a drift check β a scheduled workflow running terraform plan -detailed-exitcode nightly β and alert when exit code 2 reports changes that bypassed the pipeline. Provision CloudWatch alarms and dashboards in the same modules as the resources, so monitoring is deployed with the infrastructure rather than bolted on later.
Seven pipeline mistakes to avoid
- Applying a fresh plan in the apply job. Always download and apply the reviewed plan artifact.
- Storing AWS access keys as repository secrets. Use OIDC roles scoped to repository and branch.
- One state file for every environment. Separate state per environment; a dev mistake must not touch prod state.
- Skipping
paths:filters. Every README edit triggers a full plan and burns minutes. - No concurrency control. Add
concurrency: { group: terraform-prod }so two merges cannot apply simultaneously. - Pinning nothing. Pin the Terraform version, provider versions and action versions; commit
.terraform.lock.hcl. - Leaking secrets in plan comments. Mark variables
sensitiveand consider posting only a summary of large plans.
Frequently asked questions
Should every merge deploy straight to production?
For infrastructure, most teams automate everything up to production and keep a one-click approval for prod. Fully automatic production apply is reasonable once you have strong tests, small changes and fast rollback.
How do I roll back a bad Terraform deployment?
Revert the commit and let the pipeline apply the previous configuration. Because Terraform is declarative, reverting code reverts infrastructure. Keep state versioning enabled as a safety net.
GitHub Actions, GitLab CI, Jenkins or CodePipeline β which should I use?
Use what your team already runs. The stages are identical; only the YAML differs. The patterns here translate directly to GitLab’s .gitlab-ci.yml or a Jenkinsfile.
Do I need HCP Terraform or Atlantis?
Not to start. They add convenience β PR automation, policy checks, a UI for approvals β and become valuable when you manage dozens of root modules.
Key takeaways
- Plan on every pull request, apply on merge, and gate production behind required reviewers.
- Apply the exact plan artifact that was reviewed, never a fresh plan.
- Use OIDC for credentials, separate state per environment, and concurrency groups to serialise applies.
- Promote the same code through dev, staging and prod with smoke tests between stages.
- Add
terraform test, security scanning and scheduled drift detection.
Want to build this pipeline on a real AWS account with guidance? Our DevOps course covers Terraform, GitHub Actions, Jenkins and AWS CI/CD with live projects, mentor support and placement assistance. For hands-on demos, subscribe to our YouTube channel.



