Multi-Environment Web App Platform with Terraform (ALB + Auto-Scaling + RDS)
This guide walks you through creating a production-ready, auto-scaling web application platform on AWS using Terraform modules and separate dev/prod environments.
You’ll build:
Networking: VPC, public/private subnets, IGW, NAT, routes
Compute: ALB in public subnets, ASG (EC2) in private subnets
Database: RDS MySQL in private subnets
Environments: isolated
devandprodfoldersSafety: variables, outputs, and a sensible
.gitignore
Prerequisites
AWS account + IAM user/role with permissions for VPC/EC2/IAM/RDS/CloudWatch.
Terraform installed (
terraform -version).AWS credentials configured (
aws configure), or environment variables.A Git repo to store your code.
Project Layout
Create this repo structure:
Multi-Environment Web App/
├── environments/
│ ├── dev/
│ │ ├── main.tf
| | ├── variables.tf
│ │ ├── terraform.tfvars
│ │ └── outputs.tf
│ └── prod/
│ ├── main.tf
| ├── variables.tf
│ ├── terraform.tfvars
│ └── outputs.tf
├── modules/
│ ├── networking/
│ │ ├── main.tf
| | ├── alb.tf
| | ├── security.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ ├── compute/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ └── database/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
├── versions.tf
├── README.md
└── .gitignore
Create .gitignore:
# Terraform dirs & state
**/.terraform/*
*.tfstate
*.tfstate.*
# Locks & crash logs
.terraform.tfstate.lock.info
crash.log
# Variable files (often contain secrets)
*.tfvars
*.tfvars.json
environments/**/terraform.tfvars
# Local overrides
override.tf
override.tf.json
*_override.tf
*_override.tf.json
# Editor/OS
.vscode/
.idea/
.DS_Store
*.swp
Providers.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
Provider blocks live in each environment (not in modules).
Modules receive only inputs/outputs.
Define your cloud provider block
This is gotten from app.terraform.io
create an organisation
create a workspace and define your cloud block
Module 1: Networking
modules/networking/variables.tf (inputs)
variable "vpc_cidr" {
description = "The VPC CIDR block"
type = string
}
variable "public_subnet_cidrs" {
description = "The public subnet CIDR blocks"
type = list(string)
}
variable "private_subnet_cidrs" {
description = "The private subnet CIDR blocks"
type = list(string)
}
variable "enable_nat_per_az" {
description = "Whether to create a NAT Gateway per AZ (true = HA, false = single NAT)"
type = bool
default = false
}
variable "tags" {
description = "Common tags to apply to resources"
type = map(string)
default = {
Environment = "dev"
project = "web-platform"
}
}
modules/networking/main.tf (key resources)
aws_vpc(enable DNS hostnames/support)data "aws_availability_zones"(for AZ spread)aws_subnetpublic/private across AZsaws_internet_gatewayaws_eip+aws_nat_gateway(1 per AZ ifenable_nat_per_az, else single)aws_route_table+aws_route_table_associationfor public (→ IGW) and private (→ NAT)
NOTES:Important
In subnets, set
vpc_id = aws_vpc.main.id(never the CIDR).Tag with your env (e.g.,
Environment = "dev").Use
data.aws_availability_zones.available.namesto pick AZs.
modules/networking/outputs.tf
output "vpc_id" {
description = "The ID of the VPC"
value = aws_vpc.main.id
}
output "public_subnet_ids" {
description = "List of IDs of the public subnets"
value = [for subnet in aws_subnet.public : subnet.id]
}
output "private_subnet_ids" {
description = "List of IDs of the private subnets"
value = [for subnet in aws_subnet.private : subnet.id]
}
Module 2: Compute (ALB + ASG)
modules/compute/variables.tf
variable "vpc_id" {
type = string
description = "VPC ID where compute resources will be created"
}
variable "public_subnet_ids" {
type = list(string)
description = "Public subnet IDs for placing the Application Load Balancer"
}
variable "private_subnet_ids" {
type = list(string)
description = "Private subnet IDs for placing EC2 instances in the Auto Scaling Group"
}
variable "app_port" {
type = number
default = 80
description = "Port the application will listen on (e.g., 80 or 8080)"
}
variable "instance_type" {
type = string
default = "t3.micro"
description = "EC2 instance type for app servers"
}
variable "desired_capacity" {
type = number
default = 2
description = "Number of EC2 instances to launch by default"
}
variable "min_size" {
type = number
default = 1
description = "Minimum number of EC2 instances in the Auto Scaling Group"
}
variable "max_size" {
type = number
default = 4
description = "Maximum number of EC2 instances in the Auto Scaling Group"
}
variable "user_data" {
type = string
default = ""
description = "Optional user_data script to configure EC2 instances at launch"
}
variable "tags" {
type = map(string)
default = {}
description = "Tags to apply to compute resources"
}
variable "certificate_arn" {
type = string
default = ""
description = "Optional ACM certificate ARN for enabling HTTPS on the ALB"
}
variable "allow_ssh_cidr" {
type = string
default = ""
description = "Optional CIDR block to allow SSH access (leave empty to disable)"
}
variable "project" {
type = string
default = "HUG-IB"
description = "Project name for resource naming"
}}
modules/compute/main.tf (key resources)
SGs:
lb_sg: allow 80/443 from0.0.0.0/0.app_sg: allowapp_portfromlb_sgonly.
ALB (
aws_lb) inpublic_subnet_ids; listeners 80 → target group; optional 443 with ACM cert.Target Group (
aws_lb_target_group) with health check/.IAM role for EC2 (SSM/logs).
Launch Template (Amazon Linux 2023; user_data installs your app).
ASG (
aws_autoscaling_group) inprivate_subnet_ids, attach to TG; health checkELB.Scaling policy (target tracking 50–60% CPU).
modules/compute/outputs.tf
output "alb_dns_name" {
description = "DNS name of the Application Load Balancer"
value = aws_lb.this.dns_name
}
output "alb_security_group_id" {
description = "Security group ID of the ALB"
value = aws_security_group.alb_sg.id
}
output "asg_name" {
description = "Name of the Auto Scaling Group"
value = aws_autoscaling_group.this.name
}
output "target_group_arn" {
description = "ARN of the Target Group"
value = aws_lb_target_group.this.arn
}
7) Module 3: Database (RDS MySQL)
modules/database/variables.tf
variable "vpc_cidr" {
description = "The VPC CIDR block"
type = string
}
variable "public_subnet_cidrs" {
description = "The public subnet CIDR blocks"
type = list(string)
}
variable "private_subnet_cidrs" {
description = "The private subnet CIDR blocks"
type = list(string)
}
variable "enable_nat_per_az" {
description = "Whether to create a NAT Gateway per AZ (true = HA, false = single NAT)"
type = bool
default = false
}
variable "tags" {
description = "Common tags to apply to resources"
type = map(string)
default = {
Environment = "dev"
project = "web-platform"
}
}
modules/database/main.tf
DB Subnet Group (use
private_subnet_ids)- tip: add
lifecycle { prevent_destroy = true }to avoid accidental deletes while DB exists.
- tip: add
DB SG allowing 3306 from
app_security_group_idonly.RDS Instance with encryption, backups, windows,
db_subnet_group_name, SGs.
RDS username rules:
1–16 chars, start with a letter, letters/numbers/underscore only.
Not reserved (
admin,root,mysql,rdsadmin, …).
modules/database/outputs.tf
output "db_endpoint" {
value = aws_db_instance.RDS_instance.endpoint
}
output "db_port" {
value = aws_db_instance.RDS_instance.port
}
output "db_sg_id" {
value = aws_security_group.db_sg.id
}
Environment Wiring (dev & prod)
Each environment has its own provider, module calls, and tfvars.
environments/dev/main.tf
terraform {
cloud {
organization = "MARY"
workspaces {
name = "aws_multi_tier_environment"
}
}
required_providers {
aws = {
source = "hashicorp/aws"
version = "6.10.0"
}
}
}
provider "aws" {
# Configuration options
region = var.region
}
module "compute" {
source = "../../modules/compute"
vpc_id = module.networking.vpc_id
private_subnet_ids = module.networking.private_subnet_ids
public_subnet_ids = module.networking.public_subnet_ids
app_port = 80
desired_capacity = 2
max_size = 3
min_size = 1
instance_type = "t3.micro"
tags = {
Environment = "development"
Project = "multi-tier-app"
}
}
module "networking" {
source = "../../modules/networking"
vpc_cidr = var.vpc_cidr
private_subnet_cidrs =var.private_subnet_cidrs
public_subnet_cidrs = var.public_subnet_cidrs
}
module "database" {
source = "../../modules/database"
vpc_id = module.networking.vpc_id
private_subnet_ids = module.networking.private_subnet_ids
db_engine = "mysql"
engine_version = "8.0"
instance_class = var.instance_class
allocated_storage = 20
multi_az = false
username = var.username
password = var.password
backup_retention_period = 1
deletion_protection = false
app_security_group_id = module.compute.alb_security_group_id
}
environments/dev/terraform.tfvars (example values)
aws_region = "us-east-1"
vpc_cidr = "10.0.0.0/16"
public_subnet_cidrs = ["10.0.1.0/24", "10.0.2.0/24"]
private_subnet_cidrs = ["10.0.101.0/24", "10.0.102.0/24"]
db_password = "ChangeMe_Dev_123!" # better: SSM/Secrets Manager
Create a similar prod/ with:
enable_nat_per_az = truemulti_az = truestronger instance classes & passwords
optional ACM certificate for HTTPS
Pro tip: Store DB creds in SSM/Secrets Manager, not tfvars. Use
data "aws_ssm_parameter"to fetch.
Apply the Stack
From the environment folder:
cd environments/dev
terraform init
terraform plan
terraform apply -auto-approve


Smoke Test
Find the ALB DNS name in outputs or AWS console.
Open
http://<alb_dns_name>— you should hit your app running on instances behind the ALB.RDS reachable only from app SG (no public access).
Common Errors & Fixes encountered
InvalidVpcID.NotFound
You passed a CIDR where a VPC ID is required. Useaws_vpc.main.idin subnets.DBSubnetGroupNotFoundFault
RDS is referencing a subnet group that doesn’t exist. Ensure youraws_db_subnet_groupis created first orterraform apply -target=...db_subnet_group.Cannot delete subnet group (in use)
Terraform wants to replace the DB subnet group while RDS uses it. Add:lifecycle { prevent_destroy = true }or import the existing resource.
Invalid master user name
RDS username must be 1–16 chars, start with a letter, only letters/numbers/underscore, not reserved (admin,root,mysql,rdsadmin, …).Module path errors / symlinks
Use correct relative paths:source = "../../modules/compute"fromenvironments/*.
Clean Up
cd environments/dev
terraform destroy -auto-approve
If Terraform tries to delete a DB subnet group before the DB, destroy the DB first:
terraform destroy -target=module.database.aws_db_instance.this -auto-approve
terraform destroy -auto-approve