Managing AWS Autoscaling Groups and Load Balancers using Terraform

Using Terraform to manage AWS Autoscaling Groups and Load Balancers

This lab builds on my first AWS/Terraform example: two subnets, an Application Load Balancer (ALB), and an Auto Scaling Group (ASG) that launches web servers in two Availability Zones.

October 2026 revision: the original subnets were outside the declared VPC CIDR. This version derives them from the VPC, uses a Launch Template instead of a Launch Configuration, and restricts instance HTTP ingress to the ALB security group. AWS recommends migrating to Launch Templates; new accounts cannot create launch configurations.

Scope and prerequisites

This is an HTTP laboratory serving a static, non-sensitive page. It creates billable EC2, ALB and public IPv4 resources. The instances use public subnets for outbound package installation without a NAT gateway, but accept HTTP only from the ALB. There is no SSH ingress or embedded key pair.

For production, design private application subnets, outbound connectivity, HTTPS with an ACM certificate, logging, access management and an application-specific scaling signal. Two Availability Zones provide placement options; actual availability still depends on healthy capacity, application behavior and dependencies.

Use Terraform 1.9 or newer and the AWS provider 6.70.0. Supply an Amazon Linux 2023 x86_64 AMI ID for your selected region. The region must expose at least two usable Availability Zones. Do not substitute an Ubuntu AMI: the bootstrap uses dnf and httpd.

Authenticate using the AWS provider’s normal credential chain, such as an AWS SSO profile. Keep credentials outside the configuration:

Source codebash
1
2
aws sso login --profile sandbox
export AWS_PROFILE=sandbox

The environment-variable syntax above is for Bash. In PowerShell, use $env:AWS_PROFILE = "sandbox".

Complete configuration

Download main.tf into an empty lab directory. terraform init installs the pinned provider and writes .terraform.lock.hcl; commit that lockfile in your lab repository to retain provider checksums.

main.tfhcl · 197 linesDownload
Expand sourceCollapse source
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
terraform {
  required_version = ">= 1.9, < 2.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "6.70.0"
    }
  }
}

provider "aws" {
  region = var.region
}

variable "region" {
  type    = string
  default = "us-east-1"
}

variable "ami_id" {
  description = "An Amazon Linux 2023 x86_64 AMI available in the selected region."
  type        = string
  validation {
    condition     = can(regex("^ami-[0-9a-f]+$", var.ami_id))
    error_message = "Provide an AMI ID for the selected region."
  }
}

data "aws_availability_zones" "available" {
  state = "available"
}

resource "aws_vpc" "lab" {
  cidr_block           = "10.10.0.0/16"
  enable_dns_support   = true
  enable_dns_hostnames = true
  tags                 = { Name = "asg-lab" }
}

resource "aws_subnet" "public" {
  count                   = 2
  vpc_id                  = aws_vpc.lab.id
  cidr_block              = cidrsubnet(aws_vpc.lab.cidr_block, 8, count.index + 1)
  availability_zone       = data.aws_availability_zones.available.names[count.index]
  map_public_ip_on_launch = true
}

resource "aws_internet_gateway" "lab" {
  vpc_id = aws_vpc.lab.id
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.lab.id
  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.lab.id
  }
}

resource "aws_route_table_association" "public" {
  count          = 2
  subnet_id      = aws_subnet.public[count.index].id
  route_table_id = aws_route_table.public.id
}

resource "aws_security_group" "alb" {
  name_prefix = "asg-lab-alb-"
  vpc_id      = aws_vpc.lab.id
  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

resource "aws_security_group" "web" {
  name_prefix = "asg-lab-web-"
  vpc_id      = aws_vpc.lab.id
  ingress {
    from_port       = 80
    to_port         = 80
    protocol        = "tcp"
    security_groups = [aws_security_group.alb.id]
  }
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

resource "aws_lb" "web" {
  name               = "asg-lab-web"
  load_balancer_type = "application"
  internal           = false
  security_groups    = [aws_security_group.alb.id]
  subnets            = aws_subnet.public[*].id
}

resource "aws_lb_target_group" "web" {
  name     = "asg-lab-web"
  port     = 80
  protocol = "HTTP"
  vpc_id   = aws_vpc.lab.id
  health_check {
    path    = "/"
    matcher = "200"
  }
}

resource "aws_lb_listener" "http" {
  load_balancer_arn = aws_lb.web.arn
  port              = 80
  protocol          = "HTTP"
  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.web.arn
  }
}

resource "aws_launch_template" "web" {
  name_prefix            = "asg-lab-web-"
  image_id               = var.ami_id
  instance_type          = "t3.micro"
  vpc_security_group_ids = [aws_security_group.web.id]
  metadata_options {
    http_endpoint = "enabled"
    http_tokens   = "required"
  }
  user_data = base64encode(<<-USERDATA
    #!/bin/bash
    set -euo pipefail
    dnf install -y httpd
    printf 'ASG lab: %s\n' "$(hostname)" > /var/www/html/index.html
    systemctl enable --now httpd
  USERDATA
  )
}

resource "aws_autoscaling_group" "web" {
  name                      = "asg-lab-web"
  min_size                  = 2
  desired_capacity          = 2
  max_size                  = 4
  health_check_type         = "ELB"
  health_check_grace_period = 300
  default_instance_warmup   = 120
  vpc_zone_identifier       = aws_subnet.public[*].id
  target_group_arns         = [aws_lb_target_group.web.arn]

  launch_template {
    id      = aws_launch_template.web.id
    version = tostring(aws_launch_template.web.latest_version)
  }

  instance_refresh {
    strategy = "Rolling"
    preferences {
      min_healthy_percentage = 100
      max_healthy_percentage = 200
    }
  }

  lifecycle {
    ignore_changes = [desired_capacity]
  }
  tag {
    key                 = "Name"
    value               = "asg-lab-web"
    propagate_at_launch = true
  }
  depends_on = [aws_route_table_association.public, aws_lb_listener.http]
}

resource "aws_autoscaling_policy" "cpu" {
  name                   = "asg-lab-cpu"
  policy_type            = "TargetTrackingScaling"
  autoscaling_group_name = aws_autoscaling_group.web.name
  target_tracking_configuration {
    predefined_metric_specification {
      predefined_metric_type = "ASGAverageCPUUtilization"
    }
    target_value = 50
  }
}

output "lab_url" {
  value = "http://${aws_lb.web.dns_name}"
}

How the pieces fit

The VPC is 10.10.0.0/16. cidrsubnet derives 10.10.1.0/24 and 10.10.2.0/24, keeping both subnets inside it. A default route through the internet gateway supplies the lab’s outbound connectivity.

The ALB accepts HTTP and forwards to its target group. The ASG registers its instances with that target group; the instance security group accepts port 80 only from the ALB security group. User data installs Apache and writes the hostname into the page. IMDSv2 is required, although this bootstrap does not need to query instance metadata.

The ASG starts with two instances and a range of two to four. Target tracking adjusts capacity toward 50% average CPU utilization and manages its own alarms. ignore_changes = [desired_capacity] assigns running capacity to autoscaling after initial creation; Terraform still manages the minimum and maximum bounds.

The ASG references a numeric Launch Template version, so changing the template is visible to Terraform and can trigger an instance refresh. Refresh preferences retain healthy capacity while replacements start. This requires spare capacity and sufficient quotas; it is not a guarantee of uninterrupted application service. Check refresh status and application traffic during any rollout.

Validate, plan and verify

Source codebash
1
2
3
4
terraform init
terraform fmt -check
terraform validate
terraform plan -var="ami_id=ami-REPLACE_WITH_YOUR_AL2023_AMI" -out=lab.tfplan

Replace the illustrative AMI ID before planning. Review account, region, subnet placement, instance counts and costs in the saved plan. To provision this lab after that review:

Source codebash
1
2
terraform apply lab.tfplan
terraform output -raw lab_url

Check target-group health, fetch the page through the ALB, and observe ASG activity and CloudWatch metrics before assessing scaling. A format/schema check cannot prove that an AMI boots or that your account has sufficient quota. The configuration passed terraform validate with Terraform 1.9.8 and AWS provider 6.70.0. It was not deployed into an AWS account.

Clean up when finished:

Source codebash
1
2
terraform plan -destroy -var="ami_id=ami-REPLACE_WITH_YOUR_AL2023_AMI" -out=destroy.tfplan
terraform apply destroy.tfplan

Keep state and plan files private: they may contain sensitive infrastructure information. Review the destroy plan just as carefully as the creation plan.

References