S3CloudFrontStatic SitesAWSHTTPS

S3 + CloudFront: The Complete 2025 Guide

MakFam Solutions 4 min read

S3 + CloudFront: The Complete 2025 Guide

Static sites on AWS S3 + CloudFront are fast, cheap ($1-3/month), and nearly maintenance-free. This guide covers the full setup including Origin Access Control, cache headers, HTTPS, and custom domains — updated for 2025.

Why S3 + CloudFront?

For static sites (marketing pages, docs, blogs, React/Vue SPAs):

  • Cost: ~$0.50-2/month vs $10-50/month for a VM
  • Performance: CloudFront edge nodes in 450+ locations
  • Availability: 99.99% SLA, no servers to babysit
  • Security: DDoS protection via AWS Shield Standard, no OS to patch

The one limitation: no server-side rendering. For dynamic features, use API Gateway + Lambda or integrate with a separate API.

Architecture

Browser → Route 53 (DNS) → CloudFront → S3 Bucket (private)

                         ACM Certificate (HTTPS)

Important: The S3 bucket should NOT be publicly accessible. Only CloudFront can read it, via Origin Access Control (OAC). This is the 2023+ replacement for Origin Access Identity (OAI).

Step 1: Create the S3 Bucket

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

# Enable versioning (allows rollback)
aws s3api put-bucket-versioning \
  --bucket your-site-name \
  --versioning-configuration Status=Enabled

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

Step 2: Request ACM Certificate

ACM certs must be in us-east-1 for CloudFront, regardless of where your bucket is.

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

Add the DNS validation records to Route 53 (or your DNS provider). Certificate issues within minutes.

Step 3: Create CloudFront Distribution

{
  "Origins": [{
    "DomainName": "your-site-name.s3.us-east-1.amazonaws.com",
    "S3OriginConfig": {},
    "OriginAccessControlId": "<OAC_ID>"
  }],
  "DefaultRootObject": "index.html",
  "DefaultCacheBehavior": {
    "ViewerProtocolPolicy": "redirect-to-https",
    "CachePolicyId": "<CACHING_OPTIMIZED_POLICY_ID>",
    "Compress": true
  },
  "CustomErrorResponses": [{
    "ErrorCode": 404,
    "ResponsePagePath": "/404.html",
    "ResponseCode": "404"
  }],
  "ViewerCertificate": {
    "AcmCertificateArn": "<CERT_ARN>",
    "SslSupportMethod": "sni-only",
    "MinimumProtocolVersion": "TLSv1.2_2021"
  },
  "Aliases": ["yourdomain.com", "www.yourdomain.com"]
}

Step 4: Create Origin Access Control

OAC replaces OAI and supports additional signing behaviors:

aws cloudfront create-origin-access-control \
  --origin-access-control-config '{
    "Name": "your-site-oac",
    "OriginAccessControlOriginType": "s3",
    "SigningBehavior": "always",
    "SigningProtocol": "sigv4"
  }'

Step 5: Update S3 Bucket Policy

Allow only your CloudFront distribution to read from the bucket:

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

Step 6: Deploy With Correct Cache Headers

This is where most tutorials fall short. Cache headers matter enormously for performance and cache invalidation cost.

# HTML files — short cache (revalidate frequently)
aws s3 sync ./dist s3://your-site-name \
  --exclude "*" \
  --include "*.html" \
  --cache-control "max-age=300, must-revalidate" \
  --delete

# Hashed assets (JS, CSS) — immutable cache
aws s3 sync ./dist s3://your-site-name \
  --exclude "*" \
  --include "_astro/*" \
  --cache-control "max-age=31536000, immutable" \
  --delete

# Images — medium cache
aws s3 sync ./dist/images s3://your-site-name/images \
  --cache-control "max-age=86400"

# Invalidate CloudFront cache for HTML changes
aws cloudfront create-invalidation \
  --distribution-id YOUR_DIST_ID \
  --paths "/*"

The key insight: files with content hashes in their names (like main.abc123.js) never change content, so immutable cache is safe and eliminates unnecessary revalidation requests.

Step 7: Configure Route 53

# Create A record (ALIAS to CloudFront)
aws route53 change-resource-record-sets \
  --hosted-zone-id YOUR_ZONE_ID \
  --change-batch '{
    "Changes": [{
      "Action": "UPSERT",
      "ResourceRecordSet": {
        "Name": "yourdomain.com",
        "Type": "A",
        "AliasTarget": {
          "HostedZoneId": "Z2FDTNDATAQYW2",
          "DNSName": "abc123.cloudfront.net",
          "EvaluateTargetHealth": false
        }
      }
    }]
  }'

Z2FDTNDATAQYW2 is the CloudFront hosted zone ID — it’s always the same.

Cost Breakdown

For a typical marketing site (~10K visitors/month):

ServiceMonthly Cost
S3 storage (1GB)$0.023
S3 requests~$0.10
CloudFront transfer (10GB)~$0.85
Route 53 hosted zone$0.50
Total~$1.47

Common Mistakes

Using website endpoint instead of REST API endpoint: Use bucket.s3.region.amazonaws.com (REST), not bucket.s3-website.region.amazonaws.com. The website endpoint doesn’t support OAC.

Not enabling compression: Compress: true in CloudFront typically reduces transfer size by 60-80% for text assets.

Forgetting custom error page: Without it, 404s return an XML error from S3 instead of your styled 404 page.


This is exactly the architecture we use for this website. Want us to set it up for you? Get in touch.

J

MakFam Solutions

Cloud infrastructure and AI consultant with 6+ years of AWS expertise. Helping small and medium businesses build scalable, secure cloud systems.