Terraform CI/CD with GitHub Actions
Automate infrastructure deployments using Terraform and GitHub Actions pipelines.
Introduction
30 Second Summary
Every time a team needs to change their cloud infrastructure, there's a risk of breaking something in production. The safest approach is to automate the process so every change is reviewed before it goes live.
In this project, you will build a CI/CD pipeline that automatically plans and deploys infrastructure changes using Terraform and GitHub Actions.
What You'll Build
A complete infrastructure CI/CD pipeline that runs terraform plan on pull requests and terraform apply on merge to main, deploying an S3 static website as proof of hands-free deployment.
By the end of this project, you'll have:
- A GitHub Actions workflow that runs terraform plan on every pull request for review.
- A remote state backend in S3 with DynamoDB locking for production-grade state management.
- A live S3 static website deployed entirely through the CI/CD pipeline.
- Secret Mission: Upgrade to OIDC authentication for credential-free pipeline security.
Are there any prerequisites?
You should have basic Terraform familiarity (what resources and providers are) and basic Git/GitHub knowledge (commits, branches, pull requests). No prior CI/CD experience is needed.
Install Tools and Configure AWS
Before you can automate infrastructure deployments, you need the right tools on your machine. Terraform is the engine that provisions cloud resources. AWS CLI connects your terminal to your AWS account. Git tracks your code changes and pushes them to GitHub.
In this step, you will install all three tools, create an AWS IAM user for Terraform to authenticate with, and wire up your GitHub Actions pipeline with the credentials it needs to deploy infrastructure on your behalf.
In this step, get ready to:
- Install Git, Terraform, and AWS CLI on Windows.
- Create an IAM user with programmatic access for Terraform.
- Store AWS credentials as GitHub repository secrets.
Install Terraform, AWS CLI, and Git
You need three command-line tools installed before moving forward. Let's check for each one and install any that are missing.
Install Git
- Open a terminal in VS Code by pressing Ctrl+` (backtick key, usually above Tab).
- Check if Git is already installed by running:
git --version
✔️ I see a version number
Git is ready to go. Skip ahead to the Terraform section below.
ⓧ Command not found
Git is not installed yet. Follow these steps to install it.
- Download the Git for Windows installer from the official Git download page.
- Run the downloaded installer.
- Accept all the default options in the installer wizard by clicking Next on each screen, then click Install.
- Once installation finishes, close and reopen your VS Code terminal.
- Verify by running:
git --version
You should now see a version number like git version 2.55.0.windows.2.
Install Terraform
- Check if Terraform is already installed by running:
terraform version
✔️ I see version 1.15.7
Terraform is up to date. Skip ahead to the AWS CLI section below.
ⓧ I see a different version
You have Terraform installed, but not the version this project uses. Follow the install steps in the next tab to get version 1.15.7 . The new binary will replace your current version.
ⓧ Command not found
Terraform is not installed yet. Follow these steps to install it.
- Open the Terraform install page in your browser.
- Select Windows and download the AMD64 zip file.
- Create a folder called C:\terraform on your machine.
- Extract the downloaded zip file and move terraform.exe into C:\terraform.
- Now add this folder to your system PATH so Windows can find terraform.exe from any terminal.
How to add a folder to your system PATH
- Press the Windows key and type environment variables.
- Select Edit the system environment variables.
- Click Environment Variables.
- Under System variables, select Path and click Edit.
- Click New and type C:\terraform.
- Click OK on all open dialogs to save.
- Close and reopen your VS Code terminal so the new PATH takes effect.
- Verify the installation by running:
terraform version
You should see Terraform v1.15.7 in the output.
Install AWS CLI
- Check if the AWS CLI is already installed by running:
aws --version
✔️ I see a version starting with aws-cli/2
AWS CLI v2 is ready. Move on to the next substep.
ⓧ I see aws-cli/1 or command not found
You need AWS CLI version 2. Follow these steps to install it.
- Download the AWS CLI v2 installer by running this command in your terminal:
msiexec.exe /i https://awscli.amazonaws.com/AWSCLIV2.msi
- The MSI installer wizard will open. Click Next through each screen and accept the license agreement, then click Install.
- Once it finishes, close and reopen your VS Code terminal.
- Verify by running:
aws --version
You should see output starting with aws-cli/2 followed by a version number.
Installation not working?
If terraform version still shows 'not recognized' after restarting your terminal, double-check that C:\terraform is in your system PATH and that terraform.exe is inside that folder.
If the AWS CLI MSI fails, try downloading the installer directly from https://awscli.amazonaws.com/AWSCLIV2.msi in your browser and running it manually.
Help me troubleshoot my Terraform or AWS CLI installation on Windows.
Create an IAM user for Terraform
Terraform needs credentials to create and manage resources in your AWS account. You will create a dedicated IAM user with an access key that Terraform can use to authenticate.
- Open the AWS Management Console in your browser and sign in.
- In the search bar at the top, type IAM and select IAM from the results.
- In the left sidebar, click Users.
- Click Create user.
- Enter terraform-cicd as the user name.
- Click Next.
- Select Attach policies directly.
- Search for AdministratorAccess and check the box next to it.
- Click Next, then click Create user.
Why AdministratorAccess?
For a learning project, AdministratorAccess keeps things simple by granting full permissions. In a production environment, you would use a least-privilege policy that only grants the specific permissions Terraform needs (like S3 and DynamoDB access).
- Click on your new terraform-cicd user to open its summary page.
- Click the Security credentials tab.
- Scroll down to Access keys and click Create access key.
- Select Command Line Interface (CLI) as the use case.
- Check the confirmation box at the bottom and click Next.
- Click Create access key.
Copy your credentials now
You will see your Access Key ID and Secret Access Key on screen. Copy both values and save them somewhere safe (like a text file on your Desktop). You will not be able to see the Secret Access Key again after you close this page.
- Click Done.
- Return to your VS Code terminal and configure the AWS CLI with your new credentials by running:
aws configure
What does aws configure ask for?
The command prompts you for four values. Enter them as follows:
- AWS Access Key ID: paste the Access Key ID you just copied.
- AWS Secret Access Key: paste the Secret Access Key you just copied.
- Default region name: enter us-east-1.
- Default output format: press Enter to accept the default (json).
- Verify your credentials are working by running:
aws sts get-caller-identity
You should see a JSON response showing your Account ID, User ID, and the ARN of your terraform-cicd user.
Seeing 'Unable to locate credentials'?
Run aws configure again and make sure you paste the Access Key ID and Secret Access Key without any extra spaces. Also confirm you entered us-east-1 for the region.
Help me fix my AWS CLI credentials configuration.
Store AWS credentials as GitHub Secrets
Your GitHub Actions workflows will need the same AWS credentials to deploy infrastructure. GitHub Secrets store sensitive values securely so they never appear in your code.
- Open GitHub in your browser (you should already be signed in).
- Click the + icon in the top right corner, then select New repository.
- Enter terraform-cicd-pipeline as the repository name.
- Leave the repository set to Public.
- Check Add a README file.
- Click Create repository.
Why a public repository?
Public repositories on GitHub get unlimited free GitHub Actions minutes. Private repositories have a limit of 2,000 minutes per month on the free plan.
- On your new repository page, click Settings in the top tab bar.
- In the left sidebar, expand Secrets and variables and click Actions.
- Click New repository secret.
- For Name, enter AWS_ACCESS_KEY_ID.
- For Secret, paste your Access Key ID value.
- Click Add secret.
- Click New repository secret again.
- For Name, enter AWS_SECRET_ACCESS_KEY.
- For Secret, paste your Secret Access Key value.
- Click Add secret.
You should now see both AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY listed on the Actions secrets page.
Don't see the Secrets and variables option?
Make sure you are on the Settings tab of your repository (not your account settings). The Secrets and variables section appears in the left sidebar under the repository's Settings page.
Help me find where to add repository secrets on GitHub.
Your development environment is fully set up. Next, you will create the remote state backend that keeps Terraform's state file safe and enables team collaboration.
Set Up Remote State Backend
Your tools are installed and your AWS credentials are configured. Now it's time to solve a foundational problem: where does Terraform store its knowledge of what infrastructure exists?
By default, Terraform keeps a state file on your local machine. A CI/CD pipeline running on a different server each time needs a shared, centralized location for state. In this step, you'll create a remote backend using S3 for storage and DynamoDB for locking.
In this step, get ready to:
- Create an S3 bucket and DynamoDB table for remote state storage and locking.
- Configure the Terraform backend to point to the remote state.
Create the bootstrap Terraform configuration
There's a chicken-and-egg problem here. You need an S3 bucket to store Terraform's state, but you can't create that bucket through the pipeline because the pipeline itself needs state to work.
The solution is a separate "bootstrap" configuration that you apply locally just once. This creates the infrastructure your pipeline depends on.
- In VS Code, create a new folder called state-bootstrap inside your project repository folder.
- Create a new file called main.tf inside the state-bootstrap folder.
- Paste the following code into state-bootstrap/main.tf:
terraform {
required_version = ">= 1.15.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
# Create an S3 bucket to store Terraform state files
resource "aws_s3_bucket" "terraform_state" {
bucket = "my-terraform-state-cicd-demo"
}
# Enable versioning so you can recover previous state files
resource "aws_s3_bucket_versioning" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
versioning_configuration {
status = "Enabled"
}
}
# Create a DynamoDB table for state locking
resource "aws_dynamodb_table" "terraform_lock" {
name = "terraform-state-lock"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
}
What does this code do?
This configuration creates two AWS resources that form your remote state backend:
- aws_s3_bucket and aws_s3_bucket_versioning create an S3 bucket with versioning enabled. Versioning lets you recover a previous state file if something goes wrong.
- aws_dynamodb_table creates a lock table. When Terraform runs, it writes a lock entry here to prevent two processes from modifying state at the same time.
- The LockID partition key is the specific field name Terraform expects for state locking.
S3 bucket names must be globally unique
If my-terraform-state-cicd-demo is already taken, append your name or a random number to make it unique (e.g. my-terraform-state-cicd-demo-jane42). Update the bucket name in both files you create in this step.
- Save state-bootstrap/main.tf.
- Open a terminal in VS Code by pressing Ctrl+` (backtick key).
- Navigate into the state-bootstrap folder by running this command:
cd state-bootstrap
- Initialize Terraform in this directory by running:
terraform init
You should see Terraform downloading the AWS provider and printing a success message.
- Create the S3 bucket and DynamoDB table by running:
terraform apply
What happens when you run terraform apply?
Terraform shows you a plan of what it will create (3 resources: the bucket, versioning config, and DynamoDB table). It then prompts you for confirmation.
When you see the prompt that says Do you want to perform these actions? followed by Enter a value: type yes and press Enter to confirm.
After a few seconds, you should see a message like Apply complete! Resources: 3 added, 0 changed, 0 destroyed. confirming your backend resources are live.
Seeing an access denied error?
Make sure your AWS CLI credentials are configured correctly. Run aws sts get-caller-identity to confirm your IAM user is active. If the bucket name is already taken, you'll see a BucketAlreadyExists error. Choose a different unique name and update your code.
Still stuck? Help me debug my terraform apply error for the state bootstrap
✔️ Awesome, I've got everything!
Great. Make sure you've saved your file and confirmed the apply completed successfully.
ⓧ I'd like to double check the full code
Here's the complete state-bootstrap/main.tf file:
terraform {
required_version = ">= 1.15.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
resource "aws_s3_bucket" "terraform_state" {
bucket = "my-terraform-state-cicd-demo"
}
resource "aws_s3_bucket_versioning" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_dynamodb_table" "terraform_lock" {
name = "terraform-state-lock"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
}
Configure the Terraform backend
Now that the remote state infrastructure exists, you need to tell your main project to use it. The backend block inside a terraform configuration tells Terraform where to read and write its state file.
- Navigate back to the project root by running:
cd ..
- Create a new file called main.tf in your project root (the repository folder, not inside state-bootstrap).
- Paste the following code into the root main.tf:
terraform {
required_version = ">= 1.15.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
# Store state remotely in the S3 bucket you just created
backend "s3" {
bucket = "my-terraform-state-cicd-demo"
key = "website/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-state-lock"
encrypt = true
}
}
provider "aws" {
region = "us-east-1"
}
What does the backend block do?
- bucket points to the S3 bucket you just created for storing state.
- key is the path inside the bucket where the state file lives. Using website/terraform.tfstate keeps it organized if you add more projects later.
- dynamodb_table enables state locking so two pipeline runs can't modify state simultaneously.
- encrypt = true ensures the state file is encrypted at rest in S3.
- Save main.tf.
- Initialize the backend by running:
terraform init
You should see a message that says Successfully configured the backend "s3"! confirming Terraform is now using your remote state bucket.
Seeing a backend configuration error?
Double-check that the bucket value in your backend block matches the exact bucket name you created in the previous substep. Also confirm you're running terraform init from your project root directory, not from inside state-bootstrap.
Need more help? Help me fix my Terraform S3 backend initialization error
✔️ Awesome, I've got everything!
Great. Double check you've saved main.tf and confirmed the backend initialized successfully.
ⓧ I'd like to double check the full code
Here's the complete root main.tf file:
terraform {
required_version = ">= 1.15.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
backend "s3" {
bucket = "my-terraform-state-cicd-demo"
key = "website/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-state-lock"
encrypt = true
}
}
provider "aws" {
region = "us-east-1"
}
Why a separate bootstrap directory?
The state-bootstrap directory uses a local state file (the default). Your main project uses the S3 backend. You can't use S3 as a backend for the code that creates the S3 bucket itself. Keeping them separate avoids this circular dependency.
Your remote state backend is live and locked down. Next up, you'll write the Terraform code that deploys an actual S3 static website through the pipeline.
Write the Infrastructure Code
Your remote state backend is ready to track changes. Now you need to define the actual infrastructure that the pipeline will deploy.
In this step, you will write the Terraform configuration for an S3 static website bucket. This gives you a simple, visible resource that proves the pipeline works. After a successful deploy, you will have a live URL to visit.
In this step, get ready to:
- Define the S3 static website bucket configuration in Terraform.
- Create the HTML content for the website.
- Add output values to display the website URL after deployment.
Create the Terraform configuration for an S3 static website bucket
AWS splits S3 website hosting across several resources. You need a bucket, a website configuration, public access settings, a bucket policy, and an object upload. Each resource handles one concern.
- In VS Code, open main.tf from the Explorer sidebar.
- Scroll to the bottom of the file (below the provider "aws" block).
- Add the S3 bucket and website configuration by pasting this code at the end of the file:
# Create the S3 bucket that will host the static website
resource "aws_s3_bucket" "website" {
bucket = "my-cicd-demo-website"
}
# Enable static website hosting on the bucket
resource "aws_s3_bucket_website_configuration" "website" {
bucket = aws_s3_bucket.website.id
index_document {
suffix = "index.html"
}
}
What does this code do?
The aws_s3_bucket resource creates a new bucket named my-cicd-demo-website. This is where your website files will live.
The aws_s3_bucket_website_configuration resource tells AWS to serve this bucket as a website. The index_document block sets index.html as the default page visitors see.
- Save main.tf.
- Verify your syntax is correct by running this command in the terminal:
terraform validate
- You should see Success! The configuration is valid. in the output.
Seeing an error from terraform validate?
- Check that your resource blocks are below the closing } of the provider "aws" block. Nesting resources inside the provider block causes a parse error.
- Make sure every opening { has a matching closing }. HCL is strict about braces.
- Help me fix my terraform validate error
- Next, add the public access configuration below the code you just pasted:
# Disable S3's default public access block so the bucket policy can allow reads
resource "aws_s3_bucket_public_access_block" "website" {
bucket = aws_s3_bucket.website.id
block_public_acls = false
block_public_policy = false
ignore_public_acls = false
restrict_public_buckets = false
}
# Allow anyone to read objects in the bucket (required for a public website)
resource "aws_s3_bucket_policy" "website" {
bucket = aws_s3_bucket.website.id
depends_on = [aws_s3_bucket_public_access_block.website]
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "PublicReadGetObject"
Effect = "Allow"
Principal = "*"
Action = "s3:GetObject"
Resource = "${aws_s3_bucket.website.arn}/*"
}
]
})
}
What does this code do?
S3 buckets block all public access by default. The aws_s3_bucket_public_access_block resource disables those protections so you can attach a public policy.
The aws_s3_bucket_policy resource grants s3:GetObject permission to everyone (Principal = "*"). The depends_on ensures Terraform removes the public access block before applying the policy. Without this ordering, AWS rejects the policy.
- Save main.tf.
- You won't see a visible change yet. The next chunk adds the file upload resource that completes the website.
Now you need an HTML file for the website to serve. The Terraform code will reference this file to upload it to S3.
- Create a src directory by running this command:
mkdir src
- In VS Code, right-click the src folder in the Explorer sidebar and select New File.
- Name the file index.html.
- Paste this HTML content into src/index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Deployed via CI/CD</title>
</head>
<body>
<h1>Hello from Terraform CI/CD!</h1>
<p>This website was deployed automatically via GitHub Actions.</p>
</body>
</html>
Why this simple HTML?
The content does not matter much here. The goal is to have a visible, deployed page that proves the pipeline works end to end. You will update this content through the pipeline later to confirm automatic deployments.
- Save src/index.html.
- Confirm the file exists by running:
ls src
- You should see index.html in the output.
Now add the Terraform resource that uploads this file to S3.
- Switch back to main.tf and paste this code below the bucket policy resource:
# Upload the index.html file to the S3 bucket
resource "aws_s3_object" "index" {
bucket = aws_s3_bucket.website.id
key = "index.html"
content = file("${path.module}/src/index.html")
content_type = "text/html"
}
What does this code do?
The aws_s3_object resource uploads your local src/index.html to the S3 bucket as index.html. The file() function reads the file contents at plan time. Setting content_type to text/html ensures browsers render it as a web page instead of downloading it.
- Save main.tf.
- Validate the full configuration by running:
terraform validate
- You should see Success! The configuration is valid. again.
Seeing a file not found error?
- Make sure the src folder is in the same directory as main.tf. The ${path.module} reference looks relative to the Terraform configuration file.
- Confirm the file is named exactly index.html (not index.htm or Index.html).
- Help me fix my file not found error
Add output values
Outputs let Terraform display useful values after a successful apply. You will add an output that shows the website URL so you can visit it immediately after the pipeline deploys.
- At the bottom of main.tf, paste this output block:
# Display the website URL after deployment
output "website_url" {
value = aws_s3_bucket_website_configuration.website.website_endpoint
description = "The URL of the S3 static website"
}
What does this code do?
The output block tells Terraform to print the website_endpoint attribute after every apply. This is the public URL where your static website is accessible.
The GitHub Actions workflow logs will show this URL so you can click straight to your deployed site.
- Save main.tf.
- Run a final validation to confirm everything is correct:
terraform validate
- You should see Success! The configuration is valid. in the output.
Validation failing after adding the output?
- Check that aws_s3_bucket_website_configuration.website is spelled exactly as shown. A typo in the resource reference causes an "unsupported attribute" error.
- Make sure the output block is outside (below) all resource blocks. It should not be nested inside any resource block.
- Help me debug my output block error
✔️ Awesome, I've got everything!
Great. Double check you have saved both main.tf and src/index.html.
ⓧ 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 = "my-terraform-state-cicd-demo"
key = "website/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-state-lock"
encrypt = true
}
}
provider "aws" {
region = "us-east-1"
}
resource "aws_s3_bucket" "website" {
bucket = "my-cicd-demo-website"
}
resource "aws_s3_bucket_website_configuration" "website" {
bucket = aws_s3_bucket.website.id
index_document {
suffix = "index.html"
}
}
resource "aws_s3_bucket_public_access_block" "website" {
bucket = aws_s3_bucket.website.id
block_public_acls = false
block_public_policy = false
ignore_public_acls = false
restrict_public_buckets = false
}
resource "aws_s3_bucket_policy" "website" {
bucket = aws_s3_bucket.website.id
depends_on = [aws_s3_bucket_public_access_block.website]
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "PublicReadGetObject"
Effect = "Allow"
Principal = "*"
Action = "s3:GetObject"
Resource = "${aws_s3_bucket.website.arn}/*"
}
]
})
}
resource "aws_s3_object" "index" {
bucket = aws_s3_bucket.website.id
key = "index.html"
content = file("${path.module}/src/index.html")
content_type = "text/html"
}
output "website_url" {
value = aws_s3_bucket_website_configuration.website.website_endpoint
description = "The URL of the S3 static website"
}
And here is src/index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Deployed via CI/CD</title>
</head>
<body>
<h1>Hello from Terraform CI/CD!</h1>
<p>This website was deployed automatically via GitHub Actions.</p>
</body>
</html>
Your infrastructure code is complete. Next up, you will create the GitHub Actions workflow that runs terraform plan automatically on every pull request.
Create the GitHub Actions Plan Workflow
Your infrastructure code is written and your remote state backend is storing Terraform's state securely. Now it's time to automate things.
In this step, you'll create a GitHub Actions workflow that automatically runs terraform plan every time someone opens a pull request. This gives your team a preview of what changes will happen before anything gets deployed.
In this step, get ready to:
- Create a GitHub Actions workflow file that runs Terraform plan on pull requests.
- Test the workflow by pushing code and opening a pull request.
Create the plan workflow file
GitHub Actions workflows live in a special directory called .github/workflows/ at the root of your repository. Each YAML file in this directory defines one workflow.
- Create the workflow directory by running this command in your terminal:
mkdir -p .github/workflows
- Confirm the directory was created by running:
ls .github
You should see workflows in the output.
Don't see the workflows folder?
Make sure you're running the command from your project folder (the one containing main.tf). The -p flag creates parent directories too, so both .github and workflows are created in one command.
Help me troubleshoot
- Create a new file called terraform-plan.yml inside the .github/workflows/ directory in VS Code.
- Copy the following code into terraform-plan.yml:
name: Terraform Plan
on:
pull_request:
branches:
- main
permissions:
contents: read
jobs:
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:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Setup Terraform
uses: hashicorp/setup-terraform@v4
with:
terraform_version: "1.15.7"
- name: Terraform Init
run: terraform init
- name: Terraform Validate
run: terraform validate
- name: Terraform Plan
run: terraform plan -no-color
What does this workflow do?
- on: pull_request tells GitHub to run this workflow whenever a pull request targets the main branch.
- permissions: contents: read limits the workflow's access to read-only, following the principle of least privilege.
- actions/checkout@v4 pulls your repository code into the runner so Terraform can access the configuration files.
- aws-actions/configure-aws-credentials@v4 authenticates the runner to AWS using the secrets you stored in your GitHub repository settings.
- hashicorp/setup-terraform@v4 installs Terraform 1.15.7 on the runner, matching the version you use locally.
- terraform plan -no-color generates the execution plan without color codes, which keeps the output readable in workflow logs.
Why does the workflow need AWS credentials?
Terraform needs to connect to AWS to read the remote state from your S3 backend and to calculate what changes are needed. Even though terraform plan doesn't create anything, it still needs access to compare your configuration against the real infrastructure.
- Save terraform-plan.yml.
YAML indentation errors?
YAML is sensitive to indentation. Make sure you're using spaces (not tabs) and that each nested level is indented by exactly 2 spaces. If you see red underlines in VS Code, check that your indentation matches the code above exactly.
Help me fix my YAML
✔️ Awesome, I've got everything!
Great. Double check you've saved your file.
ⓧ I'd like to double check the full code
name: Terraform Plan
on:
pull_request:
branches:
- main
permissions:
contents: read
jobs:
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:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Setup Terraform
uses: hashicorp/setup-terraform@v4
with:
terraform_version: "1.15.7"
- name: Terraform Init
run: terraform init
- name: Terraform Validate
run: terraform validate
- name: Terraform Plan
run: terraform plan -no-color
Test the plan workflow
To trigger the plan workflow, you need to push your code to GitHub and open a pull request. The workflow file itself must exist on the main branch first for GitHub to recognize it.
You'll push an initial commit to main that includes the workflow file, then create a feature branch with a small change to trigger the plan.
- Initialize a Git repository and make your initial commit by running these commands:
git init
git add main.tf src/ .github/ state-bootstrap/
git commit -m "Initial commit with Terraform infrastructure"
Why are we adding specific files?
The git add command targets specific directories to avoid committing the .terraform/ directory and local state files that Terraform created during terraform init and terraform apply. These files contain environment-specific data and should never be checked into version control.
- Connect your local repository to GitHub and push to main by running:
git branch -M main
git remote add origin [[REPO_URL="https://github.com/your-username/your-repo.git"]]
git push -u origin main
Remote origin already exists?
If you see an error saying the remote already exists, it means your repository was already linked. Skip the git remote add line and just run git push -u origin main.
Help me fix this Git error
Now that main has the workflow file, GitHub will recognize it for pull request triggers.
- Create a feature branch to test the plan workflow by running:
git checkout -b test-plan-workflow
You need a change on this branch for the pull request to have something to compare. A small edit to src/index.html works perfectly.
- In src/index.html, find this line:
<p>This website was deployed automatically via GitHub Actions.</p>
- Add a new line below it so it looks like this:
<p>This website was deployed automatically via GitHub Actions.</p>
<p>Testing the plan workflow.</p>
- Save src/index.html, then commit and push the change by running:
git add src/index.html
git commit -m "Test plan workflow"
git push origin test-plan-workflow
Now open a pull request on GitHub to trigger the workflow.
- Open your repository on GitHub in your browser.
- You should see a banner saying your branch had recent pushes. Click Compare & pull request.
- Leave the default title and click Create pull request.
What should I see?
After creating the pull request, scroll down to the checks section at the bottom. You'll see a yellow circle (running) next to Terraform Plan that turns into a green checkmark when the workflow completes.
Click Details next to the check to see the full workflow logs. In the Terraform Plan step, you'll see Terraform's plan output listing the resources it would create.
- Click Details next to the Terraform Plan check.
- Expand the Terraform Plan step in the logs to see the plan output.
You should see Terraform's plan output showing the resources it intends to create, including your S3 website bucket.
Workflow not running?
Make sure the workflow file exists on the main branch (not just on your feature branch). GitHub only triggers pull_request workflows if the YAML file is present on the default branch.
Also check that your pull request targets main as the base branch. The workflow only triggers for PRs targeting branches listed in the on.pull_request.branches array.
Help me troubleshoot my workflow
Your CI/CD pipeline now reviews every infrastructure change before it gets merged. Next up, you'll create the apply workflow that deploys changes automatically when a pull request merges to main.
Create the Apply Workflow and Deploy
Your plan workflow is catching every proposed change before it reaches production. That is the review half of a CI/CD pipeline.
Now you need the deployment half. In this step, you will create the apply workflow that runs terraform apply automatically when code merges to main. Then you will prove the full cycle works by deploying your website and shipping a live update through the pipeline.
In this step, get ready to:
- Create the apply workflow that deploys infrastructure on merge.
- Merge your pull request and verify the website is live.
- Ship a content change entirely through the pipeline.
Create the apply workflow file
The plan workflow runs on pull requests so you can review changes. The apply workflow runs on pushes to main so that merged code automatically becomes live infrastructure.
- In VS Code, create a new file at .github/workflows/terraform-apply.yml (inside the same .github/workflows/ folder that already holds your plan workflow).
- Paste the following workflow configuration into the file:
name: Terraform Apply
on:
push:
branches:
- main
permissions:
contents: read
jobs:
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:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Setup Terraform
uses: hashicorp/setup-terraform@v4
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?
- on: push: branches: [main] triggers this workflow only when commits land on the main branch (which happens after you merge a PR).
- The credential and Terraform setup steps are identical to the plan workflow. Both workflows need the same AWS access and Terraform version.
- terraform apply -auto-approve skips the interactive yes/no prompt. This is safe because the plan workflow already gave you a chance to review before merging.
Why no validate or plan step here?
The plan workflow already validated the code on the PR. By the time code reaches main, it has already passed terraform validate and produced a plan. Running them again would add time without catching new issues.
- Save terraform-apply.yml.
- Stage and commit the new file by running these commands in your terminal:
git add .github/workflows/terraform-apply.yml
git commit -m "Add apply workflow"
- Push the commit to your feature branch by running:
git push
Your open pull request updates automatically with the new commit. The plan workflow will re-run, which is expected.
Push rejected or branch not found?
- Make sure you are still on your feature branch by running git branch. The active branch has an asterisk next to it.
- If you accidentally switched to main, check out your feature branch again with git checkout followed by your branch name.
Having trouble with Git?
✔️ Awesome, I've got everything!
Great. Double check you have saved terraform-apply.yml and pushed the commit.
ⓧ I'd like to double check the full code
name: Terraform Apply
on:
push:
branches:
- main
permissions:
contents: read
jobs:
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:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Setup Terraform
uses: hashicorp/setup-terraform@v4
with:
terraform_version: "1.15.7"
- name: Terraform Init
run: terraform init
- name: Terraform Apply
run: terraform apply -auto-approve
Merge the pull request and verify deployment
Merging your PR pushes all committed code to main. That push event triggers the apply workflow, which provisions your S3 website bucket and uploads index.html.
- In your browser, return to the open pull request on GitHub.
- Wait for the plan workflow check to complete with a green checkmark.
- Click Merge pull request.
- Click Confirm merge.
What happens after you merge?
GitHub pushes the merged commits to main. Your apply workflow detects the push and starts a new run. Terraform initializes, connects to the remote state backend, and applies all your infrastructure configuration.
This creates the my-cicd-demo-website S3 bucket, enables static website hosting, attaches the public read policy, and uploads index.html. All without you running a single local command.
- Click the Actions tab at the top of your repository.
- Click the running (or completed) Terraform Apply workflow run.
- Click the terraform-apply job to expand the step logs.
- Expand the Terraform Apply step. You should see output ending with Apply complete! followed by the website_url output value.
Where is the website URL?
In the Terraform Apply step log, look for the Outputs: section near the bottom. The website_url value is the public URL of your S3 static website.
- Copy the website_url value from the workflow log.
- Paste it into a new browser tab and press Enter.
- You should see a page displaying "Hello from Terraform CI/CD!" with a paragraph below it.
Website not loading or showing an error?
- If you see a 403 Forbidden error, the bucket policy may not have applied yet. Wait 30 seconds and refresh.
- If the workflow failed, click the failed step in the Actions log to read the error message. Common causes are incorrect AWS credentials in the GitHub secrets or a bucket name that already exists globally.
- Make sure you are using the website_url output (the S3 website endpoint), not the general S3 bucket URL.
Help me debug my apply workflow
Make a change through the pipeline
The true test of a CI/CD pipeline is whether ongoing changes flow through it smoothly. You will now modify your website, push the change through a PR, and watch it deploy automatically.
- Switch back to main and pull the merged code by running:
git checkout main
git pull
- Create a new feature branch for your update by running:
git checkout -b update-website
- Open src/index.html in VS Code.
- Find this line:
<p>This website was deployed automatically via GitHub Actions.</p>
- Replace the paragraph text with something new so you can verify the update deployed. For example:
<p>This website was updated through a CI/CD pipeline - no manual deploy needed.</p>
- Save src/index.html.
- Stage, commit, and push your change by running:
git add src/index.html
git commit -m "Update website content"
git push origin update-website
- In GitHub, open a new pull request from update-website into main.
- Click the Actions tab. You should see the plan workflow running on your new PR.
- Wait for the plan to complete. The plan output shows that aws_s3_object.index will be updated.
- Return to your pull request and click Merge pull request, then Confirm merge.
- Click the Actions tab again. Watch the apply workflow run and complete.
- Refresh your website in the browser. You should see your updated paragraph text.
What just happened?
You completed the full GitOps cycle. A code change flowed from your editor, through a reviewed pull request, and into live infrastructure without anyone running a manual deploy command. This is the standard workflow DevOps teams use in production.
Website still showing old content?
- Hard-refresh the page with Cmd+Shift+R (macOS) or Ctrl+Shift+R (Windows) to bypass browser cache.
- Check the apply workflow log completed successfully in the Actions tab.
- Confirm you merged into main (not another branch) since the apply workflow only triggers on pushes to main.
Help me figure out why my website didn't update
Secret mission
Upgrade to OIDC Authentication
Your pipeline works, but it relies on long-lived access keys stored in GitHub Secrets. In production, teams eliminate static credentials entirely by using OpenID Connect (OIDC) federation. Your challenge: set up OIDC so GitHub Actions authenticates to AWS without any stored secrets.
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. All resources in this project fit within the AWS Free Tier for 12 months, so no charges are expected while your Free Tier is active.
Cost warning
AWS Free Tier benefits expire 12 months after your account creation date. After that, the S3 buckets and DynamoDB table could incur small storage charges (fractions of a cent per month for this project's data volume). If you are unsure when your Free Tier expires, check the Billing Dashboard in the AWS Console.
Resources you used:
- S3 bucket my-cicd-demo-website (static website).
- S3 bucket my-terraform-state-cicd-demo (Terraform state storage).
- DynamoDB table terraform-state-lock (state locking).
- IAM user with access keys.
- IAM OIDC Identity Provider for token.actions.githubusercontent.com (Secret Mission).
- IAM Role TerraformCICDRole (Secret Mission).
- GitHub repository with workflow files.
- Local project files on your Desktop.
Keep everything running
No action needed. Choose this if you plan to continue experimenting with your pipeline or want to keep the live website running.
- All resources fit within the AWS Free Tier for 12 months from account creation.
- The S3 buckets and DynamoDB table have no running compute costs.
- Your GitHub Actions workflows only consume minutes when triggered by pull requests or pushes.
Pause - I'll come back to this later
This project uses only storage resources with no active compute. There is nothing to shut down or pause. Your resources remain idle at no cost within the Free Tier.
- Your S3 buckets, DynamoDB table, and IAM resources stay in place at no cost.
- No GitHub Actions workflows run unless you open a PR or push to main.
- When you return, your pipeline will work exactly as you left it.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow these steps in order.
Destroy the Terraform-managed website resources:
- Open your terminal and navigate to your project's root directory (the folder containing main.tf).
- Run the following command to destroy all resources Terraform manages:
terraform destroy
- Type yes when prompted to confirm. This removes the my-cicd-demo-website bucket and all its objects.
Delete the Terraform state bucket:
- In the AWS Console, open the S3 service.
- Select the my-terraform-state-cicd-demo bucket.
- Click Empty. Enter permanently delete to confirm, then click Empty. This removes all object versions.
- Return to the bucket list, select my-terraform-state-cicd-demo again, and click Delete.
- Enter the bucket name to confirm and click Delete bucket.
Delete the DynamoDB lock table:
- In the AWS Console, open the DynamoDB service.
- Select Tables in the left navigation pane.
- Select the checkbox next to terraform-state-lock and click Delete.
- Confirm the deletion by entering delete and clicking Delete table.
Delete the IAM user:
- In the AWS Console, open the IAM service.
- Select Users in the left navigation pane.
- Select the IAM user you created for Terraform and click Delete.
- Enter the user name to confirm and click Delete.
Delete the OIDC Identity Provider (Secret Mission):
- Run the following command, replacing the ARN with your OIDC provider ARN (find it in IAM > Identity providers):
aws iam delete-open-id-connect-provider --open-id-connect-provider-arn [[OIDC_PROVIDER_ARN="arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"]]
Delete the IAM Role (Secret Mission):
- In the AWS Console, go to IAM > Roles.
- Search for TerraformCICDRole and select it.
- Click Delete, enter the role name to confirm, and click Delete.
Delete the GitHub repository:
- Go to your repository on GitHub.
- Click the Settings tab.
- Scroll down to the Danger Zone section.
- Click Delete this repository and follow the prompts to confirm by typing the repository name.
Delete local project files:
- Remove your project directory from the Desktop by running the appropriate command for your operating system:
macOS
rm -rf ~/Desktop/terraform-cicd-pipeline
Windows
Remove-Item -Recurse -Force ~\Desktop\terraform-cicd-pipeline
Nice Work!
Nice Work!
Nice work! You just built a production-grade CI/CD pipeline that deploys infrastructure automatically using Terraform and GitHub Actions.
You've learned how to:
- Set up a remote state backend with S3 and DynamoDB locking to manage Terraform state securely.
- Build GitHub Actions workflows that run terraform plan on pull requests and terraform apply on merge to main.
- Deploy and update a live S3 static website entirely through the CI/CD pipeline with no manual intervention.
- Secret Mission: Upgraded to OIDC authentication for credential-free pipeline security, eliminating long-lived AWS access keys.
Ready to quiz yourself?