Deploy Laravel to AWS (Part 4): ALB, CloudFront CDN & SSL with ACM
In Part 3, we connected Laravel to RDS, S3, and Redis. Now let's put the production front door in place: ALB for load balancing and SSL, CloudFront for CDN, and Route 53 for DNS.
SSL Certificate with ACM
AWS Certificate Manager provides free SSL certificates — but they must be used with AWS services (ALB, CloudFront), not directly on EC2.
Request Certificate for ALB (Regional)
aws acm request-certificate \
--domain-name your-domain.com \
--subject-alternative-names "*.your-domain.com" \
--validation-method DNS \
--region ap-northeast-1 \
--tags Key=Name,Value=laravel-ssl
Request Certificate for CloudFront (Must be us-east-1!)
CloudFront requires certificates in us-east-1, regardless of where your app runs:
aws acm request-certificate \
--domain-name your-domain.com \
--subject-alternative-names "*.your-domain.com" \
--validation-method DNS \
--region us-east-1 \
--tags Key=Name,Value=laravel-cloudfront-ssl
Validate the Certificate
ACM gives you a CNAME record to add to your DNS:
aws acm describe-certificate \
--certificate-arn arn:aws:acm:ap-northeast-1:xxxx:certificate/xxxx \
--query 'Certificate.DomainValidationOptions[0].ResourceRecord'
Add the CNAME to Route 53 (or your current DNS provider). ACM validates automatically within minutes.
Application Load Balancer (ALB)
Create Target Group
The target group tells the ALB where to send traffic:
aws elbv2 create-target-group \
--name laravel-tg \
--protocol HTTP \
--port 80 \
--vpc-id vpc-xxxx \
--target-type instance \
--health-check-protocol HTTP \
--health-check-path "/up" \
--health-check-interval-seconds 30 \
--health-check-timeout-seconds 5 \
--healthy-threshold-count 2 \
--unhealthy-threshold-count 3 \
--matcher HttpCode=200
Health check path: Laravel 11+ has a built-in
/uproute. For older versions, create a health check endpoint:// routes/web.php Route::get('/health', function () { try { DB::connection()->getPdo(); return response('OK', 200); } catch (\Exception $e) { return response('DB Error', 503); } });
Register EC2 Instance
aws elbv2 register-targets \
--target-group-arn arn:aws:elasticloadbalancing:...:targetgroup/laravel-tg/xxxx \
--targets Id=i-xxxx
Create ALB
aws elbv2 create-load-balancer \
--name laravel-alb \
--subnets subnet-public-a subnet-public-c \
--security-groups sg-alb \
--scheme internet-facing \
--type application \
--tags Key=Name,Value=laravel-alb
Create Listeners
HTTPS listener (port 443):
aws elbv2 create-listener \
--load-balancer-arn arn:aws:elasticloadbalancing:...:loadbalancer/app/laravel-alb/xxxx \
--protocol HTTPS \
--port 443 \
--certificates CertificateArn=arn:aws:acm:ap-northeast-1:xxxx:certificate/xxxx \
--default-actions Type=forward,TargetGroupArn=arn:aws:elasticloadbalancing:...:targetgroup/laravel-tg/xxxx \
--ssl-policy ELBSecurityPolicy-TLS13-1-2-2021-06
HTTP listener (port 80) — redirect to HTTPS:
aws elbv2 create-listener \
--load-balancer-arn arn:aws:elasticloadbalancing:...:loadbalancer/app/laravel-alb/xxxx \
--protocol HTTP \
--port 80 \
--default-actions Type=redirect,RedirectConfig='{Protocol=HTTPS,Port=443,StatusCode=HTTP_301}'
How ALB + EC2 Works
Client → HTTPS :443 → ALB (SSL termination) → HTTP :80 → EC2 (Nginx)
The ALB handles SSL. Traffic between ALB and EC2 is plain HTTP within the VPC (private network). This is the standard approach — it offloads SSL processing from your server.
Laravel: Trust the ALB Proxy
Since the ALB terminates SSL and forwards HTTP, Laravel needs to know the original protocol was HTTPS. Update TrustProxies middleware:
// bootstrap/app.php (Laravel 11+)
->withMiddleware(function (Middleware $middleware) {
$middleware->trustProxies(
at: '*',
headers: Request::HEADER_X_FORWARDED_FOR |
Request::HEADER_X_FORWARDED_HOST |
Request::HEADER_X_FORWARDED_PORT |
Request::HEADER_X_FORWARDED_PROTO |
Request::HEADER_X_FORWARDED_AWS_ELB,
);
})
For Laravel 10 and below:
// app/Http/Middleware/TrustProxies.php
protected $proxies = '*';
protected $headers = Request::HEADER_X_FORWARDED_AWS_ELB;
Without this, url(), asset(), and redirect() will generate http:// URLs instead of https://.
Route 53 — DNS
Create Hosted Zone
aws route53 create-hosted-zone \
--name your-domain.com \
--caller-reference $(date +%s)
Update your domain registrar's nameservers to point to the Route 53 NS records.
Point Domain to ALB
aws route53 change-resource-record-sets \
--hosted-zone-id ZXXXXX \
--change-batch '{
"Changes": [{
"Action": "CREATE",
"ResourceRecordSet": {
"Name": "your-domain.com",
"Type": "A",
"AliasTarget": {
"HostedZoneId": "Z14GRHDCWA56QT",
"DNSName": "laravel-alb-xxxx.ap-northeast-1.elb.amazonaws.com",
"EvaluateTargetHealth": true
}
}
}]
}'
Note: The
HostedZoneIdfor ALB in ap-northeast-1 isZ14GRHDCWA56QT. Each region has a different ID — check the AWS docs for your region.
CloudFront CDN
CloudFront caches static assets (CSS, JS, images) at edge locations worldwide, reducing latency for users far from your AWS region.
Create Distribution
aws cloudfront create-distribution \
--distribution-config '{
"CallerReference": "laravel-cf-'$(date +%s)'",
"Comment": "Laravel CDN",
"Enabled": true,
"DefaultCacheBehavior": {
"TargetOriginId": "laravel-alb",
"ViewerProtocolPolicy": "redirect-to-https",
"AllowedMethods": {"Quantity": 7, "Items": ["GET","HEAD","OPTIONS","PUT","POST","PATCH","DELETE"]},
"CachedMethods": {"Quantity": 2, "Items": ["GET","HEAD"]},
"ForwardedValues": {
"QueryString": true,
"Cookies": {"Forward": "all"},
"Headers": {"Quantity": 3, "Items": ["Host","Origin","Authorization"]}
},
"Compress": true,
"MinTTL": 0,
"DefaultTTL": 0,
"MaxTTL": 0
},
"Origins": {
"Quantity": 1,
"Items": [{
"Id": "laravel-alb",
"DomainName": "laravel-alb-xxxx.ap-northeast-1.elb.amazonaws.com",
"CustomOriginConfig": {
"HTTPPort": 80,
"HTTPSPort": 443,
"OriginProtocolPolicy": "https-only"
}
}]
},
"ViewerCertificate": {
"ACMCertificateArn": "arn:aws:acm:us-east-1:xxxx:certificate/xxxx",
"SSLSupportMethod": "sni-only",
"MinimumProtocolVersion": "TLSv1.2_2021"
},
"Aliases": {"Quantity": 1, "Items": ["your-domain.com"]},
"PriceClass": "PriceClass_200"
}'
Via Console (Easier)
- Go to CloudFront → Create distribution
- Origin:
- Origin domain:
laravel-alb-xxxx.ap-northeast-1.elb.amazonaws.com - Protocol: HTTPS only
- Origin domain:
- Default cache behavior:
- Viewer protocol: Redirect HTTP to HTTPS
- Allowed methods: GET, HEAD, OPTIONS, PUT, POST, PATCH, DELETE
- Cache policy: CachingDisabled (for dynamic content)
- Origin request policy: AllViewer
- Add a second cache behavior for static assets:
- Path pattern:
/build/* - Cache policy: CachingOptimized (long TTL)
- Compress: Yes
- Path pattern:
- Settings:
- Alternate domain name:
your-domain.com - Custom SSL certificate: Choose the us-east-1 ACM certificate
- Price class: Use North America, Europe, Asia (PriceClass_200)
- Alternate domain name:
Cache Behaviors Strategy
| Path | Cache | Why |
|---|---|---|
/build/* |
Cache 1 year | Vite hashed filenames — immutable |
/img/*, /favicon.ico |
Cache 1 day | Static images |
* (default) |
No cache (pass-through) | Dynamic PHP responses |
Update Route 53 to Point to CloudFront
Now the domain should point to CloudFront instead of ALB directly:
aws route53 change-resource-record-sets \
--hosted-zone-id ZXXXXX \
--change-batch '{
"Changes": [{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "your-domain.com",
"Type": "A",
"AliasTarget": {
"HostedZoneId": "Z2FDTNDATAQYW2",
"DNSName": "dxxxxx.cloudfront.net",
"EvaluateTargetHealth": false
}
}
}]
}'
Z2FDTNDATAQYW2is the global hosted zone ID for CloudFront (always the same).
Final Traffic Flow
User (Tokyo) → CloudFront Edge (Tokyo) → Cache HIT → Response (fast!)
User (New York) → CloudFront Edge (NYC) → Cache MISS → ALB → EC2 → Response
→ Cache SET → (next request = fast)
For dynamic content:
User → CloudFront → ALB → EC2 (Nginx → PHP-FPM → Laravel)
CloudFront still helps by providing DDoS protection (AWS Shield Standard is free) and TLS termination at the edge.
Nginx: Update for ALB Health Checks
The ALB needs to health check EC2 directly (not through CloudFront). Update Nginx to accept the ALB's health check:
# /etc/nginx/conf.d/laravel.conf
# Health check — respond to ALB directly
location /up {
access_log off;
try_files $uri /index.php?$query_string;
}
Security Headers in Nginx
Since CloudFront passes through headers, set them at the Nginx level:
# Inside the server block
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline';" always;
Updated Architecture
Route 53
│
▼
CloudFront (CDN)
├── /build/* → Cached (1 year)
└── /* → Pass-through
│
▼
ALB (HTTPS → HTTP)
├── SSL termination (ACM cert)
├── Health check: /up
└── Forward to Target Group
│
▼
EC2 (Nginx + PHP-FPM)
├── → RDS MySQL
├── → ElastiCache Redis
└── → S3 (file storage)
Verify the Full Stack
# Check ALB health
aws elbv2 describe-target-health \
--target-group-arn arn:aws:elasticloadbalancing:...:targetgroup/laravel-tg/xxxx
# Check CloudFront distribution status
aws cloudfront get-distribution --id EXXXXX --query 'Distribution.Status'
# Test HTTPS
curl -I https://your-domain.com
# Should return HTTP/2 200
# Headers should include: x-amz-cf-id (CloudFront)
What's Next
In Part 5 (final), we'll automate everything with GitHub Actions and Laravel Envoy for zero-downtime deployments, including cache invalidation, queue restart, and automated rollbacks.
Series Navigation:
- ← Part 0: Prerequisites
- ← Part 1: Architecture & VPC
- ← Part 2: EC2 & Amazon Linux 2023
- ← Part 3: RDS, S3 & ElastiCache
- Part 4: ALB, CloudFront & SSL (You are here)
- Part 5: CI/CD & Zero-Downtime Deploy →