Terraform CI/CD Pipeline with GitHub Actions
Automate Terraform deployments with PR-based plans and OIDC auth.
Introduction
30 Second Summary
Every infrastructure change in a production environment goes through a review process before it touches real servers. Teams use automated pipelines to plan changes, get human approval, and deploy safely.
In this project, you will build a CI/CD pipeline that automatically plans Terraform infrastructure changes when you open a Pull Request and deploys them when you merge to main.
What You'll Build
A fully automated Terraform deployment pipeline using GitHub Actions with OIDC authentication to AWS.
By the end of this project, you'll have:
- A Terraform configuration that provisions a VPC with networking and security components on AWS.
- An S3 remote backend with native state locking to prevent concurrent infrastructure modifications.
- Two GitHub Actions workflows that automatically plan on Pull Request and apply on merge, authenticated via OIDC federation with zero stored credentials.
- Secret Mission: Deploy an EC2 instance through your pipeline and watch the full plan-review-apply cycle in action.
Are there any prerequisites?
You need an AWS account with the AWS CLI configured, a GitHub account, and Terraform installed (version 1.10 or higher). Step 1 walks you through verifying each of these.
Before We Start
Running terraform apply from your laptop works fine when you are building alone. Production teams take a different approach. They review infrastructure changes through Pull Requests and let automated pipelines handle the actual deployment.
In this project, you will build that exact pipeline using GitHub Actions and Terraform. Before writing any infrastructure code, you need to verify your tools are ready and set up a GitHub repository to hold your project.
In this step, get ready to:
- Verify that Terraform, AWS CLI, and Git are configured correctly.
- Create a GitHub repository and set up the project file structure.
Verify your tools are ready
This project requires three tools: Terraform (version 1.10 or higher), the AWS CLI (already configured with credentials), and Git (with your name set for commits).
- Check your Terraform version by running this command:
terraform version
You should see output starting with Terraform v followed by a version number.
✔️ I see version 1.10 or higher
Your Terraform is ready. Move on to the AWS CLI check below.
ⓧ I see an older version
Your Terraform version is below 1.10 and does not support native S3 state locking. You need to upgrade.
- On macOS or Linux, upgrade Terraform via Homebrew by running:
brew tap hashicorp/tap && brew install hashicorp/tap/terraform
- On Windows, download the latest binary from the official Terraform install page and replace your existing installation.
- After upgrading, run terraform version again to confirm the version is 1.10 or higher.
ⓧ Command not found
Terraform is not installed on your machine yet.
- On macOS or Linux, install Terraform via Homebrew by running:
brew tap hashicorp/tap && brew install hashicorp/tap/terraform
- On Windows, download and install Terraform from the official Terraform install page.
- After installing, close and reopen your terminal, then run terraform version to confirm it is version 1.10 or higher.
- Verify your AWS CLI is configured by running this command:
aws sts get-caller-identity
✔️ I see my account details
You should see a JSON response with your Account, UserId, and Arn. This confirms your AWS CLI can authenticate.
ⓧ I see an error
Your AWS CLI is not configured with valid credentials.
- Run aws configure and enter your AWS Access Key ID, Secret Access Key, default region (us-east-1), and output format (json).
- Run aws sts get-caller-identity again to confirm it works.
Still seeing an error?
Make sure your IAM user has programmatic access enabled and the credentials have not expired. Help me troubleshoot my AWS CLI configuration.
- Verify Git has your name configured by running this command:
git config user.name
✔️ I see my name
Your Git identity is set. You are ready to create your repository.
ⓧ Nothing appears
Git does not have a name configured for your commits. Every Git commit requires an author name.
- Set your name globally by running this command (replace the name with your own):
git config --global user.name "Your Name"
- Run git config user.name again to confirm your name appears.
Create the GitHub repository and project files
Your pipeline needs a GitHub repository to store your Terraform code and trigger automated workflows. You will create the repository on GitHub, clone it locally, and add the initial file structure.
- Open your browser and go to github.com/new.
- Enter terraform-cicd-pipeline in the **Repository name** field.
- Select Public for repository visibility.
- Leave all other options unchecked (no README, no .gitignore, no license).
- Click Create repository.
Why leave the repository empty?
An empty repository lets you push your local project without merge conflicts. Adding a README or .gitignore during creation would create a commit on the remote that your local clone does not have, making the first push messier.
- Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Clone your new repository by running this command:
git clone https://github.com/[[GITHUB_USERNAME="your GitHub username"]]/terraform-cicd-pipeline.git
- Move into the project folder by running:
cd terraform-cicd-pipeline
Clone failed or repository not found?
Double check that you created the repository on GitHub and that the username in the clone URL matches your actual GitHub username exactly (case-sensitive).
Help me troubleshoot my git clone error.
- Create the three Terraform project files by running this command:
touch main.tf variables.tf outputs.tf
- Confirm the files exist by running:
ls
You should see main.tf, outputs.tf, and variables.tf in the output.
What are these files for?
Terraform projects use a standard file structure. main.tf holds your resource definitions and provider configuration. variables.tf declares input variables that make your code reusable. outputs.tf defines values to display after Terraform creates your resources.
Your tools are verified and your repository is ready. Next up, you will write the Terraform code that defines your cloud infrastructure and configure a remote state backend to track it.
Set Up the S3 State Backend
When multiple people (or automated pipelines) run Terraform against the same infrastructure, they need a shared, consistent view of what already exists.
Terraform tracks this in a state file. By default, that file lives on your laptop. In this step, you will create an S3 bucket to store state remotely and configure Terraform to use it as a backend with built-in locking.
In this step, get ready to:
- Create an S3 bucket for remote state storage.
- Configure the S3 backend in main.tf with native state locking.
- Initialize Terraform to connect to the new backend.
Create the S3 state bucket
S3 bucket names must be globally unique across all AWS accounts. Pick a name that includes your username or project name to avoid collisions.
- Create your state bucket by running this command (replace the bucket name with your own unique name):
aws s3api create-bucket --bucket [[STATE_BUCKET="your-unique-state-bucket-name"]] --region us-east-1
What does this command do?
This creates a new S3 bucket in us-east-1. For us-east-1 specifically, you do not need the --create-bucket-configuration flag that other regions require.
- You should see a JSON response with a Location field showing your bucket name.
- Enable versioning on the bucket by running:
aws s3api put-bucket-versioning --bucket [[STATE_BUCKET="your-unique-state-bucket-name"]] --versioning-configuration Status=Enabled
Why enable versioning?
Versioning keeps a history of every state file write. If a deployment goes wrong, you can roll back to a previous state file version instead of losing track of your infrastructure.
- Block all public access to the bucket by running:
aws s3api put-public-access-block --bucket [[STATE_BUCKET="your-unique-state-bucket-name"]] --public-access-block-configuration BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
- Verify your bucket exists by running:
aws s3 ls
- You should see your new bucket name in the output list.
Bucket name already taken?
S3 bucket names are globally unique. If you see an error about the bucket already existing, try a different name that includes your username or a random suffix.
Help me pick a unique S3 bucket name for my Terraform state
Configure the S3 backend in main.tf
Now that the bucket exists, you need to tell Terraform to store its state there instead of on your local disk.
- Open main.tf in your code editor and paste the following configuration:
terraform {
required_version = ">= 1.15.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
backend "s3" {
bucket = "your-terraform-state-bucket-name"
key = "terraform-cicd/terraform.tfstate"
region = "us-east-1"
use_lockfile = true
}
}
provider "aws" {
region = var.aws_region
}
What does this code do?
- required_version = ">= 1.15.0" ensures anyone running this project has a compatible Terraform version.
- required_providers pins the AWS provider to major version 5, preventing unexpected breaking changes.
- backend "s3" tells Terraform to read and write state from your S3 bucket instead of a local file.
- use_lockfile = true enables native S3-based state locking. This prevents two pipeline runs from modifying state at the same time.
- provider "aws" configures the AWS provider, pulling the region from a variable you will define in the next step.
Why native locking instead of DynamoDB?
Terraform 1.10+ supports S3-native lockfile-based state locking. DynamoDB-based locking is deprecated and will be removed in a future version.
Using use_lockfile = true is simpler (no extra DynamoDB table to manage) and future-proof.
- Replace your-terraform-state-bucket-name in the bucket field with the actual bucket name you created in the previous substep.
- Save main.tf.
Initialize Terraform
Running terraform init downloads the AWS provider and connects Terraform to your S3 backend for the first time.
- Initialize your Terraform project by running:
terraform init
- You should see output ending with the message: Terraform has been successfully initialized!
What should I see?
The output reports that the S3 backend is configured and the AWS provider has been installed. You will also see a .terraform directory and a .terraform.lock.hcl file appear in your project folder.
Seeing a backend configuration error?
Double-check that the bucket value in your main.tf matches the exact name of the S3 bucket you created. Bucket names are case-sensitive and must be lowercase.
If you see a credentials error, confirm your AWS CLI is still configured by running aws sts get-caller-identity.
Help me fix my Terraform init backend error
✔️ Awesome, I've got everything!
Great. Double check you have saved main.tf and that terraform init completed without errors.
ⓧ I'd like to double check the full code
Here is the complete main.tf at this point:
terraform {
required_version = ">= 1.15.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
backend "s3" {
bucket = "your-terraform-state-bucket-name"
key = "terraform-cicd/terraform.tfstate"
region = "us-east-1"
use_lockfile = true
}
}
provider "aws" {
region = var.aws_region
}
Your remote backend is live. Next up, you will write the Terraform code that defines the actual AWS infrastructure your pipeline will deploy.
Write the Terraform Infrastructure Code
Your state backend is ready and Terraform knows where to store its records. Now you need actual infrastructure for the pipeline to deploy.
In this step, you will define a VPC with networking components using Terraform. These resources are free (no hourly charges) and demonstrate multi-resource dependencies that produce meaningful plan output.
In this step, get ready to:
- Write VPC and networking resources in main.tf.
- Define variables and outputs to parameterize the configuration.
- Validate and plan the infrastructure locally before the pipeline handles deployment.
Write the VPC and networking resources
A VPC is the isolated network where all your cloud resources live. You will create five resources that form a connected networking topology: the VPC itself, a public subnet, an internet gateway, a route table pointing traffic to that gateway, and an association linking the route table to the subnet.
- In main.tf, add the following networking resources below the provider "aws" block:
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "${var.project_name}-vpc"
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = var.public_subnet_cidr
availability_zone = "${var.aws_region}a"
map_public_ip_on_launch = true
tags = {
Name = "${var.project_name}-public-subnet"
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-igw"
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
tags = {
Name = "${var.project_name}-public-rt"
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_route_table_association" "public" {
subnet_id = aws_subnet.public.id
route_table_id = aws_route_table.public.id
}
What does this code do?
These five resources form a chain of dependencies that Terraform resolves automatically:
- aws_vpc.main creates an isolated network with DNS support enabled, so resources inside it can resolve hostnames.
- aws_subnet.public carves out a 10.0.1.0/24 slice of the VPC and automatically assigns public IPs to anything launched in it.
- aws_internet_gateway.main attaches an internet gateway to the VPC, giving resources a path to the public internet.
- aws_route_table.public defines a route sending all outbound traffic (0.0.0.0/0) through the internet gateway.
- aws_route_table_association.public links the route table to the subnet so the routing rules actually apply.
You won't see a change yet. The next chunk adds the security group to complete main.tf.
Seeing red squiggles or undefined variable errors?
Your editor may flag var.vpc_cidr and similar references as undefined. This is expected. You will define these variables in the next substep.
If you see syntax errors (mismatched braces, missing quotes), check that each resource block opens with { and closes with a matching }.
Help me fix a syntax error in my Terraform networking resources
- Still in main.tf, add the security group resource below the route table association:
resource "aws_security_group" "web" {
name = "${var.project_name}-web-sg"
description = "Security group for web traffic"
vpc_id = aws_vpc.main.id
ingress {
description = "HTTP"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
description = "HTTPS"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.project_name}-web-sg"
Environment = var.environment
ManagedBy = "terraform"
}
}
What does this security group do?
A security group acts as a virtual firewall controlling traffic in and out of your resources.
- The two ingress blocks allow inbound HTTP (port 80) and HTTPS (port 443) traffic from anywhere. This is standard for web servers.
- The egress block with protocol -1 allows all outbound traffic. Resources inside can reach the internet for updates and API calls.
- cidr_blocks = ["0.0.0.0/0"] means traffic from any IP address is permitted on those ports.
- Save main.tf.
All six resource blocks are now written. The next substep defines the variables they reference.
Editor showing errors on the security group?
Make sure the ingress and egress blocks are nested inside the resource "aws_security_group" block, not at the top level.
Check that protocol values are strings in quotes: "tcp" for ingress and "-1" for egress.
Help me debug my Terraform security group resource
✔️ Awesome, I've got everything!
Save main.tf and move on to the next substep.
ⓧ I'd like to double check the full code
terraform {
required_version = ">= 1.15.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
backend "s3" {
bucket = "your-terraform-state-bucket-name"
key = "terraform-cicd/terraform.tfstate"
region = "us-east-1"
use_lockfile = true
}
}
provider "aws" {
region = var.aws_region
}
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "${var.project_name}-vpc"
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = var.public_subnet_cidr
availability_zone = "${var.aws_region}a"
map_public_ip_on_launch = true
tags = {
Name = "${var.project_name}-public-subnet"
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-igw"
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
tags = {
Name = "${var.project_name}-public-rt"
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_route_table_association" "public" {
subnet_id = aws_subnet.public.id
route_table_id = aws_route_table.public.id
}
resource "aws_security_group" "web" {
name = "${var.project_name}-web-sg"
description = "Security group for web traffic"
vpc_id = aws_vpc.main.id
ingress {
description = "HTTP"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
description = "HTTPS"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.project_name}-web-sg"
Environment = var.environment
ManagedBy = "terraform"
}
}
Add variables and outputs
Your resources reference variables like var.vpc_cidr and var.project_name that do not exist yet. Terraform requires explicit variable declarations with types and defaults.
- Add the following variable declarations to variables.tf:
variable "aws_region" {
description = "AWS region for resources"
type = string
default = "us-east-1"
}
variable "project_name" {
description = "Name of the project used for resource naming"
type = string
default = "terraform-cicd"
}
variable "environment" {
description = "Environment name"
type = string
default = "dev"
}
variable "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
default = "10.0.0.0/16"
}
variable "public_subnet_cidr" {
description = "CIDR block for the public subnet"
type = string
default = "10.0.1.0/24"
}
Why use variables with defaults?
Variables make your configuration reusable. A teammate could override vpc_cidr or environment without editing the resource blocks directly.
Defaults mean you can run terraform plan without passing any flags. The pipeline will use these defaults unless you override them later.
- Save variables.tf.
- Confirm Terraform can resolve all references by running this command in your terminal:
terraform validate
You should see Success! The configuration is valid. confirming that all variable references in main.tf now resolve correctly.
Validate failing with variable errors?
Check that every var.X referenced in main.tf has a matching variable "X" block in variables.tf. The names must match exactly (case-sensitive).
If you see a backend error, make sure you ran terraform init in the previous step and it completed successfully.
Help me fix terraform validate errors in my configuration
✔️ Awesome, I've got everything!
Save variables.tf and move on.
ⓧ I'd like to double check the full code
variable "aws_region" {
description = "AWS region for resources"
type = string
default = "us-east-1"
}
variable "project_name" {
description = "Name of the project used for resource naming"
type = string
default = "terraform-cicd"
}
variable "environment" {
description = "Environment name"
type = string
default = "dev"
}
variable "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
default = "10.0.0.0/16"
}
variable "public_subnet_cidr" {
description = "CIDR block for the public subnet"
type = string
default = "10.0.1.0/24"
}
- Add the following output declarations to outputs.tf:
output "vpc_id" {
description = "ID of the VPC"
value = aws_vpc.main.id
}
output "public_subnet_id" {
description = "ID of the public subnet"
value = aws_subnet.public.id
}
output "security_group_id" {
description = "ID of the web security group"
value = aws_security_group.web.id
}
What are outputs for?
Outputs expose values after Terraform applies your configuration. Other Terraform modules or scripts can reference these IDs without digging through the AWS Console.
In the pipeline, these outputs will appear in the workflow logs after a successful apply, confirming which resources were created.
- Save outputs.tf.
All three Terraform files are complete. The next substep runs the full validation and plan.
✔️ Awesome, I've got everything!
All files saved. Move on to format and plan your configuration.
ⓧ I'd like to double check the full code
output "vpc_id" {
description = "ID of the VPC"
value = aws_vpc.main.id
}
output "public_subnet_id" {
description = "ID of the public subnet"
value = aws_subnet.public.id
}
output "security_group_id" {
description = "ID of the web security group"
value = aws_security_group.web.id
}
Format, validate, and plan
Before the pipeline runs your code, you want to confirm locally that everything is syntactically correct and produces the expected resources.
- Format all .tf files to the canonical style by running:
terraform fmt
- Validate the full configuration by running:
terraform validate
You should see Success! The configuration is valid. in the output.
- Preview the infrastructure changes by running:
terraform plan
The output should end with Plan: 6 to add, 0 to change, 0 to destroy. confirming Terraform will create your VPC, subnet, internet gateway, route table, route table association, and security group.
Why not apply yet?
The whole point of this project is to let the pipeline handle terraform apply. Running plan locally proves your code works. The actual deployment will happen automatically when you merge a Pull Request in a later step.
Plan showing errors or fewer than 6 resources?
If you see Error: Reference to undeclared resource, check that resource names in your references (like aws_vpc.main.id) match the resource labels in your resource blocks exactly.
If plan shows fewer than 6 resources, compare your main.tf against the full code in the double-check tab above. You may be missing the route table association or one of the other resources.
Help me debug my terraform plan output
Your infrastructure code is validated and ready for deployment. Next up, you will configure OIDC authentication so GitHub Actions can assume an AWS role and deploy these resources without any stored credentials.
Configure OIDC Authentication
Your Terraform code is validated and ready to deploy. But how does GitHub Actions get permission to create resources in your AWS account?
The production-standard answer is OIDC federation. Instead of storing long-lived AWS credentials as secrets, GitHub Actions presents a short-lived identity token to AWS. AWS verifies the token came from your specific repository, then grants temporary access. In this step, you will configure that trust relationship.
In this step, get ready to:
- Create a GitHub OIDC Identity Provider in AWS IAM.
- Create an IAM Role that GitHub Actions can assume.
- Attach permissions and store the role ARN as a GitHub secret.
Create the GitHub OIDC Identity Provider
An Identity Provider tells AWS IAM to trust tokens issued by a specific external system. You need to register GitHub's token service so AWS recognizes it.
- In the AWS Management Console, search for IAM and open the IAM dashboard.
- In the left sidebar, click Identity providers.
- Click Add provider.
- Select OpenID Connect as the provider type.
- In the Provider URL field, enter:
- https://token.actions.githubusercontent.com
- Click Get thumbprint.
- In the Audience field, enter:
- sts.amazonaws.com
- Click Add provider.
What is OIDC federation?
OIDC (OpenID Connect) lets two systems establish trust without sharing passwords or access keys. GitHub signs a token that says "this workflow is running from repo X". AWS checks the signature against the provider you just registered and, if it matches, issues temporary credentials.
The result is zero stored credentials. Every workflow run gets fresh, short-lived access that expires automatically.
You should see token.actions.githubusercontent.com listed under Identity providers in the IAM sidebar.
Don't see the provider listed?
Make sure you clicked Get thumbprint before clicking Add provider. Without the thumbprint, the form won't submit successfully.
Also confirm the Provider URL has no trailing slash.
help me troubleshoot the OIDC provider creation in AWS IAM
Create an IAM role for GitHub Actions
Now that AWS knows how to verify GitHub tokens, you need a role that GitHub Actions can assume. This role defines what the workflow is allowed to do in your account.
- In the IAM sidebar, click Roles.
- Click Create role.
- Under Trusted entity type, select Web identity.
- In the Identity provider dropdown, select token.actions.githubusercontent.com.
- In the Audience dropdown, select sts.amazonaws.com.
- Click Next.
- On the permissions page, search for and select AmazonVPCFullAccess.
- Click Next.
- In the Role name field, enter github-actions-terraform-role.
- Click Create role.
Why this role name?
The official aws-actions/configure-aws-credentials documentation notes that naming a role "GitHubActions" has been reported to not work. The name github-actions-terraform-role is descriptive and avoids that known issue.
Next, you need to edit the trust policy to scope access to your specific repository. The role wizard creates a trust policy, but it does not include the sub condition that restricts which repository can assume it.
- In the Roles list, click on github-actions-terraform-role to open it.
- Click the Trust relationships tab.
- Click Edit trust policy.
- Replace the existing policy JSON with the following:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::[[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]]:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:valiantprince/terraform-cicd-pipeline:*"
},
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
}
}
}
]
}
What does this trust policy do?
The Principal block tells AWS to accept tokens from GitHub's OIDC provider in your account.
The StringLike condition on the sub claim restricts access to workflows running from repo:valiantprince/terraform-cicd-pipeline:*. The wildcard allows any branch or event from that repository. The StringEquals condition ensures the audience is sts.amazonaws.com.
- Click Update policy.
You should see the updated trust policy displayed with token.actions.githubusercontent.com as the federated principal and the sub condition referencing your repository.
Seeing an error when updating the policy?
Double-check your 12-digit AWS account ID in the Federated ARN. It must match your actual account number exactly.
Confirm repo:valiantprince/terraform-cicd-pipeline:* matches your GitHub username and repository name. Case sensitivity matters.
help me fix my OIDC trust policy in AWS IAM
Attach permissions and add the GitHub secret
The role has AmazonVPCFullAccess for creating infrastructure. It also needs permission to read and write your Terraform state file in S3.
- On the github-actions-terraform-role page, click the Permissions tab.
- Click Add permissions, then click Create inline policy.
- Click the JSON tab in the policy editor.
- Replace the contents with the following policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::[[STATE_BUCKET="your-terraform-state-bucket-name"]]",
"arn:aws:s3:::[[STATE_BUCKET="your-terraform-state-bucket-name"]]/*"
]
}
]
}
What does this policy do?
This grants the four S3 actions Terraform needs to manage state: reading, writing, deleting state and lock files, and listing the bucket contents. The bucket ARN (without /*) covers s3:ListBucket. The ARN with /* covers the object-level actions.
- Click Next.
- Name the policy terraform-state-access.
- Click Create policy.
The role now has two policies attached: AmazonVPCFullAccess and your inline terraform-state-access policy.
Now copy the role's ARN and store it as a GitHub secret so your workflows can reference it.
- At the top of the role's summary page, find the ARN field. Click the copy icon to copy the full ARN.
- Open your terraform-cicd-pipeline repository on GitHub.
- Click Settings in the repository navigation bar.
- In the left sidebar under Security, expand Secrets and variables and click Actions.
- Click the Secrets tab.
- Click New repository secret.
- In the Name field, enter AWS_ROLE_ARN.
- In the Secret field, paste the role ARN you copied.
- Click Add secret.
Why use a secret instead of hardcoding the ARN?
The role ARN itself is not a credential, but storing it as a secret keeps your workflow files portable. Anyone who forks the repository can set their own AWS_ROLE_ARN without modifying the workflow YAML.
You should see AWS_ROLE_ARN listed in the repository's Actions secrets.
Secret not showing up?
Make sure you are on the Secrets tab (not Variables). GitHub separates secrets and variables into different tabs.
Confirm you navigated to Settings for the repository (not your profile settings).
help me add a repository secret in GitHub Actions settings
Authentication is locked in. Your GitHub Actions workflows can now assume the IAM role without any stored credentials. Next up, you will write the actual workflow files that use this role to plan and apply your Terraform code.
Build the GitHub Actions Workflows
Your OIDC authentication is wired up between GitHub and AWS. Now it is time to build the actual automation that makes this a CI/CD pipeline.
You will create two GitHub Actions workflow files. The first runs terraform plan whenever you open a Pull Request and posts the output as a comment for review. The second runs terraform apply automatically when you merge to main. Together, they give you the same deployment workflow used by production infrastructure teams.
In this step, get ready to:
- Create a plan workflow that validates Terraform code on every Pull Request.
- Create an apply workflow that deploys infrastructure on merge to main.
- Push your code and watch the pipeline deploy your VPC infrastructure automatically.
Create the plan workflow
GitHub Actions workflow files live in a .github/workflows/ directory at the root of your repository. GitHub automatically detects and runs any YAML file placed there.
- Create the workflow directory structure by running this command from your terraform-cicd-pipeline project folder:
mkdir -p .github/workflows
- Confirm the directory was created by running:
ls .github/workflows
The directory should exist and be empty (no output listed, no errors).
Seeing 'No such file or directory'?
Make sure you are in the root of your terraform-cicd-pipeline repository folder. Run ls main.tf to verify you are in the right place.
help me figure out why my .github/workflows directory was not created
The plan workflow triggers whenever someone opens a Pull Request that touches Terraform or workflow files. It runs format checks, validation, and a full plan, then posts the plan output directly on the PR as a comment.
- Create the plan workflow by adding a new file at .github/workflows/terraform-plan.yml with the following content:
name: Terraform Plan
on:
pull_request:
branches:
- main
paths:
- '**.tf'
- '.github/workflows/**'
permissions:
id-token: write
contents: read
pull-requests: write
jobs:
terraform-plan:
name: Terraform Plan
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.15.7
- name: Terraform Init
run: terraform init
- name: Terraform Format Check
run: terraform fmt -check
- name: Terraform Validate
run: terraform validate
- name: Terraform Plan
id: plan
run: terraform plan -no-color
continue-on-error: true
- name: Post Plan to PR
uses: actions/github-script@v7
if: github.event_name == 'pull_request'
env:
PLAN: ${{ steps.plan.outputs.stdout }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const output = `#### Terraform Plan Output
\`\`\`
${process.env.PLAN}
\`\`\`
*Triggered by @${{ github.actor }}*`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: output
});
- name: Plan Status
if: steps.plan.outcome == 'failure'
run: exit 1
What does this workflow do?
- The on: pull_request trigger with paths means this workflow only runs when a PR changes .tf files or workflow files. This prevents unnecessary runs when you edit a README.
- The permissions block requests an OIDC token (id-token: write) and permission to comment on PRs (pull-requests: write).
- The aws-actions/configure-aws-credentials@v4 step uses your OIDC role to get temporary AWS credentials with no stored secrets.
- The continue-on-error: true on the plan step lets the PR comment still get posted even if the plan fails, so reviewers can see what went wrong.
- The final Plan Status step fails the workflow if the plan itself failed, ensuring the PR shows a red check.
- Save terraform-plan.yml.
You will not see any visible change yet. This file will activate the first time you open a Pull Request in a later step.
✔️ Awesome, I've got everything!
Great. Make sure terraform-plan.yml is saved inside .github/workflows/.
ⓧ I'd like to double check the full code
name: Terraform Plan
on:
pull_request:
branches:
- main
paths:
- '**.tf'
- '.github/workflows/**'
permissions:
id-token: write
contents: read
pull-requests: write
jobs:
terraform-plan:
name: Terraform Plan
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.15.7
- name: Terraform Init
run: terraform init
- name: Terraform Format Check
run: terraform fmt -check
- name: Terraform Validate
run: terraform validate
- name: Terraform Plan
id: plan
run: terraform plan -no-color
continue-on-error: true
- name: Post Plan to PR
uses: actions/github-script@v7
if: github.event_name == 'pull_request'
env:
PLAN: ${{ steps.plan.outputs.stdout }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const output = `#### Terraform Plan Output
\`\`\`
${process.env.PLAN}
\`\`\`
*Triggered by @${{ github.actor }}*`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: output
});
- name: Plan Status
if: steps.plan.outcome == 'failure'
run: exit 1
Create the apply workflow
The apply workflow is simpler. It triggers on pushes to main (which happens when you merge a PR) and runs terraform apply -auto-approve to deploy the approved changes.
- Create the apply workflow by adding a new file at .github/workflows/terraform-apply.yml with the following content:
name: Terraform Apply
on:
push:
branches:
- main
paths:
- '**.tf'
- '.github/workflows/**'
permissions:
id-token: write
contents: read
jobs:
terraform-apply:
name: Terraform Apply
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.15.7
- name: Terraform Init
run: terraform init
- name: Terraform Apply
run: terraform apply -auto-approve
What does this workflow do?
- The on: push trigger with branches: main means this runs only when code lands on main. Merging a PR counts as a push to main.
- The same paths filter ensures only Terraform or workflow file changes trigger a deploy.
- This workflow does not need pull-requests: write because it does not comment on PRs.
- The -auto-approve flag skips the interactive confirmation prompt. The plan review already happened on the PR, so this is safe.
- Save terraform-apply.yml.
- Confirm both workflow files exist by running:
ls .github/workflows/
You should see terraform-apply.yml and terraform-plan.yml listed.
Only see one file?
Check that you saved the second file in the same .github/workflows/ directory. If you accidentally saved it at the repository root, move it into the correct folder.
help me find my missing workflow file
✔️ Awesome, I've got everything!
Great. Make sure both files are saved in .github/workflows/.
ⓧ I'd like to double check the full code
name: Terraform Apply
on:
push:
branches:
- main
paths:
- '**.tf'
- '.github/workflows/**'
permissions:
id-token: write
contents: read
jobs:
terraform-apply:
name: Terraform Apply
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.15.7
- name: Terraform Init
run: terraform init
- name: Terraform Apply
run: terraform apply -auto-approve
Push to main and deploy your infrastructure
With both workflow files ready, you will commit everything and push to main. Because the push includes .tf files and workflow files, the apply workflow will trigger automatically and deploy your VPC infrastructure to AWS.
- Stage all files, commit, and push to main by running these commands:
git add -A
git commit -m "Add Terraform infrastructure and CI/CD workflows"
git push origin main
What happens after you push?
GitHub detects the new workflow files in .github/workflows/ and evaluates their triggers. Since this push to main includes .tf files, the terraform-apply.yml workflow fires.
The workflow assumes your IAM role via OIDC, initializes the S3 backend, and runs terraform apply -auto-approve to create all 6 resources (VPC, subnet, internet gateway, route table, route table association, and security group).
- Open your repository on GitHub in your browser.
- Click the Actions tab at the top of the repository page.
- You should see a workflow run called Terraform Apply in progress or completed.
- Click into the run and expand the Terraform Apply job to watch the steps execute.
The workflow takes 1-2 minutes to complete. Wait for a green checkmark before continuing.
- Verify the infrastructure was deployed by running this command in your terminal:
aws ec2 describe-vpcs --filters "Name=tag:Name,Values=terraform-cicd-*"
You should see your new VPC with the tag terraform-cicd-vpc and CIDR block 10.0.0.0/16 in the JSON output.
Workflow failed with a permissions error?
Check that your AWS_ROLE_ARN secret in GitHub matches the ARN of your github-actions-terraform-role exactly. A common mistake is copying extra whitespace or missing a character.
Also verify the trust policy on the role allows repo:valiantprince/terraform-cicd-pipeline:* as the subject claim. If you scoped it to a specific branch like ref:refs/heads/main, the wildcard pattern in the workflow may not match.
help me debug my GitHub Actions OIDC permissions error
Your pipeline is live. Every push to main that touches infrastructure code will now deploy automatically. Next up, you will test the full PR workflow by creating a branch, making a change, and watching the plan appear as a comment on your Pull Request.
Test the PR Workflow
Your pipeline is live and has already deployed real infrastructure to AWS without you ever running terraform apply from your laptop. That proves the apply side works. But the real value of a CI/CD pipeline is code review.
In production teams, no infrastructure change goes live without another engineer reviewing the plan output first. In this step, you will simulate that exact workflow by creating a branch, making a small change, and opening a Pull Request to see the automated plan in action.
In this step, get ready to:
- Create a feature branch and add a new tag to the VPC resource.
- Open a Pull Request and observe the automated plan comment.
- Merge the PR and verify the apply workflow deploys the change.
Create a feature branch and add a VPC tag
In a real team workflow, you never commit directly to main. You create a feature branch, make your change, and open a Pull Request for review.
- Create a new branch called add-tags by running this command:
git checkout -b add-tags
You should see Switched to a new branch 'add-tags' in your terminal.
- In main.tf, scroll down to the aws_vpc resource block.
- Find the tags block, which currently looks like this:
tags = {
Name = "${var.project_name}-vpc"
Environment = var.environment
ManagedBy = "terraform"
}
- Add Team = "platform" as a new line inside the tags block, so it now looks like this:
tags = {
Name = "${var.project_name}-vpc"
Environment = var.environment
ManagedBy = "terraform"
Team = "platform"
}
What does this tag do?
Tags are metadata labels on AWS resources. Adding Team = "platform" tells anyone looking at this VPC who owns it.
This is a minimal change on purpose. The goal is to test your pipeline workflow, not build new infrastructure.
- Save main.tf.
- Commit the change and push the branch to GitHub by running these commands:
git add main.tf
git commit -m "Add Team tag to VPC"
git push origin add-tags
You should see output confirming the branch was pushed to origin/add-tags.
Push rejected or permission denied?
Make sure you cloned the repository using HTTPS or SSH with valid credentials. If you see a 403 error, your GitHub authentication may have expired.
help me fix a git push permission error to my GitHub repository
Open a Pull Request and observe the plan
Now that your branch is on GitHub, opening a Pull Request will trigger the plan workflow automatically.
- Open your terraform-cicd-pipeline repository on GitHub in your browser.
- You should see a banner at the top saying your add-tags branch had recent pushes. Click Compare & pull request.
- Leave the base branch as main and the compare branch as add-tags.
- Enter a title like Add Team tag to VPC and click Create pull request.
After you create the PR, the Terraform Plan workflow triggers automatically. You can watch its progress in the Actions tab or in the checks section at the bottom of the PR.
- Wait for the workflow to complete (usually 1-2 minutes).
- Once it finishes, scroll down on the PR page. You should see an automated comment showing the Terraform plan output.
What should I see in the plan comment?
The comment shows the output of terraform plan formatted in a code block. It should show an in-place update to aws_vpc.main, adding the Team tag.
The plan will report something like "1 to change" because you are modifying an existing resource, not creating a new one. This is exactly the visibility that makes PR-based workflows valuable.
No comment appearing on the PR?
Check the Actions tab for a failed workflow run. Common causes include OIDC authentication errors (check the role trust policy scoping) or missing pull-requests: write permission in the workflow file.
help me debug why my Terraform Plan workflow is not posting a comment on my Pull Request
Merge the PR and verify the deployment
The plan comment shows a safe in-place update. In a real team, this is where a reviewer would approve the change.
- Review the plan comment to confirm it shows an in-place update adding the Team tag (no resources destroyed).
- Click Merge pull request at the bottom of the PR page.
- Click Confirm merge.
Merging to main triggers the Terraform Apply workflow, which deploys the tag change to AWS.
- Click the Actions tab in your repository.
- Wait for the Terraform Apply workflow to show a green checkmark.
- Verify the tag exists in AWS by running this command in your terminal:
aws ec2 describe-vpcs --filters "Name=tag:Name,Values=terraform-cicd-*"
In the output, look for the Tags array on your VPC. You should see Team with value platform alongside the existing tags.
Why is this workflow so powerful?
You just completed the exact production workflow used by real infrastructure teams. A branch isolates the change. The plan comment gives visibility to reviewers. The merge triggers deployment. No one ever ran terraform apply from their laptop.
This separation of plan and apply across PR events is what hiring managers look for when evaluating DevOps candidates.
Tag not showing in the AWS CLI output?
Make sure the Apply workflow completed successfully in the Actions tab before running the CLI command. If the workflow failed, check its logs for OIDC or permission errors.
help me troubleshoot why my Terraform Apply workflow did not deploy the VPC tag change after merging my Pull Request
✔️ Awesome, I've got everything!
Great. Double check you have saved main.tf with the new Team tag and that the change is deployed in AWS.
ⓧ I'd like to double check the full code
Here is the complete main.tf with the tag added:
terraform {
required_version = ">= 1.15.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
backend "s3" {
bucket = "your-terraform-state-bucket-name"
key = "terraform-cicd/terraform.tfstate"
region = "us-east-1"
use_lockfile = true
}
}
provider "aws" {
region = var.aws_region
}
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "${var.project_name}-vpc"
Environment = var.environment
ManagedBy = "terraform"
Team = "platform"
}
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = var.public_subnet_cidr
availability_zone = "${var.aws_region}a"
map_public_ip_on_launch = true
tags = {
Name = "${var.project_name}-public-subnet"
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-igw"
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
tags = {
Name = "${var.project_name}-public-rt"
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_route_table_association" "public" {
subnet_id = aws_subnet.public.id
route_table_id = aws_route_table.public.id
}
resource "aws_security_group" "web" {
name = "${var.project_name}-web-sg"
description = "Security group for web traffic"
vpc_id = aws_vpc.main.id
ingress {
description = "HTTP"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
description = "HTTPS"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.project_name}-web-sg"
Environment = var.environment
ManagedBy = "terraform"
}
}
Secret mission
Deploy an EC2 Instance Through the Pipeline
Your pipeline deploys networking components. But what about compute? In this mission, you will add an EC2 instance to your Terraform code and watch the full plan-review-apply cycle deploy a real server through your pipeline.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. The VPC networking components are free, and the EC2 instance is covered under the t4g free trial until December 2026.
Cost warning
The EC2 t4g.small instance is free until December 31, 2026 (750 hours per month). After that date, you will be charged standard on-demand pricing. If you plan to leave resources running beyond December 2026, return here and delete them.
Resources you used:
- EC2 instance (terraform-cicd-web-server).
- VPC and networking (VPC, subnet, internet gateway, route table, security group).
- S3 bucket (Terraform state storage).
- IAM OIDC Identity Provider and Role (github-actions-terraform-role).
- GitHub repository (terraform-cicd-pipeline).
- Local project files.
Keep everything running
No action needed. Choose this if you are still actively building or want to keep your pipeline ready for future experiments.
- The VPC, subnet, internet gateway, route table, and security group have no hourly charges.
- The EC2 t4g.small instance is covered under the free trial until December 31, 2026 (750 free hours per month).
- The S3 state bucket stores only a few kilobytes of data. Storage cost is negligible.
- GitHub Actions is free for public repositories.
Pause - I'll come back to this later
Stop the EC2 instance to ensure zero risk of charges, while keeping your infrastructure configuration and pipeline intact.
- Stop the EC2 instance by running this command:
aws ec2 stop-instances --instance-ids $(aws ec2 describe-instances --filters "Name=tag:Name,Values=terraform-cicd-web-server" --query "Reservations[].Instances[].InstanceId" --output text)
- Your VPC, networking, S3 bucket, and IAM resources remain in place at no cost.
- Your GitHub repository and workflows remain ready. When you want to resume, start the instance again or push a new commit to trigger the pipeline.
Delete - I don't want to use this again
Remove all project resources and start fresh. This cannot be undone.
- Destroy all Terraform-managed AWS infrastructure by running this command from your project directory:
terraform destroy
- Type yes when prompted to confirm the destruction.
- This removes the EC2 instance, VPC, subnet, internet gateway, route table, security group, and route table association.
- Delete the S3 state bucket. Because versioning is enabled, use the S3 console to empty it first:
- Open the S3 console at https://console.aws.amazon.com/s3/.
- Select your state bucket.
- Click Empty, type permanently delete to confirm, then click Empty.
- Once empty, select the bucket again and click Delete. Type the bucket name to confirm.
- Delete the IAM role and OIDC provider:
- Open the IAM console at https://console.aws.amazon.com/iam/.
- Select Roles in the left sidebar. Search for github-actions-terraform-role, select it, and click Delete.
- Select Identity providers in the left sidebar. Select the token.actions.githubusercontent.com provider and click Delete.
- Delete the GitHub repository (optional):
- Go to your terraform-cicd-pipeline repository on GitHub.
- Click Settings, scroll to the bottom of the page, and click Delete this repository.
- Follow the confirmation prompts to delete.
- Delete local project files by running:
cd ~/Desktop
rm -rf terraform-cicd-pipeline
- On Windows, run:
cd ~\Desktop
rmdir /s /q terraform-cicd-pipeline
That's a Wrap!
That's a Wrap!
Nice work. You just built a production-grade Terraform CI/CD pipeline that automatically plans infrastructure changes on Pull Requests and deploys them on merge to main.
You've learned how to:
- Build a complete GitHub Actions pipeline with automated terraform plan on PR and terraform apply on merge.
- Configure OIDC federation between GitHub Actions and AWS with zero stored credentials.
- Set up S3 remote state with native file-based locking and write production-grade Terraform for a VPC with networking and security components.
- Secret Mission: Deployed an EC2 instance through the pipeline, practicing critical plan review before approving compute resources.
Ready to quiz yourself?