Terraform

Building a Continuous Deployment Pipeline with Terraform: Step-by-Step Guide

AGAnurag Gupta04 Apr 2025 Β· Updated 04 Oct 2026 Β· 7 min read
Building a Continuous Deployment Pipeline with Terraform: Step-by-Step Guide

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

  1. Applying a fresh plan in the apply job. Always download and apply the reviewed plan artifact.
  2. Storing AWS access keys as repository secrets. Use OIDC roles scoped to repository and branch.
  3. One state file for every environment. Separate state per environment; a dev mistake must not touch prod state.
  4. Skipping paths: filters. Every README edit triggers a full plan and burns minutes.
  5. No concurrency control. Add concurrency: { group: terraform-prod } so two merges cannot apply simultaneously.
  6. Pinning nothing. Pin the Terraform version, provider versions and action versions; commit .terraform.lock.hcl.
  7. Leaking secrets in plan comments. Mark variables sensitive and 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.

AG
Written byAnurag Gupta

Part of the Techknowledgehub team of industry mentors, writing practical guides to help you build a job-ready tech career.

More articles by Anurag Gupta β†’
Keep reading

Related articles

Leave a Reply