The S3 + CloudFront + ACM + Route 53 deploy pattern
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_100is the cheapest tier — North America and Europe edges only. Other tiers add edges globally at higher cost. PriceClass_100 covers ~95% of typical traffic.http2and3enables HTTP/2 and HTTP/3 (QUIC). Free perf.Compress: trueturns on gzip + Brotli at the edge.CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6is AWS's managedCachingOptimizedpolicy.ResponseHeadersPolicyId: 67f7725c-6f97-4210-82d7-5512b31e9d03is the managedSecurityHeadersPolicy(HSTS, CSP, etc.). Free hardening.CustomErrorResponsesmap 403 and 404 to/index.htmlwith 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.