GitOps Pipeline with Terraform
Deploy an S3 website using Terraform and a GitHub Actions GitOps pipeline.
Introduction
30 Second Summary
Every time you change your app's infrastructure, there's a risk of accidentally breaking something. The safest way to manage infrastructure is to define it in code that gets reviewed before anything changes.
In this project, you will build a GitOps pipeline that deploys an AWS website entirely through Terraform configuration files and GitHub Actions automation.
What You'll Build
A fully automated infrastructure pipeline that deploys and manages an AWS S3 static website through Terraform and GitHub Actions.
By the end of this project, you'll have:
- Terraform configuration that provisions an S3 bucket with static website hosting.
- A GitHub Actions pipeline that runs terraform plan on pull requests and terraform apply on merge to main.
- A complete GitOps workflow where every infrastructure change flows through code review before reaching production.
- Secret Mission: Add Terraform plan output as a PR comment so reviewers can see exactly what infrastructure will change before approving.
Are there any prerequisites?
You'll need an AWS account, a GitHub account, and Homebrew installed on macOS. Step 1 walks you through installing Terraform and configuring your AWS credentials.
Install Terraform and Configure AWS
Before you can define infrastructure as code, you need the tools that translate your configuration files into real cloud resources.
In this step, you will install Terraform (the CLI that reads your .tf files and provisions infrastructure) and configure the AWS CLI so Terraform can authenticate with your AWS account.
In this step, get ready to:
- Install the Terraform CLI and verify it works.
- Create an IAM user with S3 permissions and configure the AWS CLI with its credentials.
- Create an S3 bucket for storing Terraform state.
Install Terraform CLI via Homebrew
Terraform is a command-line tool that reads declarative configuration files and creates the infrastructure they describe.
You will install it using Homebrew, the package manager you already have on macOS.
- Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Terminal, and pressing Enter.
- Add the HashiCorp tap to Homebrew by running this command:
brew tap hashicorp/tap
- Install Terraform by running:
brew install hashicorp/tap/terraform
What do these commands do?
Homebrew organizes packages into "taps" (repositories). The first command adds HashiCorp's official tap so Homebrew knows where to find the Terraform package.
The second command installs the Terraform binary from that tap. This gives you the terraform command in your terminal.
- Verify the installation by checking your Terraform version:
terraform version
✔️ I see version 1.0 or higher
Terraform is installed and ready. You should see output showing a version like 1.15.3 or similar. Move on to the next substep.
ⓧ I see an older version
You have an outdated version of Terraform. Upgrade it by running:
brew update
brew upgrade hashicorp/tap/terraform
Run terraform version again to confirm you see 1.0 or higher.
ⓧ Command not found
Homebrew may not have linked Terraform to your PATH. Try running the install commands again:
brew tap hashicorp/tap
brew install hashicorp/tap/terraform
If you still see "command not found" after installing, close your terminal and open a new one, then run terraform version again.
Still having trouble installing Terraform?
Make sure Homebrew itself is up to date by running brew update first. If you get permission errors, try prefixing the command with sudo.
Help me troubleshoot installing Terraform via Homebrew on macOS
Create an IAM user and configure the AWS CLI
Terraform needs credentials to talk to AWS on your behalf. You will create a dedicated IAM user with permission to manage S3 buckets, then configure the AWS CLI so Terraform can use those credentials.
First, make sure the AWS CLI is installed. Check by running:
aws --version
✔️ I see a version number
The AWS CLI is ready. You should see output starting with aws-cli/2.x.x. Move on to creating your IAM user below.
ⓧ Command not found
Install the AWS CLI via Homebrew by running:
brew install awscli
After the install finishes, run aws --version again to confirm it works.
Now create an IAM user with S3 permissions in the AWS console.
- Open your browser and sign in to the AWS Management Console.
- Search for IAM in the top search bar and select IAM.
- Click Users in the left sidebar.
- Click Create user.
- Enter terraform-user as the user name.
- Click Next.
- Select Attach policies directly.
- Search for AmazonS3FullAccess and check the box next to it.
- Click Next.
- Click Create user.
What is IAM?
IAM (Identity and Access Management) controls who can do what in your AWS account. By creating a dedicated user with only S3 permissions, you limit what Terraform can touch.
This follows the principle of least privilege. The user can manage S3 buckets but nothing else.
Now create an access key for this user so Terraform can authenticate programmatically.
- Click on the terraform-user you just created.
- 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.
- Click Next.
- Click Create access key.
- Copy your Access key ID and Secret access key somewhere safe. You will not be able to see the secret key again after closing this page.
Save your secret access key now
AWS shows the secret access key only once. If you close this page without copying it, you will need to delete the key and create a new one.
Paste both values into a temporary text file or notes app before continuing.
Now configure the AWS CLI with these credentials. The aws configure command will prompt you for four values.
- Configure the AWS CLI by running:
aws configure
What will aws configure ask me?
The command prompts you for four values:
- AWS Access Key ID - paste the access key ID you just copied.
- AWS Secret Access Key - paste the secret access key you copied.
- Default region name - enter us-east-1.
- Default output format - press Enter to accept the default (or type json).
- Verify your credentials are configured correctly by running:
aws sts get-caller-identity
You should see a JSON response showing your account ID and the ARN of your terraform-user.
Seeing an access denied or invalid credentials error?
Double check that you pasted the access key ID and secret access key correctly when you ran aws configure. Run aws configure again to re-enter them.
Help me fix AWS CLI credential errors after running aws configure
Create an S3 bucket for Terraform state
Terraform tracks every resource it manages in a file called the state file. When you run Terraform from a GitHub Actions pipeline later, that pipeline needs access to this state file too.
Storing state in a remote S3 bucket solves this. Both your local machine and the CI/CD pipeline can read and write the same state.
- In the AWS console, search for S3 in the top search bar and select S3.
- Click Create bucket.
- Enter a globally unique bucket name. Use a pattern like yourname-terraform-state-2026 to make it unique.
- Set the AWS Region to US East (N. Virginia) us-east-1.
- Leave all other settings at their defaults.
- Click Create bucket.
Why a separate bucket for state?
Terraform state can contain sensitive information like resource IDs. Keeping it in a separate, private bucket with no public access is a security best practice.
This state bucket is also the one thing you will create manually. Everything else in this project will be managed by Terraform itself.
- Write down your state bucket name. You will reference it in your Terraform configuration in the next step.
You should see your new bucket listed in the S3 console.
Getting a bucket already exists error?
S3 bucket names must be globally unique across all AWS accounts. Try a more specific name, like adding your initials or today's date to the name.
Help me fix an S3 bucket name conflict
Finally, create your project folder on your Desktop so you have a clean workspace for the Terraform files you will write next.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir gitops-terraform-pipeline
cd gitops-terraform-pipeline
- Confirm the folder was created by running:
ls
The folder will be empty for now. You will fill it with Terraform configuration files in the next step.
Your tools are installed, your AWS credentials are configured, and your state bucket is ready. Next up, you will write the Terraform configuration that defines your website infrastructure as code.
Write Your Terraform Configuration
You've got Terraform installed and your AWS credentials configured. Now it's time to write the configuration files that define your infrastructure.
Terraform uses HCL (HashiCorp Configuration Language) files to describe what cloud resources you want. In this step, you'll create a set of files that tell Terraform exactly how to build an S3 website bucket, configure it for public access, and serve your HTML files.
In this step, get ready to:
- Create the project file structure with Terraform configuration files.
- Define the AWS provider, S3 backend, and S3 bucket resource with website hosting.
- Add a bucket policy for public access and upload HTML files as S3 objects.
Create the project structure with main.tf, variables.tf, and outputs.tf
Terraform projects split configuration across multiple files to keep things organized. Each file has a specific job.
- Create the project files and a website folder for your HTML content by running these commands:
touch main.tf variables.tf outputs.tf terraform.tfvars .gitignore
mkdir website
touch website/index.html website/error.html
- Confirm the files exist by running:
ls
You should see main.tf, variables.tf, outputs.tf, terraform.tfvars, .gitignore, and a website folder in the output.
What is each file for?
- main.tf holds the core infrastructure resources (your S3 bucket and its configuration).
- variables.tf defines input variables so you can reuse values without hardcoding them.
- outputs.tf prints useful information (like your website URL) after Terraform runs.
- terraform.tfvars sets the actual values for your variables.
- .gitignore keeps sensitive state files out of version control.
- Open variables.tf in your editor and paste the following code:
variable "aws_region" {
description = "AWS region for all resources"
type = string
default = "us-east-1"
}
variable "bucket_name" {
description = "Name of the S3 bucket for the website (must be globally unique)"
type = string
}
What does this code do?
You're defining two input variables that the rest of your Terraform configuration can reference.
- aws_region defaults to us-east-1 so you don't have to specify it every time.
- bucket_name has no default because S3 bucket names must be globally unique. You'll provide your own value in terraform.tfvars.
- Save variables.tf.
- Open outputs.tf and paste the following code:
output "website_endpoint" {
description = "URL of the S3 static website"
value = "http://${aws_s3_bucket_website_configuration.website.website_endpoint}"
}
output "bucket_name" {
description = "Name of the S3 bucket"
value = aws_s3_bucket.website.id
}
What does this code do?
Outputs are values Terraform prints after it finishes running.
- website_endpoint gives you the public URL of your deployed website.
- bucket_name confirms which bucket was created.
- Save outputs.tf.
- Open terraform.tfvars and paste this line:
bucket_name = "[[WEBSITE_BUCKET="your-unique-website-bucket-name"]]"
Why does the bucket name need to be unique?
S3 bucket names are globally unique across all AWS accounts. Pick a name that includes your name or a random string so it doesn't collide with anyone else's bucket.
- Save terraform.tfvars.
- Open .gitignore and paste the following:
.terraform/
*.tfstate
*.tfstate.backup
What does this code do?
This prevents Terraform's local cache (.terraform/) and any state files from being committed to Git. State files can contain sensitive information like resource IDs and should stay out of version control.
- Save .gitignore.
✔️ Awesome, I've got everything!
Great. Double check you've saved all four files before moving on.
ⓧ I'd like to double check the full code
Here's variables.tf:
variable "aws_region" {
description = "AWS region for all resources"
type = string
default = "us-east-1"
}
variable "bucket_name" {
description = "Name of the S3 bucket for the website (must be globally unique)"
type = string
}
Here's outputs.tf:
output "website_endpoint" {
description = "URL of the S3 static website"
value = "http://${aws_s3_bucket_website_configuration.website.website_endpoint}"
}
output "bucket_name" {
description = "Name of the S3 bucket"
value = aws_s3_bucket.website.id
}
Here's terraform.tfvars:
bucket_name = "your-unique-website-bucket-name"
Here's .gitignore:
.terraform/
*.tfstate
*.tfstate.backup
Define the AWS provider, S3 backend, and S3 bucket resource with website hosting configuration
The main.tf file is the heart of your project. It tells Terraform which cloud provider to use, where to store state, and what resources to create.
- Open main.tf and paste the following Terraform configuration block at the top of the file:
terraform {
required_version = ">= 1.0"
required_providers {
aws = {
source = "registry.terraform.io/hashicorp/aws"
version = ">= 5.0"
}
}
backend "s3" {
bucket = "[[STATE_BUCKET="your-terraform-state-bucket-name"]]"
key = "website/terraform.tfstate"
region = "us-east-1"
}
}
provider "aws" {
region = var.aws_region
}
What does this code do?
- required_version ensures anyone running this code has Terraform 1.0 or higher.
- required_providers tells Terraform to download the AWS provider plugin.
- backend "s3" stores your Terraform state in the S3 bucket you created in Step 1. This lets the GitHub Actions pipeline access state later.
- provider "aws" configures the AWS provider to use the region from your aws_region variable.
Use the same state bucket name from Step 1
The bucket value inside the backend "s3" block must match the state bucket you created in the previous step. Replace the placeholder with your actual bucket name.
- Below the provider block, paste the S3 bucket resource:
resource "aws_s3_bucket" "website" {
bucket = var.bucket_name
force_destroy = true
tags = {
Environment = "learning"
ManagedBy = "terraform"
}
}
What does this code do?
- resource "aws_s3_bucket" "website" creates an S3 bucket with the name from your bucket_name variable.
- force_destroy = true allows Terraform to delete the bucket even if it contains files. This makes cleanup easier at the end of the project.
- tags add metadata labels so you can identify this bucket's purpose in the AWS console.
- Below the bucket resource, paste the public access block and website configuration:
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_website_configuration" "website" {
bucket = aws_s3_bucket.website.id
index_document {
suffix = "index.html"
}
error_document {
key = "error.html"
}
}
What does this code do?
These two resources work together to turn your S3 bucket into a public website.
- aws_s3_bucket_public_access_block removes the default restrictions that prevent public access. S3 blocks public access by default for security.
- aws_s3_bucket_website_configuration enables static website hosting and tells S3 which files to serve for the homepage (index.html) and error pages (error.html).
- Save main.tf.
You won't see a change yet. The next substep adds the remaining resources and HTML files.
Seeing red underlines in your editor?
Your editor might flag references to resources that don't exist yet (like the bucket policy referencing the public access block). This is normal. The errors will resolve once all resources are added.
help me understand Terraform resource references
Add a bucket policy for public read access and upload HTML files as S3 objects
Your bucket exists but nobody can read from it yet. You need a bucket policy that grants public read access, and the actual HTML files to serve.
- In main.tf, paste the bucket policy resource below the website configuration block:
resource "aws_s3_bucket_policy" "website" {
bucket = aws_s3_bucket.website.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "PublicReadGetObject"
Effect = "Allow"
Principal = "*"
Action = "s3:GetObject"
Resource = "${aws_s3_bucket.website.arn}/*"
}
]
})
depends_on = [aws_s3_bucket_public_access_block.website]
}
What does this code do?
- This policy allows anyone (Principal = "*") to read objects from your bucket. Without it, visitors would get a 403 Forbidden error.
- depends_on ensures Terraform applies the public access block first. AWS rejects public policies if the block settings haven't been disabled yet.
- Below the bucket policy, paste the S3 object resources that upload your HTML files:
resource "aws_s3_object" "index" {
bucket = aws_s3_bucket.website.id
key = "index.html"
source = "${path.module}/website/index.html"
content_type = "text/html"
etag = filemd5("${path.module}/website/index.html")
}
resource "aws_s3_object" "error" {
bucket = aws_s3_bucket.website.id
key = "error.html"
source = "${path.module}/website/error.html"
content_type = "text/html"
etag = filemd5("${path.module}/website/error.html")
}
What does this code do?
- Each aws_s3_object resource uploads a local file to your S3 bucket.
- source points to the local HTML file in your website/ folder.
- etag uses a file hash so Terraform re-uploads the file only when its content changes.
- Save main.tf.
✔️ Awesome, I've got everything!
Great. Double check you've saved main.tf before moving on to the HTML files.
ⓧ I'd like to double check the full code
terraform {
required_version = ">= 1.0"
required_providers {
aws = {
source = "registry.terraform.io/hashicorp/aws"
version = ">= 5.0"
}
}
backend "s3" {
bucket = "your-terraform-state-bucket-name"
key = "website/terraform.tfstate"
region = "us-east-1"
}
}
provider "aws" {
region = var.aws_region
}
resource "aws_s3_bucket" "website" {
bucket = var.bucket_name
force_destroy = true
tags = {
Environment = "learning"
ManagedBy = "terraform"
}
}
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_website_configuration" "website" {
bucket = aws_s3_bucket.website.id
index_document {
suffix = "index.html"
}
error_document {
key = "error.html"
}
}
resource "aws_s3_bucket_policy" "website" {
bucket = aws_s3_bucket.website.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "PublicReadGetObject"
Effect = "Allow"
Principal = "*"
Action = "s3:GetObject"
Resource = "${aws_s3_bucket.website.arn}/*"
}
]
})
depends_on = [aws_s3_bucket_public_access_block.website]
}
resource "aws_s3_object" "index" {
bucket = aws_s3_bucket.website.id
key = "index.html"
source = "${path.module}/website/index.html"
content_type = "text/html"
etag = filemd5("${path.module}/website/index.html")
}
resource "aws_s3_object" "error" {
bucket = aws_s3_bucket.website.id
key = "error.html"
source = "${path.module}/website/error.html"
content_type = "text/html"
etag = filemd5("${path.module}/website/error.html")
}
Now you need the actual HTML files that Terraform will upload to S3.
- Open website/index.html and paste the following:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Platform Engineering - GitOps Demo</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 600px;
margin: 100px auto;
padding: 20px;
text-align: center;
background: #f8f9fa;
}
h1 { color: #2d3748; }
p { color: #4a5568; line-height: 1.6; }
.badge {
display: inline-block;
background: #48bb78;
color: white;
padding: 4px 12px;
border-radius: 12px;
font-size: 14px;
margin-top: 20px;
}
</style>
</head>
<body>
<h1>Infrastructure as Code</h1>
<p>This website was deployed automatically by Terraform through a GitOps pipeline.</p>
<p>Every change to this infrastructure goes through code review before it reaches production.</p>
<span class="badge">Deployed via GitOps</span>
</body>
</html>
What does this page do?
This is the homepage visitors see when they hit your S3 website URL. It's a simple page with a heading, two paragraphs, and a green badge. The content confirms that the site was deployed through your GitOps pipeline.
- Save website/index.html.
- Open website/error.html and paste the following:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Page Not Found</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 600px;
margin: 100px auto;
padding: 20px;
text-align: center;
}
h1 { color: #e53e3e; }
p { color: #4a5568; }
</style>
</head>
<body>
<h1>404</h1>
<p>This page does not exist.</p>
</body>
</html>
What does this page do?
S3 serves this page when a visitor requests a URL that doesn't match any file in the bucket. It shows a simple 404 message.
- Save website/error.html.
Not sure your files are in the right place?
Run ls website/ in your terminal. You should see index.html and error.html listed. If the website/ folder is empty, make sure you opened the files inside that folder (not in the project root).
help me check my Terraform project file structure
Your entire Terraform configuration is written and ready to deploy. Next up, you'll run the Terraform commands to turn these files into real AWS infrastructure.
Deploy Infrastructure Locally
Your Terraform configuration is complete. You have defined every resource your website needs, but nothing exists in AWS yet.
Terraform uses a three-command workflow to turn configuration into real infrastructure: terraform init, terraform plan, and terraform apply. In this step, you'll run all three and end up with a live website.
In this step, get ready to:
- Run terraform init to connect the backend and download providers.
- Run terraform plan to preview the infrastructure changes.
- Run terraform apply to create the resources and launch the website.
Initialize the Terraform working directory
Before Terraform can create anything, it needs to download the AWS provider plugin and connect to the S3 backend where your state will be stored.
- Initialize your project by running this command in your terminal:
terraform init
What does this command do?
The terraform init command performs three tasks at once.
- Connects to the S3 backend you configured in main.tf, so Terraform can store and retrieve state remotely.
- Downloads the AWS provider plugin matching the >= 5.0 version constraint.
- Creates a .terraform/ directory to cache the downloaded provider and a .terraform.lock.hcl file to lock the provider version.
- You should see output ending with Terraform has been successfully initialized! followed by a message suggesting you try terraform plan.
Seeing an error during init?
- If you see a message about the S3 bucket not existing, double-check that your state bucket name in the backend "s3" block of main.tf matches the bucket you created in the previous step.
- If you see a credentials error, confirm your AWS CLI is configured by running aws sts get-caller-identity. If that fails, re-run aws configure with your IAM access keys.
- If you see a region mismatch, make sure the region in your backend block (us-east-1) matches the region where your state bucket was created.
Why is terraform init failing for me?
Preview the infrastructure plan
Now that Terraform is initialized, you can ask it what it would create without actually creating anything. This is the plan phase.
- Preview the infrastructure Terraform will create by running:
terraform plan
What does this command do?
The terraform plan command compares your configuration to the current state (empty, since nothing exists yet) and shows exactly what it will create. It does not make any changes to real infrastructure.
Each resource in the output is prefixed with a + symbol, meaning it will be created. You'll see your S3 bucket, website configuration, public access block, bucket policy, and both HTML file objects listed.
- At the bottom of the output, you should see a summary line: Plan: 6 to add, 0 to change, 0 to destroy.
- This confirms Terraform will create all 6 resources defined in your configuration.
Plan shows errors instead of resources?
- If you see a variable-related error, make sure your terraform.tfvars file is in the same directory as main.tf and contains a valid bucket_name value.
- If you see a file-not-found error for website/index.html or website/error.html, confirm both files exist inside a website/ folder in your project directory.
Why is my terraform plan showing errors?
Apply the configuration and verify the live website
The plan looks good. Time to make it real. The terraform apply command will create all 6 resources in AWS.
- Deploy your infrastructure by running:
terraform apply
What happens when you run apply?
Terraform shows the same plan output you just reviewed, then asks for confirmation. It will display a prompt asking you to enter a value.
Only yes will be accepted to confirm. Typing anything else cancels the operation.
- When Terraform asks you to confirm, type yes and press Enter.
- Wait for Terraform to finish creating all resources. You should see Apply complete! Resources: 6 added, 0 changed, 0 destroyed. at the end of the output.
- Below the completion message, Terraform prints an Outputs section showing your website_endpoint URL. Copy this URL.
- Paste the URL into your browser. You should see a page with the heading "Infrastructure as Code" and a green "Deployed via GitOps" badge.
Website returning a 403 or 404 error?
- If you see a 403 Forbidden error, your AWS account may have account-level S3 Block Public Access enabled. In the AWS console, go to S3, click Block Public Access settings for this account in the left sidebar, and uncheck all four options.
- If you see a 404, make sure you are using the website_endpoint URL from the Terraform output (which includes s3-website in the domain), not the regular S3 bucket URL.
- Give it 30 seconds and refresh. S3 bucket policies sometimes take a moment to propagate.
Why is my S3 website not loading?
Your infrastructure is live and managed entirely by code. Next up, you'll push this code to GitHub and build a pipeline that automates the entire process.
Build the GitHub Actions Pipeline
Your Terraform code deploys a live website, but you still run terraform apply manually from your terminal.
In this step, you'll move that manual process into an automated pipeline using GitHub Actions. When code is pushed to main, the pipeline applies your infrastructure changes automatically. When you open a pull request, it runs terraform plan so you can review what will change before merging.
In this step, get ready to:
- Create a GitHub repository and push your Terraform code.
- Add AWS credentials as repository secrets so the pipeline can authenticate.
- Create a workflow file that automates the Terraform lifecycle on every push and pull request.
Create a GitHub repository and push your Terraform code
Your pipeline needs a Git repository so GitHub Actions can detect code changes and trigger workflow runs.
- Initialize a Git repository in your project folder by running this command:
git init
- Stage and commit all your Terraform files by running these commands:
git add .
git commit -m "Initial Terraform configuration"
What do these commands do?
- git init creates a new Git repository in your current directory.
- git add . stages every file in the folder for the next commit.
- git commit -m saves those staged files as a snapshot with a message describing the change.
Now you need a remote repository on GitHub to push this code to.
- Open your browser and go to github.com/new.
- Enter a repository name (for example, terraform-s3-pipeline).
- Leave the repository set to Public.
- Do NOT check any of the initialization options (no README, no .gitignore, no license).
- Click Create repository.
Why skip the initialization options?
You already have files locally. Adding a README or .gitignore during creation would create a conflict when you push. Starting with an empty remote lets you push your existing code cleanly.
GitHub displays setup instructions after creating the repository. You need the remote URL from that page.
- Copy the HTTPS URL from the GitHub setup page, then connect your local repo to GitHub and push by running these commands:
git remote add origin [[REPO_URL="https://github.com/YOUR_USERNAME/terraform-s3-pipeline.git"]]
git branch -M main
git push -u origin main
What do these commands do?
- git remote add origin links your local repository to the remote GitHub repository.
- git branch -M main renames the default branch to main.
- git push -u origin main uploads all your committed files to GitHub.
- Refresh your repository page on GitHub. You should see all your Terraform files (main.tf, variables.tf, outputs.tf, terraform.tfvars, .gitignore, and the website/ folder) listed in the repository.
Push rejected or authentication failed?
- If you see a password prompt, GitHub no longer accepts passwords for HTTPS pushes. You need a Personal Access Token (PAT) or to use SSH. Generate a PAT at github.com/settings/tokens and use it as your password when prompted.
- If the push is rejected because the remote contains commits, you likely checked one of the initialization options. Delete the repository on GitHub, recreate it without initialization, and try the push again.
Why won't my code push to GitHub?
Add AWS credentials as repository secrets
Your GitHub Actions pipeline needs AWS credentials to run Terraform commands against your AWS account. Repository secrets store sensitive values securely and inject them into workflow runs without exposing them in code.
- In your GitHub repository, click the Settings tab.
- In the left sidebar under the Security section, select Secrets and variables, then click Actions.
- Click the Secrets tab.
- Click New repository secret.
- In the Name field, enter AWS_ACCESS_KEY_ID.
- In the Secret field, paste the Access Key ID from the IAM user you created earlier.
- Click Add secret.
- Click New repository secret again to add the second secret.
- In the Name field, enter AWS_SECRET_ACCESS_KEY.
- In the Secret field, paste the Secret Access Key from the same IAM user.
- Click Add secret.
You should now see both AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY listed on the secrets page. The values are masked. GitHub never displays them again after creation.
Why repository secrets instead of hardcoding credentials?
Secrets are encrypted at rest and only decrypted during workflow execution. They never appear in logs or code. Hardcoding credentials in a repository would expose them to anyone who can view the code.
Create the workflow file
GitHub Actions workflows live in .github/workflows/ inside your repository. Each YAML file in that folder defines a pipeline that GitHub runs automatically based on triggers you specify.
- Create the workflow directory by running this command:
mkdir -p .github/workflows
- Create the workflow file .github/workflows/terraform.yml in your code editor and paste this content:
name: Terraform
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
terraform:
name: Terraform
runs-on: ubuntu-latest
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v6
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
- name: Terraform Format
id: fmt
run: terraform fmt -check
- name: Terraform Init
id: init
run: terraform init -input=false
- name: Terraform Validate
id: validate
run: terraform validate -no-color
- name: Terraform Plan
id: plan
if: github.event_name == 'pull_request'
run: terraform plan -no-color -input=false
continue-on-error: true
- name: Terraform Apply
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: terraform apply -auto-approve -input=false
What does this workflow do?
The workflow triggers on two events: pushes to main and pull requests targeting main.
- actions/checkout@v4 clones your repository code onto the runner.
- aws-actions/configure-aws-credentials@v6 injects your AWS secrets as environment variables so Terraform can authenticate.
- hashicorp/setup-terraform@v4 installs the Terraform CLI on the runner.
- terraform fmt -check verifies your code formatting is consistent.
- terraform init -input=false initializes the backend and downloads providers without prompting for input.
- terraform validate -no-color checks your configuration syntax.
- terraform plan runs only on pull requests so reviewers see what will change.
- terraform apply -auto-approve -input=false runs only on pushes to main, deploying changes automatically after merge.
- Save .github/workflows/terraform.yml.
✔️ Awesome, I've got everything!
Double check you've saved the file. The workflow YAML must be at exactly .github/workflows/terraform.yml for GitHub to detect it.
ⓧ I'd like to double check the full code
name: Terraform
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
terraform:
name: Terraform
runs-on: ubuntu-latest
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v6
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
- name: Terraform Format
id: fmt
run: terraform fmt -check
- name: Terraform Init
id: init
run: terraform init -input=false
- name: Terraform Validate
id: validate
run: terraform validate -no-color
- name: Terraform Plan
id: plan
if: github.event_name == 'pull_request'
run: terraform plan -no-color -input=false
continue-on-error: true
- name: Terraform Apply
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: terraform apply -auto-approve -input=false
- Commit and push the workflow file to GitHub by running these commands:
git add .github/workflows/terraform.yml
git commit -m "Add GitHub Actions workflow for Terraform"
git push
- In your GitHub repository, click the Actions tab.
- You should see a workflow run in progress or recently completed called Terraform. This was triggered by your push to main.
Why did the workflow run immediately?
Your workflow triggers on pushes to main. Pushing the workflow file itself is a push to main, so GitHub runs the pipeline. The Terraform Apply step executes because this is a push event. Since your infrastructure already exists, Terraform detects no changes and the apply completes with no modifications.
Workflow failed?
- Click the failed run to see which step errored. If Configure AWS Credentials failed, double-check that your secret names are exactly AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY (all uppercase, with underscores).
- If Terraform Init failed, verify that the S3 state bucket name in main.tf matches the bucket you created earlier.
- If Terraform Format failed, run terraform fmt locally, commit the changes, and push again.
My GitHub Actions Terraform workflow is failing. Help me debug.
Your CI/CD pipeline is live. Every push to main now triggers automatic infrastructure deployment. Next up, you'll test the full GitOps workflow by making a change through a pull request.
Test the GitOps Workflow
Your infrastructure pipeline is built and connected to GitHub. Now it's time for the moment of truth: making a real change and watching it flow through the entire GitOps cycle automatically.
In a GitOps workflow, you never apply infrastructure changes directly. Instead, you propose changes on a branch, review them through a pull request, and let the pipeline handle deployment on merge.
In this step, get ready to:
- Create a feature branch and modify the website content.
- Open a pull request and observe terraform plan running automatically.
- Merge the PR and verify terraform apply deploys the change.
Create a feature branch and modify the website content
In a GitOps workflow, every change starts on a feature branch. This keeps the main branch stable and gives the pipeline a chance to validate changes before they reach production.
- Create a new branch called update-website by running this command:
git checkout -b update-website
What does this command do?
The git checkout -b command creates a new branch and switches to it in one step. Your new update-website branch starts from the same point as main.
- In your code editor, open website/index.html.
- Find the <h1> line that currently says:
<h1>Infrastructure as Code</h1>
- Change the text between the <h1> tags to something new. For example, you could change it to Infrastructure as Code v2 or any text you like.
- Save website/index.html.
Why change the heading?
Terraform tracks the etag (file hash) of your HTML files. When the file content changes, the hash changes, and Terraform knows it needs to upload the new version to S3.
- Stage, commit, and push your change to the remote branch by running these commands:
git add .
git commit -m "Update website heading"
git push --set-upstream origin update-website
Git will push your new branch to GitHub and display a link to create a pull request.
Push rejected or authentication error?
Make sure you're pushing to the correct remote. Run git remote -v to confirm your remote URL points to your GitHub repository.
If you see a credentials error, your Git authentication may have expired. Try running git push again and re-enter your credentials when prompted.
Help me troubleshoot my git push failing with an authentication error.
Open a pull request and observe terraform plan running automatically
Now that your branch is on GitHub, you can open a pull request. This triggers the workflow you built in the previous step.
- Open your repository on GitHub in your browser.
- You should see a yellow banner near the top of the page saying your branch had recent pushes. Click Compare & pull request.
- Confirm the base branch is set to main and the compare branch is update-website.
- Enter a title like Update website heading for your pull request.
- Click Create pull request.
After you create the pull request, GitHub Actions automatically starts the Terraform workflow. You can watch it run in real time.
- Scroll down on the pull request page to the checks section at the bottom.
- Click Details next to the Terraform check to watch the workflow run.
- Wait for all steps to complete: format check, init, validate, and plan.
What should I see in the plan output?
The terraform plan step shows exactly what will change when this PR is merged. You should see that aws_s3_object.index will be updated because the file's etag hash has changed.
This is the core value of GitOps: you can review infrastructure changes before they happen, just like you review code changes.
Workflow failed?
If the format check fails, run terraform fmt locally, commit the formatting fix, and push again. The PR will re-run the workflow automatically.
If init or plan fails, check that your AWS credentials are correct in the repository secrets. The error message in the workflow logs will tell you exactly which step failed and why.
Help me figure out why my Terraform GitHub Actions workflow is failing.
Merge the PR and verify terraform apply deploys the change
The plan looks good and all checks have passed. In a team setting, this is where a colleague would review and approve. Since this is your own repository, you can merge it yourself.
- Return to the pull request page on GitHub (click the Conversation tab if you're viewing the workflow logs).
- Scroll to the bottom of the pull request.
- Click Merge pull request.
- Click Confirm merge.
Merging to main triggers a second workflow run. This time, the pipeline runs terraform apply to deploy your change automatically.
- Click the Actions tab in your repository.
- Click on the most recent workflow run (triggered by the merge to main).
- Watch the Terraform Apply step complete successfully.
Why does apply only run on main?
Look at the condition in your workflow file: if: github.ref == 'refs/heads/main' && github.event_name == 'push'. This ensures infrastructure only changes when code reaches the main branch. Pull requests get a plan (preview) but never apply changes.
- Once the workflow completes, visit your S3 website endpoint in your browser to verify the updated content is live.
You should see your new heading text displayed on the page. This change was deployed entirely through your GitOps pipeline: branch, PR, plan, merge, apply.
Website still showing old content?
Your browser might be caching the old page. Try a hard refresh with Cmd+Shift+R (macOS) or Ctrl+Shift+R (Windows).
If the apply step failed, check the workflow logs in the Actions tab. Common causes include expired AWS credentials or a mismatch between the state file and actual infrastructure.
Help me figure out why my website isn't showing the updated content after terraform apply.
What you just accomplished
You completed a full GitOps cycle. Every infrastructure change now flows through code review before reaching production. This is the exact workflow platform engineering teams use to manage infrastructure at scale.
Secret mission
Add Terraform Plan as a PR Comment
Platform engineers build tools that improve the developer experience. One common pattern is posting the Terraform plan output directly in the pull request as a comment. Reviewers can see exactly what infrastructure will change without leaving GitHub.
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 S3 buckets in this project cost fractions of a cent per month, so there is no urgency to tear them down.
Security reminder
Even though storage costs are negligible, the IAM user you created has programmatic access keys. If you are done with this project, deleting the IAM user removes a potential security exposure.
Resources you used:
- S3 website bucket (hosts the static site).
- S3 state bucket (stores Terraform state).
- IAM user with access keys (used by GitHub Actions and the AWS CLI).
- GitHub repository with stored secrets.
- Local project folder on your Desktop.
Keep everything running
No action needed. Choose this if you are still actively building or plan to extend the pipeline soon.
- Your S3 website stays live at its public endpoint.
- GitHub Actions continues to run on every push and PR.
- Ongoing cost is less than one cent per month for S3 storage.
Pause - I'll come back to this later
S3 has no compute costs, so there is nothing to pause in the traditional sense. The buckets sit idle at negligible cost. The only ongoing concern is the IAM access keys.
- Deactivate the IAM access keys to prevent any use while you are away. In the AWS IAM console, navigate to your Terraform user, select the access key, choose Actions, then Deactivate.
- You can reactivate the keys later when you return to continue building.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow each group of steps below.
Destroy the website infrastructure
- In your terminal, navigate to your project folder on the Desktop and run the following command:
terraform destroy
- Terraform lists every resource it plans to remove. Type yes when prompted to confirm.
- This removes the website bucket, its objects, the bucket policy, and the public access block.
Delete the Terraform state bucket
The state bucket is not managed by Terraform (it stores the state file itself), so you must delete it manually.
- Open the S3 console at https://console.aws.amazon.com/s3/.
- In the left navigation pane, choose General purpose buckets.
- Select the option button next to your state bucket name.
- Choose Empty, type permanently delete to confirm, then choose Empty.
- Return to the bucket list, select the state bucket again, and choose Delete at the top of the page.
- Enter the bucket name to confirm, then choose Delete bucket.
Delete the IAM user
- Open the IAM console and choose Users in the left navigation pane.
- Select the checkbox next to the Terraform IAM user you created.
- Choose Delete at the top of the page.
- Enter the username in the confirmation dialog, then choose Delete user.
Delete the GitHub repository
- On GitHub, navigate to your repository's main page.
- Click Settings.
- Scroll down to the Danger Zone section and click Delete this repository.
- Follow the confirmation prompts and type the repository name when asked.
Remove local project files
- Delete your local project folder by running the command for your operating system:
macOS
rm -rf ~/Desktop/[[PROJECT_FOLDER="your-terraform-project-folder-name"]]
Windows
Remove-Item -Recurse -Force ~\Desktop\[[PROJECT_FOLDER="your-terraform-project-folder-name"]]
That's a Wrap!
That's a Wrap!
Nice work! You just built the same infrastructure pipeline that platform engineering teams use every day to manage cloud resources at scale.
You've learned how to:
- Write Terraform configuration that provisions a complete S3 static website with public access, bucket policies, and HTML content.
- Deploy infrastructure using the full Terraform CLI lifecycle: terraform init, terraform plan, and terraform apply.
- Build a GitHub Actions pipeline that automates Terraform operations with format checking, validation, and conditional plan/apply logic.
- Complete a full GitOps cycle: branch, pull request, plan, review, merge, and apply.
- Added PR comments with plan output for a production-quality developer experience.
Ready to quiz yourself?