Terraform GitOps Foundation on AWS

Build a GitOps pipeline that plans on PR and deploys on merge via OIDC.

Introduction

30 Second Summary

Every startup platform team has a single rule: no one runs terraform apply from their laptop. Infrastructure changes go through pull requests, automated plans, and auditable deploys. Breaking this rule is how teams get breached or corrupt their state.

In this project, you will build a production-grade Infrastructure as Code foundation on AWS using Terraform, GitHub Actions, and OIDC authentication. You will deploy a VPC through a fully automated GitOps pipeline with zero static credentials.

What You'll Build

When you open a pull request, GitHub Actions automatically runs terraform plan and posts the infrastructure diff as a PR comment. When you merge to main, it deploys your changes to AWS with no human intervention and no access keys stored anywhere.

By the end of this project, you'll have:

  • A working CI/CD pipeline where you push a Terraform change on a feature branch, see the planned diff on your PR, merge, and watch GitHub Actions deploy it to AWS automatically.
  • A deployed VPC with public and private subnets across two Availability Zones, managed entirely through the GitOps workflow and ready to host containers or any workload.
  • A professional repository with Architecture Decision Records documenting every major choice: why Terraform over CloudFormation, why S3 lockfile over DynamoDB, and why OIDC over static keys.
  • Secret Mission: Intentionally break your OIDC trust policy, write a formal incident report with root cause analysis, and create a runbook for future engineers.

Are there any prerequisites?

You need an AWS account with admin access, a GitHub account, and an Ubuntu VM with VS Code already installed. Some prior experience with Terraform basics (writing resources, running plan and apply) is expected.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

This project mirrors the exact workflow a Platform Engineer uses at a real startup. You will build a production-grade Infrastructure as Code foundation on AWS, complete with a GitOps pipeline that plans on pull requests and applies on merge.

Install and Configure Your Toolchain

Mismatched tool versions between your local machine and CI/CD cause silent failures that waste hours to debug. Before writing any infrastructure code, you need a consistent, reproducible environment where every tool is pinned to the exact version the pipeline will use.

In this step, you will install Terraform 1.15.8, the AWS CLI, and Git with an SSH key connected to GitHub. Then you will create and clone the repository that holds all your infrastructure code.

In this step, get ready to:
  • Install Terraform 1.15.8 and AWS CLI v2, then configure AWS credentials locally.
  • Configure Git identity, generate an SSH key, and create and clone the startup-infra repository.
  • Verify the full toolchain works end-to-end inside the cloned repo.
Install Terraform and AWS CLI

Terraform is the Infrastructure as Code tool that will define and manage your AWS resources. The HashiCorp APT repository ensures you get the official build matched to your CI pipeline.

  • Add the HashiCorp GPG key and APT repository by running these commands:
wget -O - https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(grep -oP '(?<=UBUNTU_CODENAME=).*' /etc/os-release || lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
  • Install Terraform by running:
sudo apt update && sudo apt install terraform
  • Check which version was installed by running:
terraform version

Before you run this, what version number do you expect to see?

✔️ I see Terraform v1.15.8

Your Terraform install is correct. Move on to the AWS CLI installation below.

ⓧ I see an older version

The HashiCorp APT repository serves the latest stable release. If you see an older version, your apt cache may be stale or you may have a different install taking precedence.

  • Run which terraform to check if a binary from a different source is on your PATH.
  • Remove the old binary and re-run sudo apt update && sudo apt install terraform.

ⓧ Command not found

The APT repository was not added successfully.

  • Verify the file /etc/apt/sources.list.d/hashicorp.list exists by running cat /etc/apt/sources.list.d/hashicorp.list.
  • If the file is empty or missing, re-run the GPG and echo commands above.
  • Run sudo apt update && sudo apt install terraform again.

Next, install the AWS CLI v2 so you can interact with AWS from your terminal.

  • Download and install the AWS CLI by running these commands:
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip awscliv2.zip
sudo ./aws/install

What do these commands do?

  • curl downloads the official AWS CLI v2 zip archive from Amazon.
  • unzip extracts the installer into an aws directory.
  • sudo ./aws/install runs the installer and creates a symlink at /usr/local/bin/aws.
  • Verify the install succeeded by running:
aws --version

You should see output beginning with aws-cli/2. followed by a version number.

Seeing command not found for aws?

  • Make sure unzip is installed. Run sudo apt install unzip and try the download sequence again.
  • Check that /usr/local/bin is on your PATH by running echo $PATH.

Still stuck? Help me troubleshoot my AWS CLI installation on Ubuntu.

Now configure your local AWS credentials. These are temporary for bootstrapping. Your CI/CD pipeline will use OIDC instead of static keys.

  • Run the interactive configuration command:
aws configure

What will aws configure ask me?

You will be prompted for four values:

  • AWS Access Key ID and Secret Access Key from your IAM admin user.
  • Default region name (enter us-east-1 to match this project).
  • Default output format (enter json).
  • Confirm your credentials work by running:
aws sts get-caller-identity

You should see a JSON response containing your AWS Account ID, UserId, and ARN.

Seeing Unable to locate credentials?

  • Re-run aws configure and paste your Access Key ID and Secret Access Key carefully.
  • Make sure you are using credentials from an IAM user with admin access, not the root account.

Need more help? Help me fix my AWS CLI credentials configuration.

Configure Git and create the GitHub repository

Your infrastructure code lives in Git. Every change goes through a branch, a pull request, and a review before it touches AWS.

  • Set your Git identity by running these commands (replace the placeholder values with your own name and email):
git config --global user.name [[YOURNAME="your full name"]]
git config --global user.email [[YOUREMAIL="your email address"]]
  • Verify Git is configured by running:
git --version

You should see a version number confirming Git is installed.

  • Generate an SSH key to authenticate with GitHub by running:
ssh-keygen -t ed25519 -C [[YOUREMAIL="your email address"]]

What happens when you run ssh-keygen?

The command prompts you for a file path and a passphrase. Press Enter to accept the default path. You can leave the passphrase empty for convenience or set one for extra security.

  • Copy your public key to the clipboard by running:
cat ~/.ssh/id_ed25519.pub
  • Select and copy the entire output (starting with ssh-ed25519).
  • Open GitHub in your browser and navigate to Settings.
  • In the left sidebar, click SSH and GPG keys.
  • Click New SSH key.
  • Give the key a title (e.g. Ubuntu VM), paste the public key into the Key field, and click Add SSH key.
  • Test the SSH connection by running:
ssh -T git@github.com

You should see a message like "Hi username! You've successfully authenticated."

Seeing permission denied (publickey)?

  • Make sure you copied the .pub file (public key), not the private key without the extension.
  • Verify your SSH agent has the key loaded by running ssh-add ~/.ssh/id_ed25519.

Need help? Help me fix my GitHub SSH key connection.

Now create the GitHub repository that will hold all your infrastructure code.

  • On GitHub, click the + icon in the top-right corner and select New repository.
  • Enter startup-infra as the repository name.
  • Leave the repository set to Public.
  • Do not add a README, .gitignore, or license (you will create these manually).
  • Click Create repository.
  • Clone the repository to your Desktop by running these commands:
cd ~/Desktop
git clone git@github.com:[[GITHUB_USERNAME="your-github-username"]]/startup-infra.git
cd startup-infra
  • Confirm you are inside the cloned repo by running:
git status

You should see "On branch main" with nothing to commit. This confirms your local repo is connected to GitHub.

Seeing fatal: not a git repository?

  • Make sure you ran cd startup-infra after cloning.
  • If the clone failed, check that your GitHub username is correct in the URL and that your SSH key is properly added.
Open the repo in VS Code and verify everything
  • Open the startup-infra folder in VS Code. In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to ~/Desktop/startup-infra and click Open.
  • Install the HashiCorp Terraform extension for syntax highlighting. Press Ctrl+Shift+X to open the Extensions sidebar.
  • Search for HashiCorp Terraform and click Install on the result published by HashiCorp.

Why install the Terraform extension?

The extension gives you syntax highlighting, auto-formatting, and autocomplete for .tf files. It also validates your configuration as you type, catching typos before you ever run terraform plan.

Time for the final verification. Open a terminal inside VS Code with Ctrl+` (backtick). Before you run the commands below, what do you expect each one to return?

  • Verify the full toolchain by running these three commands:
terraform version
aws sts get-caller-identity
git status

You should see Terraform v1.15.8 from the first command, your AWS account ID in the JSON output of the second, and a clean git status showing you are on branch main.

One of the commands failed?

  • If terraform version fails, make sure the HashiCorp APT repository was added correctly and re-run sudo apt update && sudo apt install terraform.
  • If aws sts get-caller-identity fails, re-run aws configure and double-check your Access Key ID and Secret Access Key.
  • If git status fails, make sure your terminal's working directory is ~/Desktop/startup-infra.

Still stuck? Help me verify my Terraform, AWS CLI, and Git toolchain on Ubuntu.

Your environment is locked in. Every tool is pinned, authenticated, and running inside the repo that will hold your entire infrastructure. Next up, you will scaffold the professional directory structure and write your first Architecture Decision Record.

Scaffold the Repository Structure and Write Your First ADR

Your toolchain is installed and configured. Now it's time to lay down the structure that separates a professional Terraform repository from a tangled pile of files.

A flat directory of .tf files becomes unmaintainable the moment a second engineer touches it. Professional repos separate reusable modules from environment-specific configurations. They also document decisions so future team members understand the "why" behind every choice.

In this step, get ready to:
  • Create a production directory structure with modules, environments, and documentation.
  • Write an Architecture Decision Record documenting your state management choice.
  • Make your first conventional commit and push to GitHub.
Create the production directory structure

Professional Terraform repositories follow a predictable layout. Anyone joining the team can open the repo and immediately know where modules, environment configs, and documentation live.

  • In your VS Code terminal (which should already be inside your startup-infra folder), create the full directory structure by running this command:
mkdir -p bootstrap modules/vpc environments/dev docs/adr .github/workflows
  • Confirm the directories were created by running:
find . -type d -not -path './.git/*' -not -name '.git' | sort

You should see this output:

.
./.github
./.github/workflows
./bootstrap
./docs
./docs/adr
./environments
./environments/dev
./modules
./modules/vpc

What does each directory hold?

  • bootstrap/ holds the Terraform that creates the state bucket itself (the one resource you cannot manage with remote state).
  • modules/vpc/ holds a reusable VPC module you can call from any environment.
  • environments/dev/ holds the dev-specific configuration that calls your modules.
  • docs/adr/ holds Architecture Decision Records that explain the "why" behind each choice.
  • .github/workflows/ holds CI/CD pipeline definitions for GitHub Actions.
  • In VS Code, create a new file in the root of your startup-infra folder called .gitignore and paste this content:
# Terraform
.terraform/
*.tfstate
*.tfstate.*
*.tfplan
*.tfvars
!*.tfvars.example

# OS
.DS_Store
Thumbs.db

# IDE
.vscode/settings.json
.idea/

What does this .gitignore do?

It keeps generated files out of version control. The .terraform/ directory contains downloaded providers (large, reproducible from terraform init). State files (*.tfstate) belong in remote storage, not in Git.

Notice that .terraform.lock.hcl is NOT listed here. That lock file pins your exact provider versions and should be committed so every team member gets the same providers.

  • Save .gitignore.
  • Create another new file in the repo root called README.md and paste this content:
# startup-infra

Production-grade Terraform GitOps foundation on AWS.

## Structure

- `bootstrap/` - State bucket and OIDC provisioning
- `modules/vpc/` - Reusable VPC module
- `environments/dev/` - Dev environment configuration
- `docs/adr/` - Architecture Decision Records
- `.github/workflows/` - CI/CD pipelines

Why document the structure in the README?

The README is the first thing anyone sees when they open your repository. Listing the directory layout up front means a new team member (or a future interviewer) can orient themselves in seconds without digging through folders.

  • Save README.md.
  • Confirm both files exist by running:
ls -A

You should see .git, .github, .gitignore, bootstrap, docs, environments, modules, and README.md listed in the output.

✔️ Awesome, I've got everything!

Great. Double check you've saved both files before moving on.

ⓧ I'd like to double check the full code

Here is the complete .gitignore:

# Terraform
.terraform/
*.tfstate
*.tfstate.*
*.tfplan
*.tfvars
!*.tfvars.example

# OS
.DS_Store
Thumbs.db

# IDE
.vscode/settings.json
.idea/

Here is the complete README.md:

# startup-infra

Production-grade Terraform GitOps foundation on AWS.

## Structure

- `bootstrap/` - State bucket and OIDC provisioning
- `modules/vpc/` - Reusable VPC module
- `environments/dev/` - Dev environment configuration
- `docs/adr/` - Architecture Decision Records
- `.github/workflows/` - CI/CD pipelines
Write your first Architecture Decision Record

An ADR captures a single decision in a structured format: what was the context, what did you decide, and what are the trade-offs. Six months from now, you (or a teammate) can open the ADR and understand exactly why you chose S3 lockfile over DynamoDB without re-reading Terraform docs.

  • Create a new file at docs/adr/0001-use-terraform-with-s3-lockfile.md in VS Code.
  • Paste the header, status, and context sections:
# ADR-0001: Use Terraform with S3-native lockfile for state management

## Status

Accepted

## Context

We need an Infrastructure as Code tool to manage AWS resources reproducibly.
The state must be stored remotely so that both local development and CI/CD
pipelines operate on the same source of truth. State locking is required to
prevent concurrent modifications that could corrupt infrastructure.

Options considered:
- AWS CloudFormation (native, no state management needed, but vendor lock-in and limited modularity)
- Terraform with DynamoDB state locking (traditional approach, requires a DynamoDB table)
- Terraform with S3-native lockfile (new in Terraform 1.10+, uses .tflock file in S3)

What does the Context section capture?

Context states the problem and the options you evaluated. It lists CloudFormation (AWS-native but vendor-locked), DynamoDB locking (the traditional Terraform approach), and S3-native lockfile (the modern approach). A reader can immediately see the landscape of choices.

  • Below the Context section in the same file, paste the Decision and Consequences sections:
## Decision

Use Terraform with the S3 backend and `use_lockfile = true` for state locking.

Rationale:
- Terraform is cloud-agnostic and modular, skills transfer to any employer
- S3-native lockfile eliminates the need for a DynamoDB table (simpler, cheaper)
- DynamoDB-based locking is officially deprecated and will be removed in a future Terraform minor version
- The S3 lockfile approach requires only S3 permissions (GetObject, PutObject, DeleteObject on .tflock)
- Terraform 1.15.8 fully supports this feature

## Consequences

- All team members and CI/CD must use Terraform >= 1.10
- No DynamoDB table to manage or pay for
- State locking operates via a .tflock file stored alongside the state in S3
- IAM policies need s3:GetObject, s3:PutObject, s3:DeleteObject on the .tflock path
- Legacy tutorials using dynamodb_table will not apply to this project

Why use_lockfile instead of DynamoDB?

The use_lockfile option creates a .tflock file directly in S3 alongside your state. DynamoDB-based locking is deprecated and will be removed in a future Terraform minor version. The S3-native approach is simpler (no extra table), cheaper (no DynamoDB costs), and requires fewer IAM permissions.

  • Save docs/adr/0001-use-terraform-with-s3-lockfile.md.
  • Verify the file was saved correctly by running:
head -5 docs/adr/0001-use-terraform-with-s3-lockfile.md

You should see the title line and the start of the Status section.

✔️ Awesome, I've got everything!

Great. Confirm you have both the Context section (with three options listed) and the Consequences section (with five bullet points) in a single continuous file.

ⓧ I'd like to double check the full code

Your ADR file should contain two sections in order. Scroll up and confirm:

  • The first block (Title, Status, Context) is present with three options listed under "Options considered".
  • The second block (Decision, Consequences) follows immediately below, starting with ## Decision and ending with five consequence bullet points.
Commit and push to main

Your directory structure, ignore rules, README, and ADR are ready. This first commit establishes the baseline that all future feature branches will branch from.

  • Stage all files, commit using conventional commit format, and push to GitHub by running these commands:
git add .
git commit -m "feat: scaffold project structure and ADR-0001"
git push origin main

What is conventional commit format?

Conventional commits prefix the message with a type like feat:, fix:, or docs:. This makes the Git history scannable at a glance. Automated tools can also generate changelogs from these prefixes.

Before you check the result, what do you think git log --oneline will show?

  • Verify your commit landed by running:
git log --oneline

You should see a single commit with the message feat: scaffold project structure and ADR-0001.

Push rejected or permission denied?

  • If you see Permission denied (publickey), your SSH key from Step 1 is not registered with GitHub. Run ssh -T git@github.com to test the connection and re-add the key if needed.
  • If you see rejected because the remote contains work you do not have locally, run git pull --rebase origin main then push again.

Help me diagnose my git push failure.

Now confirm the full file structure is correct.

  • Run this command to see every file in your repo (excluding Git internals):
find . -type f -not -path './.git/*' | sort

You should see:

./.gitignore
./README.md
./docs/adr/0001-use-terraform-with-s3-lockfile.md

What should I see?

Three files: .gitignore, README.md, and your ADR. The empty directories (bootstrap/, modules/vpc/, environments/dev/, .github/workflows/) do not appear because Git does not track empty directories. They exist locally and will be committed once you add files to them in later steps.

Your repository now has a clean, professional structure with its first documented decision. Next up, you'll bootstrap the S3 state bucket and hit the classic chicken-and-egg problem of Terraform state management head-on.

Bootstrap Remote State and Configure the S3 Backend

Your repository structure is in place and your first ADR documents the why behind your tooling choices. Now you need a place to store Terraform's state file that both your laptop and your future CI/CD pipeline can access.

There is a classic chicken-and-egg problem here. Terraform needs an S3 bucket to store its state, but you want to use Terraform to create that bucket. You cannot manage the bucket with the same configuration that depends on it. The solution is a separate bootstrap/ configuration that uses local state for this one exception.

In this step, get ready to:
  • Create and deploy an S3 state bucket with versioning, encryption, and public access blocking.
  • Configure the dev environment to store its state in the new S3 bucket using the modern lockfile approach.
  • Commit the bootstrap and backend code on a feature branch and merge via pull request.
Create the state bucket configuration

The bootstrap/ directory holds the Terraform code that creates your state bucket. This is the one configuration that uses local state, because the bucket it creates is the very bucket all other configurations store their state in.

  • Create a new file called bootstrap/main.tf and add the following code:
terraform {
  required_version = ">= 1.15.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 5.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

resource "aws_s3_bucket" "terraform_state" {
  bucket = var.state_bucket_name

  lifecycle {
    prevent_destroy = true
  }

  tags = {
    Project     = "startup-infra"
    ManagedBy   = "terraform"
    Environment = "shared"
  }
}

What does this code do?

The terraform block pins the minimum Terraform version and declares the AWS provider source. The provider block tells Terraform which region to deploy into.

The aws_s3_bucket resource creates your state bucket. The prevent_destroy lifecycle rule stops anyone from accidentally deleting this bucket with terraform destroy. Losing your state file would orphan all your managed resources.

  • Add the security configuration below the bucket resource in the same bootstrap/main.tf file:
resource "aws_s3_bucket_versioning" "terraform_state" {
  bucket = aws_s3_bucket.terraform_state.id

  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state" {
  bucket = aws_s3_bucket.terraform_state.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "aws:kms"
    }
  }
}

resource "aws_s3_bucket_public_access_block" "terraform_state" {
  bucket                  = aws_s3_bucket.terraform_state.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

What do these resources do?

Versioning keeps a history of every state file change so you can recover from corruption. Server-side encryption with KMS ensures the state file (which can contain sensitive outputs) is encrypted at rest.

The public access block is a safety net. It prevents any policy or ACL from accidentally exposing your state bucket to the internet.

  • Initialize Terraform in the bootstrap/ directory to download the AWS provider by running:
cd bootstrap
terraform init

You should see "Terraform has been successfully initialized!" confirming the provider downloaded correctly.

Seeing a provider error?

Make sure your bootstrap/main.tf has both the terraform {} block and the provider "aws" {} block. If you see a credentials error, confirm aws sts get-caller-identity still works in your terminal.

help me troubleshoot my terraform init error in the bootstrap directory

  • Create bootstrap/variables.tf with the input variable declarations:
variable "aws_region" {
  description = "AWS region for the state bucket and OIDC resources"
  type        = string
  default     = "us-east-1"
}

variable "state_bucket_name" {
  description = "Globally unique name for the Terraform state S3 bucket"
  type        = string
}

variable "github_org" {
  description = "GitHub organization or username that owns the repository"
  type        = string
}

variable "github_repo" {
  description = "GitHub repository name (e.g., startup-infra)"
  type        = string
  default     = "startup-infra"
}

Why declare github_org and github_repo now?

These variables are not used yet. They will be needed in a later step when you add OIDC authentication to this same bootstrap/ configuration. Declaring them now keeps the variables file complete and avoids a disruptive refactor later.

  • Create bootstrap/outputs.tf to expose the bucket details for other configurations to reference:
output "state_bucket_name" {
  description = "Name of the S3 bucket storing Terraform state"
  value       = aws_s3_bucket.terraform_state.id
}

output "state_bucket_arn" {
  description = "ARN of the S3 bucket storing Terraform state"
  value       = aws_s3_bucket.terraform_state.arn
}

What do outputs do?

Outputs print values to the terminal after terraform apply completes. They also make values available to other Terraform configurations. You will reference the bucket ARN when setting up IAM policies later.

Before you run apply, what do you think will happen? Terraform is about to create real AWS resources using only local state.

  • Deploy the state bucket by running this command (replace the variable values with your own):
terraform apply \
  -var="state_bucket_name=[[STATE_BUCKET_NAME="yourname-startup-infra-tfstate"]]" \
  -var="github_org=[[GITHUB_ORG="your-github-username"]]"

What should I enter for state_bucket_name?

S3 bucket names must be globally unique across all AWS accounts. A good pattern is yourname-startup-infra-tfstate. Use lowercase letters, numbers, and hyphens only.

  • Terraform will show a plan with 4 resources to create. Type yes when prompted to confirm.

You should see "Apply complete! Resources: 4 added, 0 changed, 0 destroyed." along with your bucket name and ARN in the outputs.

Seeing a BucketAlreadyExists error?

Someone else already owns that bucket name. S3 bucket names are globally unique. Try a more specific name that includes your initials or a random number. Re-run the terraform apply command with the new name.

help me fix my S3 bucket naming error during terraform apply

Why is local state acceptable here?

This is the one exception to the "never use local state" rule. The bootstrap configuration creates the bucket that stores everyone else's state. It cannot store its own state in a bucket that does not exist yet. The local terraform.tfstate file created here is gitignored and only needed for this one-time setup.

Configure the S3 backend for the dev environment

Now that the bucket exists, you can point the dev environment at it. This is where the chicken-and-egg problem resolves: the bootstrap created the bucket with local state, and now every other configuration uses that bucket as its remote backend.

  • Move back to your repository root by running:
cd ..
  • Create environments/dev/backend.tf with your S3 backend configuration:
terraform {
  backend "s3" {
    bucket       = "[[STATE_BUCKET="yourname-startup-infra-tfstate"]]"
    key          = "environments/dev/terraform.tfstate"
    region       = "us-east-1"
    use_lockfile = true
    encrypt      = true
  }
}

What is use_lockfile?

The use_lockfile = true setting enables S3-native state locking. Terraform writes a .tflock file next to your state file to prevent two people (or two CI runs) from applying at the same time.

This replaces the deprecated dynamodb_table locking approach. No DynamoDB table needed, no extra cost, no extra IAM permissions beyond S3 access.

  • Create environments/dev/main.tf with a minimal Terraform configuration and AWS provider:
terraform {
  required_version = ">= 1.15.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 5.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}
  • Create environments/dev/variables.tf with the region variable so the provider can resolve it:
variable "aws_region" {
  description = "AWS region for dev environment"
  type        = string
  default     = "us-east-1"
}

What do you think will happen when you run terraform init here? It should connect to S3 and confirm the remote backend is ready.

  • Initialize the dev environment to connect it to your S3 backend by running:
cd environments/dev
terraform init

You should see: "Successfully configured the backend "s3"!" followed by "Terraform has been successfully initialized!"

  • Confirm there is nothing to deploy yet by running:
terraform plan

You should see "No changes. Your infrastructure matches the configuration." This proves the S3 backend is working and the state file is being stored remotely.

  • Verify the state file landed in S3 by running:
aws s3 ls s3://[[STATE_BUCKET="yourname-startup-infra-tfstate"]]/environments/dev/

You should see terraform.tfstate listed. Your remote state is live.

Seeing a backend configuration error?

Double-check that the bucket value in environments/dev/backend.tf matches the exact bucket name you used during terraform apply in the bootstrap step. Backend values must be literal strings. You cannot use variables or references here.

help me fix my S3 backend configuration error in terraform init

Commit on a feature branch and merge via pull request

Every infrastructure change goes through a branch, a PR, and a merge. This is the GitOps workflow you will use for every future change in this project.

  • Move back to the repository root by running:
cd ../..
  • Create a feature branch for this work by running:
git checkout -b feat/remote-state
  • Stage and commit all the bootstrap and backend files by running:
git add .
git commit -m "feat: bootstrap S3 state bucket and configure remote backend"
  • Push the branch to GitHub by running:
git push -u origin feat/remote-state

Git will print a URL to create a pull request directly from the terminal output.

  • Open that URL in your browser (or go to your repository on GitHub) and click Create pull request.
  • Review the files changed in the PR. You should see your bootstrap files and the dev backend configuration.
  • Click Merge pull request, then click Confirm merge.
  • Pull the merged changes back to your local main branch by running:
git checkout main
git pull origin main

Your main branch now has the state bucket bootstrap and the dev environment backend configuration. Every future change will follow this same branch-PR-merge cycle.

Delete merged branches

GitHub offers a "Delete branch" button after merging. Click it to keep your branch list clean. Locally, you can run git branch -d feat/remote-state to remove the stale local branch.

✔️ Awesome, I've got everything!

Great. Double-check you have saved all files in bootstrap/ and environments/dev/ before moving on.

ⓧ I'd like to double check the full code

Here are the key files you should have after this step. Compare them against yours:

bootstrap/main.tf

terraform {
  required_version = ">= 1.15.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 5.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

resource "aws_s3_bucket" "terraform_state" {
  bucket = var.state_bucket_name

  lifecycle {
    prevent_destroy = true
  }

  tags = {
    Project     = "startup-infra"
    ManagedBy   = "terraform"
    Environment = "shared"
  }
}
resource "aws_s3_bucket_versioning" "terraform_state" {
  bucket = aws_s3_bucket.terraform_state.id

  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state" {
  bucket = aws_s3_bucket.terraform_state.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "aws:kms"
    }
  }
}

resource "aws_s3_bucket_public_access_block" "terraform_state" {
  bucket                  = aws_s3_bucket.terraform_state.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

bootstrap/variables.tf

variable "aws_region" {
  description = "AWS region for the state bucket and OIDC resources"
  type        = string
  default     = "us-east-1"
}

variable "state_bucket_name" {
  description = "Globally unique name for the Terraform state S3 bucket"
  type        = string
}

variable "github_org" {
  description = "GitHub organization or username that owns the repository"
  type        = string
}

variable "github_repo" {
  description = "GitHub repository name (e.g., startup-infra)"
  type        = string
  default     = "startup-infra"
}

bootstrap/outputs.tf

output "state_bucket_name" {
  description = "Name of the S3 bucket storing Terraform state"
  value       = aws_s3_bucket.terraform_state.id
}

output "state_bucket_arn" {
  description = "ARN of the S3 bucket storing Terraform state"
  value       = aws_s3_bucket.terraform_state.arn
}

environments/dev/backend.tf

terraform {
  backend "s3" {
    bucket       = "[[STATE_BUCKET="yourname-startup-infra-tfstate"]]"
    key          = "environments/dev/terraform.tfstate"
    region       = "us-east-1"
    use_lockfile = true
    encrypt      = true
  }
}

environments/dev/main.tf

terraform {
  required_version = ">= 1.15.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 5.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

Your state bucket is deployed, your dev environment is wired up to it, and you have established the branch-PR-merge workflow. Next up, you will build a reusable VPC module and deploy real network infrastructure through this same pipeline.

Build and Deploy the VPC Module

In the previous step you bootstrapped remote state and established your Git branching workflow. Now it is time to build the actual infrastructure that workloads will live on.

Every production workload needs a network. A VPC with public and private subnets is the foundation for containers, databases, and load balancers. Building it as a reusable Terraform module means you write the networking code once, then deploy it to dev, staging, and prod with different parameters.

In this step, get ready to:
  • Create a reusable VPC module with subnets across 2 Availability Zones.
  • Deploy the VPC and intentionally trigger a CIDR overlap error to practice troubleshooting.
  • Commit on a feature branch and tag the release as v0.1.0.
Create the reusable VPC module

Terraform modules are self-contained packages of resources with their own inputs and outputs. You will create a modules/vpc/ directory containing everything needed to provision a VPC in any environment.

  • Create the module directory by running this command:
mkdir -p modules/vpc
  • Confirm the directory exists by running:
ls modules/

You should see vpc listed.

  • Define the module's input variables by creating modules/vpc/variables.tf with this content:
variable "vpc_cidr" {
  description = "CIDR block for the VPC"
  type        = string
}

variable "environment" {
  description = "Environment name (dev, staging, prod)"
  type        = string
}

variable "project_name" {
  description = "Project name for resource naming and tagging"
  type        = string
}

variable "public_subnet_cidrs" {
  description = "List of CIDR blocks for public subnets"
  type        = list(string)
}

variable "private_subnet_cidrs" {
  description = "List of CIDR blocks for private subnets"
  type        = list(string)
}

variable "availability_zones" {
  description = "List of availability zones to deploy subnets into"
  type        = list(string)
}

What does this code do?

Each variable defines one input the module needs from the caller. The module itself never hardcodes values like CIDRs or region names.

This design means the same module can deploy a 10.0.0.0/16 VPC in dev and a 10.1.0.0/16 VPC in prod just by changing the inputs.

  • Save modules/vpc/variables.tf.
  • Verify the syntax is valid by running:
terraform fmt modules/vpc/

If the file is correctly formatted, the command produces no output. If it prints a filename, it reformatted that file for you.

  • Create the core network resources in modules/vpc/main.tf starting with the VPC and Internet Gateway:
resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    Name        = "${var.project_name}-${var.environment}-vpc"
    Project     = var.project_name
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name        = "${var.project_name}-${var.environment}-igw"
    Project     = var.project_name
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

What does this code do?

  • aws_vpc "main" creates the virtual network using the CIDR block passed in from the caller.
  • enable_dns_support and enable_dns_hostnames allow resources inside the VPC to resolve DNS names.
  • aws_internet_gateway "main" attaches a gateway so public subnets can reach the internet.
  • Save modules/vpc/main.tf.
  • Add the public and private subnet resources below the Internet Gateway in modules/vpc/main.tf:
resource "aws_subnet" "public" {
  count                   = length(var.public_subnet_cidrs)
  vpc_id                  = aws_vpc.main.id
  cidr_block              = var.public_subnet_cidrs[count.index]
  availability_zone       = var.availability_zones[count.index]
  map_public_ip_on_launch = true

  tags = {
    Name        = "${var.project_name}-${var.environment}-public-${var.availability_zones[count.index]}"
    Project     = var.project_name
    Environment = var.environment
    ManagedBy   = "terraform"
    Tier        = "public"
  }
}

resource "aws_subnet" "private" {
  count             = length(var.private_subnet_cidrs)
  vpc_id            = aws_vpc.main.id
  cidr_block        = var.private_subnet_cidrs[count.index]
  availability_zone = var.availability_zones[count.index]

  tags = {
    Name        = "${var.project_name}-${var.environment}-private-${var.availability_zones[count.index]}"
    Project     = var.project_name
    Environment = var.environment
    ManagedBy   = "terraform"
    Tier        = "private"
  }
}

What does this code do?

  • count = length(var.public_subnet_cidrs) creates one subnet per CIDR you pass in, spreading them across Availability Zones for redundancy.
  • map_public_ip_on_launch = true on public subnets means any resource launched there gets a public IP automatically.
  • Private subnets omit that flag, so resources stay hidden from the internet.
  • Save modules/vpc/main.tf and verify your syntax is still valid by running:
terraform fmt modules/vpc/

No output means everything is formatted correctly.

  • Add the route tables below the private subnet resource in modules/vpc/main.tf:
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.main.id
  }

  tags = {
    Name        = "${var.project_name}-${var.environment}-public-rt"
    Project     = var.project_name
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

resource "aws_route_table" "private" {
  vpc_id = aws_vpc.main.id

  tags = {
    Name        = "${var.project_name}-${var.environment}-private-rt"
    Project     = var.project_name
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

What does this code do?

  • The public route table sends all traffic (0.0.0.0/0) to the Internet Gateway, giving public subnets outbound access.
  • The private route table has no routes to the internet. Resources in private subnets cannot reach external services yet.
  • Save modules/vpc/main.tf.
  • Add the route table associations below the private route table to wire subnets to their route tables:
resource "aws_route_table_association" "public" {
  count          = length(var.public_subnet_cidrs)
  subnet_id      = aws_subnet.public[count.index].id
  route_table_id = aws_route_table.public.id
}

resource "aws_route_table_association" "private" {
  count          = length(var.private_subnet_cidrs)
  subnet_id      = aws_subnet.private[count.index].id
  route_table_id = aws_route_table.private.id
}

What does this code do?

Associations link each subnet to its corresponding route table. Without them, subnets use the VPC's default route table, which has no internet route at all.

  • Save modules/vpc/main.tf and run a final format check:
terraform fmt modules/vpc/
  • Create the module's outputs in modules/vpc/outputs.tf so callers can reference the created resources:
output "vpc_id" {
  description = "ID of the VPC"
  value       = aws_vpc.main.id
}

output "vpc_cidr" {
  description = "CIDR block of the VPC"
  value       = aws_vpc.main.cidr_block
}

output "public_subnet_ids" {
  description = "List of public subnet IDs"
  value       = aws_subnet.public[*].id
}

output "private_subnet_ids" {
  description = "List of private subnet IDs"
  value       = aws_subnet.private[*].id
}

output "internet_gateway_id" {
  description = "ID of the internet gateway"
  value       = aws_internet_gateway.main.id
}

What does this code do?

Outputs expose key resource IDs so that other modules (like a future ECS cluster) can reference this VPC's subnets without hardcoding IDs.

  • Save modules/vpc/outputs.tf.
  • Verify all module files are valid by running:
terraform fmt modules/vpc/

No output confirms all three files have valid HCL syntax.

✔️ Awesome, I've got everything!

Great. Make sure you have saved all three files: variables.tf, main.tf, and outputs.tf inside modules/vpc/.

ⓧ I'd like to double check the full code

Since modules/vpc/main.tf is a large file, scroll up and confirm you have all four sections in order:

  • VPC resource + Internet Gateway.
  • Public subnets + Private subnets.
  • Public route table + Private route table.
  • Public route table association + Private route table association.

For modules/vpc/variables.tf, confirm you have 6 variables: vpc_cidr, environment, project_name, public_subnet_cidrs, private_subnet_cidrs, availability_zones.

For modules/vpc/outputs.tf, confirm you have 5 outputs: vpc_id, vpc_cidr, public_subnet_ids, private_subnet_ids, internet_gateway_id.

Getting a formatting error?

  • If terraform fmt prints an error with a line number, check that line for missing closing braces or unclosed quotes.
  • Make sure every { has a matching } and every " has a closing ".

Still stuck? Help me fix a terraform fmt error in my VPC module

Now document WHY the private subnets have no outbound internet access.

  • Create docs/adr/0002-defer-nat-gateway.md with this Architecture Decision Record:
# ADR-0002: Defer NAT Gateway to reduce cost

## Status

Accepted

## Context

Private subnets need a NAT Gateway for outbound internet access (e.g., pulling
container images, calling external APIs). However, NAT Gateways cost approximately
$0.045/hour (~$32/month) plus data transfer charges. This project targets $0 cost
using only AWS Free Tier resources.

## Decision

Deploy private subnets without a NAT Gateway. Private subnets will have a route
table with no outbound internet route. NAT Gateway will be added in Project 2
when ECS containers require outbound access, at which point the learner accepts
the cost trade-off.

## Consequences

- Private subnets cannot reach the internet (no outbound traffic)
- Resources in private subnets cannot pull container images or call external APIs
- This is acceptable because no workloads run in private subnets yet
- The VPC module is designed to accept a NAT Gateway ID as an optional variable for future use
- When NAT is needed, a single line change adds the route to the private route table

Why no NAT Gateway?

A NAT Gateway costs approximately $32/month per Availability Zone. For a free-tier project with no workloads in private subnets yet, that cost is unjustified. The ADR documents this trade-off so a future engineer (or future-you) knows exactly when to add it.

  • Save docs/adr/0002-defer-nat-gateway.md.
Wire the module into the dev environment and hit a CIDR overlap

The module is ready, but it does nothing until an environment configuration calls it. You will now wire the module into environments/dev/ and intentionally introduce a CIDR overlap to see how Terraform handles the error.

  • Create environments/dev/variables.tf with the general environment variables first:
variable "aws_region" {
  description = "AWS region for dev environment"
  type        = string
  default     = "us-east-1"
}

variable "environment" {
  description = "Environment name"
  type        = string
  default     = "dev"
}

variable "project_name" {
  description = "Project name used in resource naming and tags"
  type        = string
  default     = "startup-infra"
}

variable "vpc_cidr" {
  description = "CIDR block for the VPC"
  type        = string
  default     = "10.0.0.0/16"
}
  • Save the file, then add the subnet and Availability Zone variables below in the same file. Intentionally set private_subnet_cidrs to overlap with the public subnets:
variable "public_subnet_cidrs" {
  description = "CIDR blocks for public subnets"
  type        = list(string)
  default     = ["10.0.1.0/24", "10.0.2.0/24"]
}

variable "private_subnet_cidrs" {
  description = "CIDR blocks for private subnets"
  type        = list(string)
  default     = ["10.0.1.0/24", "10.0.2.0/24"]
}

variable "availability_zones" {
  description = "Availability zones to deploy subnets into"
  type        = list(string)
  default     = ["us-east-1a", "us-east-1b"]
}

Why use overlapping CIDRs on purpose?

Notice that private_subnet_cidrs uses the exact same ranges as public_subnet_cidrs. AWS will reject this because two subnets cannot share the same CIDR block inside one VPC. You are doing this deliberately to experience the error and document how you fixed it.

  • Save environments/dev/variables.tf.
  • Add the module call to environments/dev/main.tf below the existing provider "aws" block:
module "vpc" {
  source = "../../modules/vpc"

  environment          = var.environment
  project_name         = var.project_name
  vpc_cidr             = var.vpc_cidr
  public_subnet_cidrs  = var.public_subnet_cidrs
  private_subnet_cidrs = var.private_subnet_cidrs
  availability_zones   = var.availability_zones
}

What does this code do?

The module block calls your VPC module using a relative source path. Each argument maps a dev-environment variable to the corresponding module input. Terraform resolves the path at init time.

  • Save environments/dev/main.tf.
  • Create environments/dev/outputs.tf to expose the VPC's key IDs:
output "vpc_id" {
  description = "ID of the deployed VPC"
  value       = module.vpc.vpc_id
}

output "public_subnet_ids" {
  description = "IDs of the public subnets"
  value       = module.vpc.public_subnet_ids
}

output "private_subnet_ids" {
  description = "IDs of the private subnets"
  value       = module.vpc.private_subnet_ids
}
  • Save environments/dev/outputs.tf.
  • Re-initialize Terraform to register the new module by running:
cd environments/dev
terraform init

You should see Initializing modules... followed by the module path, confirming Terraform found your local module.

Before you run the plan, what do you think will happen when Terraform tries to create subnets with overlapping CIDRs?

  • Run Terraform apply to attempt the deployment:
terraform apply

Terraform will create the VPC and the first public subnet successfully, then fail when it attempts the private subnet. You will see an error containing:

Error: creating EC2 Subnet: InvalidSubnet.Conflict: The CIDR '10.0.1.0/24' conflicts with another subnet

This is exactly what you expected. AWS rejected the private subnet because its CIDR range is already in use by the public subnet you just created.

Why trigger this error on purpose?

CIDR planning mistakes are one of the most common VPC deployment failures in production. By hitting this error now, you learn to recognize the InvalidSubnet.Conflict message immediately and know the fix is non-overlapping CIDR allocation.

Fix the overlap, deploy, and document

The fix is straightforward: private subnets need their own CIDR ranges that do not overlap with public subnets.

  • In environments/dev/variables.tf, find the private_subnet_cidrs variable.
  • Find this line:
  default     = ["10.0.1.0/24", "10.0.2.0/24"]
  • Replace it with non-overlapping CIDRs:
  default     = ["10.0.10.0/24", "10.0.11.0/24"]
  • Save environments/dev/variables.tf.
  • First, destroy the partially-created resources from the failed apply by running:
terraform destroy
  • Type yes when prompted to confirm.
  • Now deploy cleanly with the corrected CIDRs by running:
terraform apply
  • Type yes when prompted. Terraform will show the planned resources and then create them.

You should see output listing all resources created: 1 VPC, 2 public subnets, 2 private subnets, 1 Internet Gateway, 2 route tables, and 4 route table associations. The final line reads Apply complete! with a resource count.

  • Verify the VPC exists in AWS by running:
aws ec2 describe-vpcs --filters "Name=tag:Project,Values=startup-infra" --query "Vpcs[].CidrBlock"

You should see 10.0.0.0/16 in the output, confirming your VPC is live in AWS with the correct CIDR.

Still seeing an error on apply?

  • If you still see InvalidSubnet.Conflict, verify you ran terraform destroy before retrying. The old conflicting subnet from the failed run may still exist.
  • If you see an authentication error, confirm your AWS CLI credentials are still valid with aws sts get-caller-identity.

Need more help? Help me fix a Terraform apply error for my VPC module

  • Document the error and fix by creating docs/troubleshooting.md:
# Troubleshooting Log

## Issue 1: CIDR Overlap Error During VPC Deployment

**Error:** Terraform returned an error about overlapping CIDR blocks when an invalid subnet CIDR was used.

**Root Cause:** Subnet CIDRs were not properly allocated within the VPC CIDR range without overlap.

**Fix:** Corrected subnet CIDRs to non-overlapping ranges within 10.0.0.0/16: public subnets 10.0.1.0/24 and 10.0.2.0/24, private subnets 10.0.10.0/24 and 10.0.11.0/24.
  • Save docs/troubleshooting.md.

Why keep a troubleshooting log?

Production teams document every infrastructure failure. A troubleshooting log proves you can diagnose and resolve issues systematically, not just build things when they go right.

Commit on a feature branch and tag the release

Your VPC is deployed and working. Now commit this work following the branching workflow you established in the previous step.

  • Navigate back to the repository root and create a feature branch by running:
cd ~/Desktop/startup-infra
git checkout -b feat/vpc-module
  • Stage and commit all VPC-related files by running:
git add .
git commit -m "feat: add VPC module and deploy to dev environment"
  • Push the branch to GitHub by running:
git push origin feat/vpc-module
  • Open GitHub in your browser, navigate to the startup-infra repository, and click the Compare & pull request button that appears for your branch.
  • Click Create pull request.
  • Click Merge pull request, then Confirm merge.
  • Back in your terminal, switch to main and pull the merged changes by running:
git checkout main
git pull origin main
  • Tag this commit as your first versioned infrastructure release by running:
git tag v0.1.0 -m "VPC foundation deployed"
git push origin v0.1.0

You should see the tag pushed successfully. You can verify on GitHub by navigating to your repository's Tags section where v0.1.0 now appears.

Push rejected or merge conflicts?

  • If git push is rejected, pull the latest main first with git pull origin main then retry.
  • If you see merge conflicts on GitHub, confirm you are branching from the latest main commit.

Still stuck? Help me resolve a git push or merge conflict in my startup-infra repo

Your VPC is live, tagged, and merged. Next up, you will set up the CI/CD pipeline so that future Terraform changes deploy automatically when you merge a pull request.

Set Up GitHub Actions CI/CD with OIDC Authentication

Your VPC is deployed and working. But right now the only way to change your infrastructure is to run terraform apply from your laptop.

Running apply locally is how startups get breached. A leaked laptop, a stale credential, or a mistyped command can wipe production. In this step, you will set up OIDC authentication so that GitHub Actions can assume a temporary AWS role scoped to your repository. No static access keys stored anywhere.

In this step, get ready to:
  • Create the OIDC provider and IAM role in your bootstrap configuration.
  • Write the GitHub Actions workflow with plan-on-PR and apply-on-merge jobs.
  • Test the full GitOps pipeline end-to-end.
Create the OIDC provider and IAM role in bootstrap

OIDC lets GitHub Actions prove its identity to AWS without storing any credentials. AWS issues short-lived tokens scoped to a single workflow run.

  • Open bootstrap/main.tf in VS Code.
  • Add the OIDC provider resource at the bottom of the file by pasting this code:
# Register GitHub Actions as a trusted OIDC identity provider
resource "aws_iam_openid_connect_provider" "github_actions" {
  url = "https://token.actions.githubusercontent.com"

  client_id_list = ["sts.amazonaws.com"]

  # AWS validates GitHub's cert via its own store
  thumbprint_list = ["ffffffffffffffffffffffffffffffffffffffff"]

  tags = {
    Project   = "startup-infra"
    ManagedBy = "terraform"
  }
}

What does this resource do?

This registers GitHub's OIDC token issuer (token.actions.githubusercontent.com) with AWS. Once registered, AWS can verify tokens that GitHub Actions presents during workflow runs.

The thumbprint is set to all fs because AWS validates GitHub's certificate through its own certificate store rather than a pinned thumbprint.

  • Add the IAM role resource directly below the OIDC provider by pasting this code:
# IAM role that GitHub Actions assumes via OIDC
resource "aws_iam_role" "github_actions_terraform" {
  name = "github-actions-terraform"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Principal = {
          Federated = aws_iam_openid_connect_provider.github_actions.arn
        }
        Action = "sts:AssumeRoleWithWebIdentity"
        Condition = {
          StringEquals = {
            "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
          }
          StringLike = {
            "token.actions.githubusercontent.com:sub" = "repo:${var.github_org}/${var.github_repo}:*"
          }
        }
      }
    ]
  })

  tags = {
    Project   = "startup-infra"
    ManagedBy = "terraform"
  }
}

What does the trust policy do?

The Condition block is the security boundary. StringEquals on aud ensures only AWS-bound tokens are accepted. StringLike on sub restricts access to your specific GitHub repository.

The :* wildcard allows both push events and pull request events from that repo. A tighter production setup would scope apply to refs/heads/main only.

  • Save bootstrap/main.tf.
  • Verify Terraform can parse the new resources by running:
cd ~/Desktop/startup-infra/bootstrap
terraform plan

You should see Plan: 3 to add (the OIDC provider, the IAM role, and the role policy you will add next).

Seeing a syntax error?

Check that the jsonencode block has matching braces and brackets. A missing closing } or ] inside the trust policy is the most common cause.

Help me fix a Terraform syntax error in my IAM role trust policy jsonencode block

Terraform sees 3 resources because it detected the role policy reference in the role. You have not written the policy resource yet, so Terraform would error on apply. That is expected.

  • Add the first half of the IAM permissions policy below the role resource. This grants S3 access for reading and writing Terraform state:
resource "aws_iam_role_policy" "terraform_permissions" {
  name = "terraform-permissions"
  role = aws_iam_role.github_actions_terraform.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "S3StateAccess"
        Effect = "Allow"
        Action = [
          "s3:ListBucket",
          "s3:GetObject",
          "s3:PutObject",
          "s3:DeleteObject"
        ]
        Resource = [
          aws_s3_bucket.terraform_state.arn,
          "${aws_s3_bucket.terraform_state.arn}/*"
        ]
      },
  • Immediately after the S3 statement (right after the trailing comma), add the VPC management permissions and close the resource:
      {
        Sid    = "VPCManagement"
        Effect = "Allow"
        Action = [
          "ec2:*Vpc*",
          "ec2:*Subnet*",
          "ec2:*InternetGateway*",
          "ec2:*RouteTable*",
          "ec2:*Route",
          "ec2:*Tags*",
          "ec2:*NetworkAcl*",
          "ec2:*SecurityGroup*",
          "ec2:DescribeAvailabilityZones",
          "ec2:DescribeAccountAttributes"
        ]
        Resource = "*"
      }
    ]
  })
}

Why two permission statements?

The S3 statement is scoped to your specific state bucket (least privilege). The VPC statement uses wildcards because Terraform needs to describe, create, and destroy multiple EC2 resource types when managing networking.

In production you would tighten the VPC statement to specific resource ARNs. For this project the wildcard is acceptable because the role is already scoped to a single GitHub repository.

  • Now update bootstrap/outputs.tf to expose the role ARN. Add these outputs at the bottom of the file:
output "github_actions_role_arn" {
  description = "ARN of the IAM role for GitHub Actions to assume via OIDC"
  value       = aws_iam_role.github_actions_terraform.arn
}

output "setup_instructions" {
  description = "Next steps after bootstrap"
  value       = <<-EOT
    Bootstrap complete. Next steps:
    1. Add the role ARN as a GitHub repository secret named AWS_ROLE_ARN:
       ${aws_iam_role.github_actions_terraform.arn}
    2. Configure your backend in environments/dev/backend.tf with:
       bucket = "${aws_s3_bucket.terraform_state.id}"
  EOT
}
  • Save bootstrap/outputs.tf.
  • Apply the bootstrap changes to create the OIDC infrastructure in AWS:
terraform apply
  • Terraform will show 3 resources to add (the OIDC provider, IAM role, and IAM policy). Type yes to confirm.

After apply completes, you should see the github_actions_role_arn output printed. Copy this ARN value. You will need it in the next step.

Apply failed with a permissions error?

Your local AWS credentials need IAM write permissions to create the OIDC provider and role. Run aws sts get-caller-identity to confirm you are using the correct admin account.

Help me fix an IAM permissions error when running terraform apply for OIDC resources

  • Now add the role ARN as a secret in your GitHub repository. Open your startup-infra repository on GitHub in your browser.
  • Click Settings under the repository name.
  • In the left sidebar under Security, click Secrets and variables, then click Actions.
  • Click the Secrets tab.
  • Click New repository secret.
  • In the Name field, type AWS_ROLE_ARN.
  • In the Secret field, paste the role ARN you copied from the Terraform output.
  • Click Add secret.

Why OIDC over static IAM keys?

Static access keys are long-lived secrets that need rotation and can be leaked through logs, screenshots, or repo commits. OIDC tokens are generated fresh for each workflow run and expire within minutes.

The only secret stored in GitHub (AWS_ROLE_ARN) is the role's ARN. That is a public identifier, not a credential. The actual authentication happens through cryptographic token exchange.

  • Write ADR-0003 to document this decision. Create the file docs/adr/0003-oidc-over-static-keys.md with this content:
# ADR-0003: Use OIDC over static IAM keys for CI/CD

## Status

Accepted

## Context

GitHub Actions needs AWS credentials to run Terraform. Options are static IAM access keys stored as GitHub secrets, or OIDC federated identity that issues temporary credentials per workflow run.

## Decision

Use OIDC with `aws-actions/configure-aws-credentials` and an IAM role with a trust policy scoped to this specific repository.

## Consequences

- No static credentials stored anywhere
- Credentials are temporary and scoped per workflow run
- Trust policy must exactly match the repository and branch conditions
- Slightly more complex initial setup but significantly more secure
  • Save docs/adr/0003-oidc-over-static-keys.md.
Create the GitHub Actions workflow

The workflow file defines two jobs. The plan job runs on every pull request and posts the Terraform plan as a PR comment. The apply job runs when code merges to main.

  • Create the workflow file by running:
touch .github/workflows/terraform.yml
  • Open .github/workflows/terraform.yml in VS Code and add the workflow header with triggers, permissions, and environment variables:
name: Terraform CI/CD

on:
  pull_request:
    branches: [main]
    paths: ['environments/**', 'modules/**']
  push:
    branches: [main]
    paths: ['environments/**', 'modules/**']

permissions:
  id-token: write
  contents: read
  pull-requests: write

env:
  TF_VERSION: "1.15.8"
  AWS_REGION: "us-east-1"
  WORKING_DIR: "environments/dev"

What do these permissions do?

The id-token: write permission lets the runner request an OIDC token from GitHub. Without it, the AWS credential step silently fails.

The paths filter ensures the workflow only triggers when infrastructure code changes, not on README edits or ADR additions.

  • Add the plan job below the header. This includes checkout, OIDC auth, Terraform setup, and the plan command:
jobs:
  terraform-plan:
    name: Terraform Plan
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request'
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Configure AWS credentials via OIDC
        uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: ${{ env.AWS_REGION }}

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v4
        with:
          terraform_version: ${{ env.TF_VERSION }}

      - name: Terraform Init
        working-directory: ${{ env.WORKING_DIR }}
        run: terraform init

      - name: Terraform Validate
        working-directory: ${{ env.WORKING_DIR }}
        run: terraform validate

      - name: Terraform Plan
        id: plan
        working-directory: ${{ env.WORKING_DIR }}
        run: terraform plan -no-color -out=tfplan
        continue-on-error: true

Why continue-on-error on the plan step?

Setting continue-on-error: true lets the workflow proceed to post the plan output as a PR comment even if the plan itself fails. This way reviewers can see the error in the PR rather than digging through Actions logs.

  • Add the PR comment step. This uses a JavaScript script to post the plan output directly on the pull request. First, add the script that builds the plan output:
      - name: Post Plan to PR
        uses: actions/github-script@v7
        with:
          script: |
            const { execSync } = require('child_process');
            const plan = execSync('terraform show -no-color tfplan', {
              cwd: '${{ env.WORKING_DIR }}',
              encoding: 'utf8'
            });
            const truncated = plan.length > 60000
              ? plan.substring(0, 60000) + '\n... (truncated)'
              : plan;
            const body = `### Terraform Plan Output
            \`\`\`hcl
            ${truncated}
            \`\`\`
            **Plan exit code:** \`${{ steps.plan.outcome }}\``;
  • Continue the same script block by adding the comment-posting logic. This checks if a plan comment already exists and updates it, or creates a new one:
            const { data: comments } = await github.rest.issues.listComments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
            });
            const existing = comments.find(c => c.body.includes('### Terraform Plan Output'));
            if (existing) {
              await github.rest.issues.updateComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                comment_id: existing.id,
                body: body,
              });
            } else {
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.issue.number,
                body: body,
              });
            }

      - name: Fail if plan failed
        if: steps.plan.outcome == 'failure'
        run: exit 1

Why update existing comments?

When you push new commits to a PR, the plan job runs again. By updating the existing comment rather than creating a new one, your PR stays clean with a single up-to-date plan output instead of a growing list of outdated comments.

  • Add the apply job at the bottom of the file. This runs only when code is pushed to main (after a PR merge):
  terraform-apply:
    name: Terraform Apply
    runs-on: ubuntu-latest
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Configure AWS credentials via OIDC
        uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: ${{ env.AWS_REGION }}

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v4
        with:
          terraform_version: ${{ env.TF_VERSION }}

      - name: Terraform Init
        working-directory: ${{ env.WORKING_DIR }}
        run: terraform init

      - name: Terraform Apply
        working-directory: ${{ env.WORKING_DIR }}
        run: terraform apply -auto-approve -input=false

What triggers apply vs plan?

The if conditions route traffic. Pull request events trigger only the plan job. Push events to main (which happen when you merge a PR) trigger only the apply job. This ensures infrastructure changes are always reviewed before deployment.

  • Save .github/workflows/terraform.yml.
  • Verify the file looks correct by running:
cat .github/workflows/terraform.yml

You should see the full workflow with both jobs printed in your terminal.

YAML indentation looks wrong?

YAML is whitespace-sensitive. The jobs: key must be at the root level (no indentation). Each job (terraform-plan, terraform-apply) is indented 2 spaces under jobs. Steps are indented 6 spaces total (2 for job, 2 for steps, 2 for each step item).

Help me fix YAML indentation in my GitHub Actions workflow file

✔️ Awesome, I've got everything!

Great. Double check you have saved .github/workflows/terraform.yml before continuing.

ⓧ I'd like to double check the full code

The full workflow file is large. Scroll up and confirm these sections are present in order:

  • Header: name, on, permissions, env blocks.
  • Plan job: terraform-plan with Checkout, OIDC, Setup Terraform, Init, Validate, Plan, Post Plan to PR, and Fail if plan failed steps.
  • Apply job: terraform-apply with Checkout, OIDC, Setup Terraform, Init, and Apply steps.
Test the full GitOps pipeline

Your workflow is written and the OIDC role is live. Time to commit everything and test the end-to-end flow.

  • Return to the repository root and commit all the new files by running:
cd ~/Desktop/startup-infra
git add .
git commit -m "feat: add OIDC auth, IAM role, and CI/CD workflow"
  • Push to main to establish the workflow file on the default branch:
git push origin main

Why push to main first?

GitHub Actions only recognizes workflow files that exist on the default branch. If you create a branch before the workflow file is on main, the PR will not trigger any workflow runs.

  • Create a feature branch to test the plan job by running:
git checkout -b feat/add-vpc-tags
  • Open modules/vpc/main.tf in VS Code.
  • Find the aws_vpc resource's tags block. It currently looks like this:
  tags = {
    Name        = "${var.project_name}-${var.environment}-vpc"
    Project     = var.project_name
    Environment = var.environment
    ManagedBy   = "terraform"
  }
  • The ManagedBy tag already exists in the VPC module. This is exactly the tag you want the pipeline to deploy. Your branch already modifies files in modules/ which matches the workflow's paths filter. However, since the VPC already has this tag, you need a real change. Add a new tag line below ManagedBy. Update the tags block so it looks like this:
  tags = {
    Name        = "${var.project_name}-${var.environment}-vpc"
    Project     = var.project_name
    Environment = var.environment
    ManagedBy   = "terraform"
  }

The tags are already correct in the module from your earlier work. The pipeline needs a file change to trigger. Instead, verify your workflow works by committing the workflow and OIDC files you already pushed.

  • Make a small but real change to trigger the workflow. Add a comment at the top of modules/vpc/main.tf:
# VPC module - managed by startup-infra GitOps pipeline
  • Save the file, then commit and push the branch:
git add .
git commit -m "feat: add VPC module header comment"
git push origin feat/add-vpc-tags
  • Open your startup-infra repository on GitHub in your browser.
  • Click Compare & pull request for the feat/add-vpc-tags branch.
  • Click Create pull request.

Before you check the Actions tab, what do you expect to see? The plan job should authenticate via OIDC, initialize Terraform against your S3 backend, and post a plan comment on the PR.

  • Click the Actions tab at the top of your repository to watch the workflow run.
  • Wait for the Terraform Plan job to complete (usually 1-2 minutes).
  • Return to your pull request. You should see a comment titled Terraform Plan Output showing the planned changes (likely "No changes" since the VPC module structure did not change any resources).

Plan job failed with OIDC error?

If you see "Not authorized to perform sts:AssumeRoleWithWebIdentity", check these in order:

  • Verify the AWS_ROLE_ARN secret value matches the exact ARN from terraform output github_actions_role_arn in your bootstrap/ directory.
  • Verify the trust policy's sub condition matches your GitHub username and repository name exactly.
  • Verify the workflow has permissions: id-token: write at the top level.

Help me debug a GitHub Actions OIDC authentication failure to AWS

  • Once the plan job passes and the comment appears, merge the pull request by clicking Merge pull request, then Confirm merge.
  • Click the Actions tab again. You should see a new workflow run triggered by the push to main. This is the apply job.
  • Wait for the Terraform Apply job to complete.

The apply job should succeed with no changes (since the comment you added does not affect Terraform resources). Your GitOps pipeline is live.

  • Return to your terminal and switch back to main by running:
git checkout main
git pull origin main
  • Verify your VPC still has the correct tags in AWS by running:
aws ec2 describe-vpcs --filters "Name=tag:Project,Values=startup-infra" --query "Vpcs[0].Tags" --output table

You should see a table listing all your VPC tags including Project, Environment, and ManagedBy.

Your GitOps pipeline is live. Every future infrastructure change follows the same flow: branch, plan-on-PR, review, merge, auto-apply. No more running terraform apply from your laptop.

Secret mission

Break It, Document It, Fix It

In interviews, candidates who can articulate how they troubleshot a failure are far more compelling than those who only describe what they built. In this mission, you will intentionally break your OIDC authentication, triage the failure like a real production incident, and write a formal incident report and runbook for future engineers.

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 only AWS Free Tier resources (S3, VPC, IAM) with no hourly charges, so there is no ongoing cost.

Resources you used:

  • S3 bucket for Terraform state (negligible storage cost for state files).
  • VPC with subnets, internet gateway, and route tables (no hourly charges).
  • IAM OIDC provider, IAM role, and IAM policy (free).
  • Local startup-infra repository on your Desktop.
  • GitHub startup-infra repository.

Keep everything running

No action needed. VPC and IAM resources cost $0. The S3 bucket costs fractions of a cent for state file storage. Keep everything in place if you plan to build on top of this infrastructure in a follow-up project.

Pause - I'll come back to this later

There is nothing to pause. These resources have no hourly cost and consume no compute. Leave them running and come back whenever you are ready.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow these steps in order.

Destroy the VPC
  • Navigate to your environments/dev/ directory and destroy the VPC resources by running:
cd ~/Desktop/startup-infra/environments/dev
terraform destroy
  • Type yes when prompted to confirm.
Destroy the bootstrap resources

The S3 bucket has prevent_destroy = true set in its lifecycle rule. You need to remove that protection before Terraform can delete it.

  • Open bootstrap/main.tf and delete the entire lifecycle block from the aws_s3_bucket.terraform_state resource.
  • Empty the S3 bucket by running:
aws s3 rm s3://[[STATE_BUCKET="your-state-bucket-name"]] --recursive
  • Navigate to the bootstrap/ directory and destroy all bootstrap resources (S3 bucket, OIDC provider, IAM role, IAM policy) by running:
cd ~/Desktop/startup-infra/bootstrap
terraform destroy
  • Type yes when prompted to confirm.
Delete the local repository
  • Remove the local project folder by running:
rm -rf ~/Desktop/startup-infra
Delete the GitHub repository
  • Open your startup-infra repository on GitHub.
  • Click **Settings** in the repository header.
  • Scroll down to the **Danger Zone** section.
  • Click **Delete this repository**.
  • Click **I want to delete this repository**, then click **I have read and understand these effects**.
  • Type the repository name to confirm, then click **Delete this repository**.

Nice Work!

Nice Work!

Nice work! You just built a production-grade Infrastructure as Code foundation that works exactly like a real startup platform team repository.

You've learned how to:

  • Build a professional Terraform repository with modular structure, environment separation, and Architecture Decision Records documenting every major choice.
  • Deploy a production-ready VPC with public and private subnets across 2 Availability Zones, managed through S3 remote state with the modern use_lockfile locking mechanism.
  • Set up a GitHub Actions CI/CD pipeline using OIDC authentication that runs terraform plan on pull requests and auto-applies on merge to main with zero static credentials.
  • Secret Mission: Intentionally broke your OIDC trust policy, diagnosed the failure from GitHub Actions logs, and wrote a formal incident report and runbook for future engineers.

Ready to quiz yourself?