A distribution is the CloudFront resource itself: the domain name clients hit, the origins it fetches from, and the behaviours deciding how each path is handled. One distribution can front an entire application. Static assets from S3, an API from an ALB, images from a separate bucket, under a single hostname.
aws cloudfront create-distribution --distribution-config '{
"CallerReference": "site-2026-09-16",
"Comment": "marketing site",
"Enabled": true,
"Aliases": {"Quantity": 1, "Items": ["www.example.com"]},
"Origins": {
"Quantity": 1,
"Items": [{
"Id": "s3-site",
"DomainName": "my-site.s3.eu-west-1.amazonaws.com",
"OriginAccessControlId": "E2ABCDEFGHIJKL",
"S3OriginConfig": {"OriginAccessIdentity": ""}
}]
},
"DefaultCacheBehavior": {
"TargetOriginId": "s3-site",
"ViewerProtocolPolicy": "redirect-to-https",
"Compress": true,
"CachePolicyId": "658327ea-f89d-4fab-a63d-7e88639e58f6",
"AllowedMethods": {"Quantity": 2, "Items": ["GET","HEAD"]}
},
"DefaultRootObject": "index.html",
"PriceClass": "PriceClass_100",
"ViewerCertificate": {
"ACMCertificateArn": "arn:aws:acm:us-east-1:123456789012:certificate/â¦",
"SSLSupportMethod": "sni-only",
"MinimumProtocolVersion": "TLSv1.2_2021"
}
}'CallerReference is an idempotency token. Reuse the same value and you get the existing distribution rather than a duplicate, which is useful in scripts.
That CachePolicyId is the managed CachingOptimized policy. Use managed policy IDs rather than writing your own unless you genuinely need something different.
DefaultRootObject is what gets served for /. Omitting it means a request to the root returns a directory listing attempt or a 403, which is a five-minute confusion for everyone who forgets it.
Deployment takes a few minutes:
aws cloudfront wait distribution-deployed --id E1ABCDEFGHIJKLBehaviours map path patterns to origins and caching rules. They are evaluated in order, and the first match wins. Exactly like ALB listener rules, and the source of the same class of bug.
"CacheBehaviors": {
"Quantity": 2,
"Items": [
{
"PathPattern": "/api/*",
"TargetOriginId": "alb-api",
"ViewerProtocolPolicy": "https-only",
"AllowedMethods": {"Quantity": 7, "Items": ["GET","HEAD","OPTIONS","PUT","POST","PATCH","DELETE"]},
"CachePolicyId": "4135ea2d-6df8-44a3-9df3-4b5a84be39ad",
"OriginRequestPolicyId": "216adef6-5c7f-47e4-b989-5492eafa07d3"
},
{
"PathPattern": "/images/*",
"TargetOriginId": "s3-images",
"ViewerProtocolPolicy": "redirect-to-https",
"CachePolicyId": "658327ea-f89d-4fab-a63d-7e88639e58f6"
}
]
}Order matters: put /api/auth/* before /api/* or it will never be reached. The DefaultCacheBehavior is always evaluated last, catching anything unmatched.
Two IDs in that API behaviour do specific jobs. 4135ea2d⦠is managed CachingDisabled. An API response should not be cached by path alone. 216adef6⦠is AllViewerExceptHostHeader, which forwards the headers and cookies your API needs (including Authorization) without including them in the cache key. Confusing cache policies with origin request policies is a common mistake: the first decides what forms the cache key, the second decides what reaches the origin.
Also note AllowedMethods. A behaviour permitting only GET and HEAD rejects a POST with a 403, which reads like an authentication failure and is not.
An origin group gives you automatic failover on specified status codes:
"OriginGroups": {
"Quantity": 1,
"Items": [{
"Id": "s3-failover",
"FailoverCriteria": {"StatusCodes": {"Quantity": 3, "Items": [500, 502, 503]}},
"Members": {"Quantity": 2, "Items": [{"OriginId": "s3-primary"}, {"OriginId": "s3-secondary"}]}
}]
}Point a behaviour at the group rather than at an origin. Failover triggers on those status codes, connection failures and timeouts, but only for GET, HEAD and OPTIONS, so it does not protect write paths. For anything more sophisticated, Route 53 health-check-based failover is the better tool.
"PriceClass": "PriceClass_100"| Class | Edge locations |
|---|---|
PriceClass_All | Everywhere, highest cost |
PriceClass_200 | Excludes South America and New Zealand |
PriceClass_100 | North America and Europe only |
A restricted price class does not make your site unavailable elsewhere. Requests are served from the nearest included edge, so users in Sydney are served from the US with correspondingly higher latency. If your audience is regional, PriceClass_100 is a genuine saving with no functional loss.
Two options, and they are not interchangeable:
| CloudFront Functions | Lambda@Edge | |
|---|---|---|
| Runtime | JavaScript only | Node.js or Python |
| Duration | Under 1 ms | Up to 5 or 30 s |
| Triggers | Viewer request/response | All four, including origin events |
| Network access | No | Yes |
| Cost | Roughly 1/6th | Higher |
CloudFront Functions handle header manipulation, URL rewrites and redirects. The vast majority of real edge logic:
function handler(event) {
var request = event.request;
if (request.uri.endsWith('/')) {
request.uri += 'index.html';
}
return request;
}That five-line function is the standard fix for serving a static site with clean directory URLs.
Use Lambda@Edge only when you need to call another service, read a request body, or act on origin responses.
update-distribution replaces the entire configuration. There is no partial update, and omitting a field removes it. The safe procedure is read, modify, write, with the ETag for optimistic locking:
aws cloudfront get-distribution-config --id E1ABCDEFGHIJKL > dist.json
ETAG=$(python3 -c "import json;print(json.load(open('dist.json'))['ETag'])")
python3 -c "
import json
d = json.load(open('dist.json'))['DistributionConfig']
d['Comment'] = 'updated comment'
json.dump(d, open('config.json','w'))
"
aws cloudfront update-distribution --id E1ABCDEFGHIJKL \
--distribution-config file://config.json --if-match "$ETAG"Forgetting --if-match fails; a stale ETag also fails, which is the point. It stops you overwriting someone else's concurrent change.
For risky changes, use a staging distribution with continuous deployment to send a small share of traffic to the new configuration before promoting it:
aws cloudfront create-distribution-tenant --help # see also: create-continuous-deployment-policyDeletion needs two steps, and the first is easy to forget:
# 1. Disable, and wait for it to finish deploying
aws cloudfront update-distribution --id E1ABCDEFGHIJKL \
--distribution-config file://disabled.json --if-match "$ETAG"
aws cloudfront wait distribution-deployed --id E1ABCDEFGHIJKL
# 2. Then delete
aws cloudfront delete-distribution --id E1ABCDEFGHIJKL --if-match "$NEW_ETAG"A Terraform destroy on a distribution takes a surprisingly long time for exactly this reason.
PreconditionFailed: The request failed because it didn't meet the preconditions
Missing or stale --if-match. Re-read the config to get the current ETag.
DistributionNotDisabled: The distribution you are trying to delete has not
been disabled
Disable it and wait for deployment before deleting.
InvalidDefaultRootObject
DefaultRootObject must be an object name such as index.html, with no leading slash.
POST returning 403. The matched behaviour's AllowedMethods excludes POST.
| Task | Command |
|---|---|
| Create | aws cloudfront create-distribution --distribution-config ⦠|
| Read config + ETag | aws cloudfront get-distribution-config --id <id> |
| Update | aws cloudfront update-distribution --if-match "$ETAG" |
| Wait for deploy | aws cloudfront wait distribution-deployed --id <id> |
| List managed policies | aws cloudfront list-cache-policies --type managed |
| Create an edge function | aws cloudfront create-function |
| Publish a function | aws cloudfront publish-function --if-match "$ETAG" |
| Delete | Disable, wait, then delete-distribution --if-match |
DefaultRootObject, or / does not work.AllowedMethods excluding POST returns 403, which looks like an auth failure.update-distribution replaces the whole config. Read, modify, write, with --if-match.<MCQExercise exercise={{ id: 'aws-cf-dist-quiz1', title: 'An API behaviour that is never used', description: 'A distribution has a behaviour for /api/auth/* pointing at an auth origin, listed after an existing /api/* behaviour. Requests to /api/auth/login keep reaching the general API origin. Why?', options: [ { id: 'a', text: 'Behaviours are evaluated in order and the first matching path pattern wins, so /api/* captures the request before /api/auth/* is considered' }, { id: 'b', text: 'Path patterns may contain only one wildcard, so /api/auth/* is invalid' }, { id: 'c', text: 'Authentication paths must be handled by the DefaultCacheBehavior' }, { id: 'd', text: 'Two behaviours cannot share a common path prefix' }, ], correctId: 'a', explanation: 'CloudFront walks the cache behaviours in the order they are configured and stops at the first pattern that matches the request URI, with DefaultCacheBehavior as the final fallback. A broad pattern placed earlier therefore shadows every more specific pattern below it. Reorder so that /api/auth/* precedes /api/*. This is the same precedence trap as ALB listener rules, and the fix in both cases is ordering rather than syntax.', wrongExplanations: { b: 'The pattern is valid; multiple path segments before a wildcard are fine.', c: 'DefaultCacheBehavior is the catch-all evaluated last, not a special case for authentication.', d: 'Sharing a prefix is normal and expected. Order is what resolves the overlap.', }, }} />
<MCQExercise exercise={{ id: 'aws-cf-dist-quiz2', title: 'An update that wiped settings', description: 'After running update-distribution with a hand-written config containing only the fields being changed, several unrelated settings were reset to defaults. What went wrong?', options: [ { id: 'a', text: 'update-distribution replaces the entire configuration, so any field omitted is removed: the config must be read, modified and written back whole' }, { id: 'b', text: 'The --if-match ETag was stale, which causes partial application of the change' }, { id: 'c', text: 'Configuration changes must be applied through a staging distribution to be preserved' }, { id: 'd', text: 'Distributions reset to defaults whenever their price class is not specified explicitly' }, ], correctId: 'a', explanation: 'There is no partial update for a CloudFront distribution. The API takes a complete DistributionConfig and makes it the new state, so anything absent is genuinely removed rather than left alone. The correct procedure is get-distribution-config, edit the returned DistributionConfig object, and pass the whole thing back with the ETag as --if-match. The ETag is optimistic locking: a stale one causes PreconditionFailed and applies nothing at all, which is a safety feature rather than a cause of partial writes.', wrongExplanations: { b: 'A stale ETag rejects the request outright with PreconditionFailed. Nothing is applied partially.', c: 'Staging distributions let you test a configuration on a slice of traffic; ordinary updates do not require one.', d: 'Price class is one of many fields subject to the same whole-config replacement, not a special trigger.', }, }} />
A cache policy decides what forms the cache key, which query strings, headers and cookies make one request different from another, and therefore what gets cached separately. An origin request policy decides what is forwarded to the origin, independent of the cache key. That separation is what lets you send an Authorization header to your API without fragmenting the cache per user. Using CachingDisabled with AllViewerExceptHostHeader is the standard combination for a dynamic API behaviour.
CloudFront Functions for anything that only inspects and rewrites the request or response. Appending index.html to directory URLs, adding security headers, redirecting, normalising a cache key. They run in under a millisecond, cost roughly a sixth as much, and are JavaScript only with no network access. Lambda@Edge is for the cases Functions cannot handle: calling another service, reading a request body, or acting on origin request and response events.
No. A narrower price class removes the more expensive edge locations from consideration, so a viewer in a region you excluded is served from the nearest included location instead. Correctly, just with higher latency. PriceClass_100 covers North America and Europe and is a genuine saving if that is where your audience is. Nothing is blocked, and you can change the class at any time.
Because the matched cache behaviour does not allow the POST method. AllowedMethods defaults to GET and HEAD, and CloudFront rejects anything else with a 403 before the request reaches your origin, which reads like an authentication problem and is not. For a behaviour fronting an API, allow all seven methods, and pair it with the CachingDisabled cache policy since responses to writes should not be cached.
Because it is two operations. A distribution must be disabled first, and that change propagates to every edge location before the distribution reaches the Deployed state again, typically several minutes. Only then will delete-distribution accept it. Attempting to delete an enabled distribution returns DistributionNotDisabled. This is also why a terraform destroy involving CloudFront appears to hang: it is waiting through exactly the same sequence.
Serve CloudFront on your own domain over HTTPS: request the ACM certificate in us-east-1, validate with DNS, add the alias record and pick a security policy.
Get DNS working on Route 53: public and private hosted zones, the delegation step people forget, alias records, health checks and how TTL sets failover speed.
Create the Route 53 record types you will use: A, AAAA, CNAME, MX, TXT, CAA and SRV. Covers UPSERT semantics, SPF and DMARC records and common errors.