S3 & Edge Caching
Private S3 origin, CloudFront distribution with OAC, multi-behavior routing, invalidation, versioned assets, and edge functions.
Prerequisites
S3 Origin + Distribution + OAC
What we're building in this session
A private S3 bucket with no public access, with a CloudFront distribution in front of it. CloudFront will use Origin Access Control (OAC) to sign requests to S3, so only CloudFront can read the bucket. Direct S3 access returns 403.
Create the S3 Bucket
# us-east-1 rejects LocationConstraint; every other region requires it
if [ "$AWS_REGION" = "us-east-1" ]; then
aws s3api create-bucket \
--bucket "$BUCKET_NAME" \
--region "$AWS_REGION"
else
aws s3api create-bucket \
--bucket "$BUCKET_NAME" \
--region "$AWS_REGION" \
--create-bucket-configuration LocationConstraint="$AWS_REGION"
fi
# Block ALL public access (enforce private)
aws s3api put-public-access-block \
--bucket "$BUCKET_NAME" \
--public-access-block-configuration \
BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
echo "✅ Bucket created and locked down"Upload Test Content
# Create test files locally
mkdir -p /tmp/cf-lab/assets
cat > /tmp/cf-lab/index.html << 'EOF'
<!DOCTYPE html>
<html>
<head><title>CloudFront Lab</title></head>
<body>
<h1>Hello from CloudFront + S3</h1>
<p>Served via: <span id="origin">checking...</span></p>
</body>
</html>
EOF
cat > /tmp/cf-lab/assets/app.js << 'EOF'
console.log("CloudFront lab asset loaded");
document.getElementById('origin').textContent = 'CloudFront Edge';
EOF
# Upload to S3
aws s3 sync /tmp/cf-lab/ s3://$BUCKET_NAME/ \
--exclude "*.DS_Store"
echo "✅ Files uploaded:"
aws s3 ls s3://$BUCKET_NAME/ --recursiveWhy is the bucket private at this point?
We'll attach a bucket policy after creating the OAC and distribution. The policy needs the distribution ARN, which we don't have yet. Order matters: bucket → OAC → distribution → bucket policy.
Create Origin Access Control (OAC)
OAC_CONFIG=$(cat << EOF
{
"Name": "cf-lab-${LAB_SUFFIX}-oac",
"Description": "OAC for CloudFront lab S3 origin",
"SigningProtocol": "sigv4",
"SigningBehavior": "always",
"OriginAccessControlOriginType": "s3"
}
EOF
)
export OAC_ID=$(aws cloudfront create-origin-access-control \
--origin-access-control-config "$OAC_CONFIG" \
--query 'OriginAccessControl.Id' \
--output text)
echo "✅ OAC created: $OAC_ID"SigningBehavior: always
means CloudFront signs every request to S3 with SigV4, regardless of the viewer request. This is what allows the bucket policy to trust only CloudFront. do-not-sign would be used for public origins.
Create the Distribution
DIST_CONFIG=$(cat << EOF
{
"CallerReference": "cf-lab-${LAB_SUFFIX}",
"Comment": "${DIST_COMMENT}",
"DefaultRootObject": "index.html",
"Origins": {
"Quantity": 1,
"Items": [
{
"Id": "s3-origin",
"DomainName": "${BUCKET_NAME}.s3.${AWS_REGION}.amazonaws.com",
"S3OriginConfig": {
"OriginAccessIdentity": ""
},
"OriginAccessControlId": "${OAC_ID}"
}
]
},
"DefaultCacheBehavior": {
"TargetOriginId": "s3-origin",
"ViewerProtocolPolicy": "redirect-to-https",
"AllowedMethods": {
"Quantity": 2,
"Items": ["GET", "HEAD"]
},
"CachePolicyId": "658327ea-f89d-4fab-a63d-7e88639e58f6",
"Compress": true
},
"ViewerCertificate": {
"CloudFrontDefaultCertificate": true
},
"PriceClass": "PriceClass_100",
"Enabled": true
}
EOF
)
export DIST_ID=$(aws cloudfront create-distribution \
--distribution-config "$DIST_CONFIG" \
--query 'Distribution.Id' \
--output text)
export DIST_DOMAIN=$(aws cloudfront get-distribution \
--id "$DIST_ID" \
--query 'Distribution.DomainName' \
--output text)
echo "✅ Distribution created: $DIST_ID"
echo " Domain: $DIST_DOMAIN"
echo " Status: deploying... (wait 5-10 min)"CachePolicyId `658327ea...`
is the AWS-managed CachingOptimized policy. It caches based on URL path only: no cookies and no query strings. It has a 24-hour default TTL and works well for S3 static content. See managed policies: aws cloudfront list-cache-policies --type managed
Attach Bucket Policy for OAC
# Now we have the distribution ARN, attach the bucket policy
DIST_ARN="arn:aws:cloudfront::${ACCOUNT_ID}:distribution/${DIST_ID}"
BUCKET_POLICY=$(cat << EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "cloudfront.amazonaws.com"
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::${BUCKET_NAME}/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "${DIST_ARN}"
}
}
}
]
}
EOF
)
aws s3api put-bucket-policy \
--bucket "$BUCKET_NAME" \
--policy "$BUCKET_POLICY"
echo "✅ Bucket policy attached. Only CloudFront ($DIST_ID) can read the bucket."Wait for Deployment
echo "Waiting for distribution to deploy..."
aws cloudfront wait distribution-deployed --id "$DIST_ID"
echo "✅ Distribution deployed: https://${DIST_DOMAIN}"Checkpoints
# 1. Direct S3 access should return 403
curl -I "https://${BUCKET_NAME}.s3.${AWS_REGION}.amazonaws.com/index.html"
# Expected: 403 Forbidden
# 2. CloudFront should return 200
curl -I "https://${DIST_DOMAIN}/index.html"
# Expected: 200 OK
# Look for: x-cache: Miss from cloudfront (first hit)
# 3. Hit it again; it should be cached
curl -I "https://${DIST_DOMAIN}/index.html"
# Expected: x-cache: Hit from cloudfront
# 4. Check response headers
curl -sI "https://${DIST_DOMAIN}/index.html" | grep -E "x-cache|x-amz-cf|age|cache-control"What those headers mean
x-cache: Hit from cloudfront: served from edge cachex-cache: Miss from cloudfront: edge fetched from originx-amz-cf-pop: the edge location that served it, for exampleMAA50-C1for Chennaiage: 42: seconds this object has been in cache
Break It
Break 1: Try to bypass OAC
# Attempt direct anonymous S3 REST access — should return 403
curl -I "https://${BUCKET_NAME}.s3.${AWS_REGION}.amazonaws.com/index.html"
# Create a presigned URL using your IAM identity
aws s3 presign s3://${BUCKET_NAME}/index.html --expires-in 60
# This works only if the signing IAM identity has s3:GetObject.
# It uses IAM authorization; it does not bypass the bucket policy.Break 2: Request a nonexistent path
curl -I "https://${DIST_DOMAIN}/does-not-exist.html"
# Returns: 403 from S3 (S3 returns 403 for missing objects on private buckets, not 404)
# Fix in the next section: add a custom error response.Break 3: Temporarily remove bucket policy
aws s3api delete-bucket-policy --bucket "$BUCKET_NAME"
sleep 5
curl -I "https://${DIST_DOMAIN}/index.html"
# Likely still 200 — why? Cached at edge! TTL hasn't expired.
# Wait for cache TTL or invalidate, then retry — will get 403.
# Restore policy:
aws s3api put-bucket-policy --bucket "$BUCKET_NAME" --policy "$BUCKET_POLICY"Multi-Behavior Routing
What we're building
Add a second cache behavior so /assets/* uses a long TTL, 7 days for hashed static files, while the default behavior (*) uses a short TTL, 5 min for index.html and other frequently updated content. We'll also add a custom error response to return index.html for 403s, which is the SPA routing pattern.