AWS with Terraform Tutorial: Terraform CI/CD (21)

· 10 min read · Terraform & OpenTofu Tutorials

How to automate Terraform and OpenTofu with CI/CD on AWS #

Using GitHub Actions to validate every change, show the plan in the pull request and apply it after an approval, authenticating to AWS with OpenID Connect instead of access keys.

Welcome to our tutorial series about Terraform or OpenTofu on AWS. So far tofu apply has been run from a laptop. That does not work for a team: nobody knows who changed what, the result depends on the machine and long-lived AWS keys end up in many places. In a CI/CD pipeline the change goes through Git, a reviewer reads the plan, and a controlled system applies it.

Terraform is an Infrastructure as Code (IaC) tool used to provision and manage infrastructure. It helps define and deploy resources across various cloud providers using code, making it easier to maintain and scale infrastructure.Terraform is an Infrastructure as Code (IaC) tool used to provision and manage infrastructure. It helps define and deploy resources across various cloud providers using code, making it easier to maintain and scale infrastructure.
Terraform
Basics
Terraform...
AWS is the world’s leading cloud platform, it is used by a wide range of organizations, from startups to large enterprises, to power their online businesses. AWS offers a wide range of services, including computing, storage, database, networking, analytics, machine learning, and artificial intelligence.AWS is the world’s leading cloud platform, it is used by a wide range of organizations, from startups to large enterprises, to power their online businesses. AWS offers a wide range of services, including computing, storage, database, networking, analytics, machine learning, and artificial intelligence.
AWS
Basics
AWS...
The Terraform official AWS provider acts as an abstraction layer that lets Terraform configurations written in HCL define AWS services and infrastructure using code (IaC). Internally Terraform and the AWS provider handle authentication, and make the necessary AWS API calls to query, create, modify, and destroy the resources.The Terraform official AWS provider acts as an abstraction layer that lets Terraform configurations written in HCL define AWS services and infrastructure using code (IaC). Internally Terraform and the AWS provider handle authentication, and make the necessary AWS API calls to query, create, modify, and destroy the resources.
Terraform
AWS Provider
Terraform...
How to Create, and Manage AWS VPCs with TerraformHow to Create, and Manage AWS VPCs with Terraform
AWS VPC
AWS VPC
How to configure and use the Terraform aws_subnet resource block to create and manage AWS Subnets inside a VPC.How to configure and use the Terraform aws_subnet resource block to create and manage AWS Subnets inside a VPC.
AWS Subnets
AWS Subnets
How to configure and use the Terraform aws_internet_gateway resource block to create and manage AWS Internet Gateway inside a VPC to enable Internet access to and from instances. How to configure and use the Terraform aws_internet_gateway resource block to create and manage AWS Internet Gateway inside a VPC to enable Internet access to and from instances.
AWS Internet
Gateway
AWS Internet...
How to configure and use the Terraform aws_nat_gateway and aws_eip resource blocks to create and manage AWS NAT Gateway and its corresponding Public IPs inside each availability zone to enable Internet access from instances in private subnets.How to configure and use the Terraform aws_nat_gateway and aws_eip resource blocks to create and manage AWS NAT Gateway and its corresponding Public IPs inside each availability zone to enable Internet access from instances in private subnets.
AWS NAT
Gateway
AWS NAT...
How to configure and use the Terraform aws_route_table, aws_route, and aws_main_route_table_association resource blocks to create and manage AWS Routing Tables.How to configure and use the Terraform aws_route_table, aws_route, and aws_main_route_table_association resource blocks to create and manage AWS Routing Tables.
AWS Routing
Tables
AWS Routing...
How to configure and use the Terraform aws_security_group and aws_security_group_rule resource blocks to create and manage AWS Security Groups and secure the infrastructure.How to configure and use the Terraform aws_security_group and aws_security_group_rule resource blocks to create and manage AWS Security Groups and secure the infrastructure.
AWS Security
Groups
AWS Security...
How to configure and use the Terraform aws_key_pair resource block to create and manage AWS Key Pairs for performing SSH Public Key Authentication into EC2 instances.How to configure and use the Terraform aws_key_pair resource block to create and manage AWS Key Pairs for performing SSH Public Key Authentication into EC2 instances.
AWS Key
Pairs
AWS Key...
How to configure and use the Terraform aws_ami data source block to find and use AWS AMIs as templates (root volume snapshot with operating system and applications) for EC2 instances.How to configure and use the Terraform aws_ami data source block to find and use AWS AMIs as templates (root volume snapshot with operating system and applications) for EC2 instances.
AWS AMIs
AWS AMIs
Using the Terraform aws_instance resource block to configure, launch, and secure EC2 instances.Using the Terraform aws_instance resource block to configure, launch, and secure EC2 instances.
AWS EC2
Instances
AWS EC2...
AWS RDS
AWS RDS
Using the Terraform aws_route53_delegation_set, aws_route53_zone, and aws_route53_record resource blocks to configure DNS in AWS. Using the Terraform aws_route53_delegation_set, aws_route53_zone, and aws_route53_record resource blocks to configure DNS in AWS.
AWS Route 53
(DNS)
AWS Route 53...
Amazon EC2 Auto Scaling groups keep the right number of instances running, replace failed ones and scale with the load.Amazon EC2 Auto Scaling groups keep the right number of instances running, replace failed ones and scale with the load.
AWS Auto
Scaling
AWS Auto...
Elastic Load Balancing distributes the traffic between healthy instances. The tutorial creates an Application Load Balancer with HTTPS.Elastic Load Balancing distributes the traffic between healthy instances. The tutorial creates an Application Load Balancer with HTTPS.
AWS Load
Balancers
AWS Load...
This tutorial shows how to create infrastructure in AWS using Terraform and configure the operating system and applications using Ansible.This tutorial shows how to create infrastructure in AWS using Terraform and configure the operating system and applications using Ansible.
Terraform,
AWS & Ansible
Terraform,...
Modules package resources behind variables and outputs so they can be reused. The tutorial refactors the network into a module.Modules package resources behind variables and outputs so they can be reused. The tutorial refactors the network into a module.
Terraform
Modules
Terraform...
A backend stores the Terraform state. The tutorial uses an encrypted, versioned S3 bucket with state locking.A backend stores the Terraform state. The tutorial uses an encrypted, versioned S3 bucket with state locking.
Terraform
Backends
Terraform...
fmt, validate, TFLint, Trivy, Checkov, terraform-docs, Infracost and pre-commit: check the code before it reaches AWS.fmt, validate, TFLint, Trivy, Checkov, terraform-docs, Infracost and pre-commit: check the code before it reaches AWS.
Terraform
Tools
Terraform...
Run checks, plans and approved applies automatically with GitHub Actions and OIDC, without access keys.Run checks, plans and approved applies automatically with GitHub Actions and OIDC, without access keys.
Terraform
CI/CD
Terraform...
Select a tutorial section 
Select a tutorial sectio...

Prerequisites #

Read the previous sections of the tutorial, listed in the series index at the end of this page. This section needs:

  • the code in a GitHub repository (the examples use itwonderlab/aws-terraform-tutorial, replace it with yours),
  • the S3 backend: a pipeline needs a shared state,
  • the checks of Terraform Tools.

The workflow #

Event Pipeline
Pull request Check the format, validate, lint and plan. The plan is posted as a comment for the reviewers
Merge to main Plan again, wait for the approval of an environment and apply exactly that plan

The same code is tested and reviewed in the pull request, and nothing reaches AWS without a merge and an approval.

Step 1: remove the profile from the code #

The code uses profile = "ditwl_infradmin" in the provider and in the backend. That profile does not exist in the pipeline, where the credentials come from the environment. Make it optional:

providers.tf
variable "aws_profile" {
  type        = string
  default     = null # in the pipeline the credentials come from the environment
  description = "AWS CLI profile, only for local runs"
}

provider "aws" {
  profile = var.aws_profile
}

For the backend, remove the profile argument from the backend "s3" block and use the AWS_PROFILE environment variable on your computer (export AWS_PROFILE=ditwl_infradmin), or a backend.hcl file as shown in the Terraform Backends section.

Step 2: let GitHub authenticate to AWS without keys #

Storing an access key as a GitHub secret is the easy option and a risk: it never expires and, if it leaks, it gives access to your account. OpenID Connect (OIDC) is the alternative: AWS trusts the identity token that GitHub issues for each job and returns temporary credentials for a role.

Create the provider and the roles with Terraform, in a small project of their own (like the backend bootstrap):

ci-bootstrap/main.tf
locals {
  repo = "itwonderlab/aws-terraform-tutorial" # organization/repository
}

# GitHub as an identity provider
resource "aws_iam_openid_connect_provider" "github" {
  url            = "https://token.actions.githubusercontent.com"
  client_id_list = ["sts.amazonaws.com"]
}

# Role for the pull requests and the plan jobs: read only
data "aws_iam_policy_document" "ditwl-gha-plan-assume" {
  statement {
    actions = ["sts:AssumeRoleWithWebIdentity"]

    principals {
      type        = "Federated"
      identifiers = [aws_iam_openid_connect_provider.github.arn]
    }
    condition {
      test     = "StringEquals"
      variable = "token.actions.githubusercontent.com:aud"
      values   = ["sts.amazonaws.com"]
    }
    condition {
      test     = "StringLike"
      variable = "token.actions.githubusercontent.com:sub"
      values   = ["repo:${local.repo}:pull_request", "repo:${local.repo}:ref:refs/heads/main"]
    }
  }
}

resource "aws_iam_role" "ditwl-role-gha-plan" {
  name               = "ditwl-role-gha-plan"
  assume_role_policy = data.aws_iam_policy_document.ditwl-gha-plan-assume.json
}

resource "aws_iam_role_policy_attachment" "ditwl-gha-plan-readonly" {
  role       = aws_iam_role.ditwl-role-gha-plan.name
  policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess"
}

# Role for the apply job: only the jobs of the "production" environment can use it
data "aws_iam_policy_document" "ditwl-gha-apply-assume" {
  statement {
    actions = ["sts:AssumeRoleWithWebIdentity"]

    principals {
      type        = "Federated"
      identifiers = [aws_iam_openid_connect_provider.github.arn]
    }
    condition {
      test     = "StringEquals"
      variable = "token.actions.githubusercontent.com:aud"
      values   = ["sts.amazonaws.com"]
    }
    condition {
      test     = "StringEquals"
      variable = "token.actions.githubusercontent.com:sub"
      values   = ["repo:${local.repo}:environment:production"]
    }
  }
}

resource "aws_iam_role" "ditwl-role-gha-apply" {
  name               = "ditwl-role-gha-apply"
  assume_role_policy = data.aws_iam_policy_document.ditwl-gha-apply-assume.json
}

# Give the apply role only the permissions that the infrastructure needs.
# PowerUserAccess is used here to keep the example short, it is too broad for production.
resource "aws_iam_role_policy_attachment" "ditwl-gha-apply-power" {
  role       = aws_iam_role.ditwl-role-gha-apply.name
  policy_arn = "arn:aws:iam::aws:policy/PowerUserAccess"
}

output "plan_role_arn" {
  value = aws_iam_role.ditwl-role-gha-plan.arn
}

output "apply_role_arn" {
  value = aws_iam_role.ditwl-role-gha-apply.arn
}
  • The sub condition is the security boundary: it says which repository, and which event or environment, can assume the role. Never use repo:*.
  • Both roles also need access to the state bucket (the policy from the Terraform Backends section). The plan role can be read-only if the pipeline runs tofu plan -lock=false.
  • Recent versions of the AWS provider do not need a thumbprint_list for the GitHub provider. Older versions require it.
  • The PowerUserAccess policy does not include IAM, which this very project needs for roles; use a custom least-privilege policy for your infrastructure.

In GitHub, create the production environment (Settings → Environments), add required reviewers and, as repository variables, AWS_PLAN_ROLE_ARN and AWS_APPLY_ROLE_ARN with the two outputs.

Step 3: the GitHub Actions workflow #

.github/workflows/infrastructure.yml
name: infrastructure

on:
  pull_request:
    paths: ["**.tf", ".github/workflows/infrastructure.yml"]
  push:
    branches: [main]
    paths: ["**.tf", ".github/workflows/infrastructure.yml"]

permissions:
  contents: read
  id-token: write       # request the OIDC token
  pull-requests: write  # comment the plan

env:
  TOFU_VERSION: "1.10.0" # use the version of your team
  AWS_REGION: us-east-1

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: opentofu/setup-opentofu@v2
        with:
          tofu_version: ${{ env.TOFU_VERSION }}
          tofu_wrapper: false
      - run: tofu fmt -recursive -check -diff
      - run: tofu init -backend=false -input=false
      - run: tofu validate
      - uses: terraform-linters/setup-tflint@v4
      - run: tflint --init && tflint --recursive

  plan:
    needs: validate
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: opentofu/setup-opentofu@v2
        with:
          tofu_version: ${{ env.TOFU_VERSION }}
          tofu_wrapper: false
      - uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: ${{ vars.AWS_PLAN_ROLE_ARN }}
          aws-region: ${{ env.AWS_REGION }}
      - run: tofu init -input=false
      - run: tofu plan -input=false -lock=false -no-color -out=tfplan
      - run: tofu show -no-color tfplan > plan.txt

      - name: Comment the plan in the pull request
        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);
            await github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
              body: "### OpenTofu plan\n\n```\n" + plan + "\n```",
            });

      - name: Save the plan to apply it
        if: github.event_name == 'push'
        uses: actions/upload-artifact@v4
        with:
          name: tfplan
          path: tfplan
          retention-days: 1

  apply:
    if: github.event_name == 'push'
    needs: plan
    runs-on: ubuntu-latest
    environment: production # waits for the approval of the required reviewers
    concurrency: apply-production # one apply at a time
    steps:
      - uses: actions/checkout@v4
      - uses: opentofu/setup-opentofu@v2
        with:
          tofu_version: ${{ env.TOFU_VERSION }}
          tofu_wrapper: false
      - uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: ${{ vars.AWS_APPLY_ROLE_ARN }}
          aws-region: ${{ env.AWS_REGION }}
      - uses: actions/download-artifact@v4
        with:
          name: tfplan
      - run: tofu init -input=false
      - run: tofu apply -input=false tfplan

How it works:

  • validate runs the checks of the previous section in every pull request and push.
  • plan assumes the read-only role and posts the plan, so the reviewers see exactly what would change in AWS.
  • apply runs only after the merge, uses the environment production (the approval gate and the only identity allowed to assume the apply role) and applies the saved plan, not a new one. If the state changed in the meantime, OpenTofu rejects the stale plan and the pipeline fails safely.
  • The plan file can contain sensitive values. It is kept for one day and only inside the repository's artifacts.

Protect the main branch #

Pipelines are only as safe as the rules around them. In GitHub, protect main (Settings → Branches): require a pull request, require the validate and plan checks to pass, require at least one approval, and block direct pushes.

Detect drift #

Infrastructure changes outside of Terraform (a person editing something in the AWS console) are called drift. A scheduled job finds it by running a plan and failing when it is not empty:

.github/workflows/drift.yml
name: drift
on:
  schedule:
    - cron: "0 6 * * 1-5" # every weekday at 06:00 UTC
permissions:
  contents: read
  id-token: write
jobs:
  drift:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: opentofu/setup-opentofu@v2
        with:
          tofu_wrapper: false
      - uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: ${{ vars.AWS_PLAN_ROLE_ARN }}
          aws-region: us-east-1
      - run: tofu init -input=false
      # exit code 0 = no changes, 2 = changes (drift), 1 = error
      - run: tofu plan -input=false -lock=false -detailed-exitcode

The role must allow main (or add the schedule event to the subject conditions of the plan role).

Other options #

  • GitLab CI/CD: the same steps with id_tokens for OIDC and the GitLab Terraform state or the S3 backend.
  • Atlantis, Spacelift, env0, HCP Terraform: tools specialized in running plans and applies from pull requests, with approval policies and a UI. They replace the workflow of this section, not the S3 backend or the checks.
  • AWS CodePipeline / CodeBuild: if you prefer to run everything inside AWS.

Cost #

GitHub Actions includes free minutes for public repositories and a monthly allowance for private ones. These jobs take a few minutes. OIDC, IAM roles and the S3 backend have no additional cost.

Common Questions About Terraform CI/CD #

Why apply the saved plan instead of running tofu apply again? #

Because it guarantees that what is applied is what was planned and approved. A new apply could include changes that appeared after the review.

What if two pipelines run at the same time? #

The concurrency group serializes the applies and the state lock (see Terraform Backends) prevents two processes from writing the state at once.

Where do I keep secrets such as database passwords? #

Not in the repository or in the plan comments. Use manage_master_user_password = true (as in AWS RDS) or AWS Secrets Manager, and mark variables as sensitive.

Can I use access keys instead of OIDC? #

You can, with repository secrets, but they are long-lived credentials that must be rotated and can leak. OIDC credentials last about an hour and are bound to the repository and the environment.

Congratulations #

You have completed AWS with Terraform: The Essential Guide: from the basics of Terraform and AWS, through a network, servers, a database, DNS, auto scaling and load balancers, to modules, remote state, tooling and a CI/CD pipeline. Browse the series index to review any section, or read about using Terraform, AWS and Ansible together.

#AWS #AWS IAM #Terraform #OpenTofu #IaC #AWS Terraform Tutorial