Deploy Laravel to AWS (Part 4): ALB, CloudFront CDN & SSL with ACM

· 7 min read

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 /up route. 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 HostedZoneId for ALB in ap-northeast-1 is Z14GRHDCWA56QT. 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)

  1. Go to CloudFront → Create distribution
  2. Origin:
    • Origin domain: laravel-alb-xxxx.ap-northeast-1.elb.amazonaws.com
    • Protocol: HTTPS only
  3. 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
  4. Add a second cache behavior for static assets:
    • Path pattern: /build/*
    • Cache policy: CachingOptimized (long TTL)
    • Compress: Yes
  5. 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)

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
        }
      }
    }]
  }'

Z2FDTNDATAQYW2 is 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:

Comments