Let's Connect

Network cabling and switch ports in a data center rack, representing an AWS Application Load Balancer routing HTTPS traffic to backend instances

An AWS ALB HTTPS setup terminates TLS at the load balancer, redirects every plain HTTP request to 443, and only forwards traffic to instances that pass a health check. The mistake I see most often is building those pieces in the wrong order, then wondering why the certificate dropdown is empty or why the targets sit at 'unhealthy' forever. The order is fixed: create the target group first, register your instances, then create the load balancer with two listeners. Port 80 carries a redirect-to-443 rule and nothing else. Port 443 holds the ACM certificate and forwards to the target group. Get that sequence right and the rest is wiring security groups so the instances only ever talk to the ALB.

Why terminate HTTPS at the ALB at all?

You can run Certbot on every instance and renew certificates per box, and for a single server that is fine. The moment you have two or more instances behind a load balancer, per-instance TLS becomes a renewal liability: every box needs the cert, every renewal is a place to fail, and a scaling event spins up an instance with no certificate at all. Terminating at the ALB moves all of that to one place. The ALB holds an ACM certificate that renews itself, decrypts the request, and forwards plain HTTP to your instances over the private network inside the VPC. Your application servers never run a TLS stack, never renew anything, and a new instance is healthy the second it answers the health check. If you have only ever done single-box TLS, my notes on free SSL on AWS with ACM explain how the certificate itself is issued and validated before you attach it here.

The one gotcha that bites people: an ACM certificate is regional, and the ALB can only use a certificate issued in its own Region. If your ALB is in ap-southeast-2 and you requested the cert in us-east-1 (a habit from CloudFront work, where us-east-1 is required), the certificate simply will not appear in the listener dropdown. Request it again in the ALB's Region.

Step 1: create the target group and a real health check

The target group is the pool the ALB forwards to. Create it before the load balancer, because the 443 listener needs something to point at. Target type is 'instance' when you register EC2 instances by ID, or 'ip' when you register private IPs directly (the choice you want for ECS or for cross-account targets). The protocol and port here are what the ALB uses to reach your app, not what the client sees, so this is your application port over plain HTTP inside the VPC, for example port 80 to Nginx.

Spend your time on the health check path. Point it at a dedicated endpoint like /up that returns 200 only when the app can actually serve requests, not at / which might 302 to a login page. A redirect is not a 200, and the ALB will mark the target unhealthy and stop sending it traffic. Laravel ships exactly this endpoint at /up; for anything else, add a route that checks the database connection and returns 200.

create-target-group.sh
# Create the target group. Health check hits /up and expects HTTP 200.
aws elbv2 create-target-group \
  --name app-tg \
  --protocol HTTP \
  --port 80 \
  --vpc-id vpc-0abc1234def567890 \
  --target-type instance \
  --health-check-protocol HTTP \
  --health-check-path /up \
  --matcher HttpCode=200 \
  --health-check-interval-seconds 15 \
  --healthy-threshold-count 2 \
  --unhealthy-threshold-count 3

# Register your instances into it.
aws elbv2 register-targets \
  --target-group-arn arn:aws:elasticloadbalancing:ap-southeast-2:111122223333:targetgroup/app-tg/abc123 \
  --targets Id=i-0aaa111bbb222ccc3 Id=i-0ddd444eee555fff6

If you are still standing up the instances that go into this group, my AWS EC2 for beginners guide covers launching a production instance correctly so the targets are ready to register here.

Step 2: create the ALB and its two listeners

Now create the load balancer itself. An ALB lives in at least two subnets in two Availability Zones, so pass two public subnet IDs. Attach a security group dedicated to the ALB (covered below). Once it exists, you add two listeners against its ARN.

create-alb-and-listeners.sh
# 1. Create the load balancer across two public subnets.
aws elbv2 create-load-balancer \
  --name app-alb \
  --type application \
  --scheme internet-facing \
  --subnets subnet-0aaa111 subnet-0bbb222 \
  --security-groups sg-0albsg111aaa

# 2. Port 443 listener: present the ACM cert, forward to the target group.
#    The certificate ARN must be in the SAME region as the ALB.
aws elbv2 create-listener \
  --load-balancer-arn arn:aws:elasticloadbalancing:ap-southeast-2:111122223333:loadbalancer/app/app-alb/abc \
  --protocol HTTPS \
  --port 443 \
  --ssl-policy ELBSecurityPolicy-TLS13-1-2-2021-06 \
  --certificates CertificateArn=arn:aws:acm:ap-southeast-2:111122223333:certificate/your-cert-id \
  --default-actions Type=forward,TargetGroupArn=arn:aws:elasticloadbalancing:ap-southeast-2:111122223333:targetgroup/app-tg/abc123

# 3. Port 80 listener: do NOT forward. Redirect everything to 443 with a 301.
aws elbv2 create-listener \
  --load-balancer-arn arn:aws:elasticloadbalancing:ap-southeast-2:111122223333:loadbalancer/app/app-alb/abc \
  --protocol HTTP \
  --port 80 \
  --default-actions 'Type=redirect,RedirectConfig={Protocol=HTTPS,Port=443,StatusCode=HTTP_301}'

Two details earn their keep here. First, the port 80 listener has no forward action at all; its only job is the 301 redirect to HTTPS, so a request that arrives on HTTP never reaches your instances unencrypted. Use HTTP_301 (permanent) rather than HTTP_302 so browsers and search engines cache the upgrade. Second, set an explicit ssl-policy. ELBSecurityPolicy-TLS13-1-2-2021-06 enables TLS 1.3 and drops TLS 1.0/1.1; the default policy is older than you want. AWS documents the full policy list in the ELB developer guide, and it is worth reading before you pick one.

A laptop showing a terminal session, representing the aws elbv2 CLI calls that create an ALB listener and target group
The whole ALB lives in three CLI calls: a target group, the ALB, and two listeners. The 80 listener only redirects.

How do the security groups fit together?

This is the part people get wrong in a way that quietly defeats the entire design. You need two security groups, and the second one is what keeps your instances private.

  • The ALB security group allows inbound 80 and 443 from 0.0.0.0/0 (and ::/0 if you serve IPv6). This is the only thing the public internet can reach.
  • The instance security group allows inbound on your app port (say 80) ONLY from the ALB's security group, referenced by its group ID, not from 0.0.0.0/0.
  • Outbound stays at the default allow-all so instances can reach the database, package mirrors, and AWS APIs.

Referencing the ALB's security group as the source, instead of an IP range, is what makes this robust. The ALB's private IPs change as it scales across AZs, so any CIDR you hard-code will eventually be wrong. A security-group-to-security-group rule tracks the ALB no matter which IP it uses.

instance-sg-rule.sh
# Allow the app port ONLY from the ALB's security group, never from the world.
# In a VPC, source a security group via --ip-permissions UserIdGroupPairs.
# (--source-group is EC2-Classic only and does NOT work here.)
aws ec2 authorize-security-group-ingress \
  --group-id sg-0instance222bbb \
  --ip-permissions 'IpProtocol=tcp,FromPort=80,ToPort=80,UserIdGroupPairs=[{GroupId=sg-0albsg111aaa}]'

If you skip this and leave the instance port open to 0.0.0.0/0, the load balancer still works, so nothing looks broken. But anyone who learns an instance's public IP can hit your app directly over plain HTTP, bypassing the ALB, the redirect, and TLS entirely. Lock the instances down to the ALB's group and the only door into the application is port 443 on the load balancer. For the wider VPC and instance hardening picture, my AWS production architecture guide lays out where the ALB sits relative to private subnets and the database tier.

If you can curl your app on its public instance IP, your ALB is decoration. The instance security group is the wall, not the load balancer.Md Raihan Hasan

Routing rules and sticky sessions

Once traffic flows, the 443 listener can do more than a single forward. Listener rules let you route by host or path before forwarding to different target groups, evaluated in priority order with the default action as the fallback. Host-based routing sends api.example.com to one group and app.example.com to another on the same ALB. Path-based routing sends /api/* to a backend group and everything else to the web group. This is how you put several services behind one load balancer and one certificate.

When you actually need sticky sessions

Stickiness pins a client to the same target using an ALB-generated cookie for a duration you set. You enable it on the target group, not the listener. Reach for it only when your app stores session state in instance memory; if sessions live in Redis or the database, leave stickiness off so the ALB can balance freely. Turning it on to mask a session-storage problem just moves the bug to the moment a sticky target goes unhealthy and the client lands somewhere with no session.

enable-stickiness.sh
# Only if sessions are stored in instance memory. Prefer shared session storage instead.
aws elbv2 modify-target-group-attributes \
  --target-group-arn arn:aws:elasticloadbalancing:ap-southeast-2:111122223333:targetgroup/app-tg/abc123 \
  --attributes \
    Key=stickiness.enabled,Value=true \
    Key=stickiness.type,Value=lb_cookie \
    Key=stickiness.lb_cookie.duration_seconds,Value=86400

The build order is the whole lesson here: target group with a /up health check, register the instances, then the ALB with a redirect-only port 80 listener and an ACM-backed port 443 listener, and finally a security group rule that lets the instances hear only the ALB. Do it in that sequence and the certificate shows up, the targets go healthy, and the only way into your application is encrypted. Skip the security group step and you have built an expensive proxy that anyone can route around.