DEPLOYMENTAWSS3CloudFrontStatic Hosting

Deploy static hosting with AWS S3 and CloudFront

Host a static site on S3 behind a CloudFront distribution with HTTPS, a custom domain, and cache invalidation on deploy — all for a few cents a month.

·5 min read

Static hosting on S3 + CloudFront is the cheapest and lowest-maintenance way to serve a site that doesn't need a server — a Next.js static export, a Vite/React build, or plain HTML. S3 stores the files; CloudFront puts a global CDN and HTTPS in front of them. There's no instance to patch and no process to keep alive.

What you'll end up with

  • A private S3 bucket (not publicly readable on its own) holding your built site
  • A CloudFront distribution that serves it over HTTPS, cached at edge locations worldwide
  • A custom domain pointed at CloudFront via Route 53 (or your existing DNS provider)
  • A deploy command that uploads new files and invalidates the CDN cache

NOTE

This guide assumes you already have a built static site (an out/ or dist/ folder) and a domain you can add a DNS record to. If you're exporting a Next.js app statically, make sure next.config.js has output: "export" first.

Step 1 — Create the S3 bucket

Create the bucket

aws s3api create-bucket \
  --bucket your-site-bucket \
  --region us-east-1

Pick a globally unique bucket name — it doesn't need to match your domain name.

Block public access

Keep the bucket private. CloudFront will reach it through an Origin Access Control (OAC), not the public internet.

aws s3api put-public-access-block \
  --bucket your-site-bucket \
  --public-access-block-configuration \
  BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true

Upload the built site

aws s3 sync ./out s3://your-site-bucket --delete

--delete removes files in the bucket that no longer exist locally, keeping the bucket an exact mirror of your build output.

WARNING

Don't enable "Static website hosting" on the bucket itself and point CloudFront at the resulting website endpoint — that endpoint only supports HTTP and public bucket policies. Use the bucket's regular REST endpoint with an Origin Access Control instead, which is what keeps the bucket private.

Step 2 — Request a TLS certificate

CloudFront requires certificates to live in us-east-1, regardless of where your other resources are.

aws acm request-certificate \
  --domain-name your-domain.com \
  --subject-alternative-names "*.your-domain.com" \
  --validation-method DNS \
  --region us-east-1

ACM gives you a CNAME record to add to your DNS zone for domain validation. Add it, then wait for the certificate status to become ISSUED — this is usually a few minutes with Route 53, longer with third-party DNS.

aws acm describe-certificate \
  --certificate-arn arn:aws:acm:us-east-1:...:certificate/... \
  --region us-east-1 \
  --query "Certificate.Status"

Step 3 — Create the CloudFront distribution

Create the distribution with an S3 origin, an Origin Access Control, and your certificate attached:

SettingValue
Origin domainyour-site-bucket.s3.amazonaws.com
Origin accessOrigin Access Control (OAC)
Viewer protocol policyRedirect HTTP to HTTPS
Alternate domain name (CNAME)your-domain.com
Custom SSL certificateThe ACM certificate from Step 2
Default root objectindex.html

TIP

Set "Default root object" to index.html so requests to / resolve correctly. For a single-page app with client-side routing, also add a custom error response mapping 403/404 to /index.html with a 200 status, so deep links don't break on refresh.

After creating the distribution, CloudFront generates a bucket policy snippet granting it read access via the OAC — apply it to the bucket:

aws s3api put-bucket-policy \
  --bucket your-site-bucket \
  --policy file://bucket-policy.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowCloudFrontServicePrincipal",
      "Effect": "Allow",
      "Principal": { "Service": "cloudfront.amazonaws.com" },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::your-site-bucket/*",
      "Condition": {
        "StringEquals": {
          "AWS:SourceArn": "arn:aws:cloudfront::...:distribution/YOUR_DISTRIBUTION_ID"
        }
      }
    }
  ]
}

Step 4 — Point your domain at CloudFront

In Route 53 (or your DNS provider), add an A/ALIAS record for your-domain.com pointing at the CloudFront distribution's domain name (dxxxxxxxxxxxxx.cloudfront.net). If you're using Route 53, use an alias record rather than a CNAME — alias records work at the zone apex and don't incur an extra DNS lookup.

aws route53 change-resource-record-sets \
  --hosted-zone-id YOUR_ZONE_ID \
  --change-batch file://dns-change.json

Distribution deployment takes 10-15 minutes the first time. Once it's done, your site should be reachable over HTTPS at your domain.

Step 5 — Deploy updates with cache invalidation

Every time you rebuild the site, sync the new files and invalidate CloudFront's cache so viewers don't keep seeing stale assets:

aws s3 sync ./out s3://your-site-bucket --delete
aws cloudfront create-invalidation \
  --distribution-id YOUR_DISTRIBUTION_ID \
  --paths "/*"

TIP

Invalidating /* is simple but costs more at high deploy frequency — CloudFront's first 1,000 invalidation paths per month are free, then billed per path. For a site that deploys often, invalidate only /index.html and any changed HTML routes, and rely on content-hashed filenames (the default for most bundlers) for JS/CSS so they never need invalidation at all.

Keeping cost down

  • S3 storage and CloudFront data transfer for a small static site typically runs under $1/month — this setup is close to the cheapest hosting option on AWS.
  • Skip a Lambda@Edge or CloudFront Function unless you actually need request-time logic (redirects, header rewrites) — plain S3 + CloudFront has no compute cost.
  • Use CloudFront's default caching behavior (respecting Cache-Control headers from S3) rather than custom cache policies, unless you have a specific reason to diverge.
  • Set a CloudWatch billing alarm — even though this setup is cheap, a misconfigured cache-busting loop or a traffic spike is worth knowing about early.

Next steps

From here, consider:

  • Wiring the deploy steps above into a GitHub Actions workflow that runs on every push to main
  • Adding a Cache-Control header to your build output (long max-age for hashed assets, no-cache for index.html) so CloudFront and browsers cache correctly without manual invalidation
  • Using AWS WAF in front of CloudFront if the site needs basic bot/rate-limit protection