AWS CloudFront Distributions

By Vishvesh Patel · DevOps EngineerUpdated September 16, 2026

Create and operate a distribution. Behaviour precedence, multiple origins, origin groups for failover, and CloudFront Functions vs Lambda@Edge.

CloudFront Distributions

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.

Creating one

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 E1ABCDEFGHIJKL

Behaviours and precedence

Behaviours 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.

Multiple origins and failover

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.

Price class

"PriceClass": "PriceClass_100"
ClassEdge locations
PriceClass_AllEverywhere, highest cost
PriceClass_200Excludes South America and New Zealand
PriceClass_100North 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.

Edge functions

Two options, and they are not interchangeable:

CloudFront FunctionsLambda@Edge
RuntimeJavaScript onlyNode.js or Python
DurationUnder 1 msUp to 5 or 30 s
TriggersViewer request/responseAll four, including origin events
Network accessNoYes
CostRoughly 1/6thHigher

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.

Updating a distribution safely

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-policy

Deleting one

Deletion 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.

Common errors

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.

Quick reference

TaskCommand
Createaws cloudfront create-distribution --distribution-config …
Read config + ETagaws cloudfront get-distribution-config --id <id>
Updateaws cloudfront update-distribution --if-match "$ETAG"
Wait for deployaws cloudfront wait distribution-deployed --id <id>
List managed policiesaws cloudfront list-cache-policies --type managed
Create an edge functionaws cloudfront create-function
Publish a functionaws cloudfront publish-function --if-match "$ETAG"
DeleteDisable, wait, then delete-distribution --if-match

Key takeaways

  • Behaviours evaluate in order, first match wins. Specific patterns before general ones.
  • Cache policy and origin request policy are different things: cache key versus what reaches the origin.
  • Set 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.
  • A restricted price class still serves everyone, just from further away.
  • CloudFront Functions handle most edge logic at a fraction of Lambda@Edge's cost.
  • Deleting means disable, wait, then delete.

Check your understanding

Exercise

An API behaviour that is never used

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?

Exercise

An update that wiped settings

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?


Frequently Asked Questions

How does CloudFront decide which cache behaviour applies?

It evaluates the configured behaviours in order and uses the first whose path pattern matches the request URI, falling back to DefaultCacheBehavior if none do. That means ordering is significant: a broad pattern such as /api/* placed before /api/auth/* means the more specific behaviour is never reached. When a behaviour appears to be ignored, list the behaviours in order before investigating anything else. This is the same precedence model as ALB listener rules.

What is the difference between a cache policy and an origin request policy?

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.

Should I use CloudFront Functions or Lambda@Edge?

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.

Does a restricted price class make my site unavailable in some regions?

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.

Why does my POST request to CloudFront return 403?

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.

Why does deleting a CloudFront distribution take so long?

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.

Continue Learning

Explore Related Topics

When it breaks

Related Resources