Tutorials Search / Shipping & infrastructure / Deploy a static site to S3 + CloudFront
📝 Written ● Intermediate Updated 2026-05-13

Deploy a static site to S3 + CloudFront

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.

What you'll learn

Prerequisites: An AWS account with admin or sufficient IAM permissions, the AWS CLI installed and configured (aws 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).

Step 1: Create the S3 bucket

1

Private bucket, default settings, one CLI command

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.

Step 2: Upload your site

2

aws s3 sync is the deploy command

If 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.

Step 3: Request an ACM certificate in us-east-1

3

Region matters; CloudFront only reads certs from us-east-1

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.

Cert renewal is automatic. Once issued and validated via DNS, ACM renews the cert every 13 months and rotates it into CloudFront with zero downtime. You'll never touch the cert again as long as the DNS validation records remain in place.

Step 4: Create the CloudFront distribution with OAC

4

The complicated step; do it in the console

Open CloudFront → Create distribution. Fill out:

  • Origin domain: select your S3 bucket from the dropdown (not the website endpoint — pick the bucket itself).
  • Origin access: Origin access control settings (recommended). Click Create new OAC, accept defaults, save. CloudFront will give you a bucket policy snippet at the end of distribution creation — you'll paste it into S3 in Step 5.
  • Viewer protocol policy: Redirect HTTP to HTTPS.
  • Allowed HTTP methods: GET, HEAD (static sites don't need POST).
  • Alternate domain names (CNAMEs): mybrand.com and www.mybrand.com.
  • Custom SSL certificate: select the ACM cert from Step 3.
  • Default root object: 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.

Step 5: Attach the OAC bucket policy

5

Let CloudFront read from the bucket; nothing else

The OAC creation flow shows a bucket policy snippet on the success screen. Copy it. In S3 → bucket → PermissionsBucket 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.

Step 6: Point your domain at CloudFront

6

One Alias record per domain, in Route 53

In Route 53 → your hosted zone → Create record:

  • Record name: empty (for apex) or www (for the www subdomain).
  • Record type: A.
  • Alias: on.
  • Route traffic to: Alias to CloudFront distribution → select yours.

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).

Step 7: Verify and bookmark the deploy command

7

Three checks; then it's three commands forever

# 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.

What this actually costs

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.

What's next