This walks through the actual production pattern for hosting a static site on AWS: a private S3 bucket, CloudFront in front of it via Origin Access Control, and a free ACM certificate for HTTPS — not the older, publicly-readable-bucket approach that's still floating around in outdated tutorials.
- An AWS account and the AWS CLI configured with credentials that can create S3 buckets, CloudFront distributions, and ACM certificates.
- A built static site (an
index.htmland assets, or the output of any static site generator). - A registered domain if you want a custom domain — this works with the default CloudFront domain too, without one.
Step 1: Create the S3 bucket
aws s3api create-bucket \
--bucket my-static-site-example \
--region us-east-1Block all public access explicitly — this bucket is never meant to be reached directly, only through CloudFront:
aws s3api put-public-access-block \
--bucket my-static-site-example \
--public-access-block-configuration \
BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=trueStep 2: Upload the site
aws s3 sync ./out s3://my-static-site-example --delete--delete removes objects in the bucket that no longer exist locally, keeping the bucket an exact mirror of your build output rather than accumulating stale files from previous deploys.
Step 3: Create a CloudFront distribution with Origin Access Control
Origin Access Control (OAC) is the current, recommended mechanism for letting CloudFront read from a private S3 bucket — it replaced the older Origin Access Identity (OAI), which AWS now considers legacy.
aws cloudfront create-origin-access-control \
--origin-access-control-config \
Name="my-static-site-oac",SigningProtocol=sigv4,SigningBehavior=always,OriginAccessControlOriginType=s3Creating the distribution itself is most reliably done through the console or Infrastructure as Code (see Infrastructure as Code: Why Terraform Won) given how many fields it has, but the essential shape in Terraform:
resource "aws_cloudfront_distribution" "site" {
enabled = true
default_root_object = "index.html"
origin {
domain_name = aws_s3_bucket.site.bucket_regional_domain_name
origin_id = "s3-origin"
origin_access_control_id = aws_cloudfront_origin_access_control.oac.id
}
default_cache_behavior {
target_origin_id = "s3-origin"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
cache_policy_id = data.aws_cloudfront_cache_policy.caching_optimized.id
}
restrictions {
geo_restriction {
restriction_type = "none"
}
}
viewer_certificate {
cloudfront_default_certificate = true
}
}Step 4: Lock the bucket policy to CloudFront only
With OAC in place, the bucket policy grants read access specifically to the CloudFront service principal, scoped to your exact distribution — not to the public:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowCloudFrontServicePrincipal",
"Effect": "Allow",
"Principal": { "Service": "cloudfront.amazonaws.com" },
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-static-site-example/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::123456789012:distribution/EXXXXXXXXXXXXX"
}
}
}
]
}The Condition block matters — without it, any CloudFront distribution in any AWS account could theoretically be configured to read this bucket, not just yours. Scoping it to your exact distribution ARN closes that gap.
Step 5: Add a custom domain and free HTTPS via ACM
Request a certificate in us-east-1 specifically — CloudFront only accepts ACM certificates issued in that region, regardless of which region your other resources live in:
aws acm request-certificate \
--domain-name example.com \
--validation-method DNS \
--region us-east-1After validating the certificate (adding the DNS CNAME record ACM provides), attach it to the distribution's viewer_certificate block and add your domain as an alternate domain name (aliases), then point your domain's DNS at the CloudFront distribution — a Route 53 alias record if your domain is hosted there, or a CNAME otherwise.
Step 6: Handle client-side routing correctly
A single-page app or a static export with client-side routes needs 403/404 responses from S3 mapped back to index.html, so a direct load of a deep route doesn't just show a raw S3 error:
Error caching behavior:
403 → /index.html, HTTP 200
404 → /index.html, HTTP 200
For a fully static-exported multi-page site (like this one, or most Next.js output: "export" sites) this matters less, since every real route already has its own index.html generated at build time — but it's essential for any client-only routing layer.
Step 7: Invalidate the cache on deploy
CloudFront caches aggressively by design — after uploading new files, invalidate so viewers see the update instead of a stale cached version:
aws cloudfront create-invalidation \
--distribution-id EXXXXXXXXXXXXX \
--paths "/*"A wildcard /* invalidation bills as a single path for CloudFront's invalidation pricing, not "one charge per file" — a common cost misconception. The free tier includes 1,000 invalidation paths per month, and a single deploy's /* invalidation only consumes one of them.
Adding security headers with a CloudFront Function
A static site with no server can still set real security headers (Strict-Transport-Security, X-Content-Type-Options, Content-Security-Policy) by attaching a lightweight CloudFront Function to the viewer-response event — this runs at the edge, adding negligible latency compared to a full Lambda@Edge function:
function handler(event) {
var response = event.response;
var headers = response.headers;
headers["strict-transport-security"] = { value: "max-age=63072000; includeSubDomains; preload" };
headers["x-content-type-options"] = { value: "nosniff" };
headers["x-frame-options"] = { value: "DENY" };
headers["referrer-policy"] = { value: "strict-origin-when-cross-origin" };
return response;
}aws cloudfront create-function \
--name add-security-headers \
--function-config Comment="Security headers",Runtime="cloudfront-js-2.0" \
--function-code fileb://headers.jsAttach it to the distribution's default_cache_behavior as a function-association on the viewer-response event. CloudFront Functions are the right tool specifically for cheap, stateless header/URL manipulation like this — Lambda@Edge is the heavier option, needed only when logic requires actual compute beyond simple request/response transformation (calling another service, for instance).
Automating deploys in CI
Wiring steps 2 (sync) and 7 (invalidate) into GitHub Actions, using OIDC federation rather than long-lived AWS keys, turns this into a real deploy pipeline triggered on every merge to main:
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
aws-region: us-east-1
- run: aws s3 sync ./out s3://my-static-site-example --delete
- run: |
aws cloudfront create-invalidation \
--distribution-id ${{ vars.CLOUDFRONT_DISTRIBUTION_ID }} \
--paths "/*"This is close to the exact pattern this site's own deploy.yml uses in production — see GitHub Actions Reusable Workflows for how to extract it into a shared template once you have more than one static site to deploy this way.
Handling multiple environments from one bucket structure
A single S3 bucket can serve several environments (preview builds, staging, production) by prefixing keys and pointing separate CloudFront distributions (or separate cache behaviors on one distribution) at different prefixes:
s3://my-static-site-example/production/index.html
s3://my-static-site-example/staging/index.html
s3://my-static-site-example/pr-123/index.html
aws s3 sync ./out s3://my-static-site-example/pr-123 --deleteThis is a common pattern for preview deployments on pull requests — each PR gets its own prefix, uploaded and torn down automatically in CI, without provisioning a whole new bucket or distribution per PR. The tradeoff is that all environments share the same underlying bucket's access controls and lifecycle rules — genuinely separate compliance or access requirements per environment argue for separate buckets instead, prefixing being the lighter-weight option for environments that don't need that isolation.
Verifying the setup end to end
Before considering the deploy done, three checks confirm the pieces are actually working together, not just individually configured:
# 1. Confirm the bucket itself is NOT directly reachable
curl -I https://my-static-site-example.s3.amazonaws.com/index.html
# expect: 403 Forbidden — this is correct, not a bug
# 2. Confirm CloudFront serves the site correctly
curl -I https://<distribution-id>.cloudfront.net/
# expect: 200 OK
# 3. Confirm HTTPS redirect and the custom domain both work
curl -I http://example.com/
# expect: a 301/302 redirect to https://A 403 on the direct S3 URL is the sign the Origin Access Control setup is actually working as intended, not a misconfiguration to fix — the bucket should never be reachable except through CloudFront.
Common mistakes
- Making the S3 bucket publicly readable instead of using Origin Access Control. This was the standard pattern years ago, but it means anyone who discovers the bucket's direct S3 URL can bypass CloudFront (and its caching/HTTPS) entirely — OAC keeps the bucket genuinely private.
- Requesting the ACM certificate in the wrong region. CloudFront only accepts certificates from
us-east-1— a certificate requested in any other region simply won't appear as selectable when attaching it to the distribution. - Forgetting to set
default_root_objecttoindex.html. Without it, requests to the root path (/) return a listing error instead of your homepage. - Skipping the cache invalidation step after every deploy, and assuming users are seeing the new version — CloudFront's default caching behavior can serve a stale version for hours without an explicit invalidation.
Practical tips
- Set long cache lifetimes on hashed, immutable assets (
/_next/static/..., fingerprinted JS/CSS) and short-or-no cache onindex.htmland other entry points — this gets you both aggressive edge caching and instant rollout of new deploys. - Automate the sync-and-invalidate steps in CI (GitHub Actions with OIDC, not static AWS keys) rather than running them by hand — see GitHub Actions Reusable Workflows for a template of exactly this pattern.
- Use a wildcard
/*invalidation on deploy rather than trying to compute exactly which files changed — the pricing model makes the extra precision not worth the complexity for a typical site. - Keep the bucket policy scoped to one specific distribution ARN, not a broader condition — the tighter the policy, the less blast radius if anything else in the account is misconfigured later.
Related reading
- Core AWS Services Every Developer Should Know — shares tags: cloud (same category).
- Deploying a 3-Tier Application on AWS with Public and Private Subnets — shares tags: cloud.
- Infrastructure as Code: Why Terraform Won — shares tags: cloud.
- GitHub Actions Reusable Workflows: Build CI/CD Templates Once, Use Everywhere — shares tags: cloud.
- Core Web Vitals Explained: What Actually Affects Your Score — shares tags: web-development.