What this actually gets you
A static site — HTML, CSS, JavaScript, images, no server-side code — needs somewhere to keep files and something to hand them to visitors over HTTPS. On AWS that is an S3 bucket for the files and a CloudFront distribution in front of it for TLS, custom domains, and an edge cache.
There are no servers to patch and nothing that can crash at 2am. For a typical brochure site the bill lands in the region of a dollar a month, most of it CloudFront traffic rather than storage.
The whole stack is four services, and you touch each of them once:
Website endpoint or CloudFront?
S3 has a built-in static website endpoint, and every tutorial starts there. It works, but it serves plain HTTP and requires the bucket to be publicly readable, so it cannot carry your domain with a padlock on it.
The setup worth learning is the second one: keep the bucket private and put CloudFront in front with Origin Access Control. Same files, but with HTTPS, a free certificate that renews itself, and a cache that puts your site near the visitor.
Create the bucket
The bucket name only has to be unique within AWS — it never appears in a URL once CloudFront is in front, so it does not need to match your domain. Pick a region close to you or to whoever updates the site; the cache handles distance for everyone else.
aws s3api create-bucket \
--bucket example-site-prod \
--region ap-south-1 \
--create-bucket-configuration LocationConstraint=ap-south-1
# keep every public-access door shut
aws s3api put-public-access-block \
--bucket example-site-prod \
--public-access-block-configuration \
"BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"
# versioning: a bad deploy stays recoverable
aws s3api put-bucket-versioning \
--bucket example-site-prod \
--versioning-configuration Status=Enabled
Leave Block all public access on. With CloudFront and OAC, nothing about
this setup requires the bucket to be public, and a public bucket is the single most
common way static hosting goes wrong.
Upload the build output
“Build output” is the folder your build command produces —
dist/, build/, public/ or _site/,
depending on the tool. It is not in AWS and not in your repository: it is generated
on whichever machine runs npm run build, and it is normally in
.gitignore. What you commit is the source; what you upload is the output.
That machine is either your laptop or a CI runner such as GitHub Actions. The command is the same in both places; only the credentials differ. Upload the contents of the build directory, not the directory itself — everything is relative to the bucket root, so an extra folder level is the reason a site loads with no styling.
aws s3 sync ./dist s3://example-site-prod --delete
The --delete flag removes files in the bucket that no longer exist in the
build. Without it, deleted pages stay live for months and old bundles quietly
accumulate storage cost.
Point CloudFront at the bucket
Create a distribution whose origin is the S3 bucket (the
.s3.amazonaws.com name), not the website endpoint, and attach an Origin
Access Control. The settings that matter:
- Origin access — Origin Access Control, signed with SigV4.
- Viewer protocol policy — redirect HTTP to HTTPS.
- Default root object —
index.html, so the bare domain resolves to something. - Compression — on. It is a checkbox and it is free.
- Alternate domain name — your domain, plus the
wwwvariant if you use one.
CloudFront will offer to write the bucket policy for you. It ends up looking like this — access granted to the CloudFront service principal, restricted to one distribution:
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "AllowCloudFrontRead",
"Effect": "Allow",
"Principal": { "Service": "cloudfront.amazonaws.com" },
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::example-site-prod/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::111122223333:distribution/E1A2B3C4D5E6F7"
}
}
}]
}
Note what is missing: no "Principal": "*", no public read. If your policy
has either, the bucket is open to the internet regardless of what CloudFront is doing.
Certificate and DNS
Request the certificate in AWS Certificate Manager in us-east-1. This catches almost everyone once: CloudFront only reads certificates from that region, no matter where the bucket lives. Validate by DNS so it renews without you.
Then point the domain at the distribution. In Route 53 that is an A record of type
alias targeting CloudFront — not a CNAME, which cannot sit on a bare
domain. On another DNS provider, use a CNAME for www and whatever
flattening or ALIAS feature they offer for the apex.
Pick one canonical hostname and redirect the other to it. Serving the site on both
example.com and www.example.com splits your SEO and doubles
the surprises.
Cache headers, and the deploy that ignores them
This is the part that decides whether visitors see your update. Hashed asset filenames change on every build, so they can be cached forever. HTML entry files keep the same name and point at those assets, so they must never be cached.
Set the headers at upload time, in two passes:
# 1. everything except HTML: cache hard
aws s3 sync ./dist s3://example-site-prod --delete \
--exclude "*.html" \
--cache-control "public,max-age=31536000,immutable"
# 2. HTML last, uncached, so it never points at missing bundles
aws s3 sync ./dist s3://example-site-prod \
--exclude "*" --include "*.html" \
--cache-control "no-cache"
# 3. drop the edge copies of the HTML
aws cloudfront create-invalidation \
--distribution-id E1A2B3C4D5E6F7 \
--paths "/*"
Assets first, HTML second, invalidation last. In that order there is no moment where
a new page is asking for a bundle that has not finished uploading. Invalidations are
free for the first 1,000 paths a month; "/*" counts as one.
Deploying from GitHub instead of your laptop
Running the deploy by hand is fine until you forget the header flags or upload from a
stale branch. Moving it into GitHub Actions makes every push to main
produce the same three steps, in the same order:
name: Deploy
on:
push:
branches: [main]
permissions:
id-token: write # lets the runner assume the AWS role
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npm ci && npm run build
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111122223333:role/github-deploy
aws-region: ap-south-1
- run: |
aws s3 sync ./dist s3://example-site-prod --delete \
--exclude "*.html" --cache-control "public,max-age=31536000,immutable"
aws s3 sync ./dist s3://example-site-prod \
--exclude "*" --include "*.html" --cache-control "no-cache"
aws cloudfront create-invalidation \
--distribution-id E1A2B3C4D5E6F7 --paths "/*"
Use an IAM role with GitHub's OIDC provider as the trust, not an access key stored in repository secrets. A long-lived key in CI is the credential most likely to end up somewhere it should not be; a role issues short-lived credentials to that one repository and nothing else.
Clean URLs and error pages
CloudFront serves index.html for the root, but not for
/about/ — the request arrives for a key that does not exist and you
get a 403. Two ways round it:
-
Multi-page sites — a small CloudFront Function on viewer
request that appends
index.htmlto any path ending in a slash. That is the mechanism behind directory-style URLs like the one you are reading. -
Single-page apps — a custom error response mapping 403 and 404
to
/index.htmlwith a 200 status, so the client-side router handles the path.
Also set a real 404 page for the multi-page case. The default CloudFront error is XML and it looks broken, because to a visitor it is.
Before you call it done
- Load the site over
http://and confirm it redirects to HTTPS. - Load the non-canonical hostname and confirm it redirects once, not in a loop.
- Check a response header:
x-cache: Hit from cloudfronton a second request. - Try the bucket URL directly — it should return Access Denied. If the site loads, the bucket is public.
- Deploy a visible change and reload; if you still see the old page, the HTML got cached.
- Set a billing alert. Two minutes, and it catches the mistakes that pricing pages do not.
When it is worth handing over
None of these steps are hard, but there are enough of them that one gets skipped, and the skipped one is usually the certificate region, the public bucket, or the cache header. We set this up as part of deployment work — buckets, distributions, DNS, SSL, and a deploy script your team can run — on AWS, Azure, or Google Cloud. If you already have a site on S3 behaving oddly, send the domain and the distribution ID.