Production-Ready Terraform Modules
Build reusable Terraform modules that deploy a VPC and Auto Scaling Group on AWS.
Introduction
30 Second Summary
When teams manage cloud infrastructure at scale, they need structure. Without it, resources pile up, configurations drift, and one wrong change can bring down an entire environment.
In this project, you will build a production-grade Terraform repository that deploys a complete AWS environment using modular, reusable infrastructure code.
What You'll Build
A modular Terraform project that provisions a VPC with public and private subnets, a NAT gateway, and an Auto Scaling Group on AWS.
By the end of this project, you'll have:
- An S3 bucket configured as a remote state backend with native lockfile-based locking.
- Reusable networking and compute modules with variable validation and output composition.
- A deployed VPC with an Auto Scaling Group that consumes cross-module outputs to wire infrastructure together automatically.
- Secret Mission: Run Checkov security scanning against your Terraform code and fix the findings.
Are there any prerequisites?
This project assumes basic Terraform knowledge (resource blocks, providers, and the init/plan/apply workflow). You also need an AWS account with Free Tier access and the AWS CLI configured with credentials.
Set Up Remote State Backend
When multiple engineers work on the same infrastructure, they need a shared source of truth for what has been deployed. Terraform solves this with remote state, storing your state file in a central location that the whole team can access.
In this step, you will verify your tools are working, create a production-style directory structure, and bootstrap an S3 bucket that stores your Terraform state for the rest of the project.
In this step, get ready to:
- Verify Terraform and AWS CLI are configured correctly.
- Create the project directory structure.
- Bootstrap an S3 state bucket with versioning enabled.
Verify Terraform and AWS CLI are working
Before writing any infrastructure code, confirm that both tools can reach your AWS account.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Visual Studio Code and press Enter to open it.
- Open the integrated terminal by pressing Ctrl+` (macOS and Windows).
- Confirm your Terraform version is 1.15 or higher by running this command:
terraform version
You should see output starting with Terraform v1.15 (or higher) followed by your platform.
- Confirm your AWS CLI is authenticated by running this command:
aws sts get-caller-identity
You should see JSON output containing your AWS account ID and ARN.
Not seeing your account info?
If terraform version returns "command not found", Terraform is not installed or not on your PATH. Revisit the Terraform installation docs at developer.hashicorp.com/terraform/install.
If aws sts get-caller-identity returns an error, your AWS CLI credentials may not be configured. Run aws configure and enter your access key, secret key, and default region (us-east-1).
Help me troubleshoot my Terraform or AWS CLI setup so I can proceed with the project.
Create the project directory structure
Production Terraform projects separate concerns into distinct directories. You will create a bootstrap folder for one-time setup, a modules folder for reusable components, and an environments folder for environment-specific configurations.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the project folder and all subdirectories by running this command:
mkdir -p terraform-aws-modules/{bootstrap,modules/{networking,compute},environments/dev}
- Create the placeholder files in each directory by running these commands:
touch terraform-aws-modules/bootstrap/main.tf
touch terraform-aws-modules/modules/networking/{main.tf,variables.tf,outputs.tf}
touch terraform-aws-modules/modules/compute/{main.tf,variables.tf,outputs.tf}
touch terraform-aws-modules/environments/dev/{main.tf,variables.tf,outputs.tf,backend.tf}
- Move into the project root by running this command:
- Confirm the files exist by running:
cd terraform-aws-modules
ls -R
You should see the full tree of directories and files you just created.
Why this directory layout?
The bootstrap/ directory creates shared infrastructure (like the state bucket) that other configurations depend on. It uses local state because you cannot store state in a bucket that does not exist yet.
The modules/ directory holds reusable building blocks. The environments/ directory holds configurations that compose those modules for a specific deployment target like dev or prod.
Getting a "No such file or directory" error?
Make sure you ran cd ~/Desktop before the mkdir command. The brace expansion syntax ({bootstrap,modules}) requires Bash or Zsh. If you are on Windows without Git Bash, run each mkdir separately.
Help me create the Terraform project directory structure on my operating system.
- Open the terraform-aws-modules folder in VS Code by clicking File in the menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the terraform-aws-modules folder, and click Open.
You should see the full project tree in the Explorer sidebar on the left.
Bootstrap the S3 state bucket
The bootstrap configuration creates a versioned S3 bucket that will hold your Terraform state for the rest of the project. This is a one-time setup step that uses local state intentionally.
- In the Explorer sidebar, expand the bootstrap folder and click main.tf to open it.
- Paste the following code into bootstrap/main.tf:
# Pin Terraform version and declare the AWS provider
terraform {
required_version = ">= 1.15"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
# Deploy resources in us-east-1
provider "aws" {
region = "us-east-1"
}
# Create an S3 bucket with a unique name for storing state
resource "aws_s3_bucket" "terraform_state" {
bucket = "terraform-state-${random_id.suffix.hex}"
tags = {
Name = "terraform-state"
ManagedBy = "terraform"
Environment = "shared"
}
}
# Enable versioning to allow state recovery from accidental changes
resource "aws_s3_bucket_versioning" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
versioning_configuration {
status = "Enabled"
}
}
# Generate a random suffix to ensure globally unique bucket name
resource "random_id" "suffix" {
byte_length = 4
}
# Expose the bucket name for use in backend configuration
output "bucket_name" {
value = aws_s3_bucket.terraform_state.id
description = "The name of the S3 bucket used for Terraform state"
}
# Expose the bucket ARN for IAM policy references
output "bucket_arn" {
value = aws_s3_bucket.terraform_state.arn
description = "The ARN of the S3 bucket used for Terraform state"
}
What does this code do?
The terraform block pins the minimum CLI version to 1.15 and locks the AWS provider to major version 6. This prevents unexpected breaking changes when you run terraform init.
The random_id resource generates an 8-character hex string that gets appended to the bucket name. S3 bucket names must be globally unique across all AWS accounts, so this avoids naming collisions without requiring you to invent a unique name.
The aws_s3_bucket_versioning resource enables versioning on the bucket. This means every time your state file is overwritten, S3 keeps the previous version. If a bad apply corrupts state, you can roll back to a prior version.
- Save bootstrap/main.tf.
Why local state for bootstrap?
This is a classic chicken-and-egg problem. You need the S3 bucket to exist before you can use it as a remote backend. The bootstrap directory stores its own state locally in a .terraform/ folder. Later, your environments/dev configuration will point to this bucket for its remote state.
- In the VS Code terminal, navigate to the bootstrap directory by running this command:
cd bootstrap
- Initialize Terraform to download the required providers by running this command:
terraform init
You should see a message confirming that Terraform has been successfully initialized and the hashicorp/aws and hashicorp/random providers were installed.
Seeing a provider installation error?
If you see a message about failing to query available provider packages, check your internet connection. Terraform needs to download providers from the registry.
If you see a version constraint error, confirm your Terraform version is 1.15 or higher with terraform version.
Help me fix my terraform init error in the bootstrap directory.
- Deploy the state bucket by running this command:
terraform apply
What will Terraform ask?
Terraform will show you a plan of what it intends to create (3 resources: the bucket, versioning configuration, and random ID). It will then prompt you to type yes to confirm. Type yes and press Enter to proceed.
After a few seconds, you should see "Apply complete! Resources: 3 added, 0 changed, 0 destroyed."
- Confirm your bucket name by running this command:
terraform output bucket_name
You should see a bucket name like terraform-state-a1b2c3d4 (with your own random suffix). Note this name down. You will use it in a later step.
- Double-check the bucket exists in AWS by running this command:
aws s3 ls | grep terraform-state
You should see your bucket name listed in the output with a creation date.
Bucket not showing up?
If terraform apply succeeded but aws s3 ls does not show the bucket, make sure your AWS CLI default region matches us-east-1. S3 is global but the CLI may filter by region in some configurations.
If terraform apply failed with a permissions error, your IAM user may not have S3 permissions. Verify your credentials have at minimum the AmazonS3FullAccess policy attached.
Help me troubleshoot why my S3 state bucket was not created or is not visible.
✔️ Awesome, I've got everything!
Great. Make sure you have saved bootstrap/main.tf and noted your bucket name from the output.
ⓧ I'd like to double check the full code
Here is the complete bootstrap/main.tf file. Compare it with yours:
terraform {
required_version = ">= 1.15"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
resource "aws_s3_bucket" "terraform_state" {
bucket = "terraform-state-${random_id.suffix.hex}"
tags = {
Name = "terraform-state"
ManagedBy = "terraform"
Environment = "shared"
}
}
resource "aws_s3_bucket_versioning" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
versioning_configuration {
status = "Enabled"
}
}
resource "random_id" "suffix" {
byte_length = 4
}
output "bucket_name" {
value = aws_s3_bucket.terraform_state.id
description = "The name of the S3 bucket used for Terraform state"
}
output "bucket_arn" {
value = aws_s3_bucket.terraform_state.arn
description = "The ARN of the S3 bucket used for Terraform state"
}
- Navigate back to the project root by running:
cd ..
Your remote state backend is ready. Next up, you will build a reusable networking module that provisions a full VPC with public and private subnets.
Build the Networking Module
Your remote state backend is live and ready to store infrastructure state. Now it's time to build the first reusable module that will define your network architecture.
Every cloud environment needs a network foundation. In this step, you will build a Terraform networking module that creates a VPC with public and private subnets, a NAT Gateway, and proper route tables. The module is self-contained and reusable across any environment.
In this step, get ready to:
- Define VPC, subnet, and Internet Gateway resources in the networking module.
- Add NAT Gateway and route table resources for proper traffic routing.
- Create input variables with validation rules and output values for cross-module references.
Define the VPC and subnet resources
The core of any AWS network is a VPC with subnets. Public subnets host resources that need direct internet access, while private subnets isolate resources that should only be reachable through internal routing.
- Open modules/networking/main.tf in VS Code.
- Add the VPC, public subnet, private subnet, and Internet Gateway resources by pasting this code:
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"
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = var.public_subnet_cidr
availability_zone = var.availability_zone
map_public_ip_on_launch = true
tags = {
Name = "${var.project_name}-${var.environment}-public"
Environment = var.environment
Type = "public"
}
}
resource "aws_subnet" "private" {
vpc_id = aws_vpc.main.id
cidr_block = var.private_subnet_cidr
availability_zone = var.availability_zone
tags = {
Name = "${var.project_name}-${var.environment}-private"
Environment = var.environment
Type = "private"
}
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-${var.environment}-igw"
Environment = var.environment
}
}
What does this code do?
The aws_vpc resource creates your Virtual Private Cloud with DNS support enabled. All other networking resources live inside this VPC.
The two subnets divide traffic. The public subnet sets map_public_ip_on_launch = true so instances get public IPs automatically. The private subnet does not, keeping those resources hidden from the internet.
The Internet Gateway connects your VPC to the public internet. Without it, nothing in the VPC can reach the outside world.
- Save modules/networking/main.tf.
- You won't see a change yet. The next substep adds more resources to this file.
Add NAT Gateway and route tables
Resources in the private subnet still need outbound internet access for things like downloading updates. A NAT Gateway provides this by routing outbound traffic through the public subnet without exposing private resources to inbound connections.
- In modules/networking/main.tf, add the following code below the Internet Gateway resource:
resource "aws_eip" "nat" {
domain = "vpc"
tags = {
Name = "${var.project_name}-${var.environment}-nat-eip"
Environment = var.environment
}
}
resource "aws_nat_gateway" "main" {
allocation_id = aws_eip.nat.id
subnet_id = aws_subnet.public.id
tags = {
Name = "${var.project_name}-${var.environment}-nat"
Environment = var.environment
}
depends_on = [aws_internet_gateway.main]
}
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
}
}
resource "aws_route_table" "private" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main.id
}
tags = {
Name = "${var.project_name}-${var.environment}-private-rt"
Environment = var.environment
}
}
resource "aws_route_table_association" "public" {
subnet_id = aws_subnet.public.id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "private" {
subnet_id = aws_subnet.private.id
route_table_id = aws_route_table.private.id
}
What does this code do?
The Elastic IP gives the NAT Gateway a static public address. The NAT Gateway itself sits in the public subnet and forwards outbound traffic from private resources.
The depends_on block tells Terraform to create the Internet Gateway first. The NAT Gateway needs it to function properly.
Two route tables control where traffic flows. The public route table sends internet-bound traffic to the Internet Gateway. The private route table sends it to the NAT Gateway instead. The associations bind each subnet to its correct route table.
- Save modules/networking/main.tf.
- You won't be able to validate yet because the module references variables that don't exist. The next substep creates them.
✔️ Awesome, I've got everything!
Great. Double check you've saved modules/networking/main.tf before moving on.
ⓧ I'd like to double check the full code
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"
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = var.public_subnet_cidr
availability_zone = var.availability_zone
map_public_ip_on_launch = true
tags = {
Name = "${var.project_name}-${var.environment}-public"
Environment = var.environment
Type = "public"
}
}
resource "aws_subnet" "private" {
vpc_id = aws_vpc.main.id
cidr_block = var.private_subnet_cidr
availability_zone = var.availability_zone
tags = {
Name = "${var.project_name}-${var.environment}-private"
Environment = var.environment
Type = "private"
}
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-${var.environment}-igw"
Environment = var.environment
}
}
resource "aws_eip" "nat" {
domain = "vpc"
tags = {
Name = "${var.project_name}-${var.environment}-nat-eip"
Environment = var.environment
}
}
resource "aws_nat_gateway" "main" {
allocation_id = aws_eip.nat.id
subnet_id = aws_subnet.public.id
tags = {
Name = "${var.project_name}-${var.environment}-nat"
Environment = var.environment
}
depends_on = [aws_internet_gateway.main]
}
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
}
}
resource "aws_route_table" "private" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main.id
}
tags = {
Name = "${var.project_name}-${var.environment}-private-rt"
Environment = var.environment
}
}
resource "aws_route_table_association" "public" {
subnet_id = aws_subnet.public.id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "private" {
subnet_id = aws_subnet.private.id
route_table_id = aws_route_table.private.id
}
Define variables and outputs
A well-designed module declares its inputs and outputs explicitly. Input variables let callers customize the module without editing its internals. Outputs expose key resource attributes so other modules can reference them.
- Open modules/networking/variables.tf and paste the following variable definitions:
variable "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
validation {
condition = can(cidrhost(var.vpc_cidr, 0))
error_message = "vpc_cidr must be a valid CIDR block."
}
}
variable "public_subnet_cidr" {
description = "CIDR block for the public subnet"
type = string
validation {
condition = can(cidrhost(var.public_subnet_cidr, 0))
error_message = "public_subnet_cidr must be a valid CIDR block."
}
}
variable "private_subnet_cidr" {
description = "CIDR block for the private subnet"
type = string
validation {
condition = can(cidrhost(var.private_subnet_cidr, 0))
error_message = "private_subnet_cidr must be a valid CIDR block."
}
}
variable "availability_zone" {
description = "AWS availability zone for subnets"
type = string
default = "us-east-1a"
}
variable "environment" {
description = "Environment name (e.g., dev, staging, prod)"
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment must be one of: dev, staging, prod."
}
}
variable "project_name" {
description = "Project name for resource tagging"
type = string
}
What do these validation blocks do?
The validation blocks catch bad input before Terraform tries to create resources. The can(cidrhost(...)) check ensures CIDR values are properly formatted network addresses.
The environment variable restricts values to dev, staging, or prod. This prevents typos from silently creating resources tagged with the wrong environment name.
- Save modules/networking/variables.tf.
✔️ Awesome, I've got everything!
Great. Make sure variables.tf is saved before continuing.
ⓧ I'd like to double check the full code
variable "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
validation {
condition = can(cidrhost(var.vpc_cidr, 0))
error_message = "vpc_cidr must be a valid CIDR block."
}
}
variable "public_subnet_cidr" {
description = "CIDR block for the public subnet"
type = string
validation {
condition = can(cidrhost(var.public_subnet_cidr, 0))
error_message = "public_subnet_cidr must be a valid CIDR block."
}
}
variable "private_subnet_cidr" {
description = "CIDR block for the private subnet"
type = string
validation {
condition = can(cidrhost(var.private_subnet_cidr, 0))
error_message = "private_subnet_cidr must be a valid CIDR block."
}
}
variable "availability_zone" {
description = "AWS availability zone for subnets"
type = string
default = "us-east-1a"
}
variable "environment" {
description = "Environment name (e.g., dev, staging, prod)"
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment must be one of: dev, staging, prod."
}
}
variable "project_name" {
description = "Project name for resource tagging"
type = string
}
- Open modules/networking/outputs.tf and paste the following output definitions:
output "vpc_id" {
value = aws_vpc.main.id
description = "The ID of the VPC"
}
output "public_subnet_id" {
value = aws_subnet.public.id
description = "The ID of the public subnet"
}
output "private_subnet_id" {
value = aws_subnet.private.id
description = "The ID of the private subnet"
}
output "nat_gateway_id" {
value = aws_nat_gateway.main.id
description = "The ID of the NAT Gateway"
}
Why expose these outputs?
Other modules need to reference these resource IDs. For example, the compute module you will build next needs the vpc_id to create security groups and the private_subnet_id to place instances in the correct subnet.
Without outputs, each module is a black box. Outputs create the contract between modules that makes composition possible.
- Save modules/networking/outputs.tf.
✔️ Awesome, I've got everything!
Great. Make sure outputs.tf is saved before running the validation.
ⓧ I'd like to double check the full code
output "vpc_id" {
value = aws_vpc.main.id
description = "The ID of the VPC"
}
output "public_subnet_id" {
value = aws_subnet.public.id
description = "The ID of the public subnet"
}
output "private_subnet_id" {
value = aws_subnet.private.id
description = "The ID of the private subnet"
}
output "nat_gateway_id" {
value = aws_nat_gateway.main.id
description = "The ID of the NAT Gateway"
}
- Initialize and validate the networking module by running these commands:
cd modules/networking
terraform init -backend=false
terraform validate
Why use -backend=false?
The networking module is a reusable component, not a root configuration with a backend. The -backend=false flag tells Terraform to skip backend configuration and only download the required providers. This lets you validate the module's syntax and structure in isolation.
- You should see Success! The configuration is valid. in the terminal output.
- Return to the project root by running:
cd ../..
Seeing validation errors?
- If you see an error about missing variables, check that variables.tf defines all six variables referenced in main.tf (vpc_cidr, public_subnet_cidr, private_subnet_cidr, availability_zone, environment, project_name).
- If you see a syntax error, compare your code against the full file in the double-check tabs above. A missing closing brace or misplaced quote is the most common cause.
- Help me debug my Terraform validation error
Your networking module is complete and validated. Next up, you will build the compute module that deploys an Auto Scaling Group into the private subnet this module creates.
Build the Compute Module
Your networking module creates the roads and bridges. Now you need something to drive on them.
The compute module defines the actual EC2 instances that run inside your VPC. Instead of launching a single server, you will use an Auto Scaling Group that can automatically add or remove instances based on demand.
In this step, get ready to:
- Create a security group and launch template for EC2 instances.
- Create an Auto Scaling Group with tag propagation.
- Define the module's input variables and outputs.
Create the launch template and security group
Every EC2 instance needs network rules governing what traffic can reach it. A security group acts as a virtual firewall attached to each instance.
- Open modules/compute/main.tf in VS Code and add the security group resource by pasting this code:
resource "aws_security_group" "instance" {
name_prefix = "${var.project_name}-${var.environment}-"
description = "Security group for compute instances"
vpc_id = var.vpc_id
ingress {
description = "SSH access"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
description = "All outbound traffic"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.project_name}-${var.environment}-sg"
Environment = var.environment
}
lifecycle {
create_before_destroy = true
}
}
What does this code do?
The ingress block allows inbound SSH connections (port 22) from any IP address. The egress block allows all outbound traffic so instances can download updates and communicate with AWS services.
The lifecycle block with create_before_destroy = true tells Terraform to create the replacement security group before destroying the old one during updates. This prevents downtime when security group rules change.
- Save main.tf.
You won't see runtime output yet. The launch template in the next chunk references this security group.
- Below the security group resource in modules/compute/main.tf, add the AMI data source and launch template by pasting this code:
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_launch_template" "main" {
name_prefix = "${var.project_name}-${var.environment}-"
image_id = data.aws_ami.amazon_linux.id
instance_type = var.instance_type
vpc_security_group_ids = [aws_security_group.instance.id]
tag_specifications {
resource_type = "instance"
tags = {
Name = "${var.project_name}-${var.environment}-instance"
Environment = var.environment
ManagedBy = "terraform"
}
}
tags = {
Name = "${var.project_name}-${var.environment}-lt"
Environment = var.environment
}
}
What does this code do?
The data "aws_ami" block is a lookup. It finds the latest Amazon Linux 2023 AMI at apply time so your instances always boot the most current image without hardcoding an AMI ID.
The aws_launch_template defines how each instance is created. It references the AMI, sets the instance type from a variable, attaches the security group, and applies tags to every instance launched from it.
- Save main.tf.
You won't see a change yet. The Auto Scaling Group in the next substep references this launch template.
Seeing red squiggles in VS Code?
If your editor highlights var.vpc_id or var.instance_type as unknown, that is expected. You have not defined the variables file yet. The errors disappear after substep 3.
help me understand why my Terraform variables are showing as undefined
Create the Auto Scaling Group
A single EC2 instance is a single point of failure. An Auto Scaling Group manages a fleet of identical instances and can replace any that become unhealthy.
- Below the launch template in modules/compute/main.tf, add the Auto Scaling Group resource by pasting this code:
resource "aws_autoscaling_group" "main" {
name_prefix = "${var.project_name}-${var.environment}-"
min_size = var.min_size
max_size = var.max_size
desired_capacity = var.desired_capacity
vpc_zone_identifier = [var.subnet_id]
launch_template {
id = aws_launch_template.main.id
version = "$Latest"
}
tag {
key = "Name"
value = "${var.project_name}-${var.environment}-asg"
propagate_at_launch = true
}
tag {
key = "Environment"
value = var.environment
propagate_at_launch = true
}
tag {
key = "ManagedBy"
value = "terraform"
propagate_at_launch = true
}
}
What does this code do?
The ASG starts with desired_capacity = 1 instance and can scale between min_size and max_size. The vpc_zone_identifier places instances into the subnet passed by the root configuration.
Each tag block uses propagate_at_launch = true so every instance the ASG creates inherits the same tags. This makes it easy to identify and filter instances in the AWS Console.
- Save main.tf. This completes all the resources for the compute module.
✔️ Awesome, I've got everything!
Great. Double check you have saved modules/compute/main.tf before moving on.
ⓧ I'd like to double check the full code
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}-"
description = "Security group for compute instances"
vpc_id = var.vpc_id
ingress {
description = "SSH access"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
description = "All outbound traffic"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.project_name}-${var.environment}-sg"
Environment = var.environment
}
lifecycle {
create_before_destroy = true
}
}
resource "aws_launch_template" "main" {
name_prefix = "${var.project_name}-${var.environment}-"
image_id = data.aws_ami.amazon_linux.id
instance_type = var.instance_type
vpc_security_group_ids = [aws_security_group.instance.id]
tag_specifications {
resource_type = "instance"
tags = {
Name = "${var.project_name}-${var.environment}-instance"
Environment = var.environment
ManagedBy = "terraform"
}
}
tags = {
Name = "${var.project_name}-${var.environment}-lt"
Environment = var.environment
}
}
resource "aws_autoscaling_group" "main" {
name_prefix = "${var.project_name}-${var.environment}-"
min_size = var.min_size
max_size = var.max_size
desired_capacity = var.desired_capacity
vpc_zone_identifier = [var.subnet_id]
launch_template {
id = aws_launch_template.main.id
version = "$Latest"
}
tag {
key = "Name"
value = "${var.project_name}-${var.environment}-asg"
propagate_at_launch = true
}
tag {
key = "Environment"
value = var.environment
propagate_at_launch = true
}
tag {
key = "ManagedBy"
value = "terraform"
propagate_at_launch = true
}
}
Define variables and outputs
The module's interface is defined by its variables (inputs) and outputs. Variables let the root configuration customize behaviour. Outputs expose resource attributes that other modules can consume.
- Open modules/compute/variables.tf and define all the input variables by pasting this code:
variable "subnet_id" {
description = "Subnet ID to deploy the Auto Scaling Group into"
type = string
}
variable "vpc_id" {
description = "VPC ID for the security group"
type = string
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
}
variable "environment" {
description = "Environment name"
type = string
}
variable "project_name" {
description = "Project name for resource tagging"
type = string
}
variable "min_size" {
description = "Minimum number of instances in the ASG"
type = number
default = 1
}
variable "max_size" {
description = "Maximum number of instances in the ASG"
type = number
default = 3
}
variable "desired_capacity" {
description = "Desired number of instances in the ASG"
type = number
default = 1
}
What does this code do?
Each variable block declares one input the module accepts. subnet_id and vpc_id have no defaults, so the caller must provide them. instance_type defaults to t3.micro (AWS Free Tier eligible) so callers only override it when they need a larger instance.
The scaling variables (min_size, max_size, desired_capacity) have sensible defaults that keep costs minimal while still demonstrating auto-scaling capability.
- Save variables.tf.
- Open modules/compute/outputs.tf and define the module's outputs by pasting this code:
output "autoscaling_group_name" {
value = aws_autoscaling_group.main.name
description = "The name of the Auto Scaling Group"
}
output "launch_template_id" {
value = aws_launch_template.main.id
description = "The ID of the launch template"
}
output "security_group_id" {
value = aws_security_group.instance.id
description = "The ID of the instance security group"
}
What does this code do?
Outputs expose key resource identifiers to whatever calls this module. The root configuration (or other modules) can reference module.compute.autoscaling_group_name without needing to know the internal resource structure.
This is the same pattern you used in the networking module. Outputs are the public API of a Terraform module.
- Save outputs.tf.
✔️ Awesome, I've got everything!
Great. Make sure both variables.tf and outputs.tf are saved before validating.
ⓧ I'd like to double check the full code
variable "subnet_id" {
description = "Subnet ID to deploy the Auto Scaling Group into"
type = string
}
variable "vpc_id" {
description = "VPC ID for the security group"
type = string
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
}
variable "environment" {
description = "Environment name"
type = string
}
variable "project_name" {
description = "Project name for resource tagging"
type = string
}
variable "min_size" {
description = "Minimum number of instances in the ASG"
type = number
default = 1
}
variable "max_size" {
description = "Maximum number of instances in the ASG"
type = number
default = 3
}
variable "desired_capacity" {
description = "Desired number of instances in the ASG"
type = number
default = 1
}
output "autoscaling_group_name" {
value = aws_autoscaling_group.main.name
description = "The name of the Auto Scaling Group"
}
output "launch_template_id" {
value = aws_launch_template.main.id
description = "The ID of the launch template"
}
output "security_group_id" {
value = aws_security_group.instance.id
description = "The ID of the instance security group"
}
Now validate the module to confirm everything is syntactically correct. Terraform requires initialization before validation, even for a module directory.
- Navigate into the compute module directory and run the validation by running these commands:
cd modules/compute
terraform init -backend=false
terraform validate
Why init -backend=false?
The terraform init command downloads the required provider plugins so Terraform can understand resource types. The -backend=false flag skips backend configuration because a standalone module does not have its own state file.
- You should see Success! The configuration is valid. in your terminal output.
- Navigate back to the project root by running:
cd ../..
Seeing a validation error?
If Terraform reports a missing closing brace, check that every { in main.tf has a matching }. Nested blocks like ingress, tag_specifications, and tag each need their own closing brace.
If you see an error about an unsupported argument, double check that variable names in main.tf (like var.subnet_id) match the variable names declared in variables.tf exactly.
help me fix my Terraform validate errors in the compute module
Your compute module is ready. Next up, you will compose both modules together in a root configuration and deploy real infrastructure to AWS.
Compose Modules and Deploy
You have built two reusable modules that encapsulate networking and compute logic. Now it is time to bring them together into a single environment configuration and deploy real infrastructure to AWS.
The environment layer is where you define how modules connect to each other. The networking module's outputs (VPC ID, subnet IDs) become the compute module's inputs. This is the composition pattern that makes Terraform modules powerful at scale.
In this step, get ready to:
- Configure the S3 remote backend for state storage with native lockfile locking.
- Write the root configuration that composes both modules together.
- Deploy the full infrastructure and verify it in the AWS Console.
Configure the remote backend
Before Terraform can store state remotely, you need to tell it which S3 bucket to use. You also need to define the shared variables that both modules will reference.
- In VS Code, open environments/dev/backend.tf and paste the following configuration:
terraform {
backend "s3" {
# Points to the state bucket you created in Step 1
bucket = "your-bucket-name-here"
key = "dev/terraform.tfstate"
region = "us-east-1"
use_lockfile = true
}
}
- Replace your-bucket-name-here with the actual bucket name from Step 1. You can find it by running terraform output bucket_name in the bootstrap/ directory.
What does use_lockfile do?
When use_lockfile = true is set, Terraform creates a .tflock file in S3 alongside your state file whenever someone runs plan or apply. This prevents two people from modifying the same state simultaneously.
This is the modern replacement for DynamoDB-based locking, which is now deprecated as of Terraform 1.15. No extra table to manage.
- Now open environments/dev/variables.tf and paste the shared variable definitions:
variable "environment" {
description = "Environment name"
type = string
default = "dev"
}
variable "project_name" {
description = "Project name"
type = string
default = "infra-modules"
}
What does this code do?
These two variables provide default values that get passed down to both modules. Every resource in your infrastructure will be tagged with infra-modules and dev, making it easy to identify and filter resources in the AWS Console.
- Save both backend.tf and variables.tf.
Write the root configuration that composes both modules
The root main.tf is where you call your modules and wire their outputs together. The compute module needs a VPC and subnet to deploy into, and those come directly from the networking module's outputs.
- Open environments/dev/main.tf and paste the provider configuration and networking module call:
terraform {
required_version = ">= 1.15"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
module "networking" {
source = "../../modules/networking"
# Define the network address space
vpc_cidr = "10.0.0.0/16"
public_subnet_cidr = "10.0.1.0/24"
private_subnet_cidr = "10.0.2.0/24"
availability_zone = "us-east-1a"
environment = var.environment
project_name = var.project_name
}
What does this code do?
- The terraform block pins the Terraform version to >= 1.15 and the AWS provider to ~> 6.0. This ensures consistent behavior across team members.
- The module "networking" block calls your reusable networking module using a relative path. It passes concrete CIDR values that define your network layout.
- The var.environment and var.project_name references pull from the variables file you just created.
- Now add the compute module call below the networking module. Paste this at the bottom of the same file:
module "compute" {
source = "../../modules/compute"
# Wire networking outputs into compute inputs
vpc_id = module.networking.vpc_id
subnet_id = module.networking.private_subnet_id
instance_type = "t3.micro"
environment = var.environment
project_name = var.project_name
min_size = 1
max_size = 3
desired_capacity = 1
}
What does this code do?
This is where module composition happens. The module.networking.vpc_id and module.networking.private_subnet_id references pass the networking module's outputs directly into the compute module's inputs. Terraform resolves the dependency order automatically.
The Auto Scaling Group deploys into the private subnet, meaning your instances are not directly reachable from the internet. Traffic must flow through the NAT Gateway to reach them.
- Finally, open environments/dev/outputs.tf and paste the output definitions:
output "vpc_id" {
value = module.networking.vpc_id
description = "The VPC ID"
}
output "public_subnet_id" {
value = module.networking.public_subnet_id
description = "The public subnet ID"
}
output "private_subnet_id" {
value = module.networking.private_subnet_id
description = "The private subnet ID"
}
output "autoscaling_group_name" {
value = module.compute.autoscaling_group_name
description = "The Auto Scaling Group name"
}
What does this code do?
Root-level outputs expose key resource identifiers after terraform apply completes. These are the values you would reference in other automation tools, CI/CD pipelines, or additional Terraform configurations that build on this infrastructure.
- Save main.tf and outputs.tf.
✔️ Awesome, I've got everything!
Great. Double check you have saved all four files in the environments/dev/ directory.
ⓧ I'd like to double check the full code
Here is the complete content for each file in environments/dev/:
terraform {
backend "s3" {
bucket = "your-bucket-name-here"
key = "dev/terraform.tfstate"
region = "us-east-1"
use_lockfile = true
}
}
variable "environment" {
description = "Environment name"
type = string
default = "dev"
}
variable "project_name" {
description = "Project name"
type = string
default = "infra-modules"
}
terraform {
required_version = ">= 1.15"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = "us-east-1"
}
module "networking" {
source = "../../modules/networking"
vpc_cidr = "10.0.0.0/16"
public_subnet_cidr = "10.0.1.0/24"
private_subnet_cidr = "10.0.2.0/24"
availability_zone = "us-east-1a"
environment = var.environment
project_name = var.project_name
}
module "compute" {
source = "../../modules/compute"
vpc_id = module.networking.vpc_id
subnet_id = module.networking.private_subnet_id
instance_type = "t3.micro"
environment = var.environment
project_name = var.project_name
min_size = 1
max_size = 3
desired_capacity = 1
}
output "vpc_id" {
value = module.networking.vpc_id
description = "The VPC ID"
}
output "public_subnet_id" {
value = module.networking.public_subnet_id
description = "The public subnet ID"
}
output "private_subnet_id" {
value = module.networking.private_subnet_id
description = "The private subnet ID"
}
output "autoscaling_group_name" {
value = module.compute.autoscaling_group_name
description = "The Auto Scaling Group name"
}
Deploy and verify
With all configuration files in place, you can now initialize the backend, review the plan, and apply the infrastructure.
- Navigate into the dev environment directory by running this command:
cd environments/dev
- Initialize the S3 backend and download modules by running:
terraform init
You should see a message that says Successfully configured the backend "s3"! followed by Terraform has been successfully initialized!.
Seeing a backend configuration error?
- Make sure you replaced your-bucket-name-here in backend.tf with your actual bucket name from terraform output bucket_name in the bootstrap/ directory.
- Confirm your AWS CLI credentials are still valid by running aws sts get-caller-identity.
- Check that the bucket exists by running aws s3 ls and looking for your state bucket in the list.
Why is my terraform init failing with an S3 backend error?
- Preview the infrastructure Terraform will create by running:
terraform plan
You should see a plan showing approximately 13 resources to add, including the VPC, subnets, NAT Gateway, route tables, security group, launch template, and Auto Scaling Group.
- Deploy the infrastructure by running:
terraform apply
- Terraform will show the plan again and ask for confirmation. Type yes and press Enter to proceed.
How long does this take?
The NAT Gateway typically takes 1-2 minutes to provision. The entire apply usually completes in under 3 minutes. You will see resources being created one by one in your terminal output.
Once complete, Terraform prints the outputs you defined. You should see vpc_id, public_subnet_id, private_subnet_id, and autoscaling_group_name printed with their values.
- Verify that all resources are tracked in state by running:
terraform state list
You should see a list of resources prefixed with module.networking. and module.compute. showing every resource Terraform manages.
- Confirm the state file is stored remotely by running:
aws s3 ls s3://[[STATE_BUCKET="your-state-bucket-name"]]/dev/
You should see terraform.tfstate listed in the output.
- Open the AWS Management Console in your browser.
- Navigate to VPC service. You should see a VPC named infra-modules-dev-vpc with CIDR 10.0.0.0/16.
- Navigate to EC2 service, then Auto Scaling Groups. You should see a group with 1 running instance.
Resources not appearing in the console?
- Make sure you are viewing the us-east-1 region in the AWS Console. Check the region selector in the top-right corner.
- If terraform apply completed without errors, the resources exist. Give the console a moment to refresh.
Why can't I see my Terraform resources in the AWS Console?
Your modular infrastructure is live. You have a complete VPC with network segmentation, a NAT Gateway for private subnet internet access, and an Auto Scaling Group running behind proper network isolation. Next up, you will harden this infrastructure with security scanning.
Secret mission
Security Scanning with Checkov
Your infrastructure is deployed and working. But is it secure? In production, teams run automated security scanners against their Terraform code to catch misconfigurations before they reach cloud environments. In this mission, you will install Checkov, scan your modules for vulnerabilities, and fix the findings to pass a re-scan.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. The NAT Gateway is the only meaningful ongoing cost in this project at approximately $0.045/hour.
Cost warning
Your NAT Gateway costs approximately $1.08/day while running. The t3.micro instance in your Auto Scaling Group is Free Tier eligible. Destroy your dev environment within the project session to keep total costs under $0.05.
Resources you used:
- S3 bucket (state backend) with versioning and KMS encryption.
- VPC with public and private subnets in us-east-1.
- NAT Gateway with Elastic IP (the only cost-incurring resource).
- Auto Scaling Group with 1x t3.micro instance (Free Tier eligible).
- Local project folder terraform-aws-modules/ on your Desktop.
- Checkov installed via pip.
Keep everything running
No action needed. Choose this if you are still actively building or exploring the infrastructure. Your NAT Gateway costs approximately $1.08/day. The ASG instance is Free Tier eligible.
- Your Terraform state file remains in S3 with lockfile-based locking.
- Your local terraform-aws-modules/ folder stays on your Desktop.
- Checkov remains installed on your machine.
Pause - I'll come back to this later
Destroy the dev environment to stop NAT Gateway charges. Your state bucket and local files remain so you can redeploy later.
- Navigate to your dev environment directory by running:
cd ~/Desktop/terraform-aws-modules/environments/dev
- Destroy all dev environment resources (VPC, NAT Gateway, ASG, subnets) by running:
terraform destroy
- Type yes when prompted to confirm the destruction.
- Your S3 state bucket remains intact for future redeployment.
- Your local project files remain on your Desktop.
Delete - I don't want to use this again
Remove all project resources completely and clean up your local machine.
- Destroy the dev environment by running:
cd ~/Desktop/terraform-aws-modules/environments/dev
terraform destroy
- Type yes when prompted.
- Destroy the S3 state bucket by running:
cd ~/Desktop/terraform-aws-modules/bootstrap
terraform destroy
- Type yes when prompted.
- Remove the local project folder by running:
rm -rf ~/Desktop/terraform-aws-modules
- Uninstall Checkov by running:
pip uninstall checkov -y
- Total cost if destroyed within 1 hour of creation: less than $0.05.
Nice Work!
Nice Work!
Nice work! You just built a production-grade Terraform project that deploys a complete AWS environment using modular, reusable infrastructure code.
You've learned how to:
- Built a production-structured Terraform repository with reusable networking and compute modules following enterprise directory patterns.
- Configured S3 remote state with native lockfile-based locking, the modern replacement for DynamoDB-based locking.
- Composed modules together with cross-module output references to deploy a VPC, NAT Gateway, and Auto Scaling Group as a single automated environment.
- Secret Mission: Ran Checkov security scanning against your Terraform code and hardened your infrastructure against common misconfigurations.
Ready to quiz yourself?