S3 stores the files. CloudFront serves them globally with HTTPS. ACM issues the cert for free. Route 53 makes mybrand.com reach the whole thing. It's the canonical AWS way to ship a static site — once you've set it up, deploys are a single aws s3 sync.
Most modern alternatives — Vercel, Netlify, Cloudflare Pages — bundle this whole pipeline into one button. You push a git repo, they handle storage, CDN, cert, and DNS. The reason people still set up S3 + CloudFront by hand is that it's part of AWS: the same IAM policy that lets a Lambda write a generated report to the same bucket; the same CloudFront distribution that fronts an ALB behind it; the same Route 53 zone holding records for other AWS resources. If your infrastructure is on AWS, the static site lives on AWS too — and at AWS prices, which for static content are extremely cheap.
The mental model: S3 is the origin (a private bucket holding the built files), CloudFront is the cache + entrypoint (caches files at edge locations worldwide, terminates TLS, serves the https:// URL), ACM is the cert (free TLS, but must be in us-east-1 for CloudFront), Route 53 is the DNS (Alias record points your domain at the CloudFront distribution). Four services, one for each job, none of them doing the other's work.
This tutorial walks the first-time setup end-to-end, plus what a deploy looks like after the pipeline is wired. We'll use the AWS CLI for the parts that are tedious in the console (bucket policy, sync) and the console for the parts that are tedious in CLI (CloudFront, ACM). At the end, deploys are npm run build && aws s3 sync ./dist s3://mybrand-site && aws cloudfront create-invalidation ... — three commands forever.
us-east-1aws configure), a domain with DNS at Route 53 (or willingness to add records elsewhere), and a built static site (a directory of HTML/CSS/JS).
Pick a bucket name. It has to be globally unique across all of AWS, so something like mybrand-site-prod-2026. The name doesn't matter much — users never see it; CloudFront sits in front.
aws s3api create-bucket \
--bucket mybrand-site-prod-2026 \
--region us-east-1
# (For regions other than us-east-1, add --create-bucket-configuration LocationConstraint=<region>)
Leave the default "Block all public access" settings on. With CloudFront + OAC in front (Step 4), the bucket stays private; CloudFront has its own credential to read from it. Public-bucket setups still work but are the deprecated pattern — newer AWS docs all use OAC.
aws s3 sync is the deploy commandIf your site is built into ./dist (or ./build, ./public, whatever):
aws s3 sync ./dist s3://mybrand-site-prod-2026 --delete
# --delete removes files in S3 that no longer exist locally —
# without it, removed pages stay live as orphans.
The first sync uploads everything. Subsequent syncs only upload changed files. This is the deploy command you'll script later. At this point S3 has the files but they're inaccessible — the bucket is private and there's no CloudFront yet.
This is the most common first-time error. AWS Certificate Manager certificates are region-specific. CloudFront is a global service but is fronted by us-east-1 for cert lookup. Switch the console region to "US East (N. Virginia) — us-east-1" before requesting the cert, or do it via CLI:
aws acm request-certificate \
--domain-name mybrand.com \
--subject-alternative-names www.mybrand.com \
--validation-method DNS \
--region us-east-1
ACM returns a CertificateArn and a set of CNAME validation records — one per domain in the cert. If your DNS is at Route 53 in the same account, the console offers a one-click "Create records in Route 53" button. Otherwise, you copy each CNAME to your DNS provider manually. Validation takes minutes once the records are live.
Open CloudFront → Create distribution. Fill out:
mybrand.com and www.mybrand.com.index.html.Create. CloudFront provisions the distribution; this takes 5–15 minutes the first time. You'll get a distribution domain name like d1234abcde.cloudfront.net — you can hit that URL directly to test before DNS is wired up.
The OAC creation flow shows a bucket policy snippet on the success screen. Copy it. In S3 → bucket → Permissions → Bucket policy, paste it in. It looks like:
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "AllowCloudFrontServicePrincipalReadOnly",
"Effect": "Allow",
"Principal": { "Service": "cloudfront.amazonaws.com" },
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::mybrand-site-prod-2026/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::123456789:distribution/E1ABCDEFGHIJK"
}
}
}]
}
The SourceArn condition is what makes OAC secure: only your specific distribution can read the bucket. Without it, any CloudFront distribution in any account could.
In Route 53 → your hosted zone → Create record:
www (for the www subdomain).Create. Repeat for the other name (apex / www). DNS propagates in minutes — for an Alias record on the same AWS account, often seconds.
If your DNS isn't at Route 53, add a regular CNAME record at your DNS provider with value d1234abcde.cloudfront.net for www, and use your DNS provider's CNAME-flattening / ALIAS feature for the apex (DNS tutorial covers this).
# 1. Cert is on the distribution.
curl -sI https://mybrand.com | grep -E '^(HTTP|Server)'
# Expected: HTTP/2 200 ... Server: CloudFront
# 2. Files are reachable.
curl -s https://mybrand.com | head
# Expected: your index.html.
# 3. DNS points at CloudFront.
dig mybrand.com +short
# Expected: a CloudFront edge IP (changes per region; just confirm it's not blank).
From now on, deploys look like this — save these three lines as deploy.sh in your project:
#!/usr/bin/env bash
set -e
npm run build
aws s3 sync ./dist s3://mybrand-site-prod-2026 --delete
aws cloudfront create-invalidation \
--distribution-id E1ABCDEFGHIJK \
--paths "/*"
The invalidation is the part that makes new files visible immediately. Without it, CloudFront keeps serving cached old files until the TTL expires (default 24 hours for cached responses). You get 1,000 invalidation paths free per month — for one-path invalidations on every deploy, that's 33/day, which covers most projects.
For a personal site with 10,000 monthly visitors and ~1 GB transferred:
Total: under $1/month for a real-but-quiet personal site. The first 50 GB of CloudFront data transfer is free under the AWS Free Tier for 12 months, which usually covers everything; after the free tier, costs scale linearly. A static site doing 1 TB/month would be ~$85 in transfer, which is when Cloudflare Pages (unlimited bandwidth, free) starts looking very attractive.