Production Payload CMS on AWS — 5-Part Series
Part 1: Architecture | Part 2: Database | Part 3: Docker | Part 4: ALB Routing (this post) | Part 5: Lexical Frontend
Payload CMS is a Next.js application. Next.js serves its own static assets under /_next/, API routes under /api/, and the admin UI under /admin/. The Astro frontend serves everything else. Both run as separate ECS Fargate containers. Both need to be accessible under the same domain.
A single Application Load Balancer solves this with path-based routing rules. The priority ordering of those rules is non-obvious — get it wrong and your admin UI breaks, or Next.js static assets return 404, or both.
The Listener Rule Priority Order
This is the key insight before you touch the console:
Priority 11: path starts with /_next → Payload target group
Priority 12: path starts with /api → Payload target group
Priority 13: path starts with /admin → Payload target group
Default: everything else → Astro target group Next.js serves its compiled JavaScript, CSS, and image optimization under /_next/. If those requests hit the Astro container, they return 404 and the admin UI breaks completely. These rules must have higher priority (lower number) than any catch-all. The default rule must point to the Astro frontend.
Step 1: Create the Target Groups
# Payload target group
PAYLOAD_TG=$(aws elbv2 create-target-group \
--name payload-cms-tg \
--protocol HTTP \
--port 3000 \
--vpc-id YOUR_VPC_ID \
--target-type ip \
--health-check-protocol HTTP \
--health-check-path /api/health \
--health-check-interval-seconds 30 \
--health-check-timeout-seconds 5 \
--healthy-threshold-count 2 \
--unhealthy-threshold-count 3 \
--matcher HttpCode=200-404 \
--query 'TargetGroups[0].TargetGroupArn' \
--output text)
echo "Payload TG ARN: $PAYLOAD_TG"
# Astro frontend target group
FRONTEND_TG=$(aws elbv2 create-target-group \
--name astro-frontend-tg \
--protocol HTTP \
--port 4321 \
--vpc-id YOUR_VPC_ID \
--target-type ip \
--health-check-protocol HTTP \
--health-check-path / \
--health-check-interval-seconds 30 \
--health-check-timeout-seconds 5 \
--healthy-threshold-count 2 \
--unhealthy-threshold-count 3 \
--matcher HttpCode=200 \
--query 'TargetGroups[0].TargetGroupArn' \
--output text)
echo "Frontend TG ARN: $FRONTEND_TG" /api/health returns 200 when healthy, but during startup it may return 404 before routes are registered. Using 200-404 prevents the ALB from marking the container unhealthy during the 60-second startup window.
Step 2: Create the ALB
# ALB security group
ALB_SG=$(aws ec2 create-security-group \
--group-name payload-alb-sg \
--description "Payload CMS ALB" \
--vpc-id YOUR_VPC_ID \
--query 'GroupId' \
--output text)
# Allow HTTPS and HTTP from anywhere
aws ec2 authorize-security-group-ingress \
--group-id $ALB_SG --protocol tcp --port 443 --cidr 0.0.0.0/0
aws ec2 authorize-security-group-ingress \
--group-id $ALB_SG --protocol tcp --port 80 --cidr 0.0.0.0/0
# Create the ALB
ALB_ARN=$(aws elbv2 create-load-balancer \
--name payload-cms-alb \
--subnets subnet-XXXXXXXX subnet-YYYYYYYY \
--security-groups $ALB_SG \
--scheme internet-facing \
--type application \
--query 'LoadBalancers[0].LoadBalancerArn' \
--output text)
ALB_DNS=$(aws elbv2 describe-load-balancers \
--load-balancer-arns $ALB_ARN \
--query 'LoadBalancers[0].DNSName' \
--output text)
echo "ALB DNS: $ALB_DNS" Step 3: Create the HTTPS Listener
# HTTPS listener — default action forwards to Astro frontend
LISTENER_ARN=$(aws elbv2 create-listener \
--load-balancer-arn $ALB_ARN \
--protocol HTTPS \
--port 443 \
--ssl-policy ELBSecurityPolicy-TLS13-1-2-2021-06 \
--certificates CertificateArn=YOUR_ACM_CERT_ARN \
--default-actions Type=forward,TargetGroupArn=$FRONTEND_TG \
--query 'Listeners[0].ListenerArn' \
--output text)
echo "Listener ARN: $LISTENER_ARN"
# HTTP to HTTPS redirect
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"
}
}]' Step 4: Add Path-Based Rules for Payload
# Rule for /_next/* — Next.js static assets (priority 11)
aws elbv2 create-rule \
--listener-arn $LISTENER_ARN \
--priority 11 \
--conditions '[{"Field":"path-pattern","PathPatternConfig":{"Values":["/_next*"]}}]' \
--actions '[{"Type":"forward","TargetGroupArn":"'$PAYLOAD_TG'"}]'
# Rule for /api/* (priority 12)
aws elbv2 create-rule \
--listener-arn $LISTENER_ARN \
--priority 12 \
--conditions '[{"Field":"path-pattern","PathPatternConfig":{"Values":["/api*"]}}]' \
--actions '[{"Type":"forward","TargetGroupArn":"'$PAYLOAD_TG'"}]'
# Rule for /admin/* (priority 13)
aws elbv2 create-rule \
--listener-arn $LISTENER_ARN \
--priority 13 \
--conditions '[{"Field":"path-pattern","PathPatternConfig":{"Values":["/admin*"]}}]' \
--actions '[{"Type":"forward","TargetGroupArn":"'$PAYLOAD_TG'"}]' Step 5: Update the Internal URL Secret
The Astro frontend calls the Payload API through the internal ALB endpoint — not the public domain — to avoid external DNS round-trips and egress costs. Update the placeholder secret from Part 2:
aws secretsmanager update-secret \
--secret-id "payload/internal-url" \
--secret-string "http://$ALB_DNS:3000" In your Astro frontend task definition, inject this as PAYLOAD_INTERNAL_URL and use it in your fetch calls:
const PAYLOAD_URL = import.meta.env.PAYLOAD_INTERNAL_URL || 'http://localhost:3000' Step 6: Configure DNS
# Create an alias record in Route 53 pointing to the ALB
aws route53 change-resource-record-sets \
--hosted-zone-id YOUR_HOSTED_ZONE_ID \
--change-batch '{
"Changes": [{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "cms.yourdomain.com",
"Type": "A",
"AliasTarget": {
"HostedZoneId": "Z35SXDOTRQ7X7K",
"DNSName": "'$ALB_DNS'",
"EvaluateTargetHealth": true
}
}
}]
}' The hosted zone ID Z35SXDOTRQ7X7K is fixed for all ALBs in us-east-1. Other regions have different IDs.
Step 7: Verify the Routing
# Should return Payload API response
curl -I https://cms.yourdomain.com/api/health
# Should return Next.js JavaScript asset
curl -I https://cms.yourdomain.com/_next/static/...
# Should return Payload admin HTML
curl -I https://cms.yourdomain.com/admin
# Should return Astro frontend HTML
curl -I https://cms.yourdomain.com/
curl -I https://cms.yourdomain.com/departments Troubleshooting
Admin loads but shows JavaScript errors or blank white screen
The /_next/* rule is missing or has lower priority than the default rule. Verify rule priority 11 exists and points to the Payload target group.
Requests to /api return the Astro 404 page
Same issue — the /api* rule is not being evaluated before the default rule. Check priorities.
Target group shows unhealthy
Confirm the health check path is /api/health and the matcher is 200-404. The container needs 60 to 90 seconds to start — verify the startPeriod in your task definition health check config.
Frontend shows "Failed to fetch" errors
The PAYLOAD_INTERNAL_URL environment variable is not set or points to localhost instead of the internal ALB DNS.
SSL certificate error
The ACM certificate must be in us-east-1 even if your other resources are in a different region. Verify it covers your domain and is in ISSUED status before attaching to the listener.
Part 5 covers the Lexical rich text renderer for the Astro frontend — the complete JSON-to-HTML implementation, the text format bitmask, the WHERE query format that returns empty results silently if you get it wrong, and the reusable query builder that handles all the edge cases.
The ALB routing pattern documented here is what serves the Chippewa County municipal website. Read the case study.