Skip to content
AWS projects 6 min read

Project: Static Website on S3 + CloudFront

This project walks you through deploying a real website the modern, secure way. You will store your files in a private Amazon S3 (Simple Storage Service — AWS’s object storage) bucket, serve them globally through Amazon CloudFront (a CDN, a Content Delivery Network that caches your files in data centers worldwide), and put your own custom domain in front of it with a free HTTPS certificate. The key idea: your bucket stays fully private, and only CloudFront is allowed to read from it. By the end you will have a fast, encrypted site with a clean architecture you can reuse for any front-end.

Why not just make the bucket public?

For years the classic tutorial told you to enable “static website hosting” on S3 and make the bucket public. It works, but it is no longer the recommended pattern. A public bucket has no HTTPS by default, no global caching, and is an easy way to accidentally leak data.

The modern pattern uses Origin Access Control (OAC) — a CloudFront feature that signs every request to S3 so the bucket can stay completely private. CloudFront is the only thing allowed to read your files; the public reaches CloudFront, never S3 directly.

ApproachHTTPSBucket privacyGlobal cachingWhen to use
Public S3 website endpointNo (HTTP only)PublicNoQuick throwaway demos only
CloudFront + OAC (this project)YesFully privateYesAny real production site
CloudFront + legacy OAIYesPrivateYesOlder setups; OAC replaced it

OAC (Origin Access Control) replaced the older OAI (Origin Access Identity) in 2022. Always pick OAC for new work.

Step 1: Create a private S3 bucket

Bucket names are globally unique across all of AWS, so pick something specific. We will use devcraftly-site-2026.

Console

  1. Open the S3 console and choose Create bucket.
  2. Enter the name devcraftly-site-2026 and pick a Region near your users.
  3. Leave Block all public access turned ON. This is the whole point — CloudFront, not the public, reads the bucket.
  4. Choose Create bucket.

CLI

aws s3api create-bucket \
  --bucket devcraftly-site-2026 \
  --region us-east-1

Output:

{
    "Location": "/devcraftly-site-2026"
}

Now upload your built site (your index.html, CSS, JS, and images):

aws s3 sync ./dist s3://devcraftly-site-2026 --delete

Output:

upload: dist/index.html to s3://devcraftly-site-2026/index.html
upload: dist/assets/main.css to s3://devcraftly-site-2026/assets/main.css
upload: dist/assets/app.js to s3://devcraftly-site-2026/assets/app.js

The --delete flag removes files from the bucket that no longer exist locally, keeping it in sync.

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

AWS Certificate Manager (ACM) gives you free TLS/SSL certificates for HTTPS. There is one rule that trips up almost everyone:

A certificate used by CloudFront MUST be requested in the us-east-1 (N. Virginia) Region — no matter where your bucket lives. CloudFront is a global service and only reads certificates from us-east-1. A cert in any other Region simply will not appear in the CloudFront dropdown.

Console

  1. Switch the Region selector (top right) to US East (N. Virginia) us-east-1.
  2. Open ACM and choose Request a certificateRequest a public certificate.
  3. Add your domain, e.g. www.example.com (and example.com if you want the apex too).
  4. Choose DNS validation and create the certificate.
  5. Choose Create records in Route 53 to auto-add the validation records. Validation usually completes in a few minutes.

CLI

aws acm request-certificate \
  --domain-name www.example.com \
  --validation-method DNS \
  --region us-east-1

Output:

{
    "CertificateArn": "arn:aws:acm:us-east-1:111122223333:certificate/0a1b2c3d-4e5f-6789-abcd-ef0123456789"
}

Step 3: Create the CloudFront distribution with OAC

This is where the pieces connect. CloudFront becomes the public front door and uses OAC to fetch from your private bucket.

Console

  1. Open the CloudFront console and choose Create distribution.
  2. For Origin domain, pick your bucket from the list (use the bucket’s REST endpoint, not the website endpoint).
  3. Under Origin access, choose Origin access control settings (recommended), then Create control setting and accept the defaults.
  4. Set Viewer protocol policy to Redirect HTTP to HTTPS.
  5. Set Default root object to index.html. Without this, the root URL https://example.com/ returns an error instead of your homepage.
  6. Under Settings, add your Alternate domain name (CNAME) www.example.com and select the ACM certificate from Step 2.
  7. Choose Create distribution.
  8. CloudFront shows a banner with an S3 bucket policy to copy. Open your bucket → PermissionsBucket policy and paste it. This grants only this distribution read access.

The generated bucket policy looks like this:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Sid": "AllowCloudFrontServicePrincipal",
    "Effect": "Allow",
    "Principal": { "Service": "cloudfront.amazonaws.com" },
    "Action": "s3:GetObject",
    "Resource": "arn:aws:s3:::devcraftly-site-2026/*",
    "Condition": {
      "StringEquals": {
        "AWS:SourceArn": "arn:aws:cloudfront::111122223333:distribution/E2QWERTY12345"
      }
    }
  }]
}

Step 4: Handle SPA routing (403/404 → index.html)

If you are deploying a single-page app (SPA — a React, Vue, or Angular app where routing happens in the browser), visiting a deep link like /dashboard directly will fail. S3 has no such object, so it returns 403 or 404. Tell CloudFront to serve index.html instead so the browser router can take over.

Console

  1. Open your distribution → Error pagesCreate custom error response.
  2. HTTP error code 403 → Customize response → Response page path /index.html → HTTP Response code 200.
  3. Repeat for error code 404.

If your site is plain HTML (multiple real .html files, not an SPA), skip this step.

Step 5: Point Route 53 at CloudFront

Amazon Route 53 is AWS’s DNS (Domain Name System) service that maps your domain to AWS resources.

Console

  1. Open Route 53 → Hosted zones → your domain.
  2. Choose Create record.
  3. Name www, type A, toggle Alias ON, route to CloudFront distribution, and pick your distribution.
  4. Choose Create records.

An Alias record is special: it points to an AWS resource (not an IP), is free to query, and works at the zone apex.

CLI

aws route53 change-resource-record-sets \
  --hosted-zone-id Z0A1B2C3D4E5F6 \
  --change-batch '{
    "Changes": [{
      "Action": "UPSERT",
      "ResourceRecordSet": {
        "Name": "www.example.com",
        "Type": "A",
        "AliasTarget": {
          "HostedZoneId": "Z2FDTNDATAQYW2",
          "DNSName": "d111abcdef8.cloudfront.net",
          "EvaluateTargetHealth": false
        }
      }
    }]
  }'

The Alias HostedZoneId of Z2FDTNDATAQYW2 is a fixed, AWS-wide value used for every CloudFront alias.

Step 6: Invalidate the cache after updates

CloudFront caches your files at edge locations. After you upload new files with aws s3 sync, visitors keep seeing the old cached version until it expires. You must invalidate the cache to push changes live immediately.

aws cloudfront create-invalidation \
  --distribution-id E2QWERTY12345 \
  --paths "/*"

Output:

{
    "Invalidation": {
        "Status": "InProgress",
        "InvalidationBatch": {
            "Paths": { "Quantity": 1, "Items": ["/*"] }
        }
    }
}

Cost note: The first 1,000 invalidation paths per month are free; after that it is about $0.005 per path. Invalidating /* counts as one path, so this is effectively free for normal use. The bigger cost driver is CloudFront data transfer out (roughly $0.085/GB for the first 10 TB in North America). A small personal site typically costs well under $1/month.

Cleanup

To avoid ongoing charges, tear everything down in reverse order:

# 1. Disable, then delete the CloudFront distribution (in the console it is a two-step process)
aws cloudfront delete-distribution --id E2QWERTY12345 --if-match ETAGVALUE

# 2. Empty and delete the bucket
aws s3 rm s3://devcraftly-site-2026 --recursive
aws s3api delete-bucket --bucket devcraftly-site-2026

# 3. Delete the Route 53 record (re-run the change-batch with "Action": "DELETE")
# 4. ACM certificates have no cost, but you may delete it once unused
aws acm delete-certificate \
  --certificate-arn arn:aws:acm:us-east-1:111122223333:certificate/0a1b2c3d-4e5f-6789-abcd-ef0123456789 \
  --region us-east-1

A CloudFront distribution must be disabled and fully deployed before it can be deleted, which can take several minutes.

Best Practices

  • Keep Block Public Access ON and rely entirely on OAC — never make the bucket public for a real site.
  • Always request and attach the ACM certificate in us-east-1, regardless of your bucket’s Region.
  • Set a default root object (index.html) so the bare domain loads your homepage.
  • For SPAs, map 403 and 404 to /index.html with a 200 response so client-side routes work on direct visits.
  • Invalidate the cache after every deploy, or use versioned/fingerprinted asset filenames so new files are fetched automatically.
  • Enable CloudFront access logs and set sensible cache TTLs (Time To Live — how long files stay cached) to balance freshness and cost.
Last updated June 15, 2026
Was this helpful?