Enterprise Terraform GitOps Pipeline
Build a modular Terraform pipeline with GitHub Actions CI/CD on AWS.
Introduction
30 Second Summary
Imagine making changes to live cloud infrastructure the same way developers ship code. Every change is reviewed, tested, and deployed automatically. No one logs into a console and clicks buttons manually.
In this project, you will build a production-grade GitOps pipeline that provisions real AWS resources entirely through pull requests. You will write modular Terraform code, set up automated security scanning, and configure a CI/CD pipeline that validates and deploys infrastructure changes with zero manual intervention.
What You'll Build
A fully automated Infrastructure as Code pipeline that mirrors how engineering teams manage cloud infrastructure at scale today.
By the end of this project, you'll have:
- A modular Terraform codebase provisioning a VPC, subnets, EC2 instance, S3 bucket, and IAM roles on AWS.
- A GitHub Actions CI/CD pipeline that runs security scans, posts terraform plan output to pull requests, and auto-applies infrastructure on merge to main.
- A complete GitOps workflow where infrastructure changes are version-controlled, reviewed, validated, and deployed through pull requests.
- Secret Mission: Add an RDS database module with proper subnet groups, security groups, and secrets management.
Are there any prerequisites?
You need an AWS account with the AWS CLI already configured, a GitHub account, Python 3.9+, and basic Git knowledge (clone, commit, push, branch). You should be comfortable working in a Linux terminal.
Tools like Terraform, pre-commit, tflint, and checkov will all be installed in Step 1.
Install Tools and Bootstrap Remote State
Every Terraform project needs a place to store its state file. This state file tracks what infrastructure exists so Terraform knows what to create, update, or destroy.
Here is the challenge: you need cloud infrastructure (an S3 bucket and a DynamoDB table) to exist before Terraform can manage anything. This is called the bootstrap problem. In this step, you will install all the tools you need and manually create the state infrastructure using the AWS CLI.
In this step, get ready to:
- Install Terraform CLI, tflint, pre-commit, and checkov on your Ubuntu machine.
- Create the S3 bucket and DynamoDB table that store Terraform's remote state.
- Initialize the project repository with the enterprise directory layout.
Install Terraform, tflint, pre-commit, and checkov
You need four tools installed before you can write any infrastructure code. Terraform is the core Infrastructure as Code engine. The other three tools catch errors before code leaves your machine.
- Install the Terraform CLI from the official HashiCorp APT repository by running these commands:
# Add HashiCorp's GPG key so apt trusts their packages
wget -O- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
# Add the HashiCorp repository to your system's package sources
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
# Update package lists and install Terraform
sudo apt update && sudo apt install terraform -y
What do these commands do?
- The first command downloads HashiCorp's GPG key so your system can verify that packages are authentic.
- The second command adds HashiCorp's package repository to your system's sources list.
- The third command updates your package index and installs the latest Terraform binary.
- Install tflint (a Terraform linter that catches provider-specific errors) by running:
curl -s https://raw.githubusercontent.com/terraform-linters/tflint/master/install_linux.sh | bash
- Install pre-commit and checkov using pip by running:
pip install pre-commit checkov
What are pre-commit and checkov?
pre-commit is a framework that runs automated checks on your code every time you commit. checkov is a static analysis tool that scans Terraform files for security misconfigurations.
Together with tflint, these tools form a local quality gate that catches issues before code reaches your CI pipeline.
- Verify all four tools installed correctly by running:
terraform --version
tflint --version
pre-commit --version
checkov --version
You should see version numbers for each tool. Terraform should show 1.15.x or higher.
Command not found for one of the tools?
- If terraform is not found, make sure the APT install completed without errors. Re-run sudo apt install terraform -y.
- If tflint is not found, the curl script may not have added it to your PATH. Try opening a new terminal session.
- If pre-commit or checkov is not found, ensure pip installed them to a location on your PATH. Try pip install --user pre-commit checkov and ensure ~/.local/bin is in your PATH.
- help me debug why one of my tools is not found after installation
Create the S3 bucket and DynamoDB table for remote state
Terraform needs a remote backend to store its state file safely. You will create an S3 bucket (for the state file itself) and a DynamoDB table (for state locking so two people cannot modify infrastructure at the same time).
What is the bootstrap problem?
You cannot use Terraform to create the infrastructure that stores Terraform's own state. The state backend must exist before Terraform can initialize. This is why you create the S3 bucket and DynamoDB table manually with the AWS CLI first.
Once these resources exist, Terraform manages everything else. These two resources are the only ones you create by hand in this entire project.
- Create the S3 bucket for state storage by running this command:
aws s3api create-bucket \
--bucket [[STATE_BUCKET="yourname-terraform-state"]] \
--region us-east-1
Why no LocationConstraint?
When creating a bucket in us-east-1, you do not need a --create-bucket-configuration flag. That flag is only required for other regions.
Bucket names must be globally unique
Replace the bucket name with something unique to you. A good pattern is yourname-terraform-state. You will use this exact name again in Step 4 when configuring the Terraform backend.
- Enable versioning on the bucket so you can recover previous state files if something goes wrong:
aws s3api put-bucket-versioning \
--bucket [[STATE_BUCKET="yourname-terraform-state"]] \
--versioning-configuration Status=Enabled
- Enable server-side encryption on the bucket to protect your state at rest:
aws s3api put-bucket-encryption \
--bucket [[STATE_BUCKET="yourname-terraform-state"]] \
--server-side-encryption-configuration '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}'
- Create the DynamoDB table for state locking by running:
aws dynamodb create-table \
--table-name terraform-state-locks \
--attribute-definitions AttributeName=LockID,AttributeType=S \
--key-schema AttributeName=LockID,KeyType=HASH \
--billing-mode PAY_PER_REQUEST \
--region us-east-1
What does this table do?
The DynamoDB table uses a partition key named LockID (type String). When someone runs terraform apply, Terraform writes a lock entry to this table. If a second person tries to apply at the same time, Terraform sees the lock and blocks them. This prevents two people from corrupting state simultaneously.
- Verify both resources were created successfully by running:
aws s3api head-bucket --bucket [[STATE_BUCKET="yourname-terraform-state"]]
aws dynamodb describe-table --table-name terraform-state-locks --query "Table.TableStatus" --region us-east-1
The first command should return no output (meaning the bucket exists). The second should print "ACTIVE".
Bucket already exists error?
If you see BucketAlreadyExists, someone else has already taken that bucket name globally. Choose a more unique name (add random numbers or your initials).
If you see AccessDenied, your AWS CLI credentials may not have the right permissions. Confirm you can run aws sts get-caller-identity successfully.
help me fix my S3 bucket or DynamoDB table creation error
Initialize the project directory structure
Enterprise Terraform projects follow a specific directory layout. Modules live in their own folders for reuse, and each environment gets its own configuration directory.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder with the full directory structure by running these commands:
# Create the project root and all module/environment directories in one command
mkdir -p terraform-gitops/{modules/{networking,compute,storage,iam},environments/dev,.github/workflows}
cd terraform-gitops
What is this directory layout?
- modules/ contains reusable infrastructure components (networking, compute, storage, IAM). Each module is self-contained with its own inputs and outputs.
- environments/dev/ is where you compose modules together with environment-specific values.
- .github/workflows/ holds your CI/CD pipeline definitions for GitHub Actions.
- Create the .gitignore file to keep sensitive files and Terraform internals out of version control by running:
cat > .gitignore << 'EOF'
# Terraform local directories and state
.terraform/
*.tfstate
*.tfstate.backup
*.tfplan
# Sensitive variable files
*.tfvars
!*.tfvars.example
# OS files
.DS_Store
EOF
What does this .gitignore do?
- .terraform/ is excluded because it contains downloaded providers and plugins (large, machine-specific files).
- *.tfstate files are excluded because your state lives in S3 (never commit state to Git).
- *.tfvars files often contain secrets, so they are excluded. The !*.tfvars.example exception lets you commit example variable files as templates for your team.
- Initialize a Git repository in your project folder by running:
git init
git add .gitignore
git commit -m "Initial commit: project structure and gitignore"
- Confirm the directory structure looks correct by running:
find . -type d | sort
You should see output listing ./.github/workflows, ./environments/dev, ./modules/compute, ./modules/iam, ./modules/networking, and ./modules/storage.
Missing directories in the output?
Make sure you ran the mkdir -p command from your Desktop, not from inside an existing directory. The -p flag creates all parent directories without error if they already exist.
help me fix my project directory structure
✔️ Awesome, I've got everything!
Great. Double check you have saved your .gitignore and committed it to Git.
ⓧ I'd like to double check the full code
Here is the complete .gitignore file for comparison:
# Terraform local directories and state
.terraform/
*.tfstate
*.tfstate.backup
*.tfplan
# Sensitive variable files
*.tfvars
!*.tfvars.example
# OS files
.DS_Store
Your environment is ready and your state backend is live. Next up, you will build the networking module that creates a VPC with public and private subnets across two availability zones.
Build the Networking Module
Your tools are installed and your remote state backend is ready. Now it is time to write actual infrastructure code.
Every AWS deployment starts with networking. Before you can launch a server or store data, you need a VPC (Virtual Private Cloud) that defines the network boundaries for your resources. In this step, you will build a reusable networking module that provisions a VPC, public and private subnets across two availability zones, and the routing rules that control traffic flow.
In this step, get ready to:
- Create the VPC and subnet resources spread across two availability zones.
- Add an internet gateway and route tables that control how traffic moves in and out of your subnets.
- Define validated input variables and outputs so other modules can reference this module's resources.
Create the VPC with public and private subnets
A Terraform module is a directory containing .tf files that define a logical group of resources. Your networking module will live in the modules/networking/ directory you created in Step 1.
- Create a new file called main.tf inside modules/networking/ and add the following code:
# Declare the AWS provider requirement for this module
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
# Look up which availability zones are available in the current region
data "aws_availability_zones" "available" {
state = "available"
}
# Create the VPC with DNS support enabled
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "${var.project_name}-${var.environment}-vpc"
Environment = var.environment
ManagedBy = "terraform"
}
}
# Public subnets get automatic public IPs and sit in separate AZs
resource "aws_subnet" "public" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index)
availability_zone = data.aws_availability_zones.available.names[count.index]
map_public_ip_on_launch = true
tags = {
Name = "${var.project_name}-${var.environment}-public-${count.index + 1}"
Environment = var.environment
ManagedBy = "terraform"
}
}
# Private subnets have no public IPs and use a different CIDR offset
resource "aws_subnet" "private" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index + 10)
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = {
Name = "${var.project_name}-${var.environment}-private-${count.index + 1}"
Environment = var.environment
ManagedBy = "terraform"
}
}
What does this code do?
- The terraform block declares that this module requires the AWS provider version ~> 5.0 (any 5.x release).
- The data source dynamically fetches which availability zones are active in your region, so the module works in any AWS region without hardcoding.
- cidrsubnet(var.vpc_cidr, 8, count.index) automatically carves the VPC CIDR into smaller /24 subnets. Public subnets get offsets 0 and 1. Private subnets get offsets 10 and 11.
- map_public_ip_on_launch = true gives instances in public subnets an automatic public IP. Private subnets skip this so they remain isolated.
You will not see a change yet. The module needs its variable definitions and route tables before Terraform can validate it.
Add the internet gateway and route tables
Subnets alone cannot reach the internet. An internet gateway provides the connection, and route tables tell each subnet where to send traffic.
- Add the following code to the bottom of modules/networking/main.tf, directly below the private subnets resource:
# Internet gateway allows public subnets to reach the internet
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-${var.environment}-igw"
Environment = var.environment
ManagedBy = "terraform"
}
}
# Public route table sends all non-local traffic to the internet gateway
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"
Environment = var.environment
ManagedBy = "terraform"
}
}
# Associate both public subnets with the public route table
resource "aws_route_table_association" "public" {
count = 2
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
# Private route table has no internet route (isolated by design)
resource "aws_route_table" "private" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-${var.environment}-private-rt"
Environment = var.environment
ManagedBy = "terraform"
}
}
# Associate both private subnets with the private route table
resource "aws_route_table_association" "private" {
count = 2
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table.private.id
}
What does this code do?
- The internet gateway attaches to the VPC and gives resources a path to the public internet.
- The public route table routes 0.0.0.0/0 (all non-local traffic) through the internet gateway. Each public subnet is explicitly associated with this route table.
- The private route table has no routes to the internet. Traffic from private subnets stays internal to the VPC.
Why no NAT gateway?
In production, private subnets typically use a NAT gateway so instances can pull updates from the internet. NAT gateways cost roughly $32/month. For this learning project, the private route table has no outbound internet route to keep costs at zero.
You still cannot validate the module yet. The next substep adds the variable and output definitions that complete it.
Define module inputs with validation and outputs
A well-designed Terraform module declares its inputs (variables) and outputs explicitly. Input validation catches invalid values before Terraform even contacts AWS.
- Create a new file called variables.tf inside modules/networking/ and add the following code:
variable "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
# Validates that the value is a properly formatted CIDR block
validation {
condition = can(cidrhost(var.vpc_cidr, 0))
error_message = "The vpc_cidr must be a valid IPv4 CIDR block."
}
}
variable "project_name" {
description = "Name of the project for resource tagging"
type = string
}
variable "environment" {
description = "Deployment environment"
type = string
# Only allow known environment values
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Environment must be dev, staging, or prod."
}
}
What do the validation blocks do?
The vpc_cidr validation uses can(cidrhost(...)) to check whether the string is a valid CIDR block. If someone passes "not-a-cidr", Terraform rejects it immediately with a clear error message.
The environment validation restricts values to dev, staging, or prod. This prevents typos like "production" from creating inconsistently named resources.
- Create a new file called outputs.tf inside modules/networking/ and add the following code:
output "vpc_id" {
description = "ID of the VPC"
value = aws_vpc.main.id
}
output "public_subnet_ids" {
description = "IDs of the public subnets"
value = aws_subnet.public[*].id
}
output "private_subnet_ids" {
description = "IDs of the private subnets"
value = aws_subnet.private[*].id
}
output "vpc_cidr_block" {
description = "CIDR block of the VPC"
value = aws_vpc.main.cidr_block
}
Why do modules need outputs?
Outputs are how one module shares values with another. When the compute module needs to place an EC2 instance in a private subnet, it references module.networking.private_subnet_ids. Without outputs, each module would be an isolated island.
- Save all three files (main.tf, variables.tf, outputs.tf).
- Validate the module by running these commands from your project root:
cd modules/networking
terraform init
terraform validate
You should see Success! The configuration is valid. in your terminal output.
- Navigate back to your project root by running:
cd ../..
Seeing a validation error?
- If you see Could not load plugin, make sure you ran terraform init before terraform validate. The init step downloads the AWS provider.
- If you see Reference to undeclared resource, check that the resource names in outputs.tf exactly match those in main.tf (e.g. aws_vpc.main, aws_subnet.public).
- If you see Reference to undeclared input variable, confirm your variables.tf declares vpc_cidr, project_name, and environment.
Help me debug my Terraform validate error in the networking module.
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files in modules/networking/.
ⓧ I'd like to double check the full code
Here is the complete code for each file in modules/networking/.
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
data "aws_availability_zones" "available" {
state = "available"
}
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "${var.project_name}-${var.environment}-vpc"
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_subnet" "public" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index)
availability_zone = data.aws_availability_zones.available.names[count.index]
map_public_ip_on_launch = true
tags = {
Name = "${var.project_name}-${var.environment}-public-${count.index + 1}"
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_subnet" "private" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index + 10)
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = {
Name = "${var.project_name}-${var.environment}-private-${count.index + 1}"
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-${var.environment}-igw"
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
tags = {
Name = "${var.project_name}-${var.environment}-public-rt"
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_route_table_association" "public" {
count = 2
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table" "private" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-${var.environment}-private-rt"
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_route_table_association" "private" {
count = 2
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table.private.id
}
variable "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
validation {
condition = can(cidrhost(var.vpc_cidr, 0))
error_message = "The vpc_cidr must be a valid IPv4 CIDR block."
}
}
variable "project_name" {
description = "Name of the project for resource tagging"
type = string
}
variable "environment" {
description = "Deployment environment"
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Environment must be dev, staging, or prod."
}
}
output "vpc_id" {
description = "ID of the VPC"
value = aws_vpc.main.id
}
output "public_subnet_ids" {
description = "IDs of the public subnets"
value = aws_subnet.public[*].id
}
output "private_subnet_ids" {
description = "IDs of the private subnets"
value = aws_subnet.private[*].id
}
output "vpc_cidr_block" {
description = "CIDR block of the VPC"
value = aws_vpc.main.cidr_block
}
Your networking module is complete and validated. Next up, you will build the compute, storage, and IAM modules that rely on this network foundation.
Build Compute, Storage, and IAM Modules
Your networking module gives the infrastructure a home. Now you need something to live in it.
In this step, you will build three more modules that provision the actual resources your project needs: an EC2 instance for compute, an S3 bucket for storage, and an IAM role for permissions. Each module follows the same pattern you used for networking: inputs, resources, outputs.
In this step, get ready to:
- Create the compute module with a security group and hardened EC2 instance.
- Create the storage module with encryption, versioning, and blocked public access.
- Create the IAM module with a least-privilege role and instance profile.
Create the compute module
The compute module provisions a single EC2 instance with security best practices baked in. It dynamically fetches the latest Amazon Linux 2023 AMI so you never have to hardcode AMI IDs that expire.
- Create the compute module directory by running this command:
mkdir -p modules/compute
- Confirm the directory exists by running:
ls modules/
You should see both compute and networking listed.
- Create the compute module's main configuration file by adding the following code to modules/compute/main.tf:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
resource "aws_security_group" "instance" {
name_prefix = "${var.project_name}-${var.environment}-instance-"
description = "Security group for EC2 instance"
vpc_id = var.vpc_id
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
description = "Allow all outbound traffic"
}
tags = {
Name = "${var.project_name}-${var.environment}-instance-sg"
Environment = var.environment
ManagedBy = "terraform"
}
lifecycle {
create_before_destroy = true
}
}
resource "aws_instance" "main" {
ami = data.aws_ami.amazon_linux.id
instance_type = var.instance_type
subnet_id = var.subnet_id
vpc_security_group_ids = [aws_security_group.instance.id]
iam_instance_profile = var.instance_profile_name
metadata_options {
http_endpoint = "enabled"
http_tokens = "required"
}
root_block_device {
encrypted = true
}
tags = {
Name = "${var.project_name}-${var.environment}-instance"
Environment = var.environment
ManagedBy = "terraform"
}
}
What does this code do?
This file defines three resources that work together:
- The aws_ami data source dynamically fetches the latest Amazon Linux 2023 AMI. You never hardcode an AMI ID that expires.
- The security group allows all outbound traffic but no inbound traffic. The create_before_destroy lifecycle rule prevents downtime during security group updates.
- The EC2 instance uses http_tokens = "required" to enforce IMDSv2 (blocks credential theft attacks) and encrypts the root volume.
- Save modules/compute/main.tf.
Seeing syntax errors in your editor?
HCL syntax requires exact bracket and brace placement. Double-check that every opening { has a matching closing } and that nested blocks like metadata_options and root_block_device are properly indented inside the aws_instance resource.
help me debug a syntax error in my Terraform compute module
- Define the module's input variables by adding the following code to modules/compute/variables.tf:
variable "vpc_id" {
description = "ID of the VPC"
type = string
}
variable "subnet_id" {
description = "ID of the subnet to launch the instance in"
type = string
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t2.micro"
}
variable "instance_profile_name" {
description = "Name of the IAM instance profile"
type = string
}
variable "project_name" {
description = "Name of the project for resource tagging"
type = string
}
variable "environment" {
description = "Deployment environment"
type = string
}
What are these variables for?
These are the inputs the root module will pass in when it calls this compute module. The vpc_id and subnet_id connect the instance to the networking module's outputs. The instance_type defaults to t2.micro (free tier eligible) so you do not accidentally provision an expensive instance.
- Define the module's outputs by adding the following code to modules/compute/outputs.tf:
output "instance_id" {
description = "ID of the EC2 instance"
value = aws_instance.main.id
}
output "security_group_id" {
description = "ID of the instance security group"
value = aws_security_group.instance.id
}
output "private_ip" {
description = "Private IP address of the instance"
value = aws_instance.main.private_ip
}
- Save both modules/compute/variables.tf and modules/compute/outputs.tf.
- Confirm all three compute module files exist by running:
ls modules/compute/
You should see main.tf, outputs.tf, and variables.tf listed.
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files before continuing.
ⓧ I'd like to double check the full code
Here is the complete code for each file in the compute module.
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
resource "aws_security_group" "instance" {
name_prefix = "${var.project_name}-${var.environment}-instance-"
description = "Security group for EC2 instance"
vpc_id = var.vpc_id
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
description = "Allow all outbound traffic"
}
tags = {
Name = "${var.project_name}-${var.environment}-instance-sg"
Environment = var.environment
ManagedBy = "terraform"
}
lifecycle {
create_before_destroy = true
}
}
resource "aws_instance" "main" {
ami = data.aws_ami.amazon_linux.id
instance_type = var.instance_type
subnet_id = var.subnet_id
vpc_security_group_ids = [aws_security_group.instance.id]
iam_instance_profile = var.instance_profile_name
metadata_options {
http_endpoint = "enabled"
http_tokens = "required"
}
root_block_device {
encrypted = true
}
tags = {
Name = "${var.project_name}-${var.environment}-instance"
Environment = var.environment
ManagedBy = "terraform"
}
}
variable "vpc_id" {
description = "ID of the VPC"
type = string
}
variable "subnet_id" {
description = "ID of the subnet to launch the instance in"
type = string
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t2.micro"
}
variable "instance_profile_name" {
description = "Name of the IAM instance profile"
type = string
}
variable "project_name" {
description = "Name of the project for resource tagging"
type = string
}
variable "environment" {
description = "Deployment environment"
type = string
}
output "instance_id" {
description = "ID of the EC2 instance"
value = aws_instance.main.id
}
output "security_group_id" {
description = "ID of the instance security group"
value = aws_security_group.instance.id
}
output "private_ip" {
description = "Private IP address of the instance"
value = aws_instance.main.private_ip
}
Create the storage module
The storage module provisions an S3 bucket with enterprise security defaults. Every bucket gets encryption, versioning, and blocked public access from day one.
- Create the storage module directory by running:
mkdir -p modules/storage
- Add the storage module's resource definitions to modules/storage/main.tf:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
resource "aws_s3_bucket" "main" {
bucket_prefix = "${var.project_name}-${var.environment}-"
force_destroy = true
tags = {
Name = "${var.project_name}-${var.environment}-bucket"
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_s3_bucket_versioning" "main" {
bucket = aws_s3_bucket.main.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "main" {
bucket = aws_s3_bucket.main.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
}
bucket_key_enabled = true
}
}
resource "aws_s3_bucket_public_access_block" "main" {
bucket = aws_s3_bucket.main.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
What does this code do?
This file creates an S3 bucket with four security layers:
- The bucket_prefix lets AWS append a unique suffix so you never get bucket name collisions.
- Versioning keeps a history of every object change, enabling rollback.
- Server-side encryption with aws:kms encrypts all objects at rest. The bucket_key_enabled setting reduces KMS API call costs.
- The public access block sets all four deny flags to true, making it impossible to accidentally expose the bucket publicly.
- Save modules/storage/main.tf.
Not sure about force_destroy?
The force_destroy = true setting allows Terraform to delete the bucket even if it contains objects. This is appropriate for a dev environment. In production, you would set this to false to prevent accidental data loss.
- Define the storage module's input variables by adding the following code to modules/storage/variables.tf:
variable "project_name" {
description = "Name of the project for resource tagging"
type = string
}
variable "environment" {
description = "Deployment environment"
type = string
}
- Define the storage module's outputs by adding the following code to modules/storage/outputs.tf:
output "bucket_id" {
description = "ID of the S3 bucket"
value = aws_s3_bucket.main.id
}
output "bucket_arn" {
description = "ARN of the S3 bucket"
value = aws_s3_bucket.main.arn
}
Why does the storage module output bucket_arn?
The IAM module needs the bucket's ARN to grant the EC2 instance read/write permissions. Outputs are how Terraform modules share data with each other.
- Save both modules/storage/variables.tf and modules/storage/outputs.tf.
- Confirm all three storage module files exist by running:
ls modules/storage/
You should see main.tf, outputs.tf, and variables.tf listed.
✔️ Awesome, I've got everything!
Great. Make sure all files are saved before moving on to the IAM module.
ⓧ I'd like to double check the full code
Here is the complete code for each file in the storage module.
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
resource "aws_s3_bucket" "main" {
bucket_prefix = "${var.project_name}-${var.environment}-"
force_destroy = true
tags = {
Name = "${var.project_name}-${var.environment}-bucket"
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_s3_bucket_versioning" "main" {
bucket = aws_s3_bucket.main.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "main" {
bucket = aws_s3_bucket.main.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
}
bucket_key_enabled = true
}
}
resource "aws_s3_bucket_public_access_block" "main" {
bucket = aws_s3_bucket.main.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
variable "project_name" {
description = "Name of the project for resource tagging"
type = string
}
variable "environment" {
description = "Deployment environment"
type = string
}
output "bucket_id" {
description = "ID of the S3 bucket"
value = aws_s3_bucket.main.id
}
output "bucket_arn" {
description = "ARN of the S3 bucket"
value = aws_s3_bucket.main.arn
}
Create the IAM module
The IAM module follows the principle of least privilege. It creates a role that grants the EC2 instance only the specific S3 and CloudWatch permissions it needs.
- Create the IAM module directory by running:
mkdir -p modules/iam
- Add the IAM module's resource definitions to modules/iam/main.tf:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
data "aws_iam_policy_document" "ec2_assume_role" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ec2.amazonaws.com"]
}
}
}
resource "aws_iam_role" "instance" {
name_prefix = "${var.project_name}-${var.environment}-instance-"
assume_role_policy = data.aws_iam_policy_document.ec2_assume_role.json
tags = {
Name = "${var.project_name}-${var.environment}-instance-role"
Environment = var.environment
ManagedBy = "terraform"
}
}
data "aws_iam_policy_document" "instance_permissions" {
statement {
sid = "AllowS3Access"
actions = [
"s3:GetObject",
"s3:PutObject",
"s3:ListBucket",
]
resources = [
var.s3_bucket_arn,
"${var.s3_bucket_arn}/*",
]
}
statement {
sid = "AllowCloudWatchLogs"
actions = [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents",
]
resources = ["*"]
}
}
resource "aws_iam_role_policy" "instance" {
name_prefix = "${var.project_name}-${var.environment}-"
role = aws_iam_role.instance.id
policy = data.aws_iam_policy_document.instance_permissions.json
}
resource "aws_iam_instance_profile" "instance" {
name_prefix = "${var.project_name}-${var.environment}-"
role = aws_iam_role.instance.name
}
What does this code do?
This module uses policy documents (the Terraform-native way to write IAM policies) to create a tightly scoped role:
- The ec2_assume_role document is the trust policy. It allows only the EC2 service to assume this role.
- The instance_permissions document grants S3 read/write on the project bucket and CloudWatch Logs write access. Nothing else.
- The instance profile wraps the role so EC2 can attach it. EC2 instances cannot use IAM roles directly.
Why use aws_iam_policy_document instead of inline JSON?
The aws_iam_policy_document data source generates valid IAM policy JSON from HCL. It catches syntax errors at plan time, supports variable interpolation, and makes policies readable without escaped JSON strings.
- Save modules/iam/main.tf.
Worried about the wildcard resource on CloudWatch Logs?
The resources = ["*"] on the CloudWatch Logs statement is standard practice. Log groups are created dynamically by applications, so you cannot predict their ARNs in advance. The actions are scoped to only create and write logs.
help me understand least privilege in this IAM module
- Define the IAM module's input variables by adding the following code to modules/iam/variables.tf:
variable "project_name" {
description = "Name of the project for resource tagging"
type = string
}
variable "environment" {
description = "Deployment environment"
type = string
}
variable "s3_bucket_arn" {
description = "ARN of the S3 bucket the instance can access"
type = string
}
- Define the IAM module's outputs by adding the following code to modules/iam/outputs.tf:
output "instance_role_arn" {
description = "ARN of the instance IAM role"
value = aws_iam_role.instance.arn
}
output "instance_profile_name" {
description = "Name of the instance profile"
value = aws_iam_instance_profile.instance.name
}
How do these outputs connect the modules?
The instance_profile_name output feeds directly into the compute module's instance_profile_name variable. This is how your root module will wire IAM permissions to the EC2 instance in the next step.
- Save both modules/iam/variables.tf and modules/iam/outputs.tf.
- Confirm all three IAM module files exist by running:
ls modules/iam/
You should see main.tf, outputs.tf, and variables.tf listed.
- Verify your full module structure by running:
ls modules/*/
You should see four module directories (compute, iam, networking, storage), each containing main.tf, outputs.tf, and variables.tf.
✔️ Awesome, I've got everything!
Great. Double check all files are saved before continuing.
ⓧ I'd like to double check the full code
Here is the complete code for each file in the IAM module.
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
data "aws_iam_policy_document" "ec2_assume_role" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ec2.amazonaws.com"]
}
}
}
resource "aws_iam_role" "instance" {
name_prefix = "${var.project_name}-${var.environment}-instance-"
assume_role_policy = data.aws_iam_policy_document.ec2_assume_role.json
tags = {
Name = "${var.project_name}-${var.environment}-instance-role"
Environment = var.environment
ManagedBy = "terraform"
}
}
data "aws_iam_policy_document" "instance_permissions" {
statement {
sid = "AllowS3Access"
actions = [
"s3:GetObject",
"s3:PutObject",
"s3:ListBucket",
]
resources = [
var.s3_bucket_arn,
"${var.s3_bucket_arn}/*",
]
}
statement {
sid = "AllowCloudWatchLogs"
actions = [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents",
]
resources = ["*"]
}
}
resource "aws_iam_role_policy" "instance" {
name_prefix = "${var.project_name}-${var.environment}-"
role = aws_iam_role.instance.id
policy = data.aws_iam_policy_document.instance_permissions.json
}
resource "aws_iam_instance_profile" "instance" {
name_prefix = "${var.project_name}-${var.environment}-"
role = aws_iam_role.instance.name
}
variable "project_name" {
description = "Name of the project for resource tagging"
type = string
}
variable "environment" {
description = "Deployment environment"
type = string
}
variable "s3_bucket_arn" {
description = "ARN of the S3 bucket the instance can access"
type = string
}
output "instance_role_arn" {
description = "ARN of the instance IAM role"
value = aws_iam_role.instance.arn
}
output "instance_profile_name" {
description = "Name of the instance profile"
value = aws_iam_instance_profile.instance.name
}
All four modules are built. Next up, you will wire them together in a root module and deploy real infrastructure with terraform apply.
Wire Modules Together and Deploy
You now have four independent modules sitting in your modules/ directory. Each one knows how to create its own slice of infrastructure, but none of them can run on their own.
A root module acts as the orchestrator. It calls each child module, passes in the right variables, and wires outputs from one module into inputs of another. In this step, you will create that root module, point it at your remote state backend, and deploy real AWS resources for the first time.
In this step, get ready to:
- Create the root module that composes all child modules with an S3 backend.
- Define environment-specific variables and outputs.
- Run terraform init, plan, and apply to deploy the full stack.
Create the root module with the S3 backend
The root module lives in environments/dev/ and controls everything for your dev environment. It declares which Terraform version to use, where to store state, and how to compose the child modules.
- Create a new file called main.tf inside the environments/dev/ directory.
- Add the terraform block and provider configuration by pasting this code:
terraform {
required_version = ">= 1.10.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
# Store state remotely in the S3 bucket you created in Step 1
backend "s3" {
bucket = "your-project-terraform-state"
key = "dev/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-state-locks"
}
}
# Configure the AWS provider with default tags applied to every resource
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Project = var.project_name
Environment = var.environment
ManagedBy = "terraform"
}
}
}
What does this code do?
The terraform block sets three critical things:
- required_version ensures everyone on the team runs Terraform 1.10.0 or newer.
- required_providers locks the AWS provider to the 5.x major version so upgrades are intentional.
- backend "s3" stores your state file in the S3 bucket and uses the DynamoDB table for locking. This prevents two people from modifying infrastructure at the same time.
The provider block sets a default AWS region and applies tags to every resource automatically. You will never have to manually tag resources again.
Why DynamoDB for state locking?
Terraform 1.10.0+ supports native S3 locking via use_lockfile = true as a modern alternative. However, DynamoDB locking remains the standard in most existing enterprise setups. You will encounter it in nearly every production Terraform codebase.
- Below the provider block in the same file, add the module composition blocks:
# Networking module creates VPC, subnets, and route tables
module "networking" {
source = "../../modules/networking"
vpc_cidr = var.vpc_cidr
project_name = var.project_name
environment = var.environment
}
# Storage module creates an encrypted, versioned S3 bucket
module "storage" {
source = "../../modules/storage"
project_name = var.project_name
environment = var.environment
}
# IAM module creates the instance role with least-privilege S3 and CloudWatch access
module "iam" {
source = "../../modules/iam"
project_name = var.project_name
environment = var.environment
s3_bucket_arn = module.storage.bucket_arn
}
# Compute module launches the EC2 instance in the private subnet
module "compute" {
source = "../../modules/compute"
vpc_id = module.networking.vpc_id
subnet_id = module.networking.private_subnet_ids[0]
instance_type = var.instance_type
instance_profile_name = module.iam.instance_profile_name
project_name = var.project_name
environment = var.environment
}
What does this code do?
Each module block calls a child module and passes in the values it needs. The order matters because modules reference each other's outputs.
- module.iam receives module.storage.bucket_arn so the IAM role knows which bucket to grant access to.
- module.compute receives the VPC ID and private subnet from networking, plus the instance profile from IAM.
- Terraform automatically figures out the creation order from these dependencies. Networking and storage deploy first, then IAM, then compute.
- Save environments/dev/main.tf.
Seeing a syntax error?
- Make sure every opening brace { has a matching closing brace }. HCL is strict about block nesting.
- Check that the source paths use ../../modules/ (two levels up from environments/dev/).
- Verify that bucket in the backend block matches the exact name of the S3 bucket you created in Step 1.
Help me debug my root module main.tf
Define environment variables and outputs
The root module references variables like var.vpc_cidr and var.project_name. You need to declare these with their types and default values so Terraform knows what they are.
- Create a new file called variables.tf inside environments/dev/ and paste this code:
variable "aws_region" {
description = "AWS region for resources"
type = string
default = "us-east-1"
}
variable "project_name" {
description = "Name of the project"
type = string
default = "terraform-gitops"
}
variable "environment" {
description = "Deployment environment"
type = string
default = "dev"
}
variable "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
default = "10.0.0.0/16"
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t2.micro"
}
What does this code do?
Each variable has a default value, which means you can run terraform apply without passing any flags. To override a default for a different environment, you would create a separate environments/staging/ directory with different defaults.
The t2.micro instance type keeps the project within AWS Free Tier limits.
- Save variables.tf.
- Create one more file called outputs.tf inside environments/dev/ and paste this code:
output "vpc_id" {
description = "ID of the VPC"
value = module.networking.vpc_id
}
output "instance_id" {
description = "ID of the EC2 instance"
value = module.compute.instance_id
}
output "instance_private_ip" {
description = "Private IP of the EC2 instance"
value = module.compute.private_ip
}
output "s3_bucket_id" {
description = "ID of the S3 bucket"
value = module.storage.bucket_id
}
What does this code do?
Outputs surface key values after a deployment. When terraform apply finishes, it prints these values to the terminal so you can confirm what was created.
Outputs are also stored in the state file. Other Terraform configurations or scripts can read them using terraform output.
- Save outputs.tf.
- Confirm all three files exist by running:
ls environments/dev/
You should see main.tf, variables.tf, and outputs.tf listed.
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files before moving on.
ⓧ I'd like to double check the full code
Here is the complete environments/dev/main.tf:
terraform {
required_version = ">= 1.10.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
backend "s3" {
bucket = "your-project-terraform-state"
key = "dev/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-state-locks"
}
}
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Project = var.project_name
Environment = var.environment
ManagedBy = "terraform"
}
}
}
module "networking" {
source = "../../modules/networking"
vpc_cidr = var.vpc_cidr
project_name = var.project_name
environment = var.environment
}
module "storage" {
source = "../../modules/storage"
project_name = var.project_name
environment = var.environment
}
module "iam" {
source = "../../modules/iam"
project_name = var.project_name
environment = var.environment
s3_bucket_arn = module.storage.bucket_arn
}
module "compute" {
source = "../../modules/compute"
vpc_id = module.networking.vpc_id
subnet_id = module.networking.private_subnet_ids[0]
instance_type = var.instance_type
instance_profile_name = module.iam.instance_profile_name
project_name = var.project_name
environment = var.environment
}
Here is the complete environments/dev/variables.tf:
variable "aws_region" {
description = "AWS region for resources"
type = string
default = "us-east-1"
}
variable "project_name" {
description = "Name of the project"
type = string
default = "terraform-gitops"
}
variable "environment" {
description = "Deployment environment"
type = string
default = "dev"
}
variable "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
default = "10.0.0.0/16"
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t2.micro"
}
Here is the complete environments/dev/outputs.tf:
output "vpc_id" {
description = "ID of the VPC"
value = module.networking.vpc_id
}
output "instance_id" {
description = "ID of the EC2 instance"
value = module.compute.instance_id
}
output "instance_private_ip" {
description = "Private IP of the EC2 instance"
value = module.compute.private_ip
}
output "s3_bucket_id" {
description = "ID of the S3 bucket"
value = module.storage.bucket_id
}
Deploy the full stack with Terraform
Your root module is ready. Now you will initialize the backend, preview the resources Terraform plans to create, and apply the changes to deploy real infrastructure into your AWS account.
- Navigate into the root module directory by running:
cd environments/dev
- Initialize Terraform by running:
terraform init
You should see output confirming the S3 backend was initialized and all four modules were downloaded. Look for Terraform has been successfully initialized! at the end.
What does terraform init do?
- Connects to your S3 bucket and verifies it can read/write state there.
- Downloads the AWS provider plugin (version ~> 5.0).
- Copies each module from the local ../../modules/ path into the .terraform/ cache.
- Creates a .terraform.lock.hcl file that locks provider versions for reproducible builds. This file should be committed to Git.
Seeing a backend configuration error?
- Verify the bucket value in your backend block matches the exact name of the S3 bucket you created in Step 1 (your-project-terraform-state).
- Check that the dynamodb_table value is terraform-state-locks (the table you created in Step 1).
- Make sure your AWS CLI credentials are still valid by running aws sts get-caller-identity.
Help me fix my terraform init error
- Preview the resources Terraform will create by running:
terraform plan
Terraform displays a detailed plan showing every resource it intends to create. You should see a summary line at the bottom like Plan: 14 to add, 0 to change, 0 to destroy.
What should I see in the plan?
The plan lists each resource with a + prefix (meaning it will be created). You should see VPC, subnets, route tables, an internet gateway, a security group, an EC2 instance, an S3 bucket with encryption and versioning, an IAM role, and an instance profile.
No resources are being changed or destroyed because this is a fresh deployment.
- Deploy the infrastructure by running:
terraform apply
- Terraform will show the plan again and ask for confirmation. Type yes and press Enter.
The apply takes 1-3 minutes. When it finishes, you will see your outputs printed to the terminal, including the VPC ID, instance ID, private IP, and S3 bucket ID.
What just happened?
Terraform called the AWS API to create every resource in your plan. It tracked each resource's ID and stored the full state in your remote S3 bucket. The DynamoDB table held a lock the entire time, preventing any other process from writing state simultaneously.
This is the last time you will run terraform apply manually. From the next step onward, all changes go through your CI/CD pipeline.
Apply failed with a permissions error?
- Your AWS IAM user needs permissions to create VPCs, EC2 instances, S3 buckets, and IAM roles. If you are using a restricted user, attach the AdministratorAccess managed policy temporarily for this project.
- If you see a ConflictException on the DynamoDB lock, a previous run may not have released the lock. Wait 30 seconds and try again.
- Check that your aws_region default (us-east-1) matches the region your state bucket lives in.
Help me fix my terraform apply failure
- Verify the deployment succeeded by running:
terraform output
You should see four output values: vpc_id, instance_id, instance_private_ip, and s3_bucket_id with real AWS resource IDs.
Your full AWS stack is live. A VPC, subnets, an EC2 instance, an S3 bucket, and IAM roles are all running in your account, deployed entirely through code. Next up, you will add pre-commit hooks that catch errors before they ever reach your repository.
Set Up Pre-commit Hooks for Code Quality
Your infrastructure is deployed and your Terraform codebase is modular. But what happens when someone commits a formatting inconsistency, a misconfigured resource, or an insecure setting?
Pre-commit hooks solve this by running automated checks every time you commit. In this step, you'll configure tflint, checkov, and terraform fmt to run automatically, catching problems before they ever leave your machine.
In this step, get ready to:
- Configure tflint with the AWS ruleset to catch provider-specific errors.
- Create a pre-commit configuration with formatting, linting, and security hooks.
- Verify that pre-commit hooks catch issues automatically on commit.
Configure tflint with the AWS ruleset
Terraform's built-in validation catches syntax errors, but it cannot detect AWS-specific mistakes like invalid instance types or deprecated resource configurations. tflint fills that gap with a plugin system that understands AWS resources.
- Create a file called .tflint.hcl in the root of your project repository (the same level as your modules/ and environments/ folders) by running:
touch .tflint.hcl
- Open .tflint.hcl and paste the following configuration:
# Enable the built-in Terraform plugin with recommended rules
plugin "terraform" {
enabled = true
preset = "recommended"
}
# Enable the AWS-specific ruleset for provider-level checks
plugin "aws" {
enabled = true
version = "0.47.0"
source = "github.com/terraform-linters/tflint-ruleset-aws"
}
What does this configuration do?
The terraform plugin with the recommended preset catches generic Terraform issues like deprecated syntax and missing variable descriptions.
The aws plugin (version 0.47.0) adds checks for AWS-specific problems. For example, it flags invalid instance types or security group rules that reference non-existent VPCs.
- Save .tflint.hcl.
- Download the AWS ruleset plugin by running this command:
tflint --init
- You should see output indicating the AWS plugin was installed successfully, similar to: Installing "aws" plugin... followed by Installed "aws" (source: github.com/terraform-linters/tflint-ruleset-aws, version: 0.47.0).
Seeing a download error?
If tflint --init fails with a network error, check your internet connection. GitHub API rate limits (60 requests/hour per IP) can also cause failures if you have run this many times. Wait a few minutes and try again.
Help me troubleshoot my tflint --init failure
Create the pre-commit configuration
The pre-commit framework reads a YAML file at the root of your repo to know which hooks to run on every commit. You will configure two hook sources: general-purpose checks (like trailing whitespace and credential detection) and Terraform-specific checks (formatting, validation, linting, and security scanning).
- Create a file called .pre-commit-config.yaml in the root of your project repository by running:
touch .pre-commit-config.yaml
- Open .pre-commit-config.yaml and paste the following configuration:
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
# Validate YAML syntax across the project
- id: check-yaml
# Flag unresolved merge conflict markers
- id: check-merge-conflict
# Remove trailing whitespace from lines
- id: trailing-whitespace
# Ensure files end with a newline
- id: end-of-file-fixer
# Detect accidentally committed AWS credentials
- id: detect-aws-credentials
args: ["--allow-missing-credentials"]
# Detect accidentally committed private keys
- id: detect-private-key
- repo: https://github.com/antonbabenko/pre-commit-terraform
rev: v1.105.0
hooks:
# Auto-format Terraform files to canonical style
- id: terraform_fmt
# Run terraform validate on each module
- id: terraform_validate
args:
- --hook-config=--retry-once-with-cleanup=true
# Run tflint with the project-level config
- id: terraform_tflint
args:
- --args=--config=__GIT_WORKING_DIR__/.tflint.hcl
# Run checkov security scanner
- id: terraform_checkov
What do these hooks do?
The first repo (pre-commit-hooks) provides general safety nets. detect-aws-credentials prevents you from accidentally committing secret keys. The --allow-missing-credentials flag means it won't fail just because no credentials file exists.
The second repo (pre-commit-terraform) is the Terraform-specific hook suite. terraform_fmt auto-formats code. terraform_tflint uses the __GIT_WORKING_DIR__ token so tflint always finds your .tflint.hcl regardless of which subdirectory it runs in.
Why pre-commit-terraform over manual hook scripts?
You could write your own shell scripts in .git/hooks/, but they would not be version-controlled or shareable across a team. The pre-commit-terraform project (4,000+ GitHub stars) is the community standard for Terraform hook management. It handles multi-directory projects, parallel execution, and tool configuration in one declarative YAML file.
- Save .pre-commit-config.yaml.
- Install the hooks into your local Git repository by running:
pre-commit install
- You should see: pre-commit installed at .git/hooks/pre-commit.
Seeing 'not a git repository'?
Make sure you are running the command from the root of your project where the .git/ folder exists. If you haven't initialized Git yet, run git init first.
Help me fix my pre-commit install error
Verify the hooks catch issues on commit
The hooks are installed, but you need to confirm they actually run and catch problems. You can trigger all hooks against your existing code without making a real commit.
- Stage all your project files by running:
git add -A
- Run all hooks against your staged files by running:
pre-commit run --all-files
What should I see?
Each hook runs and reports either Passed or Failed. Some hooks like terraform_fmt may show Failed if they auto-fix formatting. That is expected. The hook reformats your files and you simply re-stage and commit again.
The terraform_checkov hook scans your modules for security misconfigurations like unencrypted resources or overly permissive security groups. Your current code already follows best practices, so it should pass cleanly.
- If any hooks auto-fixed files (like trailing whitespace or formatting), re-stage the changes by running:
git add -A
- Make your first commit with all hooks passing by running:
git commit -m "Initial commit with Terraform modules and pre-commit hooks"
- You should see all hooks run and pass, followed by the commit message confirmation. Every future commit will automatically run these same checks.
Checkov failing on your code?
If terraform_checkov reports failures, read the check IDs in the output. Common findings like CKV_AWS_144 (cross-region replication) or CKV_AWS_145 (KMS CMK encryption) are advanced enterprise features beyond this project's scope. You will address these with skip rules in the CI pipeline in the next step.
If the commit is blocked, you can temporarily skip checkov by running SKIP=terraform_checkov git commit -m "Initial commit" and address the findings later.
Help me understand my checkov failures
✔️ Awesome, I've got everything!
Make sure you have saved both .tflint.hcl and .pre-commit-config.yaml and that your commit went through successfully.
ⓧ I'd like to double check the full code
Here is the complete .tflint.hcl:
plugin "terraform" {
enabled = true
preset = "recommended"
}
plugin "aws" {
enabled = true
version = "0.47.0"
source = "github.com/terraform-linters/tflint-ruleset-aws"
}
Here is the complete .pre-commit-config.yaml:
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: check-yaml
- id: check-merge-conflict
- id: trailing-whitespace
- id: end-of-file-fixer
- id: detect-aws-credentials
args: ["--allow-missing-credentials"]
- id: detect-private-key
- repo: https://github.com/antonbabenko/pre-commit-terraform
rev: v1.105.0
hooks:
- id: terraform_fmt
- id: terraform_validate
args:
- --hook-config=--retry-once-with-cleanup=true
- id: terraform_tflint
args:
- --args=--config=__GIT_WORKING_DIR__/.tflint.hcl
- id: terraform_checkov
Your code quality guardrails are now in place. Every commit to this repository will be automatically checked for formatting issues, AWS misconfigurations, and security vulnerabilities before it can go through. Next up, you will push this repository to GitHub and set up OIDC authentication so your CI/CD pipeline can deploy to AWS without stored credentials.
Configure GitHub Actions with OIDC Authentication
Your infrastructure is deployed and your pre-commit hooks catch issues before they leave your machine. The next challenge is automating the deployment pipeline itself.
For GitHub Actions to manage your AWS infrastructure, it needs a way to authenticate. The traditional approach stores long-lived AWS access keys as repository secrets. The modern enterprise approach uses OIDC (OpenID Connect) to issue short-lived tokens per workflow run. No stored credentials means no credentials to leak.
In this step, get ready to:
- Create the GitHub OIDC identity provider and IAM role in AWS.
- Push the repository to GitHub and configure the IAM role ARN secret.
- Create the GitHub Actions workflow directory for your CI/CD pipeline.
Create the GitHub OIDC identity provider and IAM role
GitHub Actions can present a signed JWT token to AWS proving which repository and workflow triggered the run. AWS needs an OIDC identity provider resource to know it should trust these tokens, and an IAM role that GitHub can assume.
Why OIDC instead of access keys?
Static AWS access keys live forever until you rotate them. If they leak in a log or a fork, anyone can use them. OIDC tokens expire after one hour and are scoped to a single workflow run.
Enterprise teams standardize on OIDC because there are literally no permanent credentials to manage or rotate.
- Retrieve your AWS account ID by running this command:
aws sts get-caller-identity --query Account --output text
Note the 12-digit number that appears. You will need it in the next commands.
- Create the GitHub OIDC identity provider in your AWS account by running:
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com
What does this command do?
This registers GitHub's token issuer (token.actions.githubusercontent.com) as a trusted identity provider in your AWS account. The audience sts.amazonaws.com tells AWS which service the tokens are intended for.
You do not need to provide a thumbprint. AWS validates GitHub's OIDC endpoint using its own library of trusted root certificate authorities.
- Verify the provider was created by running:
aws iam list-open-id-connect-providers
You should see an ARN containing oidc-provider/token.actions.githubusercontent.com in the output.
Seeing EntityAlreadyExists?
This means the OIDC provider is already registered in your account. You can safely continue to the next command. Run aws iam list-open-id-connect-providers to confirm it exists.
Help me troubleshoot the OIDC provider creation error
Now you need to create an IAM role that GitHub Actions can assume through this OIDC provider. The role needs a trust policy that specifies which repository is allowed to assume it.
- Create a trust policy file by running this command (replace the account ID and repository):
cat > trust-policy.json << 'EOF'
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::[[AWS_ACCOUNT_ID="your 12-digit AWS account ID"]]:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:[[GITHUB_OWNER_REPO="your-username/terraform-gitops"]]:*"
},
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
}
}
}
]
}
EOF
What does this trust policy do?
The Principal.Federated field points to the OIDC provider you just created. The Condition block restricts which repository can assume this role.
The StringLike condition with :* allows any branch or workflow in that repository to authenticate. In production, you could restrict this further to specific branches like ref:refs/heads/main.
- Create the IAM role with this trust policy by running:
aws iam create-role \
--role-name github-actions-terraform \
--assume-role-policy-document file://trust-policy.json
- Create a permissions policy that grants the role access to manage your Terraform resources by running:
cat > permissions-policy.json << 'EOF'
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "TerraformStateAccess",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:ListBucket",
"s3:DeleteObject"
],
"Resource": [
"arn:aws:s3:::your-project-terraform-state",
"arn:aws:s3:::your-project-terraform-state/*"
]
},
{
"Sid": "TerraformLockAccess",
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:DeleteItem",
"dynamodb:DescribeTable"
],
"Resource": "arn:aws:dynamodb:us-east-1:*:table/terraform-state-locks"
},
{
"Sid": "TerraformResourceManagement",
"Effect": "Allow",
"Action": [
"ec2:*",
"s3:*",
"iam:*",
"dynamodb:*"
],
"Resource": "*"
}
]
}
EOF
What does this permissions policy do?
The first statement allows reading and writing Terraform state files in the S3 state bucket. The second statement allows DynamoDB lock operations so Terraform can safely acquire and release state locks.
The third statement grants broad access to EC2, S3, IAM, and DynamoDB resources. This lets Terraform create, modify, and destroy the infrastructure modules. In a production environment, you would scope these permissions more tightly.
- Attach the permissions policy to the role by running:
aws iam put-role-policy \
--role-name github-actions-terraform \
--policy-name TerraformDeployAccess \
--policy-document file://permissions-policy.json
- Verify the role exists and note its ARN by running:
aws iam get-role --role-name github-actions-terraform --query Role.Arn --output text
You should see an ARN like arn:aws:iam::123456789012:role/github-actions-terraform. Copy this value. You will need it when configuring GitHub.
Seeing an access denied error?
Make sure your AWS CLI is configured with credentials that have IAM permissions. Run aws sts get-caller-identity to confirm you are authenticated as a user with admin or IAM management privileges.
Help me fix my IAM permissions for OIDC role creation
- Clean up the temporary policy files by running:
rm trust-policy.json permissions-policy.json
Push the repository to GitHub and configure the secret
Your AWS infrastructure trusts GitHub Actions via OIDC. Now you need a GitHub repository to push your code to, and you need to store the IAM role ARN as a repository secret so workflows can reference it.
- Open your browser and go to github.com.
- Click the + icon in the top right corner.
- Click New repository.
- Enter terraform-gitops as the repository name.
- Leave the repository set to Public (OIDC works with both public and private repos).
- Do not check any initialization options (no README, no .gitignore, no license).
- Click Create repository.
GitHub shows you the quick setup page with push instructions. You will use the commands below instead.
- Back in your terminal, add the GitHub remote and push your code by running these commands:
git remote add origin https://github.com/[[GITHUB_OWNER_REPO="your-username/terraform-gitops"]].git
git branch -M main
git push -u origin main
What do these commands do?
The first command connects your local repository to the GitHub remote. The second renames your default branch to main (the standard branch name for GitHub). The third pushes all your committed code to GitHub.
You should see your files appear in the GitHub repository page after refreshing.
Push failing with authentication error?
GitHub requires a Personal Access Token (PAT) or SSH key for push access. If prompted for a password, generate a PAT from your GitHub account settings. Navigate to Developer settings, then Personal access tokens, then Tokens (classic). Create a token with the repo scope and use it as your password.
Help me set up GitHub authentication for push access
Now configure the repository secret so your workflows can reference the IAM role ARN.
- In your GitHub repository page, click the Settings tab.
- In the left sidebar, expand Secrets and variables, then click Actions.
- Click New repository secret.
- Enter AWS_ROLE_ARN as the name.
- Paste your IAM role ARN as the value (the arn:aws:iam::... string you copied earlier).
- Click Add secret.
Why store the ARN as a secret?
The IAM role ARN is not a credential itself, but storing it as a secret keeps your account ID out of version-controlled workflow files. Anyone reading your public repo will not see which AWS account the pipeline targets.
Create the GitHub Actions workflow directory
GitHub Actions looks for workflow files in a specific directory structure. You need to create this directory so the workflow files you build in the next step have a home.
- Create the workflows directory by running:
mkdir -p .github/workflows
- Confirm the directory was created by running:
ls .github/workflows
You should see the empty directory listed. In the next step, you will add the workflow YAML files that authenticate via OIDC and run your Terraform pipeline.
What is the GitOps workflow?
With this setup complete, your pipeline will follow the GitOps pattern. Every infrastructure change starts as a pull request. The CI pipeline validates the change. On merge, the pipeline applies it automatically.
Git becomes the single source of truth for your infrastructure state.
Your authentication bridge between GitHub and AWS is complete. Next up, you will build the full CI/CD pipeline workflows that validate, plan, and apply your infrastructure changes automatically.
Build the Full CI/CD Pipeline
Your GitHub repository can now authenticate to AWS using OIDC. The missing piece is the actual workflow files that tell GitHub Actions what to do when code changes arrive.
In this step, you'll create two workflow files that form the complete GitOps pipeline. One runs on every pull request to validate and plan. The other applies the approved changes when code merges to main.
In this step, get ready to:
- Create a PR workflow that lints, scans, plans, and posts output to the pull request.
- Create an apply workflow that deploys infrastructure automatically on merge to main.
- Test the full GitOps cycle with a real infrastructure change through a pull request.
Create the pull request workflow
The PR workflow is the gatekeeper of your infrastructure. Every time someone opens a pull request that touches Terraform files, this workflow runs linting, security scanning, and generates a plan showing exactly what will change.
- Create the PR workflow file by running:
touch .github/workflows/terraform-plan.yml
- Open .github/workflows/terraform-plan.yml and paste the following workflow:
name: Terraform Plan
on:
pull_request:
branches:
- main
paths:
- "modules/**"
- "environments/**"
- ".github/workflows/**"
permissions:
id-token: write
contents: read
pull-requests: write
env:
AWS_REGION: us-east-1
TF_VERSION: "1.15.1"
WORKING_DIR: environments/dev
jobs:
lint:
name: Lint and Security Scan
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup TFLint
uses: terraform-linters/setup-tflint@v6
with:
tflint_version: latest
- name: Init TFLint
run: tflint --init
env:
GITHUB_TOKEN: ${{ github.token }}
- name: Run TFLint
run: tflint --recursive --config "$(pwd)/.tflint.hcl" -f compact
- name: Run Checkov
uses: bridgecrewio/checkov-action@master
with:
directory: .
framework: terraform
soft_fail: false
skip_check: CKV_AWS_144,CKV_AWS_145
plan:
name: Terraform Plan
runs-on: ubuntu-latest
needs: lint
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Terraform Init
run: terraform init
working-directory: ${{ env.WORKING_DIR }}
- name: Terraform Plan
id: plan
run: terraform plan -no-color -out=tfplan
working-directory: ${{ env.WORKING_DIR }}
- name: Post Plan to PR
uses: borchero/terraform-plan-comment@v2
with:
token: ${{ github.token }}
planfile: tfplan
working-directory: ${{ env.WORKING_DIR }}
What does this workflow do?
This workflow has two jobs that run sequentially:
- The lint job runs TFLint and Checkov to catch coding errors and security misconfigurations before planning.
- The plan job authenticates to AWS via OIDC, runs terraform plan, and posts the plan output as a comment on the PR so reviewers can see exactly what will change.
- The paths filter ensures the workflow only triggers when Terraform-related files change, not on README edits or unrelated code.
- CKV_AWS_144 (cross-region replication) and CKV_AWS_145 (KMS CMK encryption) are skipped because they add cost not relevant to this project.
Why use @master for the Checkov action?
The bridgecrewio/checkov-action major version tag (v12) is stale and not reliably updated. Using @master ensures you always get the latest version.
- Save the file.
Worried about YAML formatting?
YAML is indentation-sensitive. Make sure each level uses exactly 2 spaces (not tabs). The jobs: key should be at the root level (zero indentation), and each step should be indented 8 spaces from the left margin.
Troubleshoot my terraform-plan.yml formatting issues.
Create the apply workflow
The apply workflow is the deployment mechanism. It triggers only when code merges to main, meaning every change has already passed lint, security scanning, and human review.
- Create the apply workflow file by running:
touch .github/workflows/terraform-apply.yml
- Open .github/workflows/terraform-apply.yml and paste the following workflow:
name: Terraform Apply
on:
push:
branches:
- main
paths:
- "modules/**"
- "environments/**"
permissions:
id-token: write
contents: read
env:
AWS_REGION: us-east-1
TF_VERSION: "1.15.1"
WORKING_DIR: environments/dev
jobs:
apply:
name: Terraform Apply
runs-on: ubuntu-latest
environment: production
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Terraform Init
run: terraform init
working-directory: ${{ env.WORKING_DIR }}
- name: Terraform Apply
run: terraform apply -auto-approve
working-directory: ${{ env.WORKING_DIR }}
What does this workflow do?
- It triggers on push to main (which happens when a PR is merged), but only when module or environment files change.
- The environment: production setting enables GitHub's environment protection rules. You can add required reviewers or wait timers in your repository settings later.
- It runs terraform apply -auto-approve because the plan was already reviewed and approved during the PR process.
- Notice that .github/workflows/** is NOT in the paths filter here. Workflow-only changes should not trigger an infrastructure apply.
- Save the file.
Seeing unexpected diffs between the two files?
The plan workflow needs pull-requests: write permission (to post comments) while the apply workflow does not. Make sure each file has the correct permissions block.
Help me compare my two workflow files for correctness.
✔️ Awesome, I've got everything!
Make sure both workflow files are saved before continuing.
ⓧ I'd like to double check the full code
Here is the complete terraform-plan.yml:
name: Terraform Plan
on:
pull_request:
branches:
- main
paths:
- "modules/**"
- "environments/**"
- ".github/workflows/**"
permissions:
id-token: write
contents: read
pull-requests: write
env:
AWS_REGION: us-east-1
TF_VERSION: "1.15.1"
WORKING_DIR: environments/dev
jobs:
lint:
name: Lint and Security Scan
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup TFLint
uses: terraform-linters/setup-tflint@v6
with:
tflint_version: latest
- name: Init TFLint
run: tflint --init
env:
GITHUB_TOKEN: ${{ github.token }}
- name: Run TFLint
run: tflint --recursive --config "$(pwd)/.tflint.hcl" -f compact
- name: Run Checkov
uses: bridgecrewio/checkov-action@master
with:
directory: .
framework: terraform
soft_fail: false
skip_check: CKV_AWS_144,CKV_AWS_145
plan:
name: Terraform Plan
runs-on: ubuntu-latest
needs: lint
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Terraform Init
run: terraform init
working-directory: ${{ env.WORKING_DIR }}
- name: Terraform Plan
id: plan
run: terraform plan -no-color -out=tfplan
working-directory: ${{ env.WORKING_DIR }}
- name: Post Plan to PR
uses: borchero/terraform-plan-comment@v2
with:
token: ${{ github.token }}
planfile: tfplan
working-directory: ${{ env.WORKING_DIR }}
And here is the complete terraform-apply.yml:
name: Terraform Apply
on:
push:
branches:
- main
paths:
- "modules/**"
- "environments/**"
permissions:
id-token: write
contents: read
env:
AWS_REGION: us-east-1
TF_VERSION: "1.15.1"
WORKING_DIR: environments/dev
jobs:
apply:
name: Terraform Apply
runs-on: ubuntu-latest
environment: production
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Terraform Init
run: terraform init
working-directory: ${{ env.WORKING_DIR }}
- name: Terraform Apply
run: terraform apply -auto-approve
working-directory: ${{ env.WORKING_DIR }}
Test the complete GitOps cycle
The workflows exist, but they have never actually run. You need to push them to GitHub and then trigger them with a real infrastructure change through a pull request.
First, commit and push the workflow files to main so GitHub knows they exist.
- Stage, commit, and push the workflow files by running these commands:
git add .github/workflows/
git commit -m "Add CI/CD pipeline workflows"
git push origin main
Why push directly to main first?
The workflow files themselves need to exist on the default branch before GitHub Actions can use them to evaluate pull requests. This is the one time you push directly to main.
Now create a feature branch and make a small infrastructure change to trigger the pipeline.
- Create a feature branch by running:
git checkout -b test/gitops-pipeline
- Open modules/networking/main.tf and find the aws_vpc resource's tags block.
- Add a new tag line inside the tags block: Description = "Main project VPC managed by GitOps".
- Save the file.
- Commit and push the change by running these commands:
git add modules/networking/main.tf
git commit -m "Add description tag to VPC"
git push origin test/gitops-pipeline
What if pre-commit hooks run?
Your pre-commit hooks will run on this commit. If terraform_fmt reformats your file, it will modify the file and fail the commit. Run git add again and recommit.
- Open your repository on GitHub in your browser.
- You should see a banner saying your branch had recent pushes. Click Compare & pull request.
- Give the PR a title like Add description tag to VPC and click Create pull request.
After you create the pull request, GitHub Actions kicks off the Terraform Plan workflow. You can watch it run in real time.
- Click the Checks tab (or the status indicator at the bottom of the PR) to watch the workflow.
- Wait for both the Lint and Security Scan and Terraform Plan jobs to complete with green checkmarks.
- Scroll down on the PR conversation tab. You should see a comment posted by the workflow showing the terraform plan output with your VPC tag change.
What should I see in the plan comment?
The plan comment shows a diff of infrastructure changes. You should see something like ~ update in-place for the VPC resource, with the new Description tag appearing as a change. This is the exact output a team reviewer would check before approving.
- Once both checks pass, click Merge pull request at the bottom of the PR.
- Click Confirm merge.
Merging to main triggers the Terraform Apply workflow. This automatically deploys your VPC tag change to AWS.
- Click the Actions tab in your GitHub repository.
- You should see the Terraform Apply workflow running (or completed). Click it to view the logs.
- Verify the apply step completed successfully with Apply complete! in the logs.
Workflow failed?
If the lint job fails, check the Checkov or TFLint output in the workflow logs. Your tag change should pass all checks. If the plan or apply job fails with an authentication error, verify that the AWS_ROLE_ARN secret is set correctly in your repository settings.
If you see Error: No valid credential sources found, double-check that the OIDC trust policy on your IAM role includes your repository name.
Help me debug my GitHub Actions Terraform workflow failure.
- Back in your terminal, switch to main and pull the merged changes by running:
git checkout main
git pull origin main
Your GitOps pipeline is fully operational. From this point forward, every infrastructure change goes through code review, automated validation, and controlled deployment. No more manual terraform apply from your laptop.
Secret mission
Add an RDS Database Module with Secrets Management
Your pipeline is live and deploying infrastructure through pull requests. But real-world stacks almost always include a database. Can you extend your modular Terraform codebase with a fully-wired RDS PostgreSQL instance, complete with automated password generation and secrets management, deployed entirely through your GitOps workflow?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. All resources in this project are free tier eligible, so ongoing costs are $0 within AWS Free Tier limits. The state bucket stores minimal data and costs pennies per month if kept.
Cost warning
If your AWS Free Tier has expired, the EC2 instance (t2.micro) and S3 bucket will incur charges. If you are outside the free tier period, choose Pause or Delete below to avoid unexpected bills.
Resources you used:
- VPC with public and private subnets, route tables, and internet gateway.
- EC2 instance (t2.micro) in a private subnet.
- S3 bucket for application storage (managed by Terraform).
- IAM role and instance profile for the EC2 instance.
- S3 bucket for Terraform remote state (bootstrapped outside of Terraform).
- DynamoDB table for Terraform state locking (bootstrapped outside of Terraform).
- GitHub OIDC identity provider in AWS.
- IAM role for GitHub Actions OIDC authentication.
Keep everything running
No action needed. Choose this if you are still actively experimenting with Terraform changes or want to practice the GitOps workflow further. Costs remain at $0 within free tier limits.
- Leave all AWS resources deployed.
- Keep your GitHub repository and workflows intact for future pull requests.
- Monitor your AWS billing dashboard periodically to confirm you remain within free tier.
Pause - I'll come back to this later
Stop the EC2 instance to avoid any compute charges if you are close to exceeding free tier hours. Everything else stays in place so you can resume later.
- Stop the EC2 instance from the AWS Console or by running:
# Get the instance ID from Terraform outputs
cd ~/Desktop/terraform-gitops-pipeline/environments/dev
terraform output instance_id
# Stop the instance (replace with your actual instance ID)
aws ec2 stop-instances --instance-ids i-your-instance-id
- Your S3 buckets, VPC, DynamoDB table, and GitHub OIDC setup remain unchanged.
- When you return, start the instance again with aws ec2 start-instances --instance-ids i-your-instance-id.
Delete - I don't want to use this again
Remove all project resources and start fresh. This requires two steps because the state bucket and DynamoDB table were bootstrapped outside of Terraform.
- Destroy all Terraform-managed resources by running:
cd ~/Desktop/terraform-gitops-pipeline/environments/dev
terraform destroy
- Type yes when prompted to confirm the destruction.
- Delete the S3 state bucket manually (it was created outside of Terraform state):
# Empty the bucket first (required before deletion)
aws s3 rm s3://your-project-terraform-state --recursive
# Delete the bucket
aws s3api delete-bucket --bucket your-project-terraform-state --region us-east-1
- Delete the DynamoDB state lock table:
aws dynamodb delete-table --table-name terraform-state-locks --region us-east-1
- Optionally, delete the GitHub OIDC provider and IAM role from the AWS Console under IAM > Identity providers and Roles.
- Delete the GitHub repository if you no longer need it.
- Remove the local project folder from your Desktop.
That's a Wrap!
That's a Wrap!
Nice work. You just built a production-grade Infrastructure as Code pipeline from scratch, the exact workflow engineering teams use to manage cloud infrastructure safely at scale.
You've learned how to:
- Build a modular Terraform codebase provisioning a complete AWS stack with networking, compute, storage, and IAM modules.
- Configure remote state management with S3 and DynamoDB locking, plus pre-commit hooks enforcing code quality with tflint and checkov on every commit.
- Deploy infrastructure changes through a GitOps pipeline using GitHub Actions with passwordless OIDC authentication to AWS.
- Secret Mission: Added an RDS database module with secrets management, deployed through the same automated pipeline.
Ready to quiz yourself?