$ cat aws-with-terraform/part-03.md
Advanced Load Balancing on AWS: HTTP Headers, Query Strings, and Network Load Balancer with Auto Scaling

Why I Built These Projects
After basic ALB routing, I wanted to explore the really advanced stuff the kind of routing that powers modern SaaS applications and high-performance systems. I built two projects:
- Advanced ALB Routing – HTTP headers, query string redirects, and complex routing logic
- Network Load Balancer with Auto Scaling – Layer 4 load balancing with dynamic instance scaling
These projects taught me how production systems handle complex traffic patterns and scale automatically under load.
Project 1: Advanced ALB Routing with HTTP Headers
What Makes This Advanced?
Basic ALB routes by path (/app1) or host (app1.domain.com). Advanced ALB can route based on:
- HTTP headers – Custom headers in requests
- Query strings – URL parameters like ?website=aws-eks
- HTTP header redirects – Redirect specific domains to external URLs
- Multiple conditions – Combine different routing rules
Real-World Use Cases
HTTP Header Routing:
Mobile app sends: Header: “User-Agent: MyApp/iOS/1.0”
→ Routes to mobile-optimized backend
API client sends: Header: “API-Version: v2”
→ Routes to v2 API servers
Query String Redirects:
Old marketing URL: example.com?campaign=summer-sale
→ Redirects to: marketing.example.com/summer-sale
Documentation link: docs.com?topic=kubernetes
→ Redirects to: docs.com/kubernetes/
Technical Implementation – Advanced ALB
1. HTTP Header-Based Routing
This is my actual configuration for routing based on custom HTTP headers:
rules = {
# Rule 1: Route App1 based on custom header
myapp1-rule = {
actions = [{
type = "weighted-forward"
target_groups = [
{
target_group_key = "mytg1"
weight = 1
}
]
stickiness = {
enabled = true
duration = 3600
}
}]
conditions = [{
http_header = {
http_header_name = "custom-header"
values = ["app-1", "app1", "my-app-1"]
}
}]
}How HTTP header routing works:
Request with header:
GET / HTTP/1.1
Host: myapps11.rezaops.com
custom-header: app-1ALB checks:
- Does request have custom-header? YES
- Is value app-1, app1, or my-app-1? YES
- Route to mytg1 (App1 target group)
Request without header or different value:
GET / HTTP/1.1
Host: myapps11.rezaops.com→ Falls through to default action (fixed response)
Multiple header values:
values = ["app-1", "app1", "my-app-1"]ALB accepts ANY of these values. This is useful for:
- Different naming conventions: app-1 vs app1
- Legacy compatibility: old clients use my-app-1, new use app1
- API versioning: v1, version-1, api-v1
Testing HTTP header routing:
# Route to App1
curl -H "custom-header: app-1" https://myapps11.rezaops.com
# Route to App2
curl -H "custom-header: app-2" https://myapps11.rezaops.com
# No header - fixed response
curl https://myapps11.rezaops.com2. Query String Redirect Rule
Redirect based on URL query parameters:
my-redirect-query = {
priority = 3
actions = [{
type = "redirect"
status_code = "HTTP_302"
host = "stacksimplify.com"
path = "/aws-eks/"
query = "" # Clear original query string
protocol = "HTTPS"
}]
conditions = [{
query_string = {
key = "website"
value = "aws-eks"
}
}]
}What happens step by step:
User visits: https://myapps11.rezaops.com?website=aws-eks
- ALB receives request
- Checks query string: website=aws-eks
- Matches condition ✓
- Returns HTTP 302 redirect:
HTTP/1.1 302 Found
Location: https://stacksimplify.com/aws-eks/- Browser automatically follows redirect
- User ends up at external website
Why HTTP 302 instead of 301?
302 = Temporary Redirect
- Browser doesn’t cache
- Can change redirect destination later
- Analytics track each redirect
- Used for: campaigns, tracking, temporary moves
301 = Permanent Redirect
- Browser caches forever
- SEO passes page rank
- Can’t easily change later
- Used for: permanent URL changes
Clearing query string:
query = ""Original URL: myapps11.rezaops.com?website=aws-eks&utm=source Redirect to: stacksimplify.com/aws-eks/ (no query string)
To keep query string:
query = "#{query_string}" # Pass through original3. Host Header Redirect Rule
Redirect entire domains to external URLs:
my-redirect-hh = {
priority = 4
actions = [{
type = "redirect"
status_code = "HTTP_302"
host = "stacksimplify.com"
path = "/azure-aks/azure-kubernetes-service-introduction/"
query = ""
protocol = "HTTPS"
}]
conditions = [{
host_header = {
values = ["azure-aks11.rezaops.com"]
}
}]
}Use case: Domain migration
Old domain: azure-aks11.rezaops.com New domain: stacksimplify.com/azure-aks/…
Any request to old domain redirects to new location:
https://azure-aks11.rezaops.com
→ https://stacksimplify.com/azure-aks/azure-kubernetes-service-introduction/
https://azure-aks11.rezaops.com/docs
→ https://stacksimplify.com/azure-aks/azure-kubernetes-service-introduction/
https://azure-aks11.rezaops.com/anything
→ https://stacksimplify.com/azure-aks/azure-kubernetes-service-introduction/
Notice: ALL paths redirect to same destination. To preserve paths:
path = "/azure-aks#{path}" # Appends original path4. Rule Priority and Evaluation Order
myapp1-rule = { } # Priority 1 (automatic)
myapp2-rule = { } # Priority 2 (automatic)
my-redirect-query = {
priority = 3 # Explicit priority
}
my-redirect-hh = {
priority = 4
}How ALB evaluates rules:
- Starts with priority 1
- Checks conditions
- If match → Execute action, STOP
- If no match → Try priority 2
- Continue until match or reach default action
Example request flow:
Request: GET /?website=aws-eks HTTP/1.1
- Priority 1 (HTTP header): No custom-header → SKIP
- Priority 2 (HTTP header): No custom-header → SKIP
- Priority 3 (Query string): Has website=aws-eks → MATCH! Redirect to stacksimplify.com
Request: GET / HTTP/1.1 with custom-header: app-1
- Priority 1 (HTTP header): Has custom-header: app-1 → MATCH! Forward to App1
- (Rules 2, 3, 4 never evaluated)
5. DNS Configuration for Multiple Domains
I create Route53 records for different routing scenarios:
# Main domain for header-based routing
resource "aws_route53_record" "default_dns" {
zone_id = data.aws_route53_zone.mydomain.zone_id
name = "myapps11.rezaops.com"
type = "A"
alias {
name = module.alb.dns_name
zone_id = module.alb.zone_id
evaluate_target_health = true
}
}
# Domain for host header redirect
resource "aws_route53_record" "app1_dns" {
zone_id = data.aws_route53_zone.mydomain.zone_id
name = "azure-aks11.rezaops.com"
type = "A"
alias {
name = module.alb.dns_name
zone_id = module.alb.zone_id
evaluate_target_health = true
}
}Both domains point to SAME ALB! The ALB decides what to do based on Host header:
myapps11.rezaops.com → Check for custom headers or query strings
azure-aks11.rezaops.com → Redirect to stacksimplify.com
Project 2: Network Load Balancer with Auto Scaling
NLB vs ALB – When to Use Each
Application Load Balancer (Layer 7):
- HTTP/HTTPS only
- Content-based routing (paths, headers, cookies)
- SSL termination
- WebSocket support
- Slower (parses HTTP)
Network Load Balancer (Layer 4):
- TCP/UDP/TLS protocols
- Ultra-low latency (~100 microseconds)
- Millions of requests per second
- Static IP addresses
- Preserves source IP
- No content inspection
When I use NLB:
- Gaming servers (low latency critical)
- IoT applications (millions of devices)
- TCP-based protocols (non-HTTP)
- Need static IP for whitelisting
- Extreme performance requirements
Technical Implementation – NLB
1. NLB Configuration
module "nlb" {
source = "terraform-aws-modules/alb/aws"
version = "9.11.0"
name = "mynlb"
load_balancer_type = "network" # KEY DIFFERENCE
vpc_id = module.vpc.vpc_id
subnets = module.vpc.public_subnets
dns_record_client_routing_policy = "availability_zone_affinity"
security_groups = [module.loadbalancer_sg.security_group_id]
enable_deletion_protection = falseKey parameter: dns_record_client_routing_policy
dns_record_client_routing_policy = "availability_zone_affinity"This means:
Client in us-east-1a → Connects to NLB node in us-east-1a
Client in us-east-1b → Connects to NLB node in us-east-1b
Benefits:
- Lower latency (same AZ = faster)
- Lower data transfer costs (no cross-AZ charges)
- Better performance for latency-sensitive apps
Alternative value:
dns_record_client_routing_policy = "any_availability_zone"Distributes traffic evenly, ignoring client location.
2. TCP and TLS Listeners
listeners = {
# Listener 1: Plain TCP
my-tcp = {
port = 80
protocol = "TCP"
forward = {
target_group_key = "mytg1"
}
}
# Listener 2: TLS (encrypted TCP)
my-tls = {
port = 443
protocol = "TLS"
certificate_arn = module.acm.acm_certificate_arn
forward = {
target_group_key = "mytg1"
}
}
}TCP vs TLS listeners:
TCP (port 80):
- No encryption
- Load balancer passes packets directly
- Client connects directly to backend instance
- Fastest (no decryption overhead)
TLS (port 443):
- Load balancer terminates SSL/TLS
- Decrypts traffic at NLB
- Forwards plain HTTP to backends
- Client sees encrypted connection
- Backend sees plain HTTP
Traffic flow comparison:
ALB with HTTPS:
Client --HTTPS--> ALB --HTTP--> Instance
(decrypts here)NLB with TLS:
Client --TLS--> NLB --TCP--> Instance
(decrypts here)NLB with TCP passthrough:
Client --TCP--> NLB --TCP--> Instance
(no encryption, pure speed)3. Auto Scaling Group with Launch Template
Launch template defines what instances look like:
resource "aws_launch_template" "my_launch_template" {
name = "my_launch_template"
description = "My launch template"
image_id = data.aws_ami.amzlinux2.id
instance_type = var.instance_type
vpc_security_group_ids = [module.public_bastion_sg.security_group_id]
key_name = var.instance_keypair
user_data = filebase64("${path.module}/app1-install.sh")
ebs_optimized = true
update_default_version = true
block_device_mappings {
device_name = "/dev/sda1"
ebs {
volume_size = 20
volume_type = "gp2"
delete_on_termination = true
}
}
monitoring {
enabled = true # Detailed CloudWatch monitoring
}
tag_specifications {
resource_type = "instance"
tags = {
Name = "myasg"
}
}
}ebs_optimized = true
- Dedicated bandwidth for EBS operations
- Better disk I/O performance
- Recommended for production
update_default_version = true
- New version becomes default automatically
- ASG uses latest version for new instances
- Old instances keep old version until replaced
Monitoring enabled:
- 1-minute metrics instead of 5-minute
- More granular CloudWatch data
- Costs extra ($2.10/instance/month)
- Essential for auto scaling responsiveness
4. Auto Scaling Group Configuration
resource "aws_autoscaling_group" "my_asg" {
name_prefix = "myasg_"
desired_capacity = 2
max_size = 5
min_size = 2
vpc_zone_identifier = module.vpc.public_subnets
target_group_arns = [module.nlb.target_groups["mytg1"].arn]
health_check_type = "EC2"
launch_template {
id = aws_launch_template.my_launch_template.id
version = aws_launch_template.my_launch_template.latest_version
}
instance_refresh {
strategy = "Rolling"
preferences {
min_healthy_percentage = 50
}
triggers = [ "desired_capacity" ]
}
tag {
key = "Owners"
value = "Web-Team"
propagate_at_launch = true
}
}Instance refresh – Rolling updates:
instance_refresh {
strategy = "Rolling"
preferences {
min_healthy_percentage = 50
}
}What happens when I update launch template:
Without instance refresh:
- Old instances keep running
- New instances use new template
- Mixed versions in ASG
With instance refresh (rolling):
- ASG has 4 instances
- Terminates 2 instances (50%)
- Launches 2 new instances with new template
- Waits for health checks
- Terminates remaining 2 old instances
- Launches 2 more new instances
- All instances now use new template
min_healthy_percentage = 50:
- Always keeps 50% instances running
- Lower = faster updates, more risk
- Higher = slower updates, safer
For production:
min_healthy_percentage = 90 # Keep 90% healthyhealth_check_type = “EC2”
Options:
- EC2 – Checks if instance is running
- ELB – Checks load balancer health checks
I use EC2 for simplicity. For production, use ELB for better reliability.
5. Target Tracking Scaling Policy
This is the magic that makes auto scaling work:
resource "aws_autoscaling_policy" "avg_cpu_policy_greater_than_xx" {
name = "avg-cpu-policy-greater-than-xx"
policy_type = "TargetTrackingScaling"
autoscaling_group_name = aws_autoscaling_group.my_asg.id
estimated_instance_warmup = 180 # 3 minutes
target_tracking_configuration {
predefined_metric_specification {
predefined_metric_type = "ASGAverageCPUUtilization"
}
target_value = 50.0 # Keep CPU at 50%
}
}How target tracking works:
Current state: 2 instances, average CPU = 30%
- Below target (50%)
- No action needed
Traffic increases: 2 instances, average CPU = 70%
- Above target!
- ASG: “I need more instances to bring average down to 50%”
- Launches 1 more instance
- Now: 3 instances, average CPU = 47% ✓
Traffic increases more: 3 instances, average CPU = 65%
- Still above target
- Launches 1 more instance
- Now: 4 instances, average CPU = 49% ✓
Traffic decreases: 4 instances, average CPU = 30%
- Below target
- ASG: “I have too many instances”
- Terminates 1 instance
- Now: 3 instances, average CPU = 40% ✓
estimated_instance_warmup = 180
When new instance launches:
- Takes ~60 seconds to boot
- Another ~60 seconds to install Apache
- Another ~30 seconds to get first traffic
- Total: ~150 seconds to be “production ready”
During warmup period:
- Instance doesn’t count toward CPU average
- Prevents premature scaling decisions
- Avoids scaling thrashing
Alternative predefined metrics:
# Request count per target
predefined_metric_type = "ALBRequestCountPerTarget"
target_value = 1000 # 1000 requests per instance
# Network bytes in
predefined_metric_type = "ASGAverageNetworkIn"
target_value = 10000000 # 10MB/s
# Network bytes out
predefined_metric_type = "ASGAverageNetworkOut"
target_value = 20000000 # 20MB/s6. Scheduled Scaling Actions
Scale based on predictable patterns:
# Scale up at 7 AM UTC
resource "aws_autoscaling_schedule" "increase_capacity_7am" {
scheduled_action_name = "increase-capacity-7am"
min_size = 2
max_size = 10
desired_capacity = 8
start_time = "2030-03-30T11:00:00Z"
recurrence = "00 09 * * *" # Cron: 9 AM UTC daily
autoscaling_group_name = aws_autoscaling_group.my_asg.id
}
# Scale down at 9 PM UTC
resource "aws_autoscaling_schedule" "decrease_capacity_5pm" {
scheduled_action_name = "decrease-capacity-5pm"
min_size = 2
max_size = 10
desired_capacity = 2
start_time = "2030-03-30T21:00:00Z"
recurrence = "00 21 * * *" # Cron: 9 PM UTC daily
autoscaling_group_name = aws_autoscaling_group.my_asg.id
}Cron syntax explained:
00 09 * * *
│ │ │ │ │
│ │ │ │ └─ Day of week (0-7, 0=Sunday)
│ │ │ └─── Month (1-12)
│ │ └───── Day of month (1-31)
│ └──────── Hour (0-23)
└─────────── Minute (0-59)Examples:
00 09 * * * # Every day at 9 AM
00 09 * * 1-5 # Weekdays at 9 AM
00 */4 * * * # Every 4 hours
30 08 1 * * # First day of month at 8:30 AM
00 18 * * 5 # Every Friday at 6 PMReal-world scheduling patterns:
E-commerce site:
# Black Friday preparation
recurrence = "00 06 25 11 *" # Nov 25 at 6 AM
desired_capacity = 20
# Post-Christmas scale down
recurrence = "00 00 27 12 *" # Dec 27 at midnight
desired_capacity = 5Business hours scaling:
# Monday-Friday 8 AM
recurrence = "00 08 * * 1-5"
desired_capacity = 10
# Monday-Friday 6 PM
recurrence = "00 18 * * 1-5"
desired_capacity = 2scheduled vs target tracking:
Both can run simultaneously!
- Scheduled: Sets capacity at specific times
- Target tracking: Adjusts capacity based on metrics
Example scenario:
7 AM: Scheduled action sets desired=8
8 AM: Traffic spike, CPU>50%, target tracking adds 2 more → 10 instances
9 AM: Traffic normal, CPU<50%, target tracking removes 2 → 8 instances
9 PM: Scheduled action sets desired=2
7. SNS Notifications for Scaling Events
Get email alerts when ASG scales:
# Create SNS topic
resource "aws_sns_topic" "myasg_sns_topic" {
name = "myasg-sns-topic-${random_pet.this.id}"
}
# Subscribe email
resource "aws_sns_topic_subscription" "myasg_sns_topic_subscription" {
topic_arn = aws_sns_topic.myasg_sns_topic.arn
protocol = "email"
endpoint = "rezachegini1994@gmail.com"
}
# Configure notifications
resource "aws_autoscaling_notification" "myasg_notifications" {
group_names = [aws_autoscaling_group.my_asg.id]
notifications = [
"autoscaling:EC2_INSTANCE_LAUNCH",
"autoscaling:EC2_INSTANCE_TERMINATE",
"autoscaling:EC2_INSTANCE_LAUNCH_ERROR",
"autoscaling:EC2_INSTANCE_TERMINATE_ERROR",
]
topic_arn = aws_sns_topic.myasg_sns_topic.arn
}Email notifications I receive:
Instance launch:
Event: autoscaling:EC2_INSTANCE_LAUNCH
Time: 2025-01-15 10:30:00
Group: myasg_20250115
Cause: Target tracking increased capacity
Instance: i-0abcd1234efgh5678Instance terminate:
Event: autoscaling:EC2_INSTANCE_TERMINATE
Time: 2025-01-15 22:15:00
Group: myasg_20250115
Cause: Scheduled action decreased capacity
Instance: i-0abcd1234efgh5678Launch error:
Event: autoscaling:EC2_INSTANCE_LAUNCH_ERROR
Time: 2025-01-15 15:45:00
Group: myasg_20250115
Cause: Insufficient capacity in AZ
StatusMessage: We currently do not have sufficient t2.micro capacityrandom_pet for unique names:
resource "random_pet" "this" {
length = 2
}
# Generates: "mighty-panda", "happy-tiger", etc.I use this to avoid SNS topic name conflicts when deploying multiple times.
Comparing Load Balancer Types
Application Load Balancer (ALB)
- OSI Layer: Layer 7 (Application)
- Protocols: HTTP, HTTPS, WebSocket
- Latency: ~10–20 ms
- Throughput: ~100K requests/sec
- Routing: Path, host header, query string
- SSL Termination: Yes
- Static IP: No (DNS-based)
- Client IP: Lost (forwarded via headers)
- Health Checks: HTTP-based
- Best for: Web apps, APIs, microservices
Network Load Balancer (NLB)
- OSI Layer: Layer 4 (Transport)
- Protocols: TCP, UDP, TLS
- Latency: ~100 microseconds
- Throughput: Millions of requests/sec
- Routing: IP + Port
- SSL Termination: Yes (TLS listeners)
- Static IP: Yes (per AZ)
- Client IP: Preserved
- Health Checks: TCP-based
- Best for: Gaming, IoT, real-time & non-HTTP workloads
Real-World Lessons
1. HTTP Header Case Sensitivity
My mistake:
http_header_name = "Custom-Header"Tested with:
curl -H "custom-header: app-1" https://myapps11.rezaops.comResult: Didn’t work!
Why: HTTP headers are case-insensitive in HTTP spec, but ALB conditions are case-sensitive!
Solution:
http_header_name = "custom-header" # LowercaseOr configure ALB to normalize headers (not available in Terraform module).
2. Query String Encoding
Problem: URL with spaces: https://myapps11.rezaops.com?website=aws eks
Didn’t match:
query_string = {
key = "website"
value = "aws eks"
}Reason: Browsers URL-encode spaces: aws%20eks
Solution:
value = "aws%20eks" # URL-encodedOr use wildcards:
value = "aws*" # Matches "aws eks", "aws-eks", "aws_eks"3. Auto Scaling Cooldown vs Warmup
I confused these two concepts:
Cooldown (old, don’t use):
- Wait period after scaling action
- Blocks ALL scaling during cooldown
- Too rigid for modern applications
Warmup (modern, use this):
estimated_instance_warmup = 180- Excludes new instances from metrics temporarily
- Doesn’t block other scaling actions
- Better for dynamic workloads
Target tracking uses warmup automatically. Only set cooldown for simple scaling policies.
4. ASG Instance Refresh Failed
Error:
Instance refresh failed: MinHealthyPercentage requirement not metWhat happened:
- Tried to refresh 4 instances
- min_healthy_percentage = 75
- Killed 1 instance (3 left = 75%)
- New instance took 5 minutes to be healthy
- During that time, only 3 healthy (75%)
- Tried to kill another → Would drop to 2 (50%)
- Refused!
Solution:
min_healthy_percentage = 50 # More flexibleOr increase instance count before refresh:
# Scale up first
aws autoscaling set-desired-capacity --auto-scaling-group-name myasg --desired-capacity 6
# Then refresh
aws autoscaling start-instance-refresh --auto-scaling-group-name myasg5. NLB Preserves Source IP – Security Group Issues
Problem: Private instance security group:
ingress_cidr_blocks = [module.vpc.vpc_cidr_block] # 10.0.0.0/16Connections from internet failed health checks!
Why: NLB preserves source IP. Client IP is public (not from VPC).
Solution for NLB:
ingress_cidr_blocks = ["0.0.0.0/0"] # Allow from anywhereOr use ALB which rewrites source IP to ALB’s IP (inside VPC).
6. SNS Email Subscription Requires Confirmation
What happened: Deployed infrastructure, never got emails!
Why: SNS requires email confirmation:
- Terraform creates subscription
- AWS sends confirmation email
- Must click “Confirm subscription”
- Then emails start working
Can’t automate this for security.
Check spam folder for confirmation email!
7. Launch Template Version Management
Problem: Updated launch template, but ASG used old version!
Cause:
version = "$Latest" # Wrong!This doesn’t auto-update. Terraform doesn’t know to trigger refresh.
Solution:
version = aws_launch_template.my_launch_template.latest_versionPlus:
update_default_version = true # In launch templateNow when template changes, ASG knows to refresh.
Cost Comparison
Advanced ALB Routing:
Same as basic ALB:
- ALB: $22.50/month
- LCU: ~$5-10/month
- ACM: Free
- Route53: $0.50/month
- EC2 (4 instances): $28/month
- Total: ~$56-61/month
NLB with Auto Scaling:
- NLB: $22.50/month
- NLCU: ~$5-15/month (depends on traffic)
- EC2 (2-5 instances): $14-35/month (dynamic)
- CloudWatch: $2/month (detailed monitoring)
- SNS: $0.50/month
- Total: ~$44-75/month (varies with load)
Cost optimization for ASG: Set aggressive scale-down:
min_size = 1 # Scale to 1 at night
recurrence = "00 22 * * *" # 10 PM scale down
desired_capacity = 1Deployment Commands
Advanced ALB:
cd ALB-HTTPHeader-QueryString-Redirects/terraform-manifests
terraform init
terraform apply
# Test HTTP header routing
curl -H "custom-header: app-1" https://myapps11.rezaops.com
# Test query string redirect
curl -I "https://myapps11.rezaops.com?website=aws-eks"
# Test host header redirect
curl -I "https://azure-aks11.rezaops.com"NLB with Auto Scaling:
cd AWS-NLB-Network-Load-Balancer/terraform-manifests
terraform init
terraform apply
# Confirm SNS email subscription
# Check email and click confirmation link
# Test NLB
curl http://nlb.rezaops.com
curl https://nlb.rezaops.com
# Watch auto scaling
aws autoscaling describe-auto-scaling-groups \
--auto-scaling-group-names myasg_xxxxx
# Trigger scaling by generating load
ab -n 100000 -c 100 http://nlb.rezaops.com/What I Learned
- ALB routing is incredibly flexible – Can route on almost any HTTP attribute
- NLB is for performance – When latency matters more than features
- Auto Scaling is not magic – Requires careful tuning of thresholds and warmup times
- Scheduled scaling saves money – Scale down during off-hours
- Target tracking is powerful – Set target CPU and forget about it
- Instance refresh is crucial – For zero-downtime deployments
- Monitoring is essential – SNS alerts help catch issues early