A single EC2 instance with a public IP is fragile: it has one address, one failure domain and no managed TLS. The standard fix is an Application Load Balancer (ALB) that terminates HTTPS, spreads requests across targets in several Availability Zones and removes unhealthy ones, with Route 53 supplying the DNS name and ACM the certificate.
State of AWS on this date. ACM public certificates are free and renew automatically when used with ALB, CloudFront or API Gateway. ALBs bill per hour plus load-balancer capacity units. Target groups can contain EC2 instances, IP addresses, Lambda functions and ECS tasks.
Request path
flowchart TB
U[User browser] -->|DNS lookup app.example.com| R53[Route 53 alias record]
R53 -->|ALB DNS name| ALB
subgraph VPC[VPC]
ALB[ALB in public subnets<br/>listeners 80 and 443] -->|HTTP 301 to HTTPS| ALB
ALB -->|forward rule| TG[Target group web-tg<br/>health check /healthz]
TG --> A[Instance in AZ a<br/>private subnet]
TG --> B[Instance in AZ b<br/>private subnet]
end
ACM[ACM certificate] -.-> ALB
SG1[Security group alb-sg<br/>80 and 443 from anywhere] -.-> ALB
SG2[Security group app-sg<br/>8080 from alb-sg only] -.-> A
1. Certificate
Request it in the region where the ALB lives (for CloudFront it must be us-east-1) and validate by DNS, which is easy when the zone is in Route 53:
aws acm request-certificate --domain-name app.example.com --validation-method DNS --region ap-south-1
aws acm describe-certificate --certificate-arn <arn> --query 'Certificate.DomainValidationOptions[0].ResourceRecord'
# create that CNAME in Route 53 (the console has a one-click button), then:
aws acm wait certificate-validated --certificate-arn <arn>
Keep the validation CNAME in place; ACM uses it for automatic renewals.
2. Security groups
alb-sg allows 80 and 443 from 0.0.0.0/0. app-sg allows the application port only from alb-sg, so nobody can reach the instances directly.
3. Load balancer, target group and listeners
TG=$(aws elbv2 create-target-group --name web-tg --protocol HTTP --port 8080 --vpc-id $VPC \
--health-check-path /healthz --health-check-interval-seconds 15 --healthy-threshold-count 2 --unhealthy-threshold-count 3 \
--query 'TargetGroups[0].TargetGroupArn' --output text)
aws elbv2 register-targets --target-group-arn $TG --targets Id=i-0abc123 Id=i-0def456
ALB=$(aws elbv2 create-load-balancer --name web-alb --subnets $PUB1 $PUB2 --security-groups $ALB_SG \
--scheme internet-facing --type application --query 'LoadBalancers[0].LoadBalancerArn' --output text)
aws elbv2 create-listener --load-balancer-arn $ALB --protocol HTTPS --port 443 \
--certificates CertificateArn=<arn> --ssl-policy ELBSecurityPolicy-TLS-1-2-2017-01 \
--default-actions Type=forward,TargetGroupArn=$TG
aws elbv2 create-listener --load-balancer-arn $ALB --protocol HTTP --port 80 \
--default-actions 'Type=redirect,RedirectConfig={Protocol=HTTPS,Port=443,StatusCode=HTTP_301}'
The ALB needs subnets in at least two AZs. The /healthz endpoint should check that the app can do useful work (and optionally its database) but return quickly. Check status:
aws elbv2 describe-target-health --target-group-arn $TG
4. DNS in Route 53
For the apex or any name pointing to an ALB, use an alias record (free, follows the ALB’s changing IPs, works at the zone apex where CNAME cannot):
aws route53 change-resource-record-sets --hosted-zone-id Z123EXAMPLE --change-batch '{
"Changes":[{"Action":"UPSERT","ResourceRecordSet":{
"Name":"app.example.com","Type":"A",
"AliasTarget":{"HostedZoneId":"ZP97RAFLXTNZK","DNSName":"web-alb-123456.ap-south-1.elb.amazonaws.com","EvaluateTargetHealth":true}}}]}'
dig +short app.example.com
curl -I https://app.example.com
The HostedZoneId inside AliasTarget is the ALB’s own zone ID for the region (listed in the AWS docs), not your hosted zone. Use a low TTL (60 seconds) before migrations.
Routing and features
| Feature | Use |
|---|---|
| Path rules | /api/* to one target group, / to another |
| Host rules | admin.example.com to a different service |
| Weighted target groups | Blue/green or canary releases |
| Fixed responses and redirects | Maintenance page, HTTP to HTTPS |
| Sticky sessions | Only if the app needs them; prefer stateless apps |
| Slow-start and deregistration delay | Graceful deploys (set the delay to match request length) |
| Access logs to S3 | Request-level audit and debugging |
Operate and troubleshoot
- 502 Bad Gateway: targets close connections or send invalid responses; check app logs and keep-alive timeouts (set the app’s idle timeout higher than the ALB’s 60 seconds).
- 503: no healthy targets.
describe-target-healthshows why (health check failing, wrong port, security group blockingalb-sg). - 504: target too slow; compare with
TargetResponseTime. - Watch CloudWatch metrics:
HTTPCode_Target_5XX_Count,HTTPCode_ELB_5XX_Count,TargetResponseTime,UnHealthyHostCount; alarm on the last two. - Enable deletion protection on production ALBs, and manage everything in IaC so listeners and rules are reviewed.