Learn Terraform and AWS at Zero Cost with LocalStack: A Complete Practical Guide
November 5, 2025 · 1739 words · 9 min
When learning AWS and Terraform, the biggest pain points are often the high costs of cloud services and the financial risks of misconfigurations. This guide will show you how to use LocalStack to simulate AWS services locally, enabling you to learn and experiment with Terraform Infrastructure as Code (IaC) at zero cost and zero risk.
In this tutorial, we’ll build a complete AWS network architecture including VPC, public/private subnets, Internet Gateway, security groups, and EC2 instances. All resources run in your local LocalStack environment without incurring any AWS charges or security risks from configuration errors.
Note: LocalStack creates simulated AWS resources for local development and testing purposes only. While these resources cannot be accessed from external networks, this is the perfect way to learn Terraform and become familiar with AWS services without worrying about costs or security.
Installation
- Install Terraform: https://developer.hashicorp.com/terraform/install
- Install Localstack: https://docs.localstack.cloud/aws/getting-started/installation/
Launch Localstack
localstack start
Build a simple architecture
We’ll build a simple architecture with a VPC, a public subnet, a private subnet, an internet gateway, a route table, a route table association, a security group, and an EC2 instance.
Terraform Code
Create a new directory for your project and navigate to it.
mkdir learn-terraform-aws-localstack
cd learn-terraform-aws-localstack
provider.tf
This file contains the provider configuration for AWS.
provider "aws" {
access_key = "mock_access_key"
region = "us-east-1"
s3_use_path_style = true
secret_key = "mock_secret_key"
skip_credentials_validation = true
skip_metadata_api_check = true
skip_requesting_account_id = true
endpoints {
apigateway = "http://localhost:4566"
cloudformation = "http://localhost:4566"
cloudwatch = "http://localhost:4566"
dynamodb = "http://localhost:4566"
ec2 = "http://localhost:4566"
es = "http://localhost:4566"
firehose = "http://localhost:4566"
iam = "http://localhost:4566"
kinesis = "http://localhost:4566"
lambda = "http://localhost:4566"
route53 = "http://localhost:4566"
redshift = "http://localhost:4566"
s3 = "http://localhost:4566"
secretsmanager = "http://localhost:4566"
ses = "http://localhost:4566"
sns = "http://localhost:4566"
sqs = "http://localhost:4566"
ssm = "http://localhost:4566"
stepfunctions = "http://localhost:4566"
sts = "http://localhost:4566"
}
}
main.tf
This file contains the Terraform code for our architecture.
# VPC
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "${var.workspace}-vpc"
Workspace = var.workspace
}
}
# Internet Gateway
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.workspace}-igw"
Workspace = var.workspace
}
}
# Public Subnet
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = var.public_subnet_cidr
map_public_ip_on_launch = true
availability_zone = "${var.region}a"
tags = {
Name = "${var.workspace}-public-subnet"
Workspace = var.workspace
}
}
# Private Subnet
resource "aws_subnet" "private" {
vpc_id = aws_vpc.main.id
cidr_block = var.private_subnet_cidr
availability_zone = "${var.region}a"
tags = {
Name = "${var.workspace}-private-subnet"
Workspace = var.workspace
}
}
# Public Route Table
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.workspace}-public-rt"
Workspace = var.workspace
}
}
# Private Route Table
resource "aws_route_table" "private" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.workspace}-private-rt"
Workspace = var.workspace
}
}
# Route Table Associations
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
}
# Security Group for EC2
resource "aws_security_group" "ec2" {
name = "${var.workspace}-ec2-sg"
description = "Security group for EC2 instance"
vpc_id = aws_vpc.main.id
ingress {
description = "Allow port 8000 from anywhere"
from_port = 8000
to_port = 8000
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
description = "Allow all outbound traffic"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.workspace}-ec2-sg"
Workspace = var.workspace
}
}
# EC2 Instance in Public Subnet
resource "aws_instance" "web" {
ami = var.ec2_ami
instance_type = var.ec2_instance_type
subnet_id = aws_subnet.public.id
vpc_security_group_ids = [aws_security_group.ec2.id]
tags = {
Name = "${var.workspace}-web-server"
}
}
vars.tf
This file contains the variables for our architecture.
variable "workspace" {
type = string
description = "The workspace to use"
default = "dev"
}
variable "region" {
type = string
description = "The region to use"
default = "us-east-1"
}
variable "vpc_cidr" {
type = string
description = "CIDR block for VPC"
default = "10.0.0.0/16"
}
variable "public_subnet_cidr" {
type = string
description = "CIDR block for public subnet"
default = "10.0.1.0/24"
}
variable "private_subnet_cidr" {
type = string
description = "CIDR block for private subnet"
default = "10.0.2.0/24"
}
variable "ec2_instance_type" {
type = string
description = "EC2 instance type"
default = "t3.micro"
}
variable "ec2_ami" {
type = string
description = "AMI ID for EC2 instance"
default = "ami-0c55b159cbfafe1f0" # Amazon Linux 2
}
outputs.tf
This file contains the outputs for our architecture.
output "vpc_id" {
description = "VPC ID"
value = aws_vpc.main.id
}
output "public_subnet_id" {
description = "Public subnet ID"
value = aws_subnet.public.id
}
output "private_subnet_id" {
description = "Private subnet ID"
value = aws_subnet.private.id
}
output "ec2_instance_id" {
description = "EC2 instance ID"
value = aws_instance.web.id
}
output "ec2_public_ip" {
description = "EC2 instance public IP"
value = aws_instance.web.public_ip
}
output "ec2_private_ip" {
description = "EC2 instance private IP"
value = aws_instance.web.private_ip
}
output "security_group_id" {
description = "EC2 security group ID"
value = aws_security_group.ec2.id
}
Run Terraform
Run the following commands to build the architecture.
terraform init # This will download the provider plugins
terraform plan # This will show the changes that will be applied
terraform apply # This will apply the changes
Run the following command to see the whole state of the infrastructure.
terraform show
Here is my state:
# aws_instance.web:
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
arn = "arn:aws:ec2:us-east-1::instance/i-628f2d8a778d0398f"
associate_public_ip_address = true
availability_zone = "us-east-1a"
disable_api_stop = false
disable_api_termination = false
ebs_optimized = false
force_destroy = false
get_password_data = false
hibernation = false
host_id = null
iam_instance_profile = "dev-ec2-profile"
id = "i-628f2d8a778d0398f"
instance_initiated_shutdown_behavior = "stop"
instance_lifecycle = null
instance_state = "running"
instance_type = "t3.micro"
ipv6_address_count = 0
ipv6_addresses = []
key_name = null
monitoring = false
outpost_arn = null
password_data = null
placement_group = null
placement_group_id = null
placement_partition_number = 0
primary_network_interface_id = "eni-a320bc52a3f9cc681"
private_dns = "ip-10-0-1-4.ec2.internal"
private_ip = "10.0.1.4"
public_dns = "ec2-54-214-147-137.compute-1.amazonaws.com"
public_ip = "54.214.147.137"
region = "us-east-1"
secondary_private_ips = []
security_groups = []
source_dest_check = true
spot_instance_request_id = null
subnet_id = "subnet-296cb609d6cf6c00c"
tags = {
"Name" = "dev-web-server"
}
tags_all = {
"Name" = "dev-web-server"
}
tenancy = "default"
user_data_replace_on_change = false
vpc_security_group_ids = [
"sg-6dac1e4483215e7cd",
]
metadata_options {
http_endpoint = "enabled"
http_protocol_ipv6 = "disabled"
http_put_response_hop_limit = 1
http_tokens = "optional"
instance_metadata_tags = "disabled"
}
primary_network_interface {
delete_on_termination = true
network_interface_id = "eni-a320bc52a3f9cc681"
}
root_block_device {
delete_on_termination = true
device_name = "/dev/sda1"
encrypted = false
iops = 0
kms_key_id = null
tags = {}
tags_all = {}
throughput = 0
volume_id = "vol-863def1230da5eec3"
volume_size = 8
volume_type = "gp2"
}
}
# aws_internet_gateway.main:
resource "aws_internet_gateway" "main" {
arn = "arn:aws:ec2:us-east-1:000000000000:internet-gateway/igw-2b2b955804ed3852f"
id = "igw-2b2b955804ed3852f"
owner_id = "000000000000"
region = "us-east-1"
tags = {
"Name" = "dev-igw"
"Workspace" = "dev"
}
tags_all = {
"Name" = "dev-igw"
"Workspace" = "dev"
}
vpc_id = "vpc-1f5d8418e1e2ce4dc"
}
# aws_route_table.private:
resource "aws_route_table" "private" {
arn = "arn:aws:ec2:us-east-1:000000000000:route-table/rtb-f33ee41ac3ddc20d0"
id = "rtb-f33ee41ac3ddc20d0"
owner_id = "000000000000"
propagating_vgws = []
region = "us-east-1"
route = []
tags = {
"Name" = "dev-private-rt"
"Workspace" = "dev"
}
tags_all = {
"Name" = "dev-private-rt"
"Workspace" = "dev"
}
vpc_id = "vpc-1f5d8418e1e2ce4dc"
}
# aws_route_table.public:
resource "aws_route_table" "public" {
arn = "arn:aws:ec2:us-east-1:000000000000:route-table/rtb-d5f6d58f5ad703b08"
id = "rtb-d5f6d58f5ad703b08"
owner_id = "000000000000"
propagating_vgws = []
region = "us-east-1"
route = [
{
carrier_gateway_id = null
cidr_block = "0.0.0.0/0"
core_network_arn = null
destination_prefix_list_id = null
egress_only_gateway_id = null
gateway_id = "igw-2b2b955804ed3852f"
ipv6_cidr_block = null
local_gateway_id = null
nat_gateway_id = null
network_interface_id = null
transit_gateway_id = null
vpc_endpoint_id = null
vpc_peering_connection_id = null
},
]
tags = {
"Name" = "dev-public-rt"
"Workspace" = "dev"
}
tags_all = {
"Name" = "dev-public-rt"
"Workspace" = "dev"
}
vpc_id = "vpc-1f5d8418e1e2ce4dc"
}
# aws_route_table_association.private:
resource "aws_route_table_association" "private" {
gateway_id = null
id = "rtbassoc-084a1cbcbcbe6e0c5"
region = "us-east-1"
route_table_id = "rtb-f33ee41ac3ddc20d0"
subnet_id = "subnet-489196b43b007d2db"
}
# aws_route_table_association.public:
resource "aws_route_table_association" "public" {
gateway_id = null
id = "rtbassoc-b0f38c2456e2fef35"
region = "us-east-1"
route_table_id = "rtb-d5f6d58f5ad703b08"
subnet_id = "subnet-296cb609d6cf6c00c"
}
# aws_security_group.ec2:
resource "aws_security_group" "ec2" {
arn = "arn:aws:ec2:us-east-1:000000000000:security-group/sg-6dac1e4483215e7cd"
description = "Security group for EC2 instance"
egress = [
{
cidr_blocks = [
"0.0.0.0/0",
]
description = "Allow all outbound traffic"
from_port = 0
ipv6_cidr_blocks = []
prefix_list_ids = []
protocol = "-1"
security_groups = []
self = false
to_port = 0
},
]
id = "sg-6dac1e4483215e7cd"
ingress = [
{
cidr_blocks = [
"0.0.0.0/0",
]
description = "Allow port 8000 from anywhere"
from_port = 8000
ipv6_cidr_blocks = []
prefix_list_ids = []
protocol = "tcp"
security_groups = []
self = false
to_port = 8000
},
]
name = "dev-ec2-sg"
name_prefix = null
owner_id = "000000000000"
region = "us-east-1"
revoke_rules_on_delete = false
tags = {
"Name" = "dev-ec2-sg"
"Workspace" = "dev"
}
tags_all = {
"Name" = "dev-ec2-sg"
"Workspace" = "dev"
}
vpc_id = "vpc-1f5d8418e1e2ce4dc"
}
# aws_subnet.private:
resource "aws_subnet" "private" {
arn = "arn:aws:ec2:us-east-1:000000000000:subnet/subnet-489196b43b007d2db"
assign_ipv6_address_on_creation = false
availability_zone = "us-east-1a"
availability_zone_id = "use1-az6"
cidr_block = "10.0.2.0/24"
customer_owned_ipv4_pool = null
enable_dns64 = false
enable_lni_at_device_index = 0
enable_resource_name_dns_a_record_on_launch = false
enable_resource_name_dns_aaaa_record_on_launch = false
id = "subnet-489196b43b007d2db"
ipv6_cidr_block = null
ipv6_cidr_block_association_id = null
ipv6_native = false
map_customer_owned_ip_on_launch = false
map_public_ip_on_launch = false
outpost_arn = null
owner_id = "000000000000"
private_dns_hostname_type_on_launch = "ip-name"
region = "us-east-1"
tags = {
"Name" = "dev-private-subnet"
"Workspace" = "dev"
}
tags_all = {
"Name" = "dev-private-subnet"
"Workspace" = "dev"
}
vpc_id = "vpc-1f5d8418e1e2ce4dc"
}
# aws_subnet.public:
resource "aws_subnet" "public" {
arn = "arn:aws:ec2:us-east-1:000000000000:subnet/subnet-296cb609d6cf6c00c"
assign_ipv6_address_on_creation = false
availability_zone = "us-east-1a"
availability_zone_id = "use1-az6"
cidr_block = "10.0.1.0/24"
customer_owned_ipv4_pool = null
enable_dns64 = false
enable_lni_at_device_index = 0
enable_resource_name_dns_a_record_on_launch = false
enable_resource_name_dns_aaaa_record_on_launch = false
id = "subnet-296cb609d6cf6c00c"
ipv6_cidr_block = null
ipv6_cidr_block_association_id = null
ipv6_native = false
map_customer_owned_ip_on_launch = false
map_public_ip_on_launch = true
outpost_arn = null
owner_id = "000000000000"
private_dns_hostname_type_on_launch = "ip-name"
region = "us-east-1"
tags = {
"Name" = "dev-public-subnet"
"Workspace" = "dev"
}
tags_all = {
"Name" = "dev-public-subnet"
"Workspace" = "dev"
}
vpc_id = "vpc-1f5d8418e1e2ce4dc"
}
# aws_vpc.main:
resource "aws_vpc" "main" {
arn = "arn:aws:ec2:us-east-1:000000000000:vpc/vpc-1f5d8418e1e2ce4dc"
assign_generated_ipv6_cidr_block = false
cidr_block = "10.0.0.0/16"
default_network_acl_id = "acl-89a71b82683d7254b"
default_route_table_id = "rtb-0fcd2ecf27bae8732"
default_security_group_id = "sg-f10b7fa4f906a9ef4"
dhcp_options_id = "default"
enable_dns_hostnames = true
enable_dns_support = true
enable_network_address_usage_metrics = false
id = "vpc-1f5d8418e1e2ce4dc"
instance_tenancy = "default"
ipv6_association_id = null
ipv6_cidr_block = null
ipv6_cidr_block_network_border_group = null
ipv6_ipam_pool_id = null
ipv6_netmask_length = 0
main_route_table_id = "rtb-0fcd2ecf27bae8732"
owner_id = "000000000000"
region = "us-east-1"
tags = {
"Name" = "dev-vpc"
"Workspace" = "dev"
}
tags_all = {
"Name" = "dev-vpc"
"Workspace" = "dev"
}
}
Outputs:
ec2_instance_id = "i-628f2d8a778d0398f"
ec2_private_ip = "10.0.1.4"
ec2_public_ip = "54.214.147.137"
private_subnet_id = "subnet-489196b43b007d2db"
public_subnet_id = "subnet-296cb609d6cf6c00c"
security_group_id = "sg-6dac1e4483215e7cd"
vpc_id = "vpc-1f5d8418e1e2ce4dc"
Clean up
terraform destroy # This will destroy the infrastructure