AWS CloudFront CDN: Caching, Behaviors and Lambda@Edge
AWS CloudFront is Amazon's globally distributed content delivery network (CDN) with over 600 points of presence across 90+ cities in 47 countries. It accelerates delivery of static assets, dynamic APIs, video streams, and entire web applications by caching content close to end users. Beyond pure caching, CloudFront supports sophisticated request routing through cache behaviors, programmable edge compute via Lambda@Edge and CloudFront Functions, origin failover through origin groups, and deep integration with AWS WAF for security. This guide walks through every major capability with real CLI commands and code examples — everything you need to build production-grade CloudFront distributions in 2026.
1. Distributions and Origins (S3, ALB, Custom)
A CloudFront distribution is the top-level resource that ties together origins, cache behaviors, and global delivery settings. Every distribution gets a unique domain name like d1abc123def456.cloudfront.net. You can assign custom domains (CNAMEs) backed by ACM certificates for HTTPS.
An origin is the upstream source CloudFront fetches from on a cache miss. The three most common origin types are:
- Amazon S3 — ideal for static assets (HTML, CSS, JS, images). Use an Origin Access Control (OAC) to restrict direct S3 access so all traffic flows through CloudFront.
- Application Load Balancer (ALB) — for dynamic content served by EC2, ECS, or EKS. CloudFront forwards headers like
Host, cookies, and query strings so your application can serve personalised responses. - Custom HTTP origin — any publicly reachable HTTP/HTTPS endpoint, including on-premises servers, third-party APIs, or other cloud providers.
The example below creates a distribution with an S3 origin using the AWS CLI. The distribution config JSON sets HTTPS-only viewer protocol, enables IPv6, and uses the recommended managed cache policy for S3 static sites:
# 1. Create the distribution config file
cat > cf-dist-config.json <<'EOF'
{
"Comment": "Techoral static site distribution",
"DefaultCacheBehavior": {
"TargetOriginId": "techoral-s3-origin",
"ViewerProtocolPolicy": "redirect-to-https",
"CachePolicyId": "658327ea-f89d-4fab-a63d-7e88639e58f6",
"Compress": true,
"AllowedMethods": {
"Quantity": 2,
"Items": ["GET", "HEAD"]
}
},
"Origins": {
"Quantity": 1,
"Items": [
{
"Id": "techoral-s3-origin",
"DomainName": "techoral-assets.s3.us-east-1.amazonaws.com",
"S3OriginConfig": {
"OriginAccessIdentity": ""
},
"OriginAccessControlId": "E2QXXXXOACID"
}
]
},
"Enabled": true,
"IsIPV6Enabled": true,
"PriceClass": "PriceClass_100",
"HttpVersion": "http2and3",
"Aliases": {
"Quantity": 1,
"Items": ["assets.techoral.com"]
},
"ViewerCertificate": {
"ACMCertificateArn": "arn:aws:acm:us-east-1:123456789012:certificate/abc-def-123",
"SSLSupportMethod": "sni-only",
"MinimumProtocolVersion": "TLSv1.2_2021"
}
}
EOF
# 2. Create the distribution
aws cloudfront create-distribution \
--distribution-config file://cf-dist-config.json \
--query 'Distribution.{Id:Id,Domain:DomainName,Status:Status}'
2. Cache Behaviors and Path Patterns
A distribution can have one default cache behavior and up to 25 additional cache behaviors. CloudFront evaluates path patterns in order (most specific first) and applies the matching behavior's settings. This lets you apply radically different caching, origin routing, and Lambda@Edge triggers to different URL prefixes.
Common path pattern examples:
/api/*— routes to an ALB origin, caching disabled, all HTTP methods forwarded/static/*— routes to S3, long TTL (365 days), compressed/images/*.jpg— routes to S3, medium TTL, WebP conversion via Lambda@Edge*(default) — catch-all for the main application
Below is a CLI snippet to add an API behavior to an existing distribution. It creates a cache policy that forces all caching off (TTL 0), then adds the behavior:
# Create a no-cache policy for API routes
aws cloudfront create-cache-policy \
--cache-policy-config '{
"Name": "api-no-cache-policy",
"Comment": "Zero TTL for dynamic API responses",
"DefaultTTL": 0,
"MaxTTL": 0,
"MinTTL": 0,
"ParametersInCacheKeyAndForwardedToOrigin": {
"EnableAcceptEncodingGzip": false,
"EnableAcceptEncodingBrotli": false,
"HeadersConfig": {
"HeaderBehavior": "whitelist",
"Headers": {
"Quantity": 2,
"Items": ["Authorization", "Content-Type"]
}
},
"CookiesConfig": { "CookieBehavior": "none" },
"QueryStringsConfig": { "QueryStringBehavior": "all" }
}
}'
# Get the current distribution ETag (required for updates)
ETAG=$(aws cloudfront get-distribution-config \
--id E2XXXXXXXXXXX \
--query 'ETag' --output text)
# Note: In production, fetch the full config, merge the new behavior,
# then call update-distribution with the complete modified config JSON.
# The new cache behavior targets the ALB origin on /api/* path.
/api/v2/*) before broader ones (/api/*). The default behavior (*) always evaluates last.
3. TTL Settings and Cache-Control Headers
CloudFront determines how long to cache an object using a three-level hierarchy:
- Cache-Control / Expires headers from the origin response — CloudFront respects these by default
- Cache Policy TTL settings — specify minimum, default, and maximum TTL values
- Legacy per-behavior TTL fields — used only when no cache policy is attached (deprecated approach)
The interaction rules are straightforward: if the origin sends Cache-Control: max-age=3600, CloudFront caches the object for 3600 seconds provided that value falls within the cache policy's Min TTL and Max TTL bounds. If the origin sends no caching headers, CloudFront uses the Default TTL from the cache policy.
Creating a managed-style static asset cache policy via CLI:
aws cloudfront create-cache-policy \
--cache-policy-config '{
"Name": "static-assets-365d",
"Comment": "Long-lived cache for versioned static assets",
"DefaultTTL": 86400,
"MaxTTL": 31536000,
"MinTTL": 0,
"ParametersInCacheKeyAndForwardedToOrigin": {
"EnableAcceptEncodingGzip": true,
"EnableAcceptEncodingBrotli": true,
"HeadersConfig": { "HeaderBehavior": "none" },
"CookiesConfig": { "CookieBehavior": "none" },
"QueryStringsConfig": { "QueryStringBehavior": "none" }
}
}'
Your S3 origin should set cache headers on upload to complement this policy:
# Upload versioned asset with long cache-control header
aws s3 cp ./dist/app.a1b2c3d4.js s3://techoral-assets/static/ \
--cache-control "public, max-age=31536000, immutable" \
--content-type "application/javascript"
# Upload index.html with short or no-cache header (it references hashed assets)
aws s3 cp ./dist/index.html s3://techoral-assets/ \
--cache-control "public, max-age=0, must-revalidate" \
--content-type "text/html; charset=utf-8"
4. Lambda@Edge vs CloudFront Functions
AWS provides two options for running code at the edge. The right choice depends on complexity, latency budget, and cost:
| Feature | CloudFront Functions | Lambda@Edge |
|---|---|---|
| Runtime | JavaScript (ES5) | Node.js 18/20, Python 3.12 |
| Max execution time | 1 ms | 5 s (viewer) / 30 s (origin) |
| Max memory | 2 MB | 128 MB – 10 GB |
| Network access | No | Yes (outbound) |
| Event types | Viewer request/response | Viewer & origin request/response |
| Pricing (per 1M invocations) | $0.10 | $0.60 + duration |
| Use cases | Header rewrites, URL redirects, A/B routing | Auth, image resizing, SSR, API calls |
The example below is a Lambda@Edge origin request function that inspects the Accept header and rewrites the S3 object key to serve WebP images to browsers that support them:
// Lambda@Edge - Origin Request trigger
// Rewrites .jpg/.png requests to .webp for supporting browsers
// Deploy in us-east-1, associate with the CloudFront /images/* behavior
'use strict';
exports.handler = async (event) => {
const request = event.Records[0].cf.request;
const headers = request.headers;
// Check if the viewer accepts WebP
const acceptHeader = headers['accept']
? headers['accept'][0].value
: '';
const acceptsWebP = acceptHeader.includes('image/webp');
// Only rewrite image requests
const uri = request.uri;
const imageExtensions = /\.(jpg|jpeg|png)$/i;
if (acceptsWebP && imageExtensions.test(uri)) {
// Rewrite URI to WebP equivalent stored alongside originals in S3
request.uri = uri.replace(imageExtensions, '.webp');
// Add custom header so origin can log WebP hit rate
request.headers['x-webp-rewrite'] = [{
key: 'x-webp-rewrite',
value: 'true'
}];
}
return request;
};
Deploy this function and associate it with a distribution behavior:
# Publish Lambda version (Lambda@Edge requires a numbered version, not $LATEST)
FUNC_ARN=$(aws lambda publish-version \
--function-name cloudfront-webp-rewrite \
--query 'FunctionArn' --output text)
echo "Function ARN: $FUNC_ARN"
# Output: arn:aws:lambda:us-east-1:123456789012:function:cloudfront-webp-rewrite:3
# Associate with distribution cache behavior via update-distribution
# (Include the full distribution config JSON with LambdaFunctionAssociations
# set to EventType: origin-request, LambdaFunctionARN: $FUNC_ARN)
us-east-1 regardless of where your distribution serves traffic. AWS replicates the function to all edge locations automatically.
CloudFront Functions Example — URL Normalisation
CloudFront Functions run at sub-millisecond latency and are ideal for lightweight viewer request transformations like redirects and URL normalisation:
// CloudFront Function - Viewer Request
// Normalise URLs: strip trailing slash, lowercase path, remove index.html
function handler(event) {
var request = event.request;
var uri = request.uri;
// Remove trailing slash (except root)
if (uri !== '/' && uri.endsWith('/')) {
return {
statusCode: 301,
statusDescription: 'Moved Permanently',
headers: {
location: { value: uri.slice(0, -1) }
}
};
}
// Redirect /index.html to canonical path
if (uri.endsWith('/index.html')) {
return {
statusCode: 301,
statusDescription: 'Moved Permanently',
headers: {
location: { value: uri.replace('/index.html', '/') }
}
};
}
// Add .html extension for clean URLs (SPA fallback)
if (!uri.includes('.') && !uri.endsWith('/')) {
request.uri = uri + '.html';
}
return request;
}
5. Origin Groups for Failover
An origin group contains a primary origin and one or more secondary origins. When the primary returns specific HTTP error codes (you define the list — typically 500, 502, 503, 504), CloudFront automatically retries the request against the secondary origin. This provides active-passive failover without any application-level logic.
Common patterns:
- S3 cross-region replication failover — primary bucket in us-east-1, secondary in eu-west-1
- ALB to S3 static fallback — serve a maintenance page from S3 when the application is down
- Multi-region active-passive — primary ALB in one region, secondary ALB behind Route 53 latency routing in another
# Create origin group with S3 primary and fallback
aws cloudfront create-distribution --distribution-config '{
"Comment": "Techoral with origin failover",
"Origins": {
"Quantity": 2,
"Items": [
{
"Id": "primary-s3-us-east-1",
"DomainName": "techoral-primary.s3.us-east-1.amazonaws.com",
"S3OriginConfig": { "OriginAccessIdentity": "" },
"OriginAccessControlId": "E2QPRIMARYOAC"
},
{
"Id": "failover-s3-eu-west-1",
"DomainName": "techoral-failover.s3.eu-west-1.amazonaws.com",
"S3OriginConfig": { "OriginAccessIdentity": "" },
"OriginAccessControlId": "E3RFAILÖVEROAC"
}
]
},
"OriginGroups": {
"Quantity": 1,
"Items": [
{
"Id": "techoral-failover-group",
"FailoverCriteria": {
"StatusCodes": {
"Quantity": 4,
"Items": [500, 502, 503, 504]
}
},
"Members": {
"Quantity": 2,
"Items": [
{ "OriginId": "primary-s3-us-east-1" },
{ "OriginId": "failover-s3-eu-west-1" }
]
}
}
]
},
"DefaultCacheBehavior": {
"TargetOriginId": "techoral-failover-group",
"ViewerProtocolPolicy": "redirect-to-https",
"CachePolicyId": "658327ea-f89d-4fab-a63d-7e88639e58f6",
"Compress": true,
"AllowedMethods": { "Quantity": 2, "Items": ["GET", "HEAD"] }
},
"Enabled": true,
"IsIPV6Enabled": true,
"PriceClass": "PriceClass_All",
"HttpVersion": "http2and3"
}'
403 and 404 in the failover criteria (useful if objects are missing from primary but present in replica).
6. Signed URLs and Signed Cookies
Use signed URLs or signed cookies to restrict CloudFront content to authenticated users. Both mechanisms use a CloudFront key pair — you generate an RSA key pair, upload the public key to CloudFront, and create a key group. Your application server signs URLs or cookies with the private key.
Signed URL — use when you need to restrict access to individual files, or when the user's client does not support cookies (e.g., progressive video download links).
Signed Cookie — use when you need to grant access to multiple files under a path (e.g., all premium course videos under /courses/advanced/*).
Python example using the cryptography library to generate a signed CloudFront URL with a canned policy:
import datetime
import json
import base64
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
def generate_signed_url(
url: str,
key_pair_id: str,
private_key_pem: bytes,
expiry_minutes: int = 60
) -> str:
"""
Generate a CloudFront signed URL using a canned policy.
Args:
url: The CloudFront URL to sign (e.g., https://d123.cloudfront.net/video.mp4)
key_pair_id: The CloudFront key pair ID (e.g., K2JCJMDEHXQW5F)
private_key_pem: RSA private key in PEM format (bytes)
expiry_minutes: URL expiry from now (default 60 minutes)
Returns:
Signed URL string with Policy, Signature, and Key-Pair-Id query params
"""
# Calculate expiry timestamp
expire_time = int(
(datetime.datetime.utcnow() + datetime.timedelta(minutes=expiry_minutes))
.timestamp()
)
# Build canned policy document
policy = {
"Statement": [{
"Resource": url,
"Condition": {
"DateLessThan": {
"AWS:EpochTime": expire_time
}
}
}]
}
policy_json = json.dumps(policy, separators=(',', ':'))
policy_b64 = base64.b64encode(policy_json.encode()).decode()
# CloudFront requires URL-safe encoding
policy_b64 = policy_b64.replace('+', '-').replace('=', '_').replace('/', '~')
# Sign the policy with RSA-SHA1 (CloudFront requirement)
private_key = serialization.load_pem_private_key(private_key_pem, password=None)
signature = private_key.sign(policy_json.encode(), padding.PKCS1v15(), hashes.SHA1())
sig_b64 = base64.b64encode(signature).decode()
sig_b64 = sig_b64.replace('+', '-').replace('=', '_').replace('/', '~')
# Assemble the signed URL
separator = '&' if '?' in url else '?'
signed_url = (
f"{url}{separator}"
f"Policy={policy_b64}"
f"&Signature={sig_b64}"
f"&Key-Pair-Id={key_pair_id}"
)
return signed_url
# Usage example
if __name__ == '__main__':
with open('/secure/cloudfront-private-key.pem', 'rb') as f:
private_key_pem = f.read()
signed = generate_signed_url(
url='https://d1abc123.cloudfront.net/courses/advanced/module1.mp4',
key_pair_id='K2JCJMDEHXQW5F',
private_key_pem=private_key_pem,
expiry_minutes=120
)
print(signed)
7. WAF Integration
AWS WAF v2 integrates directly with CloudFront to inspect HTTP requests before they reach your origin or even before they hit your cache. A WAF Web ACL associated with a distribution can enforce:
- Rate limiting — block IPs exceeding N requests per 5-minute window
- Geo-blocking — allow or deny traffic by country code
- Managed rule groups — AWS-curated rules for OWASP Top 10, known bad IPs, bot detection, and Amazon IP Reputation lists
- Custom rules — regex-based header, URI, or body inspection
WAF Web ACLs for CloudFront must be created in us-east-1 (global scope), the same region requirement as Lambda@Edge.
# Create a WAF Web ACL for CloudFront in us-east-1
aws wafv2 create-web-acl \
--name techoral-cloudfront-waf \
--scope CLOUDFRONT \
--region us-east-1 \
--default-action Allow={} \
--rules '[
{
"Name": "RateLimitRule",
"Priority": 1,
"Action": { "Block": {} },
"Statement": {
"RateBasedStatement": {
"Limit": 2000,
"AggregateKeyType": "IP"
}
},
"VisibilityConfig": {
"SampledRequestsEnabled": true,
"CloudWatchMetricsEnabled": true,
"MetricName": "RateLimitRule"
}
},
{
"Name": "AWSManagedRulesCommonRuleSet",
"Priority": 2,
"OverrideAction": { "None": {} },
"Statement": {
"ManagedRuleGroupStatement": {
"VendorName": "AWS",
"Name": "AWSManagedRulesCommonRuleSet"
}
},
"VisibilityConfig": {
"SampledRequestsEnabled": true,
"CloudWatchMetricsEnabled": true,
"MetricName": "CommonRuleSet"
}
}
]' \
--visibility-config \
SampledRequestsEnabled=true,CloudWatchMetricsEnabled=true,MetricName=techoralWAF
# Associate the Web ACL ARN with your distribution by including it in
# the distribution config under WebACLId field.
# Example: "WebACLId": "arn:aws:wafv2:us-east-1:123456789012:global/webacl/techoral-cloudfront-waf/abc123"
8. Cache Invalidation Strategies
Invalidation tells CloudFront to remove cached objects from all edge locations so the next request fetches a fresh copy from the origin. Invalidations take 1–5 minutes to propagate globally.
Pricing: the first 1,000 invalidation paths per month are free; after that, $0.005 per path. A wildcard (/static/*) counts as one path, making it cost-efficient.
# Invalidate a specific file after deployment
aws cloudfront create-invalidation \
--distribution-id E2XXXXXXXXXXX \
--paths '/index.html' '/manifest.json' '/service-worker.js'
# Invalidate an entire path after a bulk asset update
aws cloudfront create-invalidation \
--distribution-id E2XXXXXXXXXXX \
--paths '/static/*'
# Full distribution invalidation (use sparingly — hammers origin)
aws cloudfront create-invalidation \
--distribution-id E2XXXXXXXXXXX \
--paths '/*'
# Check invalidation status
aws cloudfront get-invalidation \
--distribution-id E2XXXXXXXXXXX \
--id I3XXXXXXXXXXX \
--query 'Invalidation.{Id:Id,Status:Status,CreateTime:CreateTime}'
Preferred Alternative: Asset Versioning
The best invalidation strategy is to never need one. Use content-addressed filenames for all versioned assets (e.g., app.a1b2c3d4.js). When a file changes, it gets a new hash and therefore a new cache key — no invalidation required. Only the entry point files (index.html, sitemap.xml, robots.txt) need invalidation on each deploy.
A simple CI/CD deployment hook that invalidates only entry points:
#!/usr/bin/env bash
# deploy.sh — sync to S3 then invalidate CloudFront entry points
set -euo pipefail
BUCKET="techoral-assets"
DISTRIBUTION_ID="E2XXXXXXXXXXX"
echo "Step 1: Sync versioned assets with long cache TTL"
aws s3 sync ./dist/static/ "s3://${BUCKET}/static/" \
--cache-control "public, max-age=31536000, immutable" \
--delete
echo "Step 2: Upload entry points with no-cache"
for file in index.html sitemap.xml robots.txt; do
aws s3 cp "./dist/${file}" "s3://${BUCKET}/${file}" \
--cache-control "public, max-age=0, must-revalidate"
done
echo "Step 3: Invalidate only entry points"
INVALIDATION_ID=$(aws cloudfront create-invalidation \
--distribution-id "${DISTRIBUTION_ID}" \
--paths '/index.html' '/sitemap.xml' '/robots.txt' \
--query 'Invalidation.Id' --output text)
echo "Step 4: Wait for invalidation to complete"
aws cloudfront wait invalidation-completed \
--distribution-id "${DISTRIBUTION_ID}" \
--id "${INVALIDATION_ID}"
echo "Deployment complete. Invalidation ${INVALIDATION_ID} propagated."