Automate AWS Infrastructure with Terraform
Build an IaC pipeline that provisions an S3 static website via CI/CD.
Introduction
30 Second Summary
Every time you click through a cloud console to create a resource, that knowledge lives only in your memory. Close the tab, and there is no record of what you changed or why.
In this project, you will build an automated infrastructure pipeline that provisions a live website on AWS using Terraform and deploys changes automatically through GitHub Actions.
What You'll Build
A complete Infrastructure as Code pipeline that provisions an S3 static website on AWS, with a CI/CD workflow that plans changes on pull requests and applies them on merge.
By the end of this project, you'll have:
- A Terraform configuration that provisions an S3 bucket with static website hosting.
- A GitHub Actions workflow that automatically plans infrastructure changes on pull requests and applies them on merge to main.
- A live website accessible via a public URL, fully managed through code.
- Secret Mission: Add a staging environment using Terraform workspaces for multi-environment deployment.
Are there any prerequisites?
You need basic command line familiarity. No prior Terraform, AWS, or CI/CD experience is needed.
Write Your First Terraform Configuration
In the previous step, you installed all the tools you need. Now it is time to write actual infrastructure code.
With Terraform, you describe the infrastructure you want in configuration files. Terraform reads those files, compares them to what currently exists, and creates or updates resources to match. In this step, you will write configuration that provisions an S3 bucket configured as a static website.
In this step, get ready to:
- Write a Terraform configuration defining an S3 bucket with static website hosting.
- Define variables and outputs for your infrastructure.
- Run Terraform commands to provision the bucket and get a live website URL.
Create your project directory and write the provider configuration
Every Terraform project lives in its own directory. Each .tf file in the directory is part of the configuration. Let's create the project folder first.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir terraform-s3-site
cd terraform-s3-site
- Confirm the folder exists by running:
ls
You should see the terraform-s3-site folder listed in the output.
- Open the terraform-s3-site folder in VS Code. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Select the terraform-s3-site folder on your Desktop, then click Open.
Now let's create the main configuration file. This file tells Terraform which cloud provider to use and what version constraints to follow.
- Create a new file called main.tf inside your terraform-s3-site folder. In VS Code, click the New File icon in the Explorer sidebar and name it main.tf.
- Paste the following code into main.tf:
terraform {
required_version = "~> 1.15"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.52"
}
}
}
provider "aws" {
region = var.aws_region
}
What does this code do?
The terraform block sets version constraints. required_version = "~> 1.15" means any Terraform 1.15.x release is allowed.
The required_providers block tells Terraform to download the AWS provider plugin from hashicorp/aws at version ~> 5.52.
The provider "aws" block configures the AWS provider. It reads the region from a variable you will define next. Terraform uses your local AWS credentials (environment variables) to authenticate.
Now let's add the S3 bucket and its website configuration below the provider block.
- Add the following code below the provider "aws" block in main.tf:
resource "aws_s3_bucket" "website" {
bucket = "${var.bucket_prefix}-static-site"
}
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?
The aws_s3_bucket resource creates an S3 bucket. The bucket name uses a variable prefix to ensure global uniqueness.
The aws_s3_bucket_website_configuration resource enables static website hosting on that bucket. It sets index.html as the homepage and error.html as the error page.
S3 buckets block public access by default. You need to explicitly allow public reads so visitors can view your website.
- Add the public access block configuration below the website configuration in main.tf:
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
}
Why set all four to false?
AWS has four separate public-access guards on every S3 bucket. For a public website, all four must be set to false so that the bucket policy (coming next) can grant read access to anyone.
- Add the bucket policy resource below the public access block in main.tf:
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 bucket policy do?
This IAM policy allows anyone (Principal = "*") to perform s3:GetObject on every object in the bucket. That is what makes your website files publicly readable.
The depends_on line tells Terraform to create the public access block before the policy. Without it, AWS might reject the policy because public access is still blocked.
- Save main.tf.
✔️ Awesome, I've got everything!
Great. Double check you have saved main.tf before moving on.
ⓧ I'd like to double check the full code
Here is the complete main.tf so far:
terraform {
required_version = "~> 1.15"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.52"
}
}
}
provider "aws" {
region = var.aws_region
}
resource "aws_s3_bucket" "website" {
bucket = "${var.bucket_prefix}-static-site"
}
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_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
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]
}
Create your variables and outputs files
Terraform separates variable definitions from the main configuration. This keeps your code reusable and makes it easy to change values without editing resource blocks.
- Create a new file called variables.tf in the same terraform-s3-site folder.
- Paste the following code into variables.tf:
variable "aws_region" {
description = "AWS region to deploy resources"
type = string
default = "us-east-1"
}
variable "bucket_prefix" {
description = "Prefix for the S3 bucket name (must be globally unique)"
type = string
}
What does this code do?
You are defining two input variables. aws_region defaults to us-east-1 so you do not have to provide it every time.
The bucket_prefix variable has no default, so Terraform will prompt you for a value. S3 bucket names are globally unique across all AWS accounts. Using your own prefix prevents naming collisions.
- Save variables.tf.
Next, create the outputs file. Outputs let Terraform display useful values after a successful apply.
- Create a new file called outputs.tf in the same folder.
- Paste the following code into outputs.tf:
output "website_url" {
description = "URL of the static website"
value = 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?
After Terraform creates the bucket, it will print the website_url so you know where to visit your live site. It also prints the bucket_name for reference.
- Save outputs.tf.
Finally, create a file to provide a value for your bucket_prefix variable so Terraform does not prompt you interactively.
- Create a new file called terraform.tfvars in the same folder.
- Paste the following into terraform.tfvars, replacing the placeholder with your own unique prefix (use your name or initials plus some numbers):
bucket_prefix = "[[BUCKET_PREFIX="your-unique-prefix e.g. jsmith-2026"]]"
Why does the prefix need to be unique?
S3 bucket names share a single global namespace across every AWS account in the world. If someone else already has my-static-site, you cannot use that name. Combining your name, initials, or a date makes collisions unlikely.
- Save terraform.tfvars.
Not sure what prefix to use?
Pick something like yourname-2026 or jd-nextwork-05. Keep it lowercase, use hyphens instead of spaces, and avoid special characters. Help me pick a unique bucket prefix
.
Run Terraform to provision your S3 website
You have four configuration files ready. Before Terraform can create resources, it needs to authenticate with AWS using the access keys you set up in Step 1.
- Set your AWS credentials as environment variables. In your terminal (still inside terraform-s3-site), run the following commands:
macOS
export AWS_ACCESS_KEY_ID=your-access-key-id-here
export AWS_SECRET_ACCESS_KEY=your-secret-access-key-here
Windows
$env:AWS_ACCESS_KEY_ID="your-access-key-id-here"
$env:AWS_SECRET_ACCESS_KEY="your-secret-access-key-here"
Why environment variables?
The AWS provider reads credentials from AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables automatically. This keeps secrets out of your configuration files so you never accidentally commit them to version control.
Now you will run the three core Terraform commands: init, plan, and apply.
- Initialize Terraform by running this command:
terraform init
You should see output ending with Terraform has been successfully initialized! and a note about running terraform plan.
What does terraform init do?
This command downloads the AWS provider plugin specified in your required_providers block. It creates a .terraform directory to store the plugin and a .terraform.lock.hcl lock file to pin the exact version for reproducibility.
Seeing an error during init?
Make sure your terminal is inside the terraform-s3-site folder where your .tf files live. Terraform looks for configuration files in the current directory.
If you see a network error, check your internet connection. Terraform needs to download the provider plugin from the registry. Help me debug my terraform init error
.
- Preview what Terraform will create by running:
terraform plan
Terraform reads your configuration, compares it to the current state (empty, since this is a fresh project), and shows what it plans to create. You should see output listing 4 resources to add.
What is terraform plan?
The plan command is a dry run. It shows exactly what Terraform will create, modify, or destroy without making any changes. Always review the plan before applying.
- Apply the configuration to create real AWS resources by running:
terraform apply
- Terraform will show the plan again and ask for confirmation. Type yes and press Enter.
After a few seconds, you should see output ending with Apply complete! Resources: 4 added, 0 changed, 0 destroyed. followed by the website_url output.
What is terraform apply?
The apply command executes the plan. It calls the AWS API to create each resource in the correct order (bucket first, then website config, then public access block, then policy). Terraform tracks what it created in a local terraform.tfstate file so it knows the current state next time you run a command.
- Copy the website_url value from the output and open it in your browser.
You should see a 404 error page because the bucket is empty. That is expected. The bucket exists and is publicly accessible. In the next step, you will upload website content.
Seeing an access denied or credential error?
Double check that you exported both AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in the same terminal session where you ran terraform apply. If you opened a new terminal window, the environment variables are lost.
Also verify your IAM user has the necessary S3 permissions. The user you created in Step 1 should have programmatic access with S3 permissions. Help me fix my AWS credential error in Terraform
.
Bucket name already exists?
If you see an error about the bucket name being taken, someone else in the world already has that name. Open terraform.tfvars and change your bucket_prefix to something more unique (add extra numbers or your initials). Then run terraform apply again. Help me pick a unique S3 bucket name
.
Your S3 bucket is live and publicly accessible. Next up, you will upload actual website content so visitors see a real page instead of a 404.
Add Website Content via Terraform
Your S3 bucket is live, but if you visit the website URL right now, you'll see an error page. That's because the bucket is empty.
In this step, you'll create HTML files and use Terraform to upload them to your bucket. This demonstrates a core Infrastructure as Code principle: even your content deployment is defined in code and repeatable.
In this step, get ready to:
- Create an HTML homepage and error page for your website.
- Add Terraform resources that upload files to S3.
- Deploy the content and see your live website.
Create your website files
You need two HTML files: a homepage that visitors see by default, and an error page for broken links. You'll store them in a website/ subdirectory to keep your project organized.
- Create the website directory inside your project folder by running this command:
mkdir website
- Confirm the directory was created by running:
ls
You should see website listed alongside your .tf files.
- Create a new file called index.html inside the website/ directory in your code editor.
- Paste the following HTML into website/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 with Terraform</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
max-width: 600px;
margin: 100px auto;
padding: 20px;
text-align: center;
background: #f8f9fa;
}
h1 { color: #7b42f6; }
p { color: #555; line-height: 1.6; }
.badge {
display: inline-block;
background: #7b42f6;
color: white;
padding: 8px 16px;
border-radius: 20px;
font-size: 14px;
margin-top: 20px;
}
</style>
</head>
<body>
<h1>Hello from Terraform!</h1>
<p>This website was provisioned with Infrastructure as Code and deployed automatically via a CI/CD pipeline.</p>
<span class="badge">Powered by Terraform + GitHub Actions + AWS S3</span>
</body>
</html>
What does this page do?
This is a simple styled HTML page with a purple heading, a description paragraph, and a colored badge at the bottom. It confirms your Terraform pipeline is working when you see it live.
- Save website/index.html.
- Now create a second file called error.html inside the same website/ directory.
- Paste the following HTML into website/error.html:
<!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', sans-serif;
max-width: 600px;
margin: 100px auto;
padding: 20px;
text-align: center;
background: #f8f9fa;
}
h1 { color: #e74c3c; }
p { color: #555; }
</style>
</head>
<body>
<h1>404 - Page Not Found</h1>
<p>The page you're looking for doesn't exist.</p>
</body>
</html>
What does this page do?
This is the page visitors see when they navigate to a URL that doesn't exist in your bucket. The S3 website configuration you wrote in the previous step already points to error.html as the error document.
- Save website/error.html.
- Confirm both files exist by running:
ls website/
You should see index.html and error.html in the output.
Don't see both files?
Make sure you created both files inside the website/ subdirectory, not in the project root. Check for typos in the filenames.
help me find where my HTML files were saved
Add S3 object resources to your Terraform configuration
Your HTML files exist locally, but Terraform doesn't know about them yet. You need to define aws_s3_object resources that tell Terraform to upload these files to your S3 bucket.
- Open main.tf in your code editor.
- Scroll to the bottom of the file, after the aws_s3_bucket_policy resource.
- Add the following two resources at the end of the file:
resource "aws_s3_object" "index" {
bucket = aws_s3_bucket.website.id
key = "index.html"
source = "website/index.html"
content_type = "text/html"
etag = filemd5("website/index.html")
}
resource "aws_s3_object" "error" {
bucket = aws_s3_bucket.website.id
key = "error.html"
source = "website/error.html"
content_type = "text/html"
etag = filemd5("website/error.html")
}
What do these resources do?
Each aws_s3_object resource uploads a local file to your S3 bucket. The key is the filename it gets in the bucket, and source points to the local file.
The etag uses filemd5() to calculate a hash of the file contents. This tells Terraform to re-upload the file whenever its content changes.
- Save main.tf.
Seeing a syntax error?
Make sure the new resources are placed after the closing } of the aws_s3_bucket_policy resource, not inside it. Each resource block needs its own opening and closing braces.
help me fix a syntax error in my main.tf
✔️ Awesome, I've got everything!
Great. Make sure you've saved main.tf before running Terraform in the next substep.
ⓧ I'd like to double check the full code
terraform {
required_version = "~> 1.15"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.52"
}
}
}
provider "aws" {
region = var.aws_region
}
resource "aws_s3_bucket" "website" {
bucket = "${var.bucket_prefix}-static-site"
}
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_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
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 = "website/index.html"
content_type = "text/html"
etag = filemd5("website/index.html")
}
resource "aws_s3_object" "error" {
bucket = aws_s3_bucket.website.id
key = "error.html"
source = "website/error.html"
content_type = "text/html"
etag = filemd5("website/error.html")
}
Deploy the content with Terraform
Your configuration now describes two new objects to upload. Time to preview the changes and apply them.
- Preview what Terraform will do by running:
terraform plan
What should I see?
Terraform shows a plan with 2 resources to add: aws_s3_object.index and aws_s3_object.error. Everything else remains unchanged.
Look for the line that says Plan: 2 to add, 0 to change, 0 to destroy at the bottom of the output.
- Apply the changes by running:
terraform apply
- When Terraform asks you to confirm, type yes and press **Enter**.
Terraform uploads both HTML files to your S3 bucket. You should see Apply complete! Resources: 2 added, 0 changed, 0 destroyed. in the output.
- Copy the website_url value from the Terraform output.
- Open it in your browser by prefixing http:// before the URL (S3 website endpoints use HTTP, not HTTPS).
You should see a page with a purple "Hello from Terraform!" heading and a badge that says "Powered by Terraform + GitHub Actions + AWS S3".
Seeing a 404 or Access Denied?
If you see your error.html page, the bucket is working but the index file may not have uploaded correctly. Run terraform plan again to check for pending changes.
If you see "Access Denied", the bucket policy may not have applied yet. Wait 30 seconds and refresh. S3 bucket policies can take a moment to propagate.
help me troubleshoot my Terraform S3 website
Your website is live and fully managed through code. Next up, you'll move your Terraform state to a remote backend so it's safe, shareable, and ready for a CI/CD pipeline.
Store Terraform State Remotely
Your website is live and Terraform is managing all the infrastructure. But right now, Terraform tracks everything it created in a local file called terraform.tfstate on your machine.
This is a problem for teamwork and automation. If someone else runs Terraform from their machine, they won't have your state file. In this step, you'll move Terraform's state to a shared S3 bucket so that anyone (or any CI/CD pipeline) can access it.
In this step, get ready to:
- Create a separate S3 bucket to store Terraform state.
- Add a remote backend configuration to your Terraform project.
- Migrate your local state file to the remote S3 backend.
Create the state bucket via AWS CLI
Terraform needs a place to store its state file that is accessible from anywhere. You'll create a dedicated S3 bucket just for state storage, separate from your website bucket.
- Choose a globally unique name for your state bucket. A good pattern is yourname-terraform-state (replace with your actual name or a unique identifier).
- Create the state bucket by running this command in your terminal:
aws s3api create-bucket --bucket [[STATE_BUCKET="your-terraform-state-bucket-name"]] --region us-east-1
What does this command do?
This uses the AWS CLI to create a new S3 bucket specifically for storing Terraform state files. You create it manually (outside of Terraform) because Terraform needs this bucket to already exist before it can use it as a backend.
For us-east-1, no additional location configuration is needed because it is the default AWS region.
- You should see a JSON response containing a Location field with your bucket's URL. This confirms the bucket was created successfully.
Why not manage the state bucket with Terraform?
This is a chicken-and-egg problem. Terraform needs the state bucket to exist before it can store state there. If Terraform managed its own state bucket, it would have nowhere to record that it created the bucket. The standard practice is to create the state bucket manually or with a separate bootstrap script.
Seeing a BucketAlreadyExists error?
S3 bucket names are globally unique across all AWS accounts. If your chosen name is taken, try adding random numbers or your initials to make it unique.
Help me fix my S3 bucket creation error.
Add the backend configuration to your Terraform project
Now you'll tell Terraform to store its state in the S3 bucket you just created instead of locally on your machine.
- In main.tf, find the terraform {} block at the top of the file. It currently looks like this:
terraform {
required_version = "~> 1.15"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.52"
}
}
}
- Add a backend "s3" block inside the terraform {} block, after the required_providers block. Replace the bucket name with the name of the state bucket you just created:
terraform {
required_version = "~> 1.15"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.52"
}
}
# Store state remotely in S3 so CI/CD and teammates can access it
backend "s3" {
bucket = "your-terraform-state-bucket-name"
key = "static-site/terraform.tfstate"
region = "us-east-1"
}
}
What does this backend block do?
- bucket is the name of the S3 bucket you created for state storage.
- key is the file path inside the bucket where Terraform will write the state file.
- region tells Terraform which AWS region the state bucket lives in.
- Save main.tf.
Not sure what to put for the bucket name?
Use the exact bucket name you chose when running the aws s3api create-bucket command in the previous substep. The bucket value in the backend block must match exactly.
Help me configure my Terraform S3 backend block.
Migrate your local state to the remote backend
Terraform already has state from your previous runs stored locally. You need to tell Terraform to pick up the new backend configuration and move the existing state to S3.
- Run the following command to re-initialize Terraform with the new backend:
terraform init -migrate-state
- Terraform will ask if you want to copy the existing state to the new backend. Type yes and press Enter.
What does this command do?
The -migrate-state flag tells Terraform to detect the backend change and safely copy your existing local state into the remote S3 bucket. After migration, Terraform reads and writes state from S3 instead of the local file.
- You should see a message confirming the backend was successfully configured. Terraform is now storing state in your S3 bucket at the path static-site/terraform.tfstate.
Seeing an access denied error?
Make sure your AWS credentials (the IAM user you set up in Step 1) have permission to read and write to the state bucket. The IAM user needs S3 access for both your website bucket and the state bucket.
Help me fix my Terraform state migration error.
✔️ Awesome, I've got everything!
Great. Double check you've saved main.tf and that your migration completed successfully.
ⓧ I'd like to double check the full code
Here is the complete main.tf file after adding the backend block:
terraform {
required_version = "~> 1.15"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.52"
}
}
backend "s3" {
bucket = "your-terraform-state-bucket-name"
key = "static-site/terraform.tfstate"
region = "us-east-1"
}
}
provider "aws" {
region = var.aws_region
}
resource "aws_s3_bucket" "website" {
bucket = "${var.bucket_prefix}-static-site"
}
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_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
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 = "website/index.html"
content_type = "text/html"
etag = filemd5("website/index.html")
}
resource "aws_s3_object" "error" {
bucket = aws_s3_bucket.website.id
key = "error.html"
source = "website/error.html"
content_type = "text/html"
etag = filemd5("website/error.html")
}
Your infrastructure state is now safely stored in S3 where both your teammates and your CI/CD pipeline can reach it. Next up, you'll push this project to GitHub and build the automated pipeline that plans and applies changes for you.
Build the GitHub Actions CI/CD Pipeline
Your Terraform state is safely stored in S3, so any machine with the right credentials can manage your infrastructure. Now it's time to remove yourself from the loop entirely.
In this step, you'll create a CI/CD pipeline using GitHub Actions that automatically runs terraform plan on pull requests and terraform apply when code merges to main.
In this step, get ready to:
- Push your Terraform project to a new GitHub repository.
- Store AWS credentials as GitHub repository secrets.
- Create a workflow file that plans and applies infrastructure changes automatically.
Push your project to GitHub
For GitHub Actions to run your Terraform code, the project needs to live in a GitHub repository. You'll initialize a local Git repository, then push it to GitHub.
- Initialize a Git repository in your project folder by running this command:
git init
- Stage your Terraform files by running:
git add main.tf variables.tf outputs.tf terraform.tfvars website/
Why not git add . ?
The .terraform/ directory contains downloaded provider binaries that can be hundreds of megabytes. You never want to commit these to version control. By adding specific files, you keep your repository lean.
- Commit your files by running:
git commit -m "Initial Terraform configuration"
- Open GitHub in your browser.
- In the upper-right corner of any page, click the + icon, then click New repository.
- Enter terraform-s3-pipeline as the repository name.
- Select Public for the repository visibility.
- Leave all other options unchecked (no README, no .gitignore, no license).
- Click Create repository.
Why leave the repository empty?
You already have a local Git history from the commit you just made. Adding a README or .gitignore on GitHub would create a conflicting commit that makes your first push more complicated.
- Connect your local repository 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 repo to the GitHub repository you just created.
- git branch -M main renames your current branch to main (the standard default branch name).
- git push -u origin main uploads your code to GitHub and sets the tracking branch.
- Refresh your GitHub repository page in the browser.
- You should see your main.tf, variables.tf, outputs.tf, terraform.tfvars, and website/ folder listed in the repository.
Push rejected or authentication failed?
If Git asks for credentials, GitHub no longer accepts passwords for HTTPS pushes. You need a Personal Access Token (PAT) instead. Go to Settings > Developer settings > Personal access tokens in GitHub to generate one with repo scope.
If you see "failed to push some refs," make sure you did NOT check "Add a README file" when creating the repository on GitHub.
help me push my Terraform project to GitHub
Store your AWS credentials as repository secrets
Your GitHub Actions workflow needs AWS credentials to run Terraform commands against your account. GitHub encrypts repository secrets so they stay safe while being available to your workflows.
- On your GitHub repository page, click Settings.
- In the Security section of the sidebar, select Secrets and variables, then click Actions.
- Click the Secrets tab.
- Click New repository secret.
- In the Name field, type AWS_ACCESS_KEY_ID.
- In the Secret field, paste the Access Key ID you saved from your IAM user setup.
- Click Add secret.
- Click New repository secret again to add the second secret.
- In the Name field, type AWS_SECRET_ACCESS_KEY.
- In the Secret field, paste the Secret Access Key from your IAM user setup.
- Click Add secret.
- You should now see both AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY listed on the repository secrets page with their values hidden.
Why use secrets instead of hardcoding credentials?
GitHub encrypts secrets using libsodium sealed boxes before they reach GitHub's servers. They're automatically redacted from workflow logs, so even if a command accidentally prints them, they appear as ***.
Hardcoding credentials in your workflow file would expose them to anyone who can see your repository.
Don't see the Settings tab?
Only repository owners can access Settings. Make sure you're viewing your own repository, not a fork or someone else's repo.
help me find repository settings to add secrets in GitHub
Create the GitHub Actions workflow file
GitHub Actions reads workflow definitions from YAML files in the .github/workflows/ directory. You'll create a single workflow that handles both planning and applying infrastructure changes.
- Create the workflow directory by running:
mkdir -p .github/workflows
- Create a new file called terraform.yml inside .github/workflows/ in your code editor.
- Copy the following workflow configuration into the file:
name: Terraform
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
pull-requests: write
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_REGION: us-east-1
jobs:
terraform:
name: Terraform
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v4
with:
terraform_version: "1.15.3"
- name: Terraform Init
run: terraform init
- name: Terraform Format
run: terraform fmt -check
- name: Terraform Plan
run: terraform plan -input=false -var="bucket_prefix=[[BUCKET_PREFIX="your-unique-prefix"]]"
- name: Terraform Apply
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: terraform apply -auto-approve -input=false -var="bucket_prefix=[[BUCKET_PREFIX="your-unique-prefix"]]"
What does this workflow do?
- on: triggers the workflow on pushes to main and on pull requests targeting main.
- permissions: grants the workflow read access to your code and write access to pull request comments.
- env: injects your AWS secrets as environment variables so Terraform can authenticate.
- actions/checkout@v4 clones your repository onto the runner.
- hashicorp/setup-terraform@v4 installs Terraform 1.15.3 on the runner.
- terraform fmt -check fails the workflow if any file has incorrect formatting.
- terraform plan runs on every trigger to show proposed changes.
- terraform apply only runs when code is pushed directly to main (the if: condition prevents it from running on PRs).
- Save .github/workflows/terraform.yml.
- Commit and push the workflow file by running:
git add .github/workflows/terraform.yml
git commit -m "Add GitHub Actions workflow"
git push
- In your GitHub repository, click the Actions tab.
- You should see a workflow run in progress or recently completed called "Add GitHub Actions workflow."
Workflow not appearing in Actions tab?
Make sure the file is at exactly .github/workflows/terraform.yml (note the dot prefix on .github). GitHub only detects workflows from this specific directory path.
Check that you pushed to the main branch. The workflow is configured to trigger on push to main only.
help me debug why my GitHub Actions workflow isn't running
✔️ Awesome, I've got everything!
Great. Double check you've saved your file and that the Actions tab shows a workflow run.
ⓧ I'd like to double check the full code
Here is the complete workflow file:
name: Terraform
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
pull-requests: write
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_REGION: us-east-1
jobs:
terraform:
name: Terraform
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v4
with:
terraform_version: "1.15.3"
- name: Terraform Init
run: terraform init
- name: Terraform Format
run: terraform fmt -check
- name: Terraform Plan
run: terraform plan -input=false -var="bucket_prefix=[[BUCKET_PREFIX="your-unique-prefix"]]"
- name: Terraform Apply
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: terraform apply -auto-approve -input=false -var="bucket_prefix=[[BUCKET_PREFIX="your-unique-prefix"]]"
Your CI/CD pipeline is live. Next up, you'll test the full workflow by opening a pull request and watching Terraform plan your changes automatically.
Test the Pipeline End-to-End
Your CI/CD pipeline is configured, but you haven't actually seen it run yet. The real power of this setup is the feedback loop: propose a change, see exactly what Terraform will do, then deploy with confidence.
In this step, you'll simulate a real development workflow. You'll create a branch, make a change to your website, open a pull request, and watch GitHub Actions handle the rest.
In this step, get ready to:
- Create a feature branch and modify your website content.
- Open a pull request and observe the plan workflow.
- Merge the PR and confirm the apply workflow deploys your update.
Create a branch and update the website
In a professional workflow, you never push changes directly to main. You create a feature branch, make your changes there, and open a pull request for review.
- Create a new branch called update-website by running this command:
git checkout -b update-website
- Open website/index.html in your editor.
- Find the <p> tag and change its text to something different so you can confirm the pipeline deployed your update. For example, change it to:
"This website was updated through a CI/CD pipeline. Infrastructure as Code in action."
The exact wording doesn't matter. Pick any text that's visibly different from what's currently on the page so you can confirm the deployment worked.
- Save website/index.html.
- Stage, commit, and push your branch by running these commands:
git add website/index.html
git commit -m "Update website content"
git push origin update-website
What do these commands do?
- git add stages the file for commit.
- git commit saves a snapshot of the change with a message.
- git push origin update-website uploads the branch to GitHub, where the pipeline can see it.
You should see Git output confirming the branch was pushed to GitHub.
Push rejected or authentication error?
- If Git asks for credentials, make sure you've set up a personal access token or SSH key with your GitHub account.
- If the push is rejected, confirm you're on the update-website branch (not main) by running git branch.
Help me troubleshoot my git push
Open a pull request and observe the plan
When you open a pull request targeting main, your workflow triggers automatically. The pipeline runs terraform plan so you can see exactly what will change before anything is applied.
- Open your repository on GitHub in your browser.
- You should see a banner saying your update-website branch had recent pushes, with a Compare & pull request button.
- Click Compare & pull request.
- Leave the title and description as defaults.
- Click Create pull request.
The workflow starts running immediately. You can watch it in real time.
- Click the Actions tab at the top of your repository.
- Click the running workflow (it will have your commit message as the title).
- Click the Terraform job to see the step-by-step logs.
- Expand the Terraform Plan step to see the proposed changes.
What should I see in the plan output?
The plan output shows which resources Terraform will modify. Since you changed index.html, you should see the aws_s3_object.index resource marked for update. The etag value changes because the file content is different.
Notice that the Terraform Apply step is skipped. The if condition in your workflow prevents apply from running on pull requests. This is the safety net that lets you review changes before they go live.
You should also see a green checkmark on the pull request page indicating all checks passed.
Workflow failed with a red X?
- If the format check failed, run terraform fmt locally, commit the formatted files, and push again.
- If init failed, check that your AWS credentials in Settings > Secrets and variables > Actions are correct and that your state bucket still exists.
- If plan failed with a permission error, verify the IAM user has the required S3 permissions.
Help me debug my failed GitHub Actions workflow
Merge the PR and verify the deployment
The plan looks good. Now when you merge, the push to main triggers the workflow again. This time the apply step runs because the condition is met.
- Navigate back to your pull request page on GitHub.
- Click Merge pull request.
- Click Confirm merge.
This push to main kicks off a second workflow run. This one will apply the changes to your live infrastructure.
- Click the Actions tab again.
- Click the new workflow run triggered by the merge.
- Click the Terraform job and expand the Terraform Apply step.
What should I see in the apply output?
The apply output shows Terraform updating the aws_s3_object.index resource. You should see a message confirming 1 resource changed and 0 destroyed. The website_url output is also printed at the end.
- Wait for the workflow to complete with a green checkmark.
- Open your website URL in a browser (the same URL from your terraform output earlier).
- Confirm you see your updated text on the live page.
Your updated content is now live. It was deployed automatically, without you ever running terraform apply yourself.
Still seeing the old content?
- Try a hard refresh in your browser: Cmd+Shift+R (macOS) or Ctrl+Shift+R (Windows). S3 may serve cached content briefly.
- Check the Actions tab to confirm the apply workflow finished with a green checkmark. If it's still running, wait for it to complete.
- Make sure you're visiting the S3 website endpoint URL (ending in .s3-website-us-east-1.amazonaws.com), not the S3 console.
Help me figure out why my deployed website isn't showing the updated content
- Finally, sync your local main branch with the merged changes by running these commands:
git checkout main
git pull origin main
You've just completed the full Infrastructure as Code workflow. Code change, peer review, automated plan, and automated deploy. This is the exact pattern used by DevOps teams to manage production infrastructure safely. Next up, you'll tackle the Secret Mission and add multi-environment deployment.
Secret mission
Multi-Environment Deployment with Terraform Workspaces
Real teams never deploy straight to production. Add a staging environment using Terraform workspaces so your pipeline deploys to staging on merge and production only on a release tag.
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. This project uses AWS S3 buckets that cost pennies per month, well within Free Tier credits. The IAM user has no associated cost.
Resources you used:
- S3 bucket (production website): your-unique-prefix-prod-static-site.
- S3 bucket (staging website): your-unique-prefix-staging-static-site (Secret Mission only).
- S3 bucket (Terraform state): your-terraform-state-bucket-name.
- IAM user with programmatic access keys.
- GitHub repository with stored secrets.
- Local project files on your Desktop.
Keep everything running
No action needed. Choose this if you're still actively building or want to keep your live website as a portfolio piece.
- Your S3 static site costs virtually nothing. Storage for a few HTML files is a fraction of a cent per month, covered by Free Tier credits.
- The IAM user has no cost.
- Your GitHub repository is free for public repos and free-tier private repos.
- Terraform CLI remains installed on your machine as a single binary.
Pause - I'll come back to this later
S3 charges are usage-based and near-zero for a simple HTML site. There is nothing to pause in the traditional sense.
- You can disable the GitHub Actions workflow to prevent accidental applies. Go to your repository on GitHub, click the Actions tab, select the Terraform workflow on the left, click the ... menu, and select Disable workflow.
- Your infrastructure stays live but no automatic changes will be triggered.
- When you come back, re-enable the workflow from the same menu.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow these steps in order.
Destroy Terraform-managed infrastructure
- Open your terminal and navigate to your Terraform project folder on the Desktop.
- If you completed the Secret Mission, destroy the staging workspace first by running:
terraform workspace select staging
terraform destroy
- Type yes when prompted to confirm destruction.
- Switch back to the default workspace and destroy production by running:
terraform workspace select default
terraform destroy
- Type yes when prompted to confirm.
Delete the Terraform state bucket
The state bucket was created manually, so Terraform does not manage it. Delete it with the AWS CLI.
- Remove the state bucket and all its contents by running:
aws s3 rb s3://[[STATE_BUCKET="your-terraform-state-bucket-name"]] --force
Delete the IAM user
- Open the AWS Management Console and navigate to the IAM service.
- In the left navigation pane, click Users.
- Select the checkbox next to the IAM user you created for Terraform.
- Click Delete at the top of the page.
- Type the username in the confirmation dialog and click Delete.
What about the access keys?
When you delete an IAM user through the AWS Console, it automatically removes the user's access keys, password, and group memberships. You do not need to delete these separately.
Delete the GitHub repository
- Navigate to your repository's main page on GitHub.
- Click Settings under your repository name.
- Scroll down to the Danger Zone section and click Delete this repository.
- Click I want to delete this repository.
- Click I have read and understand these effects.
- Type your repository name in the text box to confirm, then click Delete this repository.
What about the GitHub secrets?
Your AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY secrets are deleted automatically when the repository is deleted. No separate action needed.
Remove local project files
- Delete the project folder from your Desktop by running:
cd ~/Desktop
rm -rf [[PROJECT_FOLDER="your-terraform-project-folder-name"]]
On Windows, use this command instead:
cd ~/Desktop
Remove-Item -Recurse -Force [[PROJECT_FOLDER="your-terraform-project-folder-name"]]
That's a Wrap!
That's a Wrap!
Nice work! You just built a production-grade Infrastructure as Code pipeline from scratch.
You've learned how to:
- Wrote Terraform configuration to provision AWS infrastructure declaratively and managed state remotely in S3.
- Built a CI/CD pipeline with GitHub Actions that plans on pull requests and applies on merge to main.
- Deployed a live static website that's fully managed through code, with every change version-controlled and automatically deployed.
- Added multi-environment deployment with Terraform workspaces, deploying to staging on merge and production on release tags.
Ready to quiz yourself?