AWS with Terraform Tutorial: Terraform CI/CD (21)
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.
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:
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):
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
subcondition is the security boundary: it says which repository, and which event or environment, can assume the role. Never userepo:*. - 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_listfor the GitHub provider. Older versions require it. - The
PowerUserAccesspolicy 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 #
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 tfplanHow it works:
validateruns the checks of the previous section in every pull request and push.planassumes the read-only role and posts the plan, so the reviewers see exactly what would change in AWS.applyruns only after the merge, uses the environmentproduction(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:
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-exitcodeThe 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_tokensfor 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.