What You'll Build
You will transform a hardcoded Terraform configuration for the CricketPulse networking layer into a fully parameterised, multi-environment configuration. Starting from a configuration with hardcoded values, you will extract variables with appropriate types and validation, compute subnet CIDRs dynamically using `cidrsubnet()`, use `for_each` to create all subnets from a map variable, add conditional resources for production-only features, implement meaningful outputs, and create dev and production tfvars files. The result will be a single configuration deployable to both environments with environment-appropriate sizing, subnets, and features.
Prerequisites
- Terraform 1.5+ installed with AWS provider configured (or LocalStack for local testing).
- AWS credentials configured — either via `aws configure` or environment variables.
- Lessons 9–11 completed — understanding of variables, data sources, functions, and for_each.
- The completed configuration from Lesson 8 (remote backend setup) or a fresh directory with `terraform init`.
- Familiarity with basic networking concepts — CIDR blocks, subnets, public vs private routing.
Setup & Project Structure
Create the project structure with separate files for variables, locals, resources, and outputs — this is the conventional Terraform file organisation that makes large configurations navigable.
mkdir -p cricketpulse-networking
cd cricketpulse-networking
# Create the conventional file structure
touch main.tf variables.tf locals.tf outputs.tf
touch terraform.tfvars production.tfvars
# Initial main.tf with provider config
cat > main.tf << 'EOF'
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
# Data sources for dynamic values
data "aws_availability_zones" "available" {
state = "available"
}
data "aws_region" "current" {}
EOF
terraform init
echo 'Project initialised'Step 1 — Foundation
Define all input variables with types, descriptions, defaults, and validation rules. This is the variable layer that callers use to customise the configuration — getting these right from the start saves significant rework later.
cat > variables.tf << 'EOF'
variable "environment" {
type = string
description = "Deployment environment: dev, staging, or production"
validation {
condition = contains(["dev", "staging", "production"], var.environment)
error_message = "Environment must be dev, staging, or production."
}
}
variable "aws_region" {
type = string
description = "AWS region to deploy CricketPulse networking"
default = "ap-south-1"
}
variable "project" {
type = string
description = "Project name used as resource name prefix"
default = "cricketpulse"
}
variable "vpc_cidr" {
type = string
description = "CIDR block for the CricketPulse VPC (e.g. 10.0.0.0/16)"
default = "10.0.0.0/16"
validation {
condition = can(cidrhost(var.vpc_cidr, 0))
error_message = "VPC CIDR must be a valid CIDR notation."
}
}
variable "az_count" {
type = number
description = "Number of availability zones to use for subnets"
default = 2
validation {
condition = var.az_count >= 1 && var.az_count <= 3
error_message = "AZ count must be between 1 and 3."
}
}
variable "enable_nat_gateway" {
type = bool
description = "Whether to create NAT gateway(s) for private subnet internet access"
default = true
}
variable "enable_vpn_gateway" {
type = bool
description = "Whether to create a VPN gateway (production only)"
default = false
}
EOFStep 2 — Core Logic
Implement the locals for computed values and the main networking resources using `for_each` and dynamic subnet CIDR calculation. The locals layer is where the variable values are transformed into the exact values resources need.
cat > locals.tf << 'EOF'
locals {
name_prefix = "${var.project}-${var.environment}"
common_tags = {
Project = var.project
Environment = var.environment
ManagedBy = "terraform"
}
is_production = var.environment == "production"
# Calculate CIDRs dynamically from vpc_cidr
az_list = slice(data.aws_availability_zones.available.names, 0, var.az_count)
private_subnets = {
for i, az in local.az_list :
"${local.name_prefix}-private-${az}" => {
cidr = cidrsubnet(var.vpc_cidr, 8, i)
az = az
tier = "private"
}
}
public_subnets = {
for i, az in local.az_list :
"${local.name_prefix}-public-${az}" => {
cidr = cidrsubnet(var.vpc_cidr, 8, i + 100)
az = az
tier = "public"
}
}
}
EOF
cat > main.tf << 'EOF'
terraform {
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.0" }
}
}
provider "aws" { region = var.aws_region }
data "aws_availability_zones" "available" { state = "available" }
data "aws_region" "current" {}
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
tags = merge(local.common_tags, { Name = "${local.name_prefix}-vpc" })
}
resource "aws_subnet" "private" {
for_each = local.private_subnets
vpc_id = aws_vpc.main.id
cidr_block = each.value.cidr
availability_zone = each.value.az
tags = merge(local.common_tags, {
Name = each.key
Tier = each.value.tier
})
}
resource "aws_subnet" "public" {
for_each = local.public_subnets
vpc_id = aws_vpc.main.id
cidr_block = each.value.cidr
availability_zone = each.value.az
map_public_ip_on_launch = true
tags = merge(local.common_tags, {
Name = each.key
Tier = each.value.tier
})
}
# Optional NAT gateway (conditional)
resource "aws_eip" "nat" {
count = var.enable_nat_gateway ? length(local.az_list) : 0
domain = "vpc"
tags = merge(local.common_tags, { Name = "${local.name_prefix}-nat-eip-${count.index}" })
}
EOFStep 3 — Integration & Enhancement
Add meaningful outputs and create the two tfvars files. The outputs expose the VPC ID, subnet IDs, and AZ information for downstream configurations. The tfvars files demonstrate how the same configuration deploys differently to dev and production.
cat > outputs.tf << 'EOF'
output "vpc_id" {
value = aws_vpc.main.id
description = "ID of the CricketPulse VPC"
}
output "private_subnet_ids" {
value = [for s in aws_subnet.private : s.id]
description = "List of private subnet IDs"
}
output "public_subnet_ids" {
value = [for s in aws_subnet.public : s.id]
description = "List of public subnet IDs"
}
output "availability_zones" {
value = local.az_list
description = "Availability zones used"
}
output "network_summary" {
value = {
environment = var.environment
vpc_cidr = var.vpc_cidr
az_count = var.az_count
private_count = length(aws_subnet.private)
public_count = length(aws_subnet.public)
nat_enabled = var.enable_nat_gateway
}
}
EOF
# terraform.tfvars (dev — minimal cost)
cat > terraform.tfvars << 'EOF'
environment = "dev"
vpc_cidr = "10.1.0.0/16"
az_count = 1
enable_nat_gateway = false
enable_vpn_gateway = false
EOF
# production.tfvars (production — full scale)
cat > production.tfvars << 'EOF'
environment = "production"
vpc_cidr = "10.0.0.0/16"
az_count = 3
enable_nat_gateway = true
enable_vpn_gateway = true
EOFStep 4 — Testing & Verification
Validate both environment configurations and observe how the same code produces different infrastructure plans based on the variable values.
# Test variable validation
terraform plan -var="environment=invalid"
# Error: Environment must be dev, staging, or production.
# Plan for dev environment (default terraform.tfvars)
terraform plan
# Plan: 1 VPC + 1 private subnet + 1 public subnet + 0 NAT gateways = 3 to add
# Plan for production environment
terraform plan -var-file=production.tfvars
# Plan: 1 VPC + 3 private + 3 public + 3 EIPs + 3 NAT = 13 to add
# Check what outputs would be produced (dry run without apply)
terraform plan -var-file=production.tfvars -out=tfplan
terraform show tfplan | grep -A 5 'output'
# Validate the dynamic CIDRs are correct
terraform console
# > cidrsubnet("10.0.0.0/16", 8, 0)
# "10.0.0.0/24"
# > cidrsubnet("10.0.0.0/16", 8, 100)
# "10.0.100.0/24"
# > exit
echo 'Exercise verification complete'Warning: The `cidrsubnet()` function calculates CIDRs based on the VPC CIDR and subnet index. If you change `var.vpc_cidr` in a running configuration, Terraform will plan to destroy and recreate ALL subnets (because their CIDRs change). Never change the VPC CIDR of a running environment — it requires creating a new VPC and migrating all resources. This is why the VPC CIDR should be set once and committed: `10.0.0.0/16` for production, `10.1.0.0/16` for staging, `10.2.0.0/16` for dev — distinct ranges that don't overlap and won't need changing.
Extension Challenge: Add a variable `var.tags` of type `map(string)` that allows callers to pass additional tags beyond the standard `common_tags`. Merge this with `local.common_tags` using `merge(local.common_tags, var.tags)` in all resources. Then add a variable `var.subnet_configs` of type `map(object({cidr_suffix=number, public=bool}))` that allows callers to define any number of subnets with arbitrary CIDR suffixes and public/private settings, replacing the current fixed private+public pattern. This makes the networking module truly generic and reusable across different CricketPulse deployment topologies.
- Separate configuration into conventional files: `variables.tf`, `locals.tf`, `main.tf`, `outputs.tf` — this organisation makes large configurations navigable.
- `cidrsubnet(vpc_cidr, 8, index)` dynamically computes subnet CIDRs from a VPC CIDR — change `az_count` and new subnets are automatically sized and addressed.
- The `for i, az in local.az_list : key => {...}` pattern creates a map for `for_each` from a list of AZ names, enabling one resource instance per AZ.
- `count = var.enable_feature ? 1 : 0` is the standard optional resource pattern — clean conditional inclusion without duplicate blocks.
- Two tfvars files (`terraform.tfvars` for dev, `production.tfvars` for production) implement environment-specific configuration without duplicating the resource definitions.
- Always validate variable values with `validation` blocks to catch misconfigurations before any API calls are made.