AWS Intermediate #7: CloudFront for static site delivery

10 min read

In #3 S3 static hosting we saw a few limits — no HTTPS, no edge cache near users, no custom domain + SSL. The service that solves all three at once is CloudFront.

CloudFront is AWS’s CDN (Content Delivery Network). With caches in 600+ Edge Locations worldwide, the closest Edge to the user responds. It’s versatile — not just for static sites but also for proxying dynamic API traffic.

In this post we thread CloudFront’s shape → the S3 + CloudFront pattern → OAC → cache policies → invalidation.

What a CDN solves #

S3 static sites alone are weak in these areas:

ProblemWhat CloudFront solves
US user fetching from Seoul S3 takes 200ms+Edge cache responds in ms
HTTPS isn’t directACM cert + auto HTTPS
Egress is expensive ($0.09/GB)CloudFront → internet ($0.085/GB) + cache hits free
Cache headers / compression / HTTP/2 / HTTP/3Auto
WAF / Shield directIntegrated with CloudFront
DDoSAWS Shield Standard auto

S3 + CloudFront is the standard pattern for every static site.

Shape of CloudFront #

CloudFront distribution structure
 user
  │ HTTPS request: example.com/path/to/asset.js
┌────────────────────────────────────┐
│       CloudFront Edge Location     │
│       (closest to the user)         │
│                                      │
│    Cache check                       │
│      ├── HIT → serve from cache      │
│      └── MISS                        │
└────────────────────────────────────┘
                │ on MISS
        ┌────────────────────┐
        │   Cache Behavior   │
        │  (path match / pol) │
        └────────┬───────────┘
            ┌────────┐
            │ Origin │  ← S3, ALB, EC2, Lambda Function URL ...
            └────────┘

Three core parts:

  • Distribution — one bundle of settings (domain + cert + behaviors)
  • Origin — where to fetch on cache miss (S3, ALB, custom domain, …)
  • Cache Behavior — path match + cache policy + Origin match

Creating a Distribution #

Simple CloudFront distribution
aws cloudfront create-distribution \
  --origin-domain-name my-bucket.s3.ap-northeast-2.amazonaws.com \
  --default-root-object index.html

Settings:

OptionMeaning
Origin domainS3 bucket, ALB DNS, or a custom domain
Default root objectFile for / requests (index.html)
Alternate domain (CNAME)example.com, www.example.com
SSL certificateACM (must be us-east-1)
Price classWhich continents to use Edges in
LoggingSave access logs to S3
WAFAttach Web ACL

Price Class #

Price ClassMeaning
AllAll Edges worldwide — highest cost
200US / Europe / Asia / Australia
100US / Europe / Israel / parts of Asia — cheapest

For audiences in Korea and Japan only, 200 or above is recommended. Global services use All.

Origin — where to fetch from #

S3 Origin #

The most common use. Static sites / images / videos.

S3 Origin
Origin: my-bucket.s3.ap-northeast-2.amazonaws.com
        (REST API endpoint, NOT the s3-website-... URL)

Use the REST API URL, not the s3-website-* URL. Otherwise OAC (below) can’t protect it safely.

Custom Origin (ALB / arbitrary HTTP server) #

ALB / EC2 / external servers also work as Origin. This pattern is for dynamic API acceleration.

ALB Origin
Origin: my-alb-1234567890.elb.ap-northeast-2.amazonaws.com
HTTPS Port: 443
SSL Protocols: TLSv1.2, TLSv1.3
Origin Path: (none or /api)

Routing through CloudFront → ALB → user gives you WAF + Edge SSL termination + caching all at once.

Lambda Function URL Origin #

Lambda directly as Origin. Serverless static + dynamic site.

Origin Group — failover #

Fail over to Secondary if Primary Origin dies. For multi-region DR.

Cache Behavior — routing + cache policy #

Within the same Distribution, you can have different behavior per path.

Cache Behavior examples
Path Pattern   | Origin | Cache Policy | TTL
---------------+--------+--------------+--------
/api/*         | ALB    | NoCaching    | 0
/static/*      | S3     | Optimized    | 1 day
default (*)    | S3     | Optimized    | 1 hour

Order: path-pattern match (more specific first) → default if none.

Cache Policy #

Defines cache behavior. AWS-provided defaults + custom.

Default cache policies
CachingOptimized               ← static assets (images / JS / CSS)
CachingOptimizedForUncompressedObjects  ← uncompressed assets
CachingDisabled                ← API, no cache
Elemental-MediaPackage         ← media streaming

Cache key parts (= what makes it a separate cache entry):

  • Query string — all / some / none
  • Header — which header(s) distinguish
  • Cookie — which cookie(s) distinguish
Cache key for plain static assets
Query: ignore
Header: ignore (or Accept, Accept-Encoding)
Cookie: ignore
Cache key for SSR with language / device split
Query: all
Header: Accept-Language, CloudFront-Is-Mobile-Viewer
Cookie: session-id

Origin Request Policy #

Controls what to send to Origin. Different from the cache key.

Cache PolicyOrigin Request Policy
What it decidesCache key + TTLHeaders / queries / cookies sent to Origin
EffectCache hit rateWhat can affect dynamic responses

Response Headers Policy #

Adds / modifies response headers. Used for security headers like Strict-Transport-Security and X-Content-Type-Options.

Recommended security headers
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Content-Security-Policy: default-src 'self'; ...

TTL — how long to cache #

Cache TTL guide:

TTLUse
0No cache. Every request hits Origin
60–300 secFrequently-changing content (news headlines)
1 hour – 1 dayGeneral static assets
1 year (max-age=31536000)Hashed filename (app.abc123.js)

Cache-Control header #

Origin’s Cache-Control header takes priority (within Min/Default/Max TTL).

Origin Cache-Control responses
Cache-Control: public, max-age=31536000, immutable   ← hashed asset (1 year)
Cache-Control: public, max-age=300                   ← HTML page (5 min)
Cache-Control: no-store                              ← never cache

Operational guide:

  • HTML = short (60s – 5min) — new builds visible quickly
  • Hashed JS / CSS / images = 1 year + immutable — content changes get a new filename
  • API = usually no cache (or short)

S3 + CloudFront pattern #

The standard static-site setup.

Standard S3 + CloudFront pattern
user
example.com (Route 53 Alias) ──▶ CloudFront
                                    ▼ (OAC)
                                  S3 my-bucket  ← all Public Access Block on
                                    ▼ Bucket Policy
                                  only CloudFront allowed GetObject

OAC — Origin Access Control #

The legacy approach is OAI (Origin Access Identity). The new recommended one is OAC.

Where OAC sits
- All PAB on for the S3 bucket (never public)
- Bucket Policy allows only CloudFront's OAC:

  {
    "Version": "2012-10-17",
    "Statement": [{
      "Effect": "Allow",
      "Principal": { "Service": "cloudfront.amazonaws.com" },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::my-bucket/*",
      "Condition": {
        "StringEquals": {
          "AWS:SourceArn": "arn:aws:cloudfront::123456789012:distribution/EXXXXXX"
        }
      }
    }]
  }

Result:

  • Direct S3 access blockeds3.amazonaws.com/my-bucket/x returns 403
  • Only CloudFront passes through — must go through Edge cache to respond
  • Egress savings + cache hit acceleration + WAF

OAI vs OAC #

OAI (legacy)OAC (new)
Auth methodCloudFront’s virtual IAM IdentitySigV4 signature
New regions / featuresPartialAll
KMS-encrypted S3Extra config neededSmooth
RecommendedConsider migrationDefault for new projects

New setups go OAC, legacy keeps OAI and migrates over time.

Invalidation #

A way to forcibly clear cached objects before expiry. Show new versions immediately after deploy.

Create an invalidation
aws cloudfront create-invalidation \
  --distribution-id EXXXXXX \
  --paths "/*"

Usage:

  • One line /* after a static-site deploy
  • Specific pages only: /index.html /about.html
  • Wildcards: /blog/2026/*

Cost trap #

CloudFront invalidations are first 1000 paths / month free, then $0.005 per path. /* once = 1 path, so calling it often is fine.

Cache versioning pattern — avoid invalidation #

Real production doesn’t use invalidation. Instead:

Hashed filename pattern
Build outputs:
  /static/app.abc123.js   ← content hash
  /static/app.def456.js   ← next build, different hash

Cache HTML short (1 min), JS/CSS 1 year:
  Cache-Control: public, max-age=31536000, immutable

On new deploy:
  /index.html points to the new-hash JS
  HTML cache expires (1 min) → new JS loads automatically

This pattern reflects instantly without invalidation + maximizes cache efficiency.

Lambda@Edge / CloudFront Functions #

Run code at the Edge to intercept requests / responses.

CloudFront Functions #

  • JavaScript ES5 only
  • Viewer request / response stages only
  • Within 5ms, 1MB memory
  • Very cheap ($0.10 / million)
  • Uses: redirect, header injection, A/B testing, auth-token check
Simple redirect
function handler(event) {
  var request = event.request;
  if (request.uri === '/old-page') {
    return {
      statusCode: 301,
      statusDescription: 'Moved Permanently',
      headers: { 'location': { value: '/new-page' } }
    };
  }
  return request;
}

Lambda@Edge #

  • Node.js / Python
  • 4 stages (viewer request / origin request / origin response / viewer response)
  • 5 sec (viewer) / 30 sec (origin)
  • More expensive and slower, but powerful
  • Uses: dynamic content transformation, multi-region routing, complex auth

Start with CloudFront Functions whenever possible and only escalate to Lambda@Edge when the extra capability is required.

Signed URL / Signed Cookie — private content #

For paid videos / restricted downloads. CloudFront’s signature limits time / IP.

Shape of a signed URL
https://d111111abcdef8.cloudfront.net/video.mp4?
  Expires=1714992000&
  Signature=...
  Key-Pair-Id=APKAEX...
  • Signed URL — one URL per resource
  • Signed Cookie — one user accessing many resources (subscription site)

Unlike S3’s presigned URL, CloudFront signatures are verified at the Edge — making them more globally distributed and cache-friendly.

CloudFront and ACM #

The region rule from #6:

CloudFront cert is always us-east-1
ACM (us-east-1)
  ├── *.example.com         ← used by CloudFront
  └── example.com

ACM (ap-northeast-2)
  ├── api.example.com       ← used by ALB

When requesting a cert in the console, switch the region to N. Virginia (us-east-1).

Compression and HTTP/2 / HTTP/3 #

CloudFront automatically does:

  • gzip / Brotli compression — toggle on
  • HTTP/2 — default
  • HTTP/3 (QUIC) — option
  • TLS 1.3 — via Security Policy

This is smoother than exposing ALB directly.

Common pitfalls #

1) S3 still public #

OAC set up but PAB off → S3 directly reachable. Turn on all 4 PAB + Bucket Policy that only allows CloudFront.

2) CNAME mismatch #

ACM cert is *.example.com but Distribution Alternate domain is app.example.com (wildcard child, OK) — wrong setting yields SSL mismatch errors. Match cert SAN and the domain exactly.

3) Cache too strong, new deploy invisible #

max-age=31536000 cached HTML → users see old HTML after new deploy. Short cache for HTML / long only for hashed assets.

4) Wrong query cache key #

Caching every query → same file but ?v=1, ?v=2 cache separately → 0% hit rate. Only include query params that actually affect the response.

5) Missing default root object #

/ requests yield 403/404. Set Default root object = index.html.

6) Host header sent to Origin #

For S3 Origin, CloudFront handles Host automatically. But for ALB / custom servers, explicitly forward the Host header in the Origin Request Policy.

7) Origin SSL cert untrusted #

Custom Origin (your own server) with self-signed → CloudFront rejects. Use ACM-issued or trusted CA.

8) index.html redirect #

S3 static-site /about//about/index.html auto-rewrite doesn’t happen with OAC. Use a CloudFront Function for path rewrite:

Path rewrite Function
function handler(event) {
  var request = event.request;
  var uri = request.uri;
  if (uri.endsWith('/')) {
    request.uri = uri + 'index.html';
  } else if (!uri.includes('.')) {
    request.uri = uri + '/index.html';
  }
  return request;
}

Wrap-up #

What we took home this time:

  • CloudFront = global CDN. Edges in 600+ places respond from cache
  • Distribution + Origin + Cache Behavior trinity
  • Origin = S3 / ALB / Custom HTTP / Lambda URL / Origin Group
  • S3 + CloudFront + OAC is the standard static-site pattern
  • OAC is the new recommended path. All 4 PAB on, Bucket Policy allows only CloudFront
  • Cache Policy for cache key (query/header/cookie) + TTL. Origin Request Policy for what to send to Origin
  • TTL strategy — short HTML, long + immutable for hashed assets
  • Invalidation sometimes. Hashed filename pattern is the way to skip it
  • CloudFront Functions = fast and cheap. Lambda@Edge = powerful but heavy
  • Signed URL / Cookie for private content
  • ACM cert must be us-east-1
  • Compression / HTTP/2 / HTTP/3 automatic
  • Pitfalls — S3 public, CNAME mismatch, cache too strong, wrong query key, default root, Host header, Origin SSL, path rewrite

Next — Starting AWS Advanced #

That wraps up AWS Intermediate 7 posts. EC2 / VPC / S3 / RDS / Route 53 / ALB / CloudFront — the basic toolbox is gathered in one place.

Now we’ll add containers / serverless / event-driven pieces on top. In AWS Advanced #1 ECS and Fargate we move from launching apps directly on EC2 to containers, and lay out the operational side of ECS / Fargate — the container-native version of ASG.

X