AWS Builder Center
Stop Creating an ALB for Every Service: Master Multi-Service Routing and Save Thousands with Terraform ⚡

Stop Creating an ALB for Every Service: Master Multi-Service Routing and Save Thousands with Terraform ⚡

Each AWS ALB costs $16-22/month. Most teams create way too many. Here’s how to consolidate using host-based and path-based routing with Terraform.

Series: Save on AWS with Terraform (21 articles)

  1. …
  2. 7
    Stop Creating an ALB for Every Service: Master Multi-Service Routing and Save Thousands with Terraform ⚡ This article

Each AWS ALB costs $16-22/month. Most teams create way too many. Here’s how to consolidate using host-based and path-based routing with Terraform.

Let me guess: You have one Application Load Balancer (ALB) per service.
  • api-prod-alb
  • web-prod-alb
  • admin-prod-alb
  • blog-prod-alb
  • workers-prod-alb
  • … and 5 more
That’s 10 ALBs × $16/month = $160/month = $1,920/year just sitting there.
Here’s the kicker: You probably only need 1-2 ALBs total.
Let me show you how to consolidate with Terraform and cut your load balancer bill by 70%.

💸 The ALB Cost Breakdown

Each ALB costs:
  • $0.0225/hour (~$16.40/month) base charge
  • $0.008/LCU-hour for traffic (Load Balancer Capacity Units)
Typical setup (10 microservices):
1
2
3
4
5
10 ALBs × $16.40/month base = $164/month
LCU charges (varies) = $30-50/month
Total: ~$200/month

Annual cost: $2,400
Most of these ALBs are barely used. Your staging blog-alb might serve 100 requests/day but still costs $16/month.

🎯 The Solution: Path-Based & Host-Based Routing

ALBs support routing rules that let one ALB serve multiple services:
Path-based routing:
1
2
3
https://example.com/api/* → API service
https://example.com/admin/* → Admin service
https://example.com/blog/* → Blog service
Host-based routing:
1
2
3
https://api.example.com → API service
https://admin.example.com → Admin service
https://blog.example.com → Blog service
One ALB. Multiple services. Massive savings.

📊 Cost Comparison

Before Consolidation

1
2
3
4
5
6
10 ALBs (one per service):
- 10 × $16.40/month = $164/month
- LCU charges across 10 ALBs = $50/month
- Total: $214/month

Annual cost: $2,568

After Consolidation

1
2
3
4
5
6
7
2 ALBs (production + staging):
- 2 × $16.40/month = $32.80/month
- LCU charges (consolidated) = $35/month
- Total: $67.80/month

Annual cost: $813.60
Savings: $1,754.40/year (68% reduction!) 🎉

🛠️ Terraform Implementation

Path-Based Routing (Single Domain)

Perfect when all services are under one domain (e.g., example.com):
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
# modules/consolidated-alb/main.tf

resource "aws_lb" "main" {
name = "consolidated-alb"
internal = false
load_balancer_type = "application"
security_groups = [aws_security_group.alb.id]
subnets = var.public_subnet_ids

enable_deletion_protection = true
enable_http2 = true

tags = {
Name = "consolidated-alb"
}
}

# HTTPS listener with path-based routing
resource "aws_lb_listener" "https" {
load_balancer_arn = aws_lb.main.arn
port = "443"
protocol = "HTTPS"
ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
certificate_arn = var.certificate_arn

# Default action - return 404
default_action {
type = "fixed-response"
fixed_response {
content_type = "text/plain"
message_body = "Not Found"
status_code = "404"
}
}
}

# HTTP listener - redirect to HTTPS
resource "aws_lb_listener" "http" {
load_balancer_arn = aws_lb.main.arn
port = "80"
protocol = "HTTP"

default_action {
type = "redirect"
redirect {
port = "443"
protocol = "HTTPS"
status_code = "HTTP_301"
}
}
}

# API service (path: /api/*)
resource "aws_lb_target_group" "api" {
name = "api-tg"
port = 3000
protocol = "HTTP"
vpc_id = var.vpc_id

health_check {
path = "/api/health"
healthy_threshold = 2
unhealthy_threshold = 3
timeout = 5
interval = 30
}
}

resource "aws_lb_listener_rule" "api" {
listener_arn = aws_lb_listener.https.arn
priority = 100

action {
type = "forward"
target_group_arn = aws_lb_target_group.api.arn
}

condition {
path_pattern {
values = ["/api/*"]
}
}
}

# Admin service (path: /admin/*)
resource "aws_lb_target_group" "admin" {
name = "admin-tg"
port = 4000
protocol = "HTTP"
vpc_id = var.vpc_id

health_check {
path = "/admin/health"
}
}

resource "aws_lb_listener_rule" "admin" {
listener_arn = aws_lb_listener.https.arn
priority = 200

action {
type = "forward"
target_group_arn = aws_lb_target_group.admin.arn
}

condition {
path_pattern {
values = ["/admin/*"]
}
}
}

# Blog service (path: /blog/*)
resource "aws_lb_target_group" "blog" {
name = "blog-tg"
port = 8080
protocol = "HTTP"
vpc_id = var.vpc_id

health_check {
path = "/blog/health"
}
}

resource "aws_lb_listener_rule" "blog" {
listener_arn = aws_lb_listener.https.arn
priority = 300

action {
type = "forward"
target_group_arn = aws_lb_target_group.blog.arn
}

condition {
path_pattern {
values = ["/blog/*"]
}
}
}

# Web frontend (default - root path)
resource "aws_lb_target_group" "web" {
name = "web-tg"
port = 80
protocol = "HTTP"
vpc_id = var.vpc_id

health_check {
path = "/health"
}
}

resource "aws_lb_listener_rule" "web" {
listener_arn = aws_lb_listener.https.arn
priority = 1000 # Lower priority = later evaluation

action {
type = "forward"
target_group_arn = aws_lb_target_group.web.arn
}

condition {
path_pattern {
values = ["/*"] # Catch-all for everything else
}
}
}

Host-Based Routing (Multiple Subdomains)

Better for services with their own subdomains:
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
# host-based-alb.tf

resource "aws_lb" "multi_host" {
name = "multi-host-alb"
load_balancer_type = "application"
security_groups = [aws_security_group.alb.id]
subnets = var.public_subnet_ids

tags = { Name = "multi-host-alb" }
}

resource "aws_lb_listener" "https" {
load_balancer_arn = aws_lb.multi_host.arn
port = "443"
protocol = "HTTPS"
ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
certificate_arn = var.wildcard_certificate_arn # *.example.com

default_action {
type = "fixed-response"
fixed_response {
content_type = "text/plain"
message_body = "Not Found"
status_code = "404"
}
}
}

# api.example.com
resource "aws_lb_listener_rule" "api" {
listener_arn = aws_lb_listener.https.arn
priority = 100

action {
type = "forward"
target_group_arn = aws_lb_target_group.api.arn
}

condition {
host_header {
values = ["api.example.com"]
}
}
}

# admin.example.com
resource "aws_lb_listener_rule" "admin" {
listener_arn = aws_lb_listener.https.arn
priority = 200

action {
type = "forward"
target_group_arn = aws_lb_target_group.admin.arn
}

condition {
host_header {
values = ["admin.example.com"]
}
}
}

# blog.example.com
resource "aws_lb_listener_rule" "blog" {
listener_arn = aws_lb_listener.https.arn
priority = 300

action {
type = "forward"
target_group_arn = aws_lb_target_group.blog.arn
}

condition {
host_header {
values = ["blog.example.com"]
}
}
}

# www.example.com (main site)
resource "aws_lb_listener_rule" "web" {
listener_arn = aws_lb_listener.https.arn
priority = 400

action {
type = "forward"
target_group_arn = aws_lb_target_group.web.arn
}

condition {
host_header {
values = ["www.example.com", "example.com"]
}
}
}

Combined: Host + Path Routing (Most Flexible)

Mix both approaches for maximum flexibility:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# Combined routing - host + path
resource "aws_lb_listener_rule" "api_v2" {
listener_arn = aws_lb_listener.https.arn
priority = 150

action {
type = "forward"
target_group_arn = aws_lb_target_group.api_v2.arn
}

condition {
host_header {
values = ["api.example.com"]
}
}

condition {
path_pattern {
values = ["/v2/*"]
}
}
}

🎓 Consolidation Strategy

What to Consolidate

✅ Consolidate these:
  • Dev/staging/QA environments (low traffic)
  • Internal tools and dashboards
  • Microservices in the same application
  • Services with similar traffic patterns
❌ Keep separate ALBs for:
  • Production vs non-production (isolation)
  • Internet-facing vs internal services
  • Services with wildly different traffic (1M req/day vs 100 req/day)
  • Compliance-separated environments

Recommended Setup

For most teams:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1. Production ALB (internet-facing)
- api.example.com
- www.example.com
- admin.example.com

2. Internal ALB (private subnets)
- monitoring.internal
- logs.internal

3. Non-prod ALB (dev/staging/qa)
- dev.example.com
- staging.example.com

Total: 3 ALBs instead of 15+
Savings: 80% on ALB costs

💡 Pro Tips

1. Use Priority Wisely

Lower priority = evaluated first. Structure your rules:
1
2
3
Priority 100-500: Specific paths/hosts
Priority 500-900: General patterns
Priority 1000+: Catch-all defaults

2. Wildcard SSL Certificate

One *.example.com cert works for all subdomains:
1
2
3
4
5
6
resource "aws_acm_certificate" "wildcard" {
domain_name = "*.example.com"
validation_method = "DNS"

subject_alternative_names = ["example.com"] # Include root domain
}
Saves money vs per-service certificates.

3. Use Target Group Attributes

Fine-tune performance per service:
1
2
3
4
5
6
7
8
9
10
11
12
13
resource "aws_lb_target_group" "api" {
# ... other config ...

deregistration_delay = 30 # Faster deploys

stickiness {
type = "lb_cookie"
cookie_duration = 86400
enabled = true
}

load_balancing_algorithm_type = "least_outstanding_requests"
}

4. Monitor Per-Target Group

Even with one ALB, you can track each service separately:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
resource "aws_cloudwatch_metric_alarm" "api_5xx" {
alarm_name = "api-high-5xx-errors"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 2
metric_name = "HTTPCode_Target_5XX_Count"
namespace = "AWS/ApplicationELB"
period = 60
statistic = "Sum"
threshold = 10

dimensions = {
TargetGroup = aws_lb_target_group.api.arn_suffix
LoadBalancer = aws_lb.main.arn_suffix
}
}

⚠️ Common Gotchas

1. Path order matters
More specific paths MUST have lower priority numbers:
1
2
3
4
5
6
7
# WRONG - catch-all evaluated first
priority = 100: /*
priority = 200: /api/*

# RIGHT - specific first
priority = 100: /api/*
priority = 200: /*
2. Trailing slashes
/api ≠ /api/ in path patterns. Use both:
1
2
3
4
5
condition {
path_pattern {
values = ["/api", "/api/*"]
}
}
3. Health check paths
Each target group needs its own health check:
1
2
3
4
5
# DON'T use same path for all services
health_check { path = "/health" } # ❌

# DO use service-specific paths
health_check { path = "/api/health" } # ✅
4. Target registration
Targets register to target groups, not ALBs:
1
2
3
4
5
resource "aws_lb_target_group_attachment" "api" {
target_group_arn = aws_lb_target_group.api.arn
target_id = aws_instance.api.id
port = 3000
}

🚀 Migration Checklist

Step 1: Audit existing ALBs
1
2
3
4
5
aws elbv2 describe-load-balancers \
--query 'LoadBalancers[?Type==`application`].[LoadBalancerName,DNSName]' \
--output table

# Count them - if > 5, you can probably consolidate
Step 2: Plan consolidation
1
2
3
4
Group services by:
- Environment (prod/staging/dev)
- Network (public/private)
- Traffic pattern (high/low)
Step 3: Deploy consolidated ALB
1
terraform apply -target=module.consolidated_alb
Step 4: Test with one service
1
2
3
# Update DNS for one service to new ALB
# Test thoroughly before migrating others
curl -I https://api.example.com/health
Step 5: Migrate remaining services
1
2
3
# Update DNS records one by one
# Wait for old ALB traffic to drain
# Delete old ALBs
Step 6: Celebrate savings 🎉

📊 Real-World Example

Before consolidation:
1
2
3
4
5
6
7
15 ALBs across environments:
- 5 production services = 5 ALBs
- 5 staging services = 5 ALBs
- 5 dev services = 5 ALBs

Cost: 15 × $16.40 = $246/month
Annual: $2,952
After consolidation:
1
2
3
4
5
6
7
8
3 ALBs total:
- 1 production (multi-service) = $16.40
- 1 staging (multi-service) = $16.40
- 1 dev (multi-service) = $16.40

Cost: 3 × $16.40 = $49.20/month
Annual: $590.40
Savings: $2,361.60/year (80% reduction!) 💰

🎯 Summary

SetupMonthly CostAnnual Cost
10 ALBs (1 per service)$214$2,568
2 ALBs (consolidated)$68$816
Savings$146$1,752
Key takeaways:
✅ Most teams over-provision ALBs (one per service)
✅ Path-based and host-based routing consolidate multiple services
✅ Typical savings: 60-80% on ALB costs
✅ Implementation time: 2-4 hours
✅ Zero performance impact
✅ Better resource utilization
Stop creating an ALB for every service. Consolidate with Terraform and watch your AWS bill drop. 🚀

Consolidated your ALBs? How many did you eliminate? Share in the comments! 💬
Follow for more AWS cost optimization with Terraform! ⚡

Series: Save on AWS with Terraform (21 articles)

  1. …
  2. 7
    Stop Creating an ALB for Every Service: Master Multi-Service Routing and Save Thousands with Terraform ⚡ This article
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article