DevConda — blog-workspace

Put ALB HTTPS in Front of ECS Fargate Spring Boot

Part 3 system architecture at a glance

When should you read this?

You already run Spring Boot on ECS Fargate and can curl the task public IP on port 8080. That is fine for a smoke test. It is not how you put the API on a domain with HTTPS, and leaving 8080 open to the internet is a bad default once real traffic starts.

Part 3 of 4 (ECR -> Fargate -> ALB HTTPS -> Actions). This post ends when https://api.example.com/actuator/health (or /) works through an Application Load Balancer, and the task security group allows 8080 only from the ALB.

It continues from Push a Spring Boot Docker Image to Amazon ECR and Run Spring Boot on ECS Fargate. The one-box path still ends TLS on the host (Nginx + Spring, Actions to EC2, health gate). Here TLS ends on the ALB — not on Nginx on an instance.

What you are building

Client
  -> ALB :443 (ACM cert)
      -> target group (type ip, port 8080)
          -> Fargate task :8080
SG: ALB allows 443 from 0.0.0.0/0
    task allows 8080 only from ALB SG

Same account and region as parts 1–2 (ap-northeast-2 in examples). Reuse cluster api, service api, and image tag 1.0.0. Replace api.example.com with your hostname.

Part 3: system architecture (request flow) – ALB / Target Group / ACM

Before: curl the task public IP on :8080. After: curl the hostname on :443.

Public IP smoke test versus ALB HTTPS entry
Public IP smoke test versus ALB HTTPS entry

Before you start

You need:

  1. A hostname you control (example api.example.com) and DNS that can point at the ALB (Route 53 or any DNS that supports CNAME/alias).
  2. The part 2 service still running (or recreatable) in the same VPC. Part 2 could use one subnet for the Fargate task — that still works. Only the ALB needs two subnets in different Availability Zones (AWS rule for Application Load Balancers).
  3. Shell variables from part 2, or set them again:
# STS (Security Token Service): read the current AWS account ID
ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
REGION=ap-northeast-2
CLUSTER=api
DOMAIN=api.example.com

# Find the default VPC (same path as part 2)
VPC_ID=$(aws ec2 describe-vpcs \
  --filters Name=isDefault,Values=true \
  --query 'Vpcs[0].VpcId' --output text --region $REGION)

# List subnet IDs by AZ — pick two different AZs for the ALB only
aws ec2 describe-subnets \
  --filters Name=vpc-id,Values=$VPC_ID \
  --query 'Subnets[].{id:SubnetId,az:AvailabilityZone}' \
  --output table --region $REGION

Pick two subnet IDs in different AZs for the load balancer (the Fargate service can keep the single subnet from part 2):

SUBNET_A=subnet-aaaaaaaa
SUBNET_B=subnet-bbbbbbbb

If you only have one AZ in the VPC, create a second public subnet before continuing — create-load-balancer will reject a single-AZ set. You do not need to move the ECS service into both subnets for this part.

ACM certificate

Request a public certificate in the same region as the ALB (regional ALB cannot use us-east-1-only CloudFront certs unless that is where the ALB lives):

# Request an ACM public cert for $DOMAIN (DNS validation)
CERT_ARN=$(aws acm request-certificate \
  --domain-name $DOMAIN \
  --validation-method DNS \
  --region $REGION \
  --query CertificateArn --output text)

echo $CERT_ARN

Add the DNS validation CNAME ACM prints (console Certificate manager → your cert → Create records in Route 53, or copy the CNAME to your DNS). Wait until the cert is ISSUED:

# Block until ACM marks the certificate ISSUED
aws acm wait certificate-validated --certificate-arn $CERT_ARN --region $REGION

Pending validation for a long time usually means the CNAME is missing, wrong, or cached at the DNS provider — not an ALB problem yet.

ACM request, DNS validation, ISSUED certificate
ACM request, DNS validation, ISSUED certificate

Target group and health check

Fargate uses awsvpc, so the target type must be ip (instance type is for EC2 launch type):

# Create an IP target group for container port 8080
TG_ARN=$(aws elbv2 create-target-group \
  --name api-tg \
  --protocol HTTP \
  --port 8080 \
  --vpc-id $VPC_ID \
  --target-type ip \
  --health-check-protocol HTTP \
  --health-check-path /actuator/health \
  --health-check-interval-seconds 30 \
  --healthy-threshold-count 2 \
  --unhealthy-threshold-count 3 \
  --region $REGION \
  --query 'TargetGroups[0].TargetGroupArn' --output text)

echo $TG_ARN

If Actuator is not enabled, set --health-check-path / (or any path that returns 200). A target stuck unhealthy with a running task usually means wrong path, app not listening on 8080, or security group blocking the ALB.

IP target group health check on port 8080
IP target group health check on port 8080

ALB, listeners, and security groups

Create a security group for the load balancer and one rule set for the tasks:

# Security group for the ALB (public HTTPS)
ALB_SG=$(aws ec2 create-security-group \
  --group-name api-alb-sg \
  --description "ALB for api" \
  --vpc-id $VPC_ID \
  --region $REGION \
  --query GroupId --output text)

# Allow inbound HTTPS from the internet
aws ec2 authorize-security-group-ingress \
  --group-id $ALB_SG \
  --protocol tcp --port 443 --cidr 0.0.0.0/0 \
  --region $REGION

# Optional: HTTP 80 so you can redirect to HTTPS
aws ec2 authorize-security-group-ingress \
  --group-id $ALB_SG \
  --protocol tcp --port 80 --cidr 0.0.0.0/0 \
  --region $REGION

Reuse or look up the part 2 task security group (api-fargate-sg):

# Find the Fargate task security group from part 2
TASK_SG=$(aws ec2 describe-security-groups \
  --filters Name=group-name,Values=api-fargate-sg Name=vpc-id,Values=$VPC_ID \
  --query 'SecurityGroups[0].GroupId' --output text --region $REGION)

# Allow 8080 only from the ALB security group
aws ec2 authorize-security-group-ingress \
  --group-id $TASK_SG \
  --protocol tcp --port 8080 \
  --source-group $ALB_SG \
  --region $REGION

Remove the temporary world-open 8080 rule from part 2 (adjust the rule id if yours differs):

# Revoke 0.0.0.0/0:8080 left over from the Fargate smoke test
aws ec2 revoke-security-group-ingress \
  --group-id $TASK_SG \
  --protocol tcp --port 8080 --cidr 0.0.0.0/0 \
  --region $REGION

Create the ALB across both subnets:

# Create an internet-facing Application Load Balancer
ALB_ARN=$(aws elbv2 create-load-balancer \
  --name api-alb \
  --type application \
  --scheme internet-facing \
  --subnets $SUBNET_A $SUBNET_B \
  --security-groups $ALB_SG \
  --region $REGION \
  --query 'LoadBalancers[0].LoadBalancerArn' --output text)

# Read the ALB DNS name for Route 53 / CNAME
ALB_DNS=$(aws elbv2 describe-load-balancers \
  --load-balancer-arns $ALB_ARN \
  --query 'LoadBalancers[0].DNSName' --output text --region $REGION)

echo $ALB_DNS

HTTPS listener (and optional HTTP → HTTPS redirect):

# HTTPS :443 -> forward to target group (ACM cert)
aws elbv2 create-listener \
  --load-balancer-arn $ALB_ARN \
  --protocol HTTPS --port 443 \
  --certificates CertificateArn=$CERT_ARN \
  --default-actions Type=forward,TargetGroupArn=$TG_ARN \
  --region $REGION

# HTTP :80 -> 301 redirect to HTTPS
aws elbv2 create-listener \
  --load-balancer-arn $ALB_ARN \
  --protocol HTTP --port 80 \
  --default-actions "Type=redirect,RedirectConfig={Protocol=HTTPS,Port=443,StatusCode=HTTP_301}" \
  --region $REGION

Point DNS at the ALB: Route 53 alias to the load balancer, or a CNAME from api.example.com to $ALB_DNS.

ALB SG open on 443; task SG only from ALB
ALB SG open on 443; task SG only from ALB

Attach the ECS service

Register the service with the target group. Container name must match the task definition (api):

# Attach service api to the IP target group on container port 8080
aws ecs update-service \
  --cluster $CLUSTER \
  --service api \
  --load-balancers "targetGroupArn=$TG_ARN,containerName=api,containerPort=8080" \
  --region $REGION

If update-service rejects load balancer changes on an old service, delete and recreate the service with the same task definition plus --load-balancers and the same network config as part 2 (assignPublicIp=ENABLED is still fine in a public subnet for ECR pulls; inbound 8080 is now ALB-only).

Wait for stability and healthy targets:

# Wait until ECS reports the service stable
aws ecs wait services-stable --cluster $CLUSTER --services api --region $REGION

# Show target health (should become healthy)
aws elbv2 describe-target-health \
  --target-group-arn $TG_ARN \
  --region $REGION \
  --output table

TLS stops at the ALB; the task still speaks HTTP on 8080. If the app builds absolute URLs or issues redirects, set server.forward-headers-strategy=framework (Spring Boot 2.2+) so it trusts X-Forwarded-* from the load balancer. Actuator health checks do not need that.

Verify

# HTTPS health through the hostname (ACM + ALB + target)
curl -sf "https://$DOMAIN/actuator/health" || curl -sf "https://$DOMAIN/"

# Direct task public IP :8080 should fail or time out after SG revoke

Certificate name mismatch means DNS still points elsewhere or you curled the raw ALB DNS with a cert issued only for $DOMAIN. 502 / 503 with unhealthy targets → health path, SG, or Spring not listening. CloudWatch /ecs/api still holds container logs.

DNS, curl HTTPS, healthy targets
DNS, curl HTTPS, healthy targets

Next in series

Part 4 wires GitHub Actions to build the jar, push a new ECR tag, register a task revision, and force a new deployment on this service behind the ALB. Planned slug: deploy-spring-boot-to-fargate-from-github-actions.

Minimal checklist

  1. ACM certificate for $DOMAIN is ISSUED in the ALB region.
  2. Target group api-tg is type ip, port 8080, health path returns 200.
  3. ALB has HTTPS :443 (and optional HTTP redirect); DNS points at the ALB.
  4. Task SG allows 8080 from ALB SG only — not 0.0.0.0/0.
  5. curl https://$DOMAIN/actuator/health (or /) succeeds; targets show healthy.

After that, public traffic hits HTTPS on the ALB; Fargate still runs the same ECR image from parts 1–2.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *