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.

Amazon Web Services logo

The whole stack is four services, and you touch each of them once:

Four services, nothing else Storage, delivery, certificate, name. Amazon S3 the files CloudFront HTTPS and cache Certificate Manager us-east-1 only Route 53 alias record
S3 holds the files, CloudFront serves them, ACM issues the certificate, Route 53 carries the name.
Static site architecture on AWS One request path in, one deploy path down. AWS Cloud Region · ap-south-1 OAC Visitors https://example.com Route 53 alias A record CloudFront edge cache, TLS Amazon S3 private bucket certificate ACM issued in us-east-1 OUTSIDE AWS Build output dist/ from the build your laptop or CI aws s3 sync Nothing reaches the bucket directly: CloudFront is the only reader, and the deploy is the only writer.
The request path runs left to right; the deploy path is the dashed line into the bucket.

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.

Two ways to serve the bucket One is quick. One is the one you ship. S3 website endpoint http:// only Bucket must be publicly readable No HTTPS on a custom domain No edge cache, no global speed Index and error documents built in Fine for a throwaway test. CloudFront + OAC https:// everywhere Bucket stays fully private Free ACM certificate, auto renewed Cached near the visitor Custom error pages and redirects The default for anything with a domain.
The website endpoint is a test tool. CloudFront is what you launch on.

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 objectindex.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 www variant 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.

Cache the assets, never the HTML The single header that decides whether a deploy is visible. FILE CACHE-CONTROL WHY app.4f1c2a.js hashed bundles and CSS max-age=31536000, immutable New build, new name. logo.png images and fonts max-age=604800 A week is plenty. index.html every entry document no-cache It points at the new bundles.
Long cache on anything with a hash in the name, no cache on the documents that reference them.

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.html to 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.html with 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 cloudfront on 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.