The S3 + CloudFront + ACM + Route 53 deploy pattern

The S3 + CloudFront + ACM + Route 53 deploy pattern

May 5, 2026 · awss3cloudfrontdeploymentinfrastructure

Most "cheap AWS hosting" tutorials skip the parts that actually matter — Origin Access Control, ACM in us-east-1, the right CloudFront error mappings for SPAs, the magic alias zone ID for Route 53. Here's the complete recipe with every gotcha called out.

By the end you'll have a static site live on your custom domain over HTTPS, served from a global CDN, costing $0.50/month flat.

The components

        Internet
           ↓
    Route 53 (DNS)
           ↓ alias records (apex + www)
    CloudFront (CDN)
           ↑ ACM cert (us-east-1)
           ↓ OAC-signed reads
       S3 bucket
       (private)

Four services. Each does one thing well.

Step 1: Create the S3 bucket

The bucket holds your built site files. It must be private — public S3 buckets are an anti-pattern, even for "public" content. CloudFront reads from it through Origin Access Control (OAC) instead.

aws s3api create-bucket \
  --bucket your-site-frontend-${ACCOUNT_ID} \
  --region us-east-1

aws s3api put-public-access-block \
  --bucket your-site-frontend-${ACCOUNT_ID} \
  --public-access-block-configuration \
  "BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"

aws s3api put-bucket-ownership-controls \
  --bucket your-site-frontend-${ACCOUNT_ID} \
  --ownership-controls "Rules=[{ObjectOwnership=BucketOwnerEnforced}]"

Naming convention: include the account ID to avoid global naming conflicts.

Step 2: Request the ACM certificate (in us-east-1)

ACM is regional. CloudFront requires the certificate to be in us-east-1, regardless of where your other services are. Easy mistake to make: creating the cert in a region near you and then wondering why CloudFront can't see it.

aws acm request-certificate \
  --domain-name your-domain.com \
  --subject-alternative-names www.your-domain.com \
  --validation-method DNS \
  --region us-east-1

ACM gives you DNS validation records (CNAMEs). You'll add them in step 4.

Step 3: Create the Route 53 hosted zone

aws route53 create-hosted-zone \
  --name your-domain.com \
  --caller-reference "$(date +%s)"

This returns four nameservers. Update your domain registrar to point at these (covered in the domain transfer post).

Step 4: Validate the ACM certificate

The CNAME records ACM provided in step 2 go into Route 53 now:

aws route53 change-resource-record-sets \
  --hosted-zone-id Z0XXXXXXXXX \
  --change-batch file://acm-validation.json

Where acm-validation.json upserts the two CNAMEs ACM gave you. Within 5–30 minutes, the cert flips from PENDING_VALIDATION to ISSUED.

Step 5: Create the CloudFront Origin Access Control

OAC is the modern way for CloudFront to read from a private S3 bucket. It replaces the older Origin Access Identity (OAI) pattern. Use OAC.

aws cloudfront create-origin-access-control \
  --origin-access-control-config '{
    "Name": "your-site-oac",
    "Description": "OAC for your-site-frontend bucket",
    "OriginAccessControlOriginType": "s3",
    "SigningBehavior": "always",
    "SigningProtocol": "sigv4"
  }'

Returns an OAC ID like E1ABC.... Save it.

Step 6: Create the CloudFront distribution

This is the big one. The full distribution config:

{
  "CallerReference": "your-site-1",
  "Comment": "your-site static site",
  "Enabled": true,
  "IsIPV6Enabled": true,
  "HttpVersion": "http2and3",
  "PriceClass": "PriceClass_100",
  "DefaultRootObject": "index.html",
  "Aliases": {
    "Quantity": 2,
    "Items": ["your-domain.com", "www.your-domain.com"]
  },
  "Origins": {
    "Quantity": 1,
    "Items": [{
      "Id": "s3-your-site",
      "DomainName": "your-site-frontend-${ACCOUNT_ID}.s3.us-east-1.amazonaws.com",
      "OriginAccessControlId": "${OAC_ID}",
      "S3OriginConfig": { "OriginAccessIdentity": "" },
      "ConnectionAttempts": 3,
      "ConnectionTimeout": 10
    }]
  },
  "DefaultCacheBehavior": {
    "TargetOriginId": "s3-your-site",
    "ViewerProtocolPolicy": "redirect-to-https",
    "AllowedMethods": {
      "Quantity": 2,
      "Items": ["GET", "HEAD"],
      "CachedMethods": { "Quantity": 2, "Items": ["GET", "HEAD"] }
    },
    "Compress": true,
    "CachePolicyId": "658327ea-f89d-4fab-a63d-7e88639e58f6",
    "ResponseHeadersPolicyId": "67f7725c-6f97-4210-82d7-5512b31e9d03"
  },
  "CustomErrorResponses": {
    "Quantity": 2,
    "Items": [
      { "ErrorCode": 403, "ResponseCode": "200", "ResponsePagePath": "/index.html", "ErrorCachingMinTTL": 10 },
      { "ErrorCode": 404, "ResponseCode": "200", "ResponsePagePath": "/index.html", "ErrorCachingMinTTL": 10 }
    ]
  },
  "ViewerCertificate": {
    "ACMCertificateArn": "${ACM_ARN}",
    "SSLSupportMethod": "sni-only",
    "MinimumProtocolVersion": "TLSv1.2_2021"
  },
  "Restrictions": { "GeoRestriction": { "RestrictionType": "none", "Quantity": 0 } }
}

Highlights:

  • PriceClass_100 is the cheapest tier — North America and Europe edges only. Other tiers add edges globally at higher cost. PriceClass_100 covers ~95% of typical traffic.
  • http2and3 enables HTTP/2 and HTTP/3 (QUIC). Free perf.
  • Compress: true turns on gzip + Brotli at the edge.
  • CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6 is AWS's managed CachingOptimized policy.
  • ResponseHeadersPolicyId: 67f7725c-6f97-4210-82d7-5512b31e9d03 is the managed SecurityHeadersPolicy (HSTS, CSP, etc.). Free hardening.
  • CustomErrorResponses map 403 and 404 to /index.html with status 200. This is what makes single-page-app routing work — any unknown path renders your app shell, and React handles the route client-side.
aws cloudfront create-distribution \
  --distribution-config file://cf-config.json

Returns a distribution ID (E1ABC...) and a domain name (d2hc0fvjfmp1yp.cloudfront.net). Save both.

Step 7: Attach the S3 bucket policy for OAC

Now S3 needs to allow CloudFront (specifically, your distribution) to read:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Sid": "AllowCloudFrontServicePrincipal",
    "Effect": "Allow",
    "Principal": { "Service": "cloudfront.amazonaws.com" },
    "Action": "s3:GetObject",
    "Resource": "arn:aws:s3:::your-site-frontend-${ACCOUNT_ID}/*",
    "Condition": {
      "StringEquals": {
        "AWS:SourceArn": "arn:aws:cloudfront::${ACCOUNT_ID}:distribution/${DISTRIBUTION_ID}"
      }
    }
  }]
}

The AWS:SourceArn condition scopes the access to your specific distribution. Without it, any CloudFront distribution in any AWS account could read your bucket.

aws s3api put-bucket-policy \
  --bucket your-site-frontend-${ACCOUNT_ID} \
  --policy file://s3-policy.json

Step 8: Create Route 53 alias records

Apex and www, A and AAAA, all aliased to the CloudFront distribution. The hosted zone ID Z2FDTNDATAQYW2 is the universal CloudFront alias zone — magic value, same for everyone.

{
  "Changes": [
    {
      "Action": "UPSERT",
      "ResourceRecordSet": {
        "Name": "your-domain.com.",
        "Type": "A",
        "AliasTarget": {
          "HostedZoneId": "Z2FDTNDATAQYW2",
          "DNSName": "d2hc0fvjfmp1yp.cloudfront.net.",
          "EvaluateTargetHealth": false
        }
      }
    }
  ]
}

Repeat for AAAA (IPv6), www.your-domain.com A, and www.your-domain.com AAAA. Four records total.

Step 9: Build and deploy

Three commands:

npm run build

# Hashed assets get long cache (immutable, since the hash changes when content changes)
aws s3 sync dist/assets/ s3://your-site-frontend-${ACCOUNT_ID}/assets/ \
  --delete \
  --cache-control "public, max-age=31536000, immutable"

# Index.html and other top-level files: no-cache so updates appear immediately
aws s3 sync dist/ s3://your-site-frontend-${ACCOUNT_ID}/ \
  --delete \
  --exclude "assets/*" \
  --cache-control "no-cache, no-store, must-revalidate"

# Invalidate CDN
aws cloudfront create-invalidation \
  --distribution-id ${DISTRIBUTION_ID} \
  --paths "/*"

Within 60 seconds, your changes are live globally.

The cost recap

  • Route 53 hosted zone: $0.50/month
  • CloudFront: free (within 1TB/month + 10M requests free tier)
  • S3: <$0.01/month (for a sub-1MB site)
  • ACM cert: free

Total: $0.50/month.

The deploy script in one file

Save the build + sync + invalidate commands as scripts/deploy.sh. Wrap with the AWS profile and resource IDs as variables. From then on, deploying is one command.

This site's deploy script is open source — find it in the Vibe Coder's Guide to MVP starter repo.

That's the full pattern. Cheap, reliable, the same shape every other AWS-hosted static site uses. The internet runs on this. Your site can too.

Did this land for you?

← All posts