Skip to main content

Command Palette

Search for a command to run...

Multi-Environment Web App Platform with Terraform (ALB + Auto-Scaling + RDS)

Updated
8 min read

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 dev and prod folders

  • Safety: 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_subnet public/private across AZs

  • aws_internet_gateway

  • aws_eip + aws_nat_gateway (1 per AZ if enable_nat_per_az, else single)

  • aws_route_table + aws_route_table_association for 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.names to 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 from 0.0.0.0/0.

    • app_sg: allow app_port from lb_sg only.

  • ALB (aws_lb) in public_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) in private_subnet_ids, attach to TG; health check ELB.

  • 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.
  • DB SG allowing 3306 from app_security_group_id only.

  • 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 = true

  • multi_az = true

  • stronger 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. Use aws_vpc.main.id in subnets.

  • DBSubnetGroupNotFoundFault
    RDS is referencing a subnet group that doesn’t exist. Ensure your aws_db_subnet_group is created first or terraform 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" from environments/*.


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