Edge and DNS
AWS Intermediate/Advanced
Chapter 10 ยท Edge and DNS: CloudFront, Route 53 and WAF
AWS Intermediate/Advanced 9 was about what happens behind an API. This chapter is about what happens in front of it: how a visitor's browser finds your site (DNS), how a copy of your content is served from a place near them (a CDN, Amazon CloudFront), and how bad requests are stopped before they reach you (a web application firewall, AWS WAF). It leans on Web Hosting Essentials and HTTPS/TLS Fundamentals for what DNS records and certificates are, and on AWS Fundamentals 4 (S3) and 5 (VPC) for the services behind the edge. API Gateway's own edge options were in AWS Intermediate/Advanced 8.
- Run here (level 1): models I wrote from the documentation and tested on the documentation's own examples (cache duration, cache keys, an S3 bucket policy for origin access control, weighted routing and health-check rules, WAF rule evaluation); dnspython 2.9.0 as a real DNS library parsing zone text; the moto 5.2.4 emulator (Route 53, WAF, CloudFront, ACM) checked against the docs; cfn-lint 1.57.2 on a CloudFormation template for the whole design; and the IAM evaluation model from AWS Intermediate/Advanced 2.
- From the documentation, not run (level 3): everything the real services do. The pages were read on 2026-10-13 through a page-fetch tool that summarises pages; several came back thin (the AWS WAF pages gave only an overview until I fetched their sub-pages, and the AWS WAF quotas page came from the AWS General Reference), and each gap is named where it matters.
- Needs an account, a domain, or both, not run (level 2): deploying the template, validating a certificate, delegating a domain and sending real requests.
A Request's Journey
The picture below is the path this chapter follows. The order of the middle steps (WAF first, then the cache) is how I understand CloudFront and WAF to be combined; the docs pages I read describe each piece, not the full sequence, so treat the diagram as an orientation aid rather than a quotation.
CloudFront
Limits to know
From the CloudFront quotas page (2026-10-13):
| What | Default |
|---|---|
| Distributions per account; origins per distribution; cache behaviours per distribution | 500; 100; 75 (all adjustable) |
| Alternate domain names (CNAMEs) per distribution | 100 (adjustable) |
| Origin response timeout ("origin read timeout") | 1 to 120 seconds |
| Largest cacheable file per GET response; largest request body | 50 GB; 64 GB |
| Request or origin response length with headers and query strings; URL length | 32,768 bytes; 8,192 bytes |
| Requests per second and data rate per distribution | 250,000; 150 Gbps (both can be raised; not for flat-rate plans) |
| Custom cache policies per account; headers, cookies and query strings per cache policy | 20; 10 each |
| Certificates per distribution; certificates per account on dedicated IPs | 1; 2 (no quota with SNI) |
| Invalidation: paths or tags per second; wildcard invalidations per second | 150; 1 |
| CloudFront Functions: per account; size; memory | 100; 10 KB; 2 MB |
| Lambda@Edge: timeout; compressed size; generated response (viewer events / origin events) | 30 seconds; 50 MB; 40 KB / 1 MB |
Cache keys: what makes two requests the same
From the documentation: a cache policy says which headers, cookies and query strings go into the cache key, "the unique identifier for every object in the cache". A cache hit happens when a request produces the same key as an earlier one and the stored object is still valid, and "including fewer values in the cache key increases the likelihood of a cache hit". The same policy holds the TTL settings (minimum, default and maximum) and whether compressed objects are cached. The managed policies are fixed bundles of these settings:
The table is the model's result for what each managed policy does with an origin that sends nothing, max-age=60, no-store, private, or a three-year max-age. Notice two rows. CachingOptimized (minimum TTL 1 second) keeps a no-store object for 1 second, and Amplify (minimum 2 seconds) for 2: the docs print a warning for every managed policy with a minimum TTL above 0, because "CloudFront will cache content for at least the duration specified in the cache policy's minimum TTL, even if the Cache-Control: no-cache, no-store, or private directives are present". Only the policies with a minimum TTL of 0 (CachingDisabled, the UseOriginCacheControlHeaders pair and Elemental-MediaPackage) respect them. The default cache key is small: CachingOptimized uses no query strings, no cookies and no headers, only a normalised Accept-Encoding.
To see the effect of the key on hit rate, I generated a synthetic log (my own, not real traffic): 20,000 requests to 30 paths with a random marketing query string on 60% of them, a version parameter on 20%, a session cookie from 200 users, and a mix of encodings and countries. For each policy I counted distinct cache keys, which gives a ceiling for the hit ratio (every repeat of a key counts as a hit, ignoring expiry and eviction):
The same traffic could reach a ceiling of 99.6% with CachingOptimized and 3.8% with Amplify, because that policy puts every query string, every cookie and the Authorization header into the key. A custom policy that keeps only the v query string stayed at 98.7%. The lesson from the docs' sentence, now measurable: put in the key only what changes the response, and check what your marketing and session parameters do to it.
How long an object stays cached
The expiration page has a table that combines the origin's headers with the policy's minimum, default and maximum TTL. I encoded every row and tested it, using a policy of minimum 0 (then 50), default 100 and maximum 1,000 seconds, so that each branch gives a different number:
All 19 cases agree with the page. They are checks of my reading of the table (I wrote both the model and the expected values from the same page), so they show the model is faithful to the page, not that the service behaves that way. Other facts from the same page: with no cache policy the default TTL is 24 hours; "if you specify values both for Cache-Control max-age and for Expires, CloudFront uses only the value of Cache-Control max-age"; a viewer's Cache-Control or Pragma header cannot force a trip to the origin; and CloudFront may evict a rarely used object before it expires.
Stale content and errors
Stale directives, from the docs. stale-while-revalidate lets CloudFront answer from an expired object while it fetches a fresh one; stale-if-error keeps serving an expired object when the origin fails or returns a 5xx error. In both cases CloudFront serves stale content "up to the value of the directive or the maximum TTL, whichever is less". The page does not say which moment that period is measured from, so my model computes only its length.
Error caching. CloudFront caches an error from the origin for the larger of the error caching minimum TTL (10 seconds by default) and the origin's max-age or s-maxage. It always caches 404, 414, 500, 501, 502, 503 and 504, and caches 400, 403, 405, 412 and 415 only when the origin sends one of those two directives, and only for GET and HEAD (OPTIONS optionally). If an object is in the cache but expired and the origin returns a 5xx error, CloudFront keeps serving the old object for the error caching minimum TTL; a 4xx is passed through instead of the object. The tests for these rules are in the output below: all agree.
Invalidation or versioned names?
From the documentation: to replace a cached file you can invalidate it or serve a new name. The docs recommend file versioning if you update often: it works even when a browser or proxy cached the old file, it shows up in access logs, it lets you roll forward and back, and it is cheaper, because "you still have to pay for CloudFront to transfer new versions of your files to edge locations, but you don't have to pay for invalidating files". The first 1,000 invalidation paths a month are free across the account; a wildcard path such as /images/* counts as one, "even if it causes CloudFront to invalidate thousands of files", and tag invalidations count against the same 1,000. The page text I read did not give the price of a path above the free 1,000. Quotas: 150 paths or tags per second, and 1 wildcard invalidation per second. The model reproduces the docs' two worked examples (3 distributions ร 600 paths = 800 charged; 500 + 600 = 100 charged).
A private S3 origin with origin access control
From the documentation: origin access control (OAC) lets CloudFront send signed requests to an S3 bucket so that the bucket can stay private. It is preferred over the older origin access identity (OAI) because it supports all Regions, SSE-KMS and PUT/DELETE. The bucket policy names the CloudFront service principal and uses a condition so that only your distribution is allowed: "Use a Condition element in the policy to allow CloudFront to access the bucket only when the request is on behalf of the CloudFront distribution that contains the S3 origin". Caveats on the page: Object Ownership must be "Bucket owner enforced" (the default for new buckets); a bucket configured as a website endpoint cannot use OAC at all (it is a custom origin); with the recommended signing setting always the S3 connection is always HTTPS; with never the bucket has to be publicly readable; for SSE-KMS the KMS key policy needs its own statement for CloudFront.
Run here: I evaluated the page's own statements with the IAM model from chapter 2. A mistake of mine surfaced first: the model crashed when a service requester was compared with a statement that names an AWS principal (a service has no ARN), instead of answering "no". I fixed it in this chapter's copy of the model. Results:
The model agrees with the page's intent in all 12 cases, including the two that matter: without the AWS:SourceArn condition, CloudFront acting for someone else's distribution could read your bucket (the "confused deputy" problem the condition exists to stop), and a Resource of the bucket instead of bucket/* grants nothing for objects. This is a model of documented evaluation rules (AWS Intermediate/Advanced 2), not S3.
HTTPS and certificates
From the documentation: for a certificate from AWS Certificate Manager "make sure you request (or import) the certificate in the US East (N. Virginia) Region (us-east-1)", wherever your site's other resources are. When you add an alternate domain name, CloudFront checks that the certificate covers it: the SAN field must contain "an exact match for the alternate domain name, or contain a wildcard at the same level". CloudFront supports RSA keys of 1,024, 2,048, 3,072 and 4,096 bits and ECDSA 256- and 384-bit keys, but ACM issues RSA certificates only up to 2,048 bits (longer ones must be imported). A third-party certificate must be renewed or re-imported at least 24 hours before it expires, and CloudFront can take up to 24 hours to show the change; ACM-issued certificates renew themselves. If an origin's certificate has no name matching the origin domain name, CloudFront returns 502. SNI is the default way to serve HTTPS; dedicated IP addresses are limited to 2 certificates per account.
Price
The CloudFront pricing page text I read had no pay-as-you-go prices (data transfer and request prices were on a separate page I did not get). It listed flat-rate plans, one monthly price per distribution with no overage charges: Free $0 (1 million requests, 100 GB), Pro $15 (10 million requests, 50 TB), Business $200 (125 million, 50 TB) and Premium $1,000 (500 million, 50 TB). The WAF page says these plans bundle CloudFront, AWS WAF and DDoS protection, bot management, Route 53 DNS, CloudWatch Logs ingestion, a TLS certificate, edge compute and S3 storage credits, and that a web ACL must stay associated while on a plan. How a plan treats a web ACL's own charges I did not read.
Route 53
Alias records, and why the apex is special
From the documentation: a CNAME says "this name is another name". "You can't create a CNAME record for example.com" (the zone apex), but "you can create an alias record" there. An alias record is a Route 53 extension: it points at selected AWS resources (CloudFront distributions, load balancers, API Gateway APIs, S3 website buckets, another record in the same zone and others), is listed in dig as an ordinary A or AAAA record, responds only when the query's name and type match, tracks changes in the target by itself, and you cannot set its TTL for an AWS resource. Route 53 does not charge for alias queries to the listed AWS services; a CNAME query is charged, and a CNAME pointing at another record in a Route 53 zone counts as two.
The apex rule is not a Route 53 quirk but a DNS rule. Run here: dnspython, a real DNS library, parsing zone files:
The parser refuses a CNAME next to the apex's SOA and NS records, and a CNAME next to an A record on any name, and accepts a lone CNAME on a subdomain. That is the problem an alias record solves for example.com itself. (It also accepted two CNAMEs for one name, which I did not expect; what Route 53 does with that I do not know.)
Routing policies
The docs list eight: simple (one resource), failover (active-passive), geolocation (by user location), geoproximity (by resource location, with a bias), latency (best Region), IP-based (by the client's IP range), multivalue answer (up to eight healthy records at random) and weighted (traffic in proportions). Records with the same name and type under weighted, latency, geolocation, multivalue and IP-based routing are limited to 100 per name and type (geoproximity: 30).
Weighted routing, from the docs: a record's share is "weight for a specified record / sum of the weights for all records", so weights of 1 and 255 give 1/256 and 255/256 (a 0.4% canary); a weight of 0 stops traffic to a record. With health checks, "Route 53 initially considers only the nonzero weighted records"; if all of those are unhealthy, it considers the zero-weight ones. The page prints two tables, with and without a health check on a zero-weight record. I turned every row into a test:
All eight rows match, including the odd one: when every record is unhealthy, the page's table shows Route 53 still answering with the nonzero records (U,U,U โ records 1 and 2) rather than returning nothing. This model's rule was inferred from those tables, so it is as good as the tables, and no better.
Health checks
From the documentation: checkers in many locations probe the endpoint every 10 or 30 seconds; Route 53 aggregates the answers, and "if more than 18% of health checkers report that an endpoint is healthy, Route 53 considers it healthy". An HTTP or HTTPS check needs a TCP connection within 4 seconds and a 2xx or 3xx status within 2 seconds after that; a TCP check allows 10 seconds; string matching must find the text in the first 5,120 bytes of the body. HTTPS health checks do not validate the certificate, so an expired certificate does not fail them. A new health check counts as healthy "until there's enough data". A calculated health check can combine up to 255 child checks. The page does not say how many checkers there are, so I tabulated what 18% means for a few possible counts:
The point of the odd threshold, per the page, is that a few locations cut off from the endpoint by network problems do not make it look dead. A related effect I did not test: answers cached by resolvers keep being used until their TTL runs out, so the time before visitors move to a healthy record is the detection time plus the record's TTL (my reasoning, not a page I read); an alias to an AWS resource uses that resource's TTL, which you cannot set.
Quotas and price
Quotas (2026-10-13): hosted zones, initial 500 per account; records, 10,000 per hosted zone (more costs extra); 400 records in one record set; health checks, 200 active per account; domains, 20; a ChangeResourceRecordSets request, at most 1,000 record elements (an UPSERT counts each twice) and 32,000 characters of values. Prices from the pricing page (it does not tie the standard rates to a Region): $0.50 per hosted zone per month for the first 25 and $0.10 after; standard queries $0.40 per million for the first billion; latency queries $0.60; geolocation and geoproximity $0.70; IP-based $0.80; health checks $0.50 (AWS endpoints) or $0.75 (others) plus $1.00 or $2.00 for each optional feature such as HTTPS or string matching.
AWS WAF
From the documentation: a web ACL holds rules (and rule groups, which hold rules). Each rule has a priority that must be unique "within that protection pack (web ACL)", and "inside any rule group" its own unique priorities; evaluation goes "from the lowest numeric priority setting on up until it either finds a match that terminates the evaluation or exhausts all of the rules". Allow and Block terminate; Count does not; CAPTCHA and Challenge terminate only when the request lacks a valid token. "If the rule evaluation doesn't result in any terminating action, then AWS WAF applies the protection pack (web ACL) default action". The page's example: an Allow rule at priority 50 and a Count rule at priority 100 means requests that match both are never counted, and "if you want count metrics from the count rule even for requests that match the allow rule, you'd need to give the count rule a lower numeric priority".
Rate-based rules, from the docs: they count requests per aggregation instance (for example per IP) over an evaluation window of 60, 120, 300 (default) or 600 seconds; the lowest limit is 10; the action can be anything except Allow; a limit is applied "near the limit that you set, but does not guarantee an exact limit match". A rate-based rule costs 2 WCUs plus 30 for each custom aggregation key. A web ACL is priced for up to 1,500 WCUs, can use up to 5,000, and a web ACL can have 10 rate-based statements (4 per rule group).
Run here: a model of the evaluation order, actions, default action, validation rules and a rate-based rule, tested on the page's own priority example (RuleGroupA with rules at 10,000 and 20,000 between Rule1 at 0 and Rule2 at 200, and so on) and on the Allow/Count example:
The evaluation order matches the page, and the two priority arrangements give 1 and 2 count hits as the page predicts. In the rate-based demonstration the client sending one request a second was allowed 10 and then blocked for the rest; my model counts a blocked request toward the rate, so a client that keeps sending stays blocked. I did not read the page on how real rate limiting treats blocked requests, so do not take that behaviour from the model.
Limits and price, from the docs: 100 web ACLs per account for each of CloudFront and regional scope; 100 rule groups; 100 IP sets of up to 10,000 addresses; regex patterns of up to 200 characters; 100,000 requests per second per web ACL (adjustable). Request bodies are inspected only up to 64 KB for CloudFront, API Gateway, Cognito, App Runner and Verified Access, but 8 KB for Application Load Balancer and AppSync, so anything beyond that limit is not inspected. Prices (from the page's examples, no Region table): $5.00 per web ACL per month, $1.00 per rule (each rule group counts), $0.60 per million requests, and extra for more than 1,500 WCUs, for body inspection beyond the default and for CAPTCHA. Which Region a web ACL for CloudFront must be created in was not on a page I read.
Can the Emulator Be Trusted Here?
The outline's rule is to use an emulator only where it is shown to be adequate. For these four services it is not. I sent moto inputs that the pages say are invalid, and a few that are valid:
moto accepted every one of the nine invalid inputs I could score, including a CNAME at the apex, a 20-second health check interval, a rate-based rule with the Allow action, duplicate rule priorities, a certificate from eu-west-1 on a distribution, and 101 alternate domain names. It accepted the valid ones too, so its answers carry no information about validity. Two more notes: moto does not implement the CloudFront cache-policy calls (the server returned 404), and its rate-limit rejection came from boto3's own parameter checking, not from the emulator, so I left both out of the score. My first run counted those as emulator rejections, which would have made moto look better than it is. Emulator-based tests on these services would only prove that your code can call the API.
The Whole Design as a Template
Run here: one CloudFormation template with a private bucket and its policy, the OAC, a cache policy, a web ACL, the distribution, the alias records, a health check and two weighted records:
It linted clean with cfn-lint 1.57.2. Then I changed one thing at a time (32 changes, each labelled with the page behind the expectation):
cfn-lint reported an error for 15 of 32. It checks types, ranges and enumerations: a negative TTL, a protocol policy that does not exist, a rate limit of 5, a window of 90, a missing default action or visibility config, an alias record with a TTL. It misses everything that depends on meaning: a minimum TTL above the maximum, a bucket policy with no source condition or the wrong resource or service, a certificate from another Region, an apex CNAME, a web ACL with the wrong scope, a rate-based rule that allows, duplicate rule priorities, a health check interval of 20 seconds, and a weighted record without a set identifier. (The warning it printed for the distribution with no certificate was about an unused parameter, not the real problem, so I did not count it as a catch.)
Run here: I then wrote a checker for those cross-property rules from the pages above and ran both tools on all 32 changes:
Together they covered all 32 (cfn-lint alone 15, the checker alone 20; both on 3). One caution that matters: I wrote the checker's rules after I saw which changes cfn-lint missed, so "neither: 0" shows that these rules can be written, not that the checker would catch a mistake it was not written for. Two rules in it are my own understanding rather than something I read (a CloudFront web ACL needs the CLOUDFRONT scope, and CloudFront's alias hosted zone ID Z2FDTNDATAQYW2), and it is tagged that way in the code.
What a Real Run Involves (not run)
Level 2 and 3: needs an account and a domain. In outline, and partly from my own understanding of the process rather than pages I read this session: you need a domain whose name servers point at a Route 53 hosted zone (or another way to put records in the DNS); you request the certificate in us-east-1 and prove you control the name (DNS validation adds a record); you deploy the template and wait for the distribution to finish deploying; then you check with curl -I that a second request is a hit (CloudFront reports it in a response header), that the S3 URL itself is refused, and with dig that the records resolve. The nameserver delegation steps and the exact response headers were not on pages I read, so I give no commands. Set a budget alert first (AWS Fundamentals 9), keep any test short and small, and delete the stack afterwards; a web ACL and a hosted zone bill by the month and the page says hosted-zone fees "are not prorated for partial months".
The priced lines I could read, for an example of a 20-million-query API record, a HTTPS health check, one web ACL with two rules and 10 million requests:
Cost and Clean-Up
Nothing in this chapter created anything in AWS. On this machine: the scratch folder C:/lwaws/c10 (a Python environment and test files) and the moto server, which I stopped. No Docker containers or images were used.
Practice
Write a model of CloudFront cache duration from the documentation's expiration table and test every cell, plus the stale-content, error-caching and invalidation-counting rules. Print what each managed cache policy does with five origin header scenarios. Measure the hit-ratio ceiling of five cache policies on a synthetic request log. Evaluate the documentation's S3 bucket policy for origin access control against twelve requests, including the confused-deputy case.
๐ View solutionModel Route 53 weighted routing with health checks and test it on the two tables on the documentation page; work out what the 18% rule means for several checker counts; parse zone files with dnspython to see which CNAME placements a DNS parser rejects. Model AWS WAF evaluation (priority order, Allow/Count/Block, default action, a rate-based rule) and test it on the documentation's examples.
๐ View solutionCheck an emulator against the documentation on Route 53, WAF, CloudFront and ACM (separate rejections by the emulator from rejections by the client library and from calls it does not implement). Write a CloudFormation template for the edge design, lint it, make 32 one-change mutations, write a checker for the cross-property rules the linter misses, and say why its score is not independent evidence. Sketch the monthly cost from the pricing pages and list what you could not read.
๐ View solutionChapter 10 Quick Reference
- CloudFront cache key = what a cache policy includes (headers, cookies, query strings, normalised encoding). Fewer values means more hits: on my synthetic log, ceilings of 99.6% (nothing in the key) against 3.8% (all query strings, all cookies, Authorization)
- Cache duration: origin
max-age(ors-maxagefor CloudFront) clamped by the policy's minimum and maximum TTL; no header means the default TTL (or the larger of minimum and default); a minimum TTL above 0 overridesno-storeandprivate(CachingOptimizedkeeps them for 1 second) - Errors are cached for at least 10 seconds by default (404, 414, 5xx always; 400, 403, 405, 412, 415 only with
max-age/s-maxage); update content with versioned names rather than invalidation; the first 1,000 invalidation paths a month are free and a wildcard is one path - Private S3 origin: OAC plus a bucket policy for
cloudfront.amazonaws.comwith anAWS:SourceArncondition for your distribution andResourceofbucket/*; website endpoints cannot use OAC - CloudFront certificates from ACM must be in
us-east-1; the certificate must cover every alternate domain name; renew imported certificates 24 hours early - Route 53: no CNAME at the zone apex, use an alias A/AAAA (free queries to AWS targets, no TTL to set); weighted share = weight / sum of weights; health checks are healthy above 18% of checkers, probe every 10 or 30 s, and do not validate HTTPS certificates; answers cached by resolvers add the record's TTL to failover time (my reasoning)
- WAF: lowest priority first; Allow and Block terminate, Count does not; no terminating match means the default action; rate limit at least 10, window 60/120/300/600, any action but Allow; bodies inspected up to 64 KB for CloudFront but 8 KB for an ALB
- moto accepted all 9 invalid inputs on these services and does not implement CloudFront cache policies; cfn-lint caught 15 of 32 template mistakes (types, ranges, enumerations), not meaning. Keep your own cross-property checks, and test them on mistakes you did not write them for