---
title: "The S3 + CloudFront + ACM + Route 53 deploy pattern"
description: "Four AWS services. Each does one thing. Together they serve a static site at any scale you'll realistically hit, for $0.50 a month. Here's the full recipe."
date_published: 2026-05-06
last_updated: 2026-05-06
canonical: https://vibecodersguidetomvp.help/blog/s3-cloudfront-acm-route53-deploy/
author: Titan Alpha
tags: ["aws","s3","cloudfront","deployment","infrastructure"]
---

# The S3 + CloudFront + ACM + Route 53 deploy pattern

Four AWS services. Each does one thing. Together they serve a static site at any scale you'll realistically hit, for $0.50 a month. Here's the full recipe.

> Canonical HTML: https://vibecodersguidetomvp.help/blog/s3-cloudfront-acm-route53-deploy/
> This is the agent-friendly markdown alternate for the page above.


Most "cheap AWS hosting" tutorials skip the parts that actually matter — Origin Access Control, ACM in us-east-1, the right CloudFront error mappings for SPAs, the magic alias zone ID for Route 53. Here's the complete recipe with every gotcha called out.

By the end you'll have a static site live on your custom domain over HTTPS, served from a global CDN, costing $0.50/month flat.

## The components

```
        Internet
           ↓
    Route 53 (DNS)
           ↓ alias records (apex + www)
    CloudFront (CDN)
           ↑ ACM cert (us-east-1)
           ↓ OAC-signed reads
       S3 bucket
       (private)
```

Four services. Each does one thing well.

## Step 1: Create the S3 bucket

The bucket holds your built site files. **It must be private** — public S3 buckets are an anti-pattern, even for "public" content. CloudFront reads from it through Origin Access Control (OAC) instead.

```bash
aws s3api create-bucket \
  --bucket your-site-frontend-${ACCOUNT_ID} \
  --region us-east-1

aws s3api put-public-access-block \
  --bucket your-site-frontend-${ACCOUNT_ID} \
  --public-access-block-configuration \
  "BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"

aws s3api put-bucket-ownership-controls \
  --bucket your-site-frontend-${ACCOUNT_ID} \
  --ownership-controls "Rules=[{ObjectOwnership=BucketOwnerEnforced}]"
```

Naming convention: include the account ID to avoid global naming conflicts.

## Step 2: Request the ACM certificate (in us-east-1)

ACM is regional. **CloudFront requires the certificate to be in us-east-1**, regardless of where your other services are. Easy mistake to make: creating the cert in a region near you and then wondering why CloudFront can't see it.

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

ACM gives you DNS validation records (CNAMEs). You'll add them in step 4.

## Step 3: Create the Route 53 hosted zone

```bash
aws route53 create-hosted-zone \
  --name your-domain.com \
  --caller-reference "$(date +%s)"
```

This returns four nameservers. Update your domain registrar to point at these (covered in the [domain transfer post](/blog/domain-transfer-vs-delegation/)).

## Step 4: Validate the ACM certificate

The CNAME records ACM provided in step 2 go into Route 53 now:

```bash
aws route53 change-resource-record-sets \
  --hosted-zone-id Z0XXXXXXXXX \
  --change-batch file://acm-validation.json
```

Where `acm-validation.json` upserts the two CNAMEs ACM gave you. Within 5–30 minutes, the cert flips from `PENDING_VALIDATION` to `ISSUED`.

## Step 5: Create the CloudFront Origin Access Control

OAC is the modern way for CloudFront to read from a private S3 bucket. It replaces the older Origin Access Identity (OAI) pattern. Use OAC.

```bash
aws cloudfront create-origin-access-control \
  --origin-access-control-config '{
    "Name": "your-site-oac",
    "Description": "OAC for your-site-frontend bucket",
    "OriginAccessControlOriginType": "s3",
    "SigningBehavior": "always",
    "SigningProtocol": "sigv4"
  }'
```

Returns an OAC ID like `E1ABC...`. Save it.

## Step 6: Create the CloudFront distribution

This is the big one. The full distribution config:

```json
{
  "CallerReference": "your-site-1",
  "Comment": "your-site static site",
  "Enabled": true,
  "IsIPV6Enabled": true,
  "HttpVersion": "http2and3",
  "PriceClass": "PriceClass_100",
  "DefaultRootObject": "index.html",
  "Aliases": {
    "Quantity": 2,
    "Items": ["your-domain.com", "www.your-domain.com"]
  },
  "Origins": {
    "Quantity": 1,
    "Items": [{
      "Id": "s3-your-site",
      "DomainName": "your-site-frontend-${ACCOUNT_ID}.s3.us-east-1.amazonaws.com",
      "OriginAccessControlId": "${OAC_ID}",
      "S3OriginConfig": { "OriginAccessIdentity": "" },
      "ConnectionAttempts": 3,
      "ConnectionTimeout": 10
    }]
  },
  "DefaultCacheBehavior": {
    "TargetOriginId": "s3-your-site",
    "ViewerProtocolPolicy": "redirect-to-https",
    "AllowedMethods": {
      "Quantity": 2,
      "Items": ["GET", "HEAD"],
      "CachedMethods": { "Quantity": 2, "Items": ["GET", "HEAD"] }
    },
    "Compress": true,
    "CachePolicyId": "658327ea-f89d-4fab-a63d-7e88639e58f6",
    "ResponseHeadersPolicyId": "67f7725c-6f97-4210-82d7-5512b31e9d03"
  },
  "CustomErrorResponses": {
    "Quantity": 2,
    "Items": [
      { "ErrorCode": 403, "ResponseCode": "200", "ResponsePagePath": "/index.html", "ErrorCachingMinTTL": 10 },
      { "ErrorCode": 404, "ResponseCode": "200", "ResponsePagePath": "/index.html", "ErrorCachingMinTTL": 10 }
    ]
  },
  "ViewerCertificate": {
    "ACMCertificateArn": "${ACM_ARN}",
    "SSLSupportMethod": "sni-only",
    "MinimumProtocolVersion": "TLSv1.2_2021"
  },
  "Restrictions": { "GeoRestriction": { "RestrictionType": "none", "Quantity": 0 } }
}
```

Highlights:

- **`PriceClass_100`** is the cheapest tier — North America and Europe edges only. Other tiers add edges globally at higher cost. PriceClass_100 covers ~95% of typical traffic.
- **`http2and3`** enables HTTP/2 and HTTP/3 (QUIC). Free perf.
- **`Compress: true`** turns on gzip + Brotli at the edge.
- **`CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6`** is AWS's managed `CachingOptimized` policy.
- **`ResponseHeadersPolicyId: 67f7725c-6f97-4210-82d7-5512b31e9d03`** is the managed `SecurityHeadersPolicy` (HSTS, CSP, etc.). Free hardening.
- **`CustomErrorResponses`** map 403 and 404 to `/index.html` with status 200. This is what makes single-page-app routing work — any unknown path renders your app shell, and React handles the route client-side.

```bash
aws cloudfront create-distribution \
  --distribution-config file://cf-config.json
```

Returns a distribution ID (`E1ABC...`) and a domain name (`d2hc0fvjfmp1yp.cloudfront.net`). Save both.

## Step 7: Attach the S3 bucket policy for OAC

Now S3 needs to allow CloudFront (specifically, your distribution) to read:

```json
{
  "Version": "2012-10-17",
  "Statement": [{
    "Sid": "AllowCloudFrontServicePrincipal",
    "Effect": "Allow",
    "Principal": { "Service": "cloudfront.amazonaws.com" },
    "Action": "s3:GetObject",
    "Resource": "arn:aws:s3:::your-site-frontend-${ACCOUNT_ID}/*",
    "Condition": {
      "StringEquals": {
        "AWS:SourceArn": "arn:aws:cloudfront::${ACCOUNT_ID}:distribution/${DISTRIBUTION_ID}"
      }
    }
  }]
}
```

The `AWS:SourceArn` condition scopes the access to your specific distribution. Without it, any CloudFront distribution in any AWS account could read your bucket.

```bash
aws s3api put-bucket-policy \
  --bucket your-site-frontend-${ACCOUNT_ID} \
  --policy file://s3-policy.json
```

## Step 8: Create Route 53 alias records

Apex and www, A and AAAA, all aliased to the CloudFront distribution. The hosted zone ID `Z2FDTNDATAQYW2` is the universal CloudFront alias zone — magic value, same for everyone.

```json
{
  "Changes": [
    {
      "Action": "UPSERT",
      "ResourceRecordSet": {
        "Name": "your-domain.com.",
        "Type": "A",
        "AliasTarget": {
          "HostedZoneId": "Z2FDTNDATAQYW2",
          "DNSName": "d2hc0fvjfmp1yp.cloudfront.net.",
          "EvaluateTargetHealth": false
        }
      }
    }
  ]
}
```

Repeat for `AAAA` (IPv6), `www.your-domain.com` `A`, and `www.your-domain.com` `AAAA`. Four records total.

## Step 9: Build and deploy

Three commands:

```bash
npm run build

# Hashed assets get long cache (immutable, since the hash changes when content changes)
aws s3 sync dist/assets/ s3://your-site-frontend-${ACCOUNT_ID}/assets/ \
  --delete \
  --cache-control "public, max-age=31536000, immutable"

# Index.html and other top-level files: no-cache so updates appear immediately
aws s3 sync dist/ s3://your-site-frontend-${ACCOUNT_ID}/ \
  --delete \
  --exclude "assets/*" \
  --cache-control "no-cache, no-store, must-revalidate"

# Invalidate CDN
aws cloudfront create-invalidation \
  --distribution-id ${DISTRIBUTION_ID} \
  --paths "/*"
```

Within 60 seconds, your changes are live globally.

## The cost recap

- Route 53 hosted zone: **$0.50/month**
- CloudFront: **free** (within 1TB/month + 10M requests free tier)
- S3: **<$0.01/month** (for a sub-1MB site)
- ACM cert: **free**

Total: **$0.50/month**.

## The deploy script in one file

Save the build + sync + invalidate commands as `scripts/deploy.sh`. Wrap with the AWS profile and resource IDs as variables. From then on, deploying is one command.

This site's deploy script is open source — find it in the [Vibe Coder's Guide to MVP starter](https://github.com/titan-alpha/vibe-coders-guide-to-mvp-starter) repo.

That's the full pattern. Cheap, reliable, the same shape every other AWS-hosted static site uses. The internet runs on this. Your site can too.

