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}'
OAC vs OAI: Origin Access Identities (OAI) are the legacy method for restricting S3 access. AWS recommends migrating to Origin Access Controls (OAC), which support server-side encryption with customer-managed KMS keys and all S3 bucket regions, including us-east-1 without extra configuration.

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.
Order matters: CloudFront matches behaviors in the order they appear in the distribution config. Always list more specific path patterns (like /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:

  1. Cache-Control / Expires headers from the origin response — CloudFront respects these by default
  2. Cache Policy TTL settings — specify minimum, default, and maximum TTL values
  3. 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"
Cache key tip: Keep the cache key as narrow as possible. Including unnecessary headers, cookies, or query strings in the cache key creates more cache variants, lowers hit rates, and increases origin load. Use Origin Request Policies to forward headers to the origin without including them in the cache key.

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
RuntimeJavaScript (ES5)Node.js 18/20, Python 3.12
Max execution time1 ms5 s (viewer) / 30 s (origin)
Max memory2 MB128 MB – 10 GB
Network accessNoYes (outbound)
Event typesViewer request/responseViewer & origin request/response
Pricing (per 1M invocations)$0.10$0.60 + duration
Use casesHeader rewrites, URL redirects, A/B routingAuth, 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)
Lambda@Edge region: Lambda@Edge functions must be created and published in 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"
}'
Failover scope: Origin group failover only triggers on the configured HTTP error codes from the origin. It does not failover on connection timeouts or SSL handshake failures unless you also include 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)
Key rotation: Store your CloudFront private key in AWS Secrets Manager or Parameter Store (SecureString). Rotate it every 90 days. CloudFront supports multiple active public keys per key group, so you can add the new key before removing the old one to achieve zero-downtime rotation.

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"
WAF pricing: AWS WAF charges per Web ACL ($5/month), per rule ($1/month per rule), and per million requests ($0.60). Managed rule groups add ~$1/month each. Factor these costs into your decision — for low-traffic sites the WAF cost can exceed CloudFront itself.

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."
Monitoring cache performance: Enable CloudFront access logs to an S3 bucket and use CloudWatch metrics (CacheHitRate, Requests, BytesDownloaded) to track performance. A healthy cache hit rate for a static site should be above 85%. Low hit rates usually indicate overly granular cache keys (too many cookies/headers included) or very short TTLs.

Read Next