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.

How every example in this chapter was verified
This is the chapter where an account matters most, and there is none. CloudFront distributions take minutes to deploy, certificates need a domain you control, DNS needs a registered domain and delegated name servers, and none of that exists on the machine that wrote this course. So nothing was deployed and no real request travelled through CloudFront, Route 53 or WAF.
  • 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.
Docker was not needed for this chapter.

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.

browser | 1. DNS: www.example.com ? Route 53: an alias A/AAAA record answers with CloudFront addresses v CloudFront edge location | 2. AWS WAF web ACL evaluates the request (rules by priority, then the default action) | 3. cache behaviour chosen by path; cache key built from the cache policy | 4a. hit and still fresh -> answer from the edge cache | 4b. miss or expired -> origin request (signed by origin access control) v S3 bucket (private) -- the response is cached for a time set by TTLs and Cache-Control

CloudFront

Limits to know

From the CloudFront quotas page (2026-10-13):

WhatDefault
Distributions per account; origins per distribution; cache behaviours per distribution500; 100; 75 (all adjustable)
Alternate domain names (CNAMEs) per distribution100 (adjustable)
Origin response timeout ("origin read timeout")1 to 120 seconds
Largest cacheable file per GET response; largest request body50 GB; 64 GB
Request or origin response length with headers and query strings; URL length32,768 bytes; 8,192 bytes
Requests per second and data rate per distribution250,000; 150 Gbps (both can be raised; not for flat-rate plans)
Custom cache policies per account; headers, cookies and query strings per cache policy20; 10 each
Certificates per distribution; certificates per account on dedicated IPs1; 2 (no quota with SNI)
Invalidation: paths or tags per second; wildcard invalidations per second150; 1
CloudFront Functions: per account; size; memory100; 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:

py cache_tests.py # managed policies against origin headers
== managed cache policies against origin headers (seconds CloudFront keeps an object) == policy no header max-age=60 no-store private max-age=3 years CachingOptimized 86,400 60 1 1 31,536,000 CachingOptimizedForUncompressedObjects 86,400 60 1 1 31,536,000 CachingDisabled 0 0 0 0 0 UseOriginCacheControlHeaders 0 60 0 0 31,536,000 UseOriginCacheControlHeaders-QueryStrings 0 60 0 0 31,536,000 Amplify 2 60 2 2 600 Elemental-MediaPackage 86,400 60 0 0 31,536,000

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):

py cache_tests.py # synthetic request log
== cache key: upper bound of the hit ratio on a synthetic request log == policy distinct keys hit-ratio ceiling CachingOptimized 90 99.6% custom: only the 'v' query string 269 98.7% UseOriginCacheControlHeaders 7,786 61.1% UseOriginCacheControlHeaders-QueryStrings 15,458 22.7% Amplify 19,240 3.8% 20,000 requests, 30 paths; 'ceiling' = 1 - distinct keys / requests, i.e. every repeat of a key is a hit (no expiry, no eviction) failed checks: 0

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:

def edge_ttl(minttl, default, maxttl, cc=None, expires_in=None): """seconds CloudFront caches an object. cc: set of Cache-Control directives as {'max-age': 60, 's-maxage': 30, 'no-cache': True, ...}; expires_in: seconds until the Expires header's date (only used when there is no max-age, per the page).""" cc = cc or {} blockers = any(cc.get(k) for k in ("no-cache", "no-store", "private")) if blockers: # table row: Min TTL = 0 -> CloudFront respects the headers (not cached); Min TTL > 0 -> cached for the minimum TTL return minttl if minttl > 0 else 0 if "s-maxage" in cc: v = cc["s-maxage"] # table: with both max-age and s-maxage the s-maxage value is used for CloudFront return min(v, maxttl) if minttl == 0 else clamp(v, minttl, maxttl) if "max-age" in cc: v = cc["max-age"] return min(v, maxttl) if minttl == 0 else clamp(v, minttl, maxttl) if expires_in is not None: return min(expires_in, maxttl) if minttl == 0 else clamp(expires_in, minttl, maxttl) return default if minttl == 0 else max(minttl, default)
py cache_tests.py # expiration table
== the expiration table (min 0 or >0; origin headers) == ok min 0, max-age 500 model 500 docs 500 lesser of max-age and maximum TTL ok min 0, max-age 5000 (above max) model 1000 docs 1000 capped by maximum TTL ok min 0, no header model 100 docs 100 default TTL ok min 0, max-age 500 and s-maxage 200 model 200 docs 200 s-maxage for CloudFront ok min 0, s-maxage 5000 model 1000 docs 1000 capped ok min 0, Expires in 300 s model 300 docs 300 until Expires or max TTL, sooner ok min 0, Expires in 9000 s model 1000 docs 1000 capped ok min 0, no-store model 0 docs 0 headers respected ok min 0, private model 0 docs 0 headers respected ok min 0, no-cache with max-age 500 model 0 docs 0 headers respected ok min 50, max-age 500 model 500 docs 500 min < max-age < max ok min 50, max-age 10 model 50 docs 50 max-age < min -> min ok min 50, max-age 5000 model 1000 docs 1000 max-age > max -> max ok min 50, no header, default 100 model 100 docs 100 greater of min and default ok min 500, no header, default 100 model 500 docs 500 greater of min and default ok min 50, s-maxage 10 (max-age 500) model 50 docs 50 s-maxage < min -> min ok min 50, Expires in 10 s model 50 docs 50 Expires < min -> min ok min 50, Expires in 9000 s model 1000 docs 1000 Expires > max -> max ok min 50, no-store model 50 docs 50 the page's warning: cached for the minimum TTL anyway

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.

py cache_tests.py # stale, errors, invalidation
== stale directives and errors (page examples) == ok stale-while-revalidate=600, max TTL 31,536,000 model 600 docs 600 example: 10 minutes ok stale-if-error=86400, max TTL 3600 model 3600 docs 3600 'whichever is less' ok 404, no headers model 10 docs 10 error caching minimum TTL, 10 s ok 404 with max-age=60 model 60 docs 60 maximum of the two ok 503, error min TTL 30, max-age 5 model 30 docs 30 maximum of the two ok 403 without a header model 0 docs 0 only cached with max-age / s-maxage ok 403 with s-maxage=20 model 20 docs 20 cached with the header ok 500 for a POST model 0 docs 0 GET and HEAD only ok 418 (not in either list) model 0 docs 0 not listed == invalidation (page examples) == ok 3 distributions x 600 paths model 800 docs 800 'charge for 800 invalidation paths' ok 500 path + 600 tag invalidations model 100 docs 100 'charged for 100' ok a single /* wildcard path model 0 docs 0 counts as one path

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:

SiteBucketPolicy: Type: AWS::S3::BucketPolicy Properties: Bucket: !Ref SiteBucket PolicyDocument: Version: "2012-10-17" Statement: - Sid: AllowCloudFrontServicePrincipalReadOnly Effect: Allow Principal: {Service: cloudfront.amazonaws.com} Action: s3:GetObject Resource: !Sub "${SiteBucket.Arn}/*" Condition: StringEquals: "AWS:SourceArn": !Sub "arn:aws:cloudfront::${AWS::AccountId}:distribution/${Distribution}" OriginAccess: Type: AWS::CloudFront::OriginAccessControl Properties: OriginAccessControlConfig: Name: site-oac OriginAccessControlOriginType: s3 SigningBehavior: always SigningProtocol: sigv4
py oac_policy.py
case expect model docs read-only policy: CloudFront, its own distribution, GetObject True True resource-based policy grants the service directly same policy: CloudFront on behalf of ANOTHER distribution False False no identity-based Allow (implicit deny) same policy: CloudFront request with no SourceArn False False no identity-based Allow (implicit deny) same policy: PutObject (read-only statement) False False no identity-based Allow (implicit deny) same policy: an unrelated user in another account False False no identity-based Allow (implicit deny) mistake: the Condition left out, another distribution True True resource-based policy grants the service directly mistake: Resource is the bucket, not bucket/* False False no identity-based Allow (implicit deny) migration policy (OAC + legacy OAI): the OAI user True True resource-based policy grants the user directly migration policy: CloudFront with OAC True True resource-based policy grants the service directly KMS key policy from the page: Decrypt for the distribution True True resource-based policy grants the service directly KMS key policy: Decrypt for another distribution False False this resource policy must explicitly allow the principal (it does not) KMS key policy: ScheduleKeyDeletion (not listed) False False this resource policy must explicitly allow the principal (it does not) 12 of 12 cases as expected from the page

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:

py route53_model.py # dnspython part
== zone text parsed by dnspython 2.9.0 == a plain zone accepted CNAME at the apex (@ IN CNAME www.example.com.) rejected: CNAMEAndOtherData: CNAME rdataset is not compatible with a regular data node CNAME next to an A record at the same name rejected: CNAMEAndOtherData: rdataset type is not compatible with a CNAME node CNAME at a subdomain on its own accepted two CNAMEs for one name accepted A record at the apex accepted

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:

def answer(records): """records: list of dicts {name, weight, hc: None|True|False} (hc = has a health check and it is healthy) Returns the names Route 53 may answer with. Derived from the docs' tables: nonzero weights first; if none of them is healthy, zero-weight records; if nothing is healthy at all, the nonzero records are answered anyway (table row U,U,U).""" ok = lambda r: r["hc"] is None or r["hc"] is True nonzero = [r for r in records if r["weight"] > 0] zero = [r for r in records if r["weight"] == 0] a = [r for r in nonzero if ok(r)] if a: return [r["name"] for r in a] b = [r for r in zero if ok(r)] if b: return [r["name"] for r in b] return [r["name"] for r in nonzero]
py route53_model.py # weighted routing
== zero-weight record WITH a health check (page table) == ok health R1,R2,R3 = UUH answered ['R3'] page: ['R3'] ok health R1,R2,R3 = UUU answered ['R1', 'R2'] page: ['R1', 'R2'] ok health R1,R2,R3 = UHU answered ['R2'] page: ['R2'] ok health R1,R2,R3 = HHU answered ['R1', 'R2'] page: ['R1', 'R2'] ok health R1,R2,R3 = HHH answered ['R1', 'R2'] page: ['R1', 'R2'] == zero-weight record WITHOUT a health check (page table) == ok health R1,R2 = HH, R3 unchecked answered ['R1', 'R2'] page: ['R1', 'R2'] ok health R1,R2 = UU, R3 unchecked answered ['R3'] page: ['R3'] ok health R1,R2 = UH, R3 unchecked answered ['R2'] page: ['R2'] == weights 1 and 255 (page example: 1/256 and 255/256) == model shares: {'canary': '0.00391', 'stable': '0.99609'} 1/256 = 0.00391 200,000 simulated queries: {'stable': 199200, 'canary': 800} canary share 0.00400 weight 0 on a healthy record: {'b': 1.0}

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:

py route53_model.py # the 18% rule
== 'more than 18% of health checkers report healthy' == the page does not say how many checkers there are; for a few possible counts, the number of healthy reports needed: 5 checkers: healthy once 1 of them report healthy (20%); 0 of 5 (0%) is unhealthy 10 checkers: healthy once 2 of them report healthy (20%); 1 of 10 (10%) is unhealthy 15 checkers: healthy once 3 of them report healthy (20%); 2 of 15 (13%) is unhealthy 16 checkers: healthy once 3 of them report healthy (19%); 2 of 16 (12%) is unhealthy 20 checkers: healthy once 4 of them report healthy (20%); 3 of 20 (15%) is unhealthy

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:

py waf_model.py
== the page's priority example == ok evaluation order ['Rule1', 'RuleGroupA/RuleA1', 'RuleGroupA/RuleA2', 'Rule2', 'RuleGroupB/RuleB1', 'RuleGroupB/RuleB2'] == Allow (priority 50) and Count (priority 100), the page's example == ok Allow before Count: results, count-rule hits (['Allow', 'Block', 'Block'], 1) ok Count before Allow (lower number): results, count-rule hits (['Allow', 'Block', 'Block'], 2) == actions and the default action == ok only a Count rule matches -> default action Allow ok Count then Allow: /health Allow ok Count then Allow: anything else (default Block) Block == validation rules from the pages == ok duplicate priorities in a web ACL ['priorities in the web ACL must be unique'] ok rate limit 5 ['rl: lowest rate limit is 10'] ok evaluation window 90 ['rl: window must be 60, 120, 300 or 600'] ok rate-based rule with Allow ['rl: a rate-based rule cannot use Allow'] == rate-based rule: limit 10 per 60 s per IP, 3 clients == 198.51.100.1 allowed 10 blocked 110 198.51.100.2 allowed 10 blocked 350 198.51.100.3 allowed 12 blocked 0 (1 request/s = 60 per window, above the limit of 10; the page says real counting is 'near the limit', not exact) == capacity and price == ok rate-based rule, no custom keys 2 ok rate-based rule with 2 custom aggregation keys 62 ok 5 rules/groups, 10 million requests a month: $ 16.0 ok 1 web ACL, 3 rule groups + 2 rules, 100 million requests: $ 70.0 failed checks: 0

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:

py edge_moto_tests.py # condensed
case docs say moto 5.2.4 CNAME record at the zone apex rejected accepted DIFF CNAME record at a subdomain accepted accepted ok alias A record at the apex, CloudFront target accepted accepted ok alias record that also sets a TTL rejected accepted DIFF health check with a 20 s request interval rejected accepted DIFF health check with a 30 s request interval accepted accepted ok weighted record without a SetIdentifier rejected accepted DIFF weighted record with a SetIdentifier accepted accepted ok web ACL with a rate-based rule, limit 100, window 300 accepted accepted ok rate-based limit 5 rejected rejected by boto3 itself n/a rate-based window 90 rejected accepted DIFF rate-based rule with the Allow action rejected accepted DIFF two rules with the same priority rejected accepted DIFF two rules with different priorities accepted accepted ok distribution with a certificate requested in us-east-1 accepted accepted ok distribution with a certificate requested in eu-west-1 rejected accepted DIFF distribution with 101 alternate domain names rejected accepted DIFF cache policy with 10 headers in the cache key accepted call not implemented n/a cache policy with 11 headers in the cache key rejected call not implemented n/a 7 of 16 scored cases agree with the documentation (n/a = not scored: the emulator does not implement the call, or boto3 itself rejected the input before it was sent)

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:

SiteCachePolicy: Type: AWS::CloudFront::CachePolicy Properties: CachePolicyConfig: Name: site-cache-policy MinTTL: 1 DefaultTTL: 86400 MaxTTL: 31536000 ParametersInCacheKeyAndForwardedToOrigin: EnableAcceptEncodingGzip: true EnableAcceptEncodingBrotli: true HeadersConfig: {HeaderBehavior: none} CookiesConfig: {CookieBehavior: none} QueryStringsConfig: {QueryStringBehavior: none}
Distribution: Type: AWS::CloudFront::Distribution Properties: DistributionConfig: Enabled: true Comment: chapter 10 site Aliases: [!Ref DomainName] DefaultRootObject: index.html HttpVersion: http2and3 PriceClass: PriceClass_100 WebACLId: !GetAtt WebAcl.Arn Origins: - Id: site DomainName: !GetAtt SiteBucket.RegionalDomainName OriginAccessControlId: !GetAtt OriginAccess.Id S3OriginConfig: {OriginAccessIdentity: ""} DefaultCacheBehavior: TargetOriginId: site ViewerProtocolPolicy: redirect-to-https Compress: true CachePolicyId: !Ref SiteCachePolicy CustomErrorResponses: - ErrorCode: 404 ResponseCode: 404 ResponsePagePath: /404.html ErrorCachingMinTTL: 30 ViewerCertificate: AcmCertificateArn: !Ref CertificateArn SslSupportMethod: sni-only MinimumProtocolVersion: TLSv1.2_2021

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):

py mutate_edge.py # condensed
change cfn-lint 1.57.2 (control) template as written exit 0 cache policy MinTTL 1 -> -1 caught E3034 cache policy MinTTL above MaxTTL missed cache policy DefaultTTL 'a day' caught E3012 headers 'whitelist' with no header list missed ViewerProtocolPolicy 'https' caught E3030 PriceClass 'PriceClass_50' caught E3030 HttpVersion 'http4' caught E3030 OAC SigningBehavior 'sometimes' caught E3031 OAC origin type 'bucket' caught E3031 OAC with signing 'never' on a private bucket missed bucket policy without the SourceArn Condition missed bucket policy Resource is the bucket, not bucket/* missed bucket policy trusts the wrong service missed certificate in eu-west-1 instead of us-east-1 missed alias domain with no certificate warning W2001 custom error ErrorCachingMinTTL -5 missed WebACL Scope REGIONAL while attached to CloudFront missed WebACL without a DefaultAction caught E3003 rate-based Limit 1000 -> 5 caught E3034 rate-based EvaluationWindowSec 300 -> 90 caught E3030 rate-based rule with Action Allow missed two rules with the same Priority missed a rule without VisibilityConfig caught E3003 managed rule group with a vendor that does not exist missed apex record becomes a CNAME missed alias record that also sets a TTL caught E3029 alias hosted zone ID is not CloudFront's missed health check interval 20 s missed health check FailureThreshold 'three' caught E3012 health check type 'HTTP3' caught E3030 weighted record without a SetIdentifier missed weighted Weight 'heavy' caught E3012 32 one-change mutations: cfn-lint reported an error for 15; 17 passed or only warned

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:

def check(text): t = yaml.load(text, Loader=Loader) R, P = t["Resources"], t.get("Parameters", {}) out = [] by_type = lambda ty: [(k, v) for k, v in R.items() if v["Type"] == ty] # cache policies for k, r in by_type("AWS::CloudFront::CachePolicy"): c = r["Properties"]["CachePolicyConfig"] if isinstance(c.get("MinTTL"), (int, float)) and isinstance(c.get("MaxTTL"), (int, float)) and c["MinTTL"] > c["MaxTTL"]: out.append(("ERROR", k, f"MinTTL {c['MinTTL']} is above MaxTTL {c['MaxTTL']} (the expiration table assumes min < max)")) h = c.get("ParametersInCacheKeyAndForwardedToOrigin", {}).get("HeadersConfig", {}) if h.get("HeaderBehavior") == "whitelist" and not h.get("Headers"): out.append(("ERROR", k, "HeaderBehavior whitelist with no Headers list (understanding)")) # OAC and the bucket policy for k, r in by_type("AWS::CloudFront::OriginAccessControl"): sb = r["Properties"]["OriginAccessControlConfig"].get("SigningBehavior") if sb in ("never", "no-override"): out.append(("WARN", k, f"SigningBehavior {sb}: with 'never' the bucket must be publicly readable (OAC page)")) for k, r in by_type("AWS::S3::BucketPolicy"): for s in r["Properties"]["PolicyDocument"]["Statement"]: p = s.get("Principal", {}) if p.get("Service") == "cloudfront.amazonaws.com": if not s.get("Condition") or "AWS:SourceArn" not in str(s["Condition"]): out.append(("ERROR", k, "CloudFront service principal without an AWS:SourceArn condition: any distribution could read the bucket (OAC page)"))
py mutate_edge2.py # both tools
edgecheck on the unchanged template: no findings change cfn-lint edgecheck cache policy MinTTL 1 -> -1 error - cache policy MinTTL above MaxTTL - finding cache policy DefaultTTL 'a day' error - headers 'whitelist' with no header list - finding ViewerProtocolPolicy 'https' error - PriceClass 'PriceClass_50' error - HttpVersion 'http4' error - OAC SigningBehavior 'sometimes' error - OAC origin type 'bucket' error - OAC with signing 'never' on a private bucket - finding bucket policy without the SourceArn Condition - finding bucket policy Resource is the bucket, not bucket/* - finding bucket policy trusts the wrong service - finding certificate in eu-west-1 instead of us-east-1 - finding alias domain with no certificate - finding custom error ErrorCachingMinTTL -5 - finding WebACL Scope REGIONAL while attached to CloudFront - finding WebACL without a DefaultAction error - rate-based Limit 1000 -> 5 error finding rate-based EvaluationWindowSec 300 -> 90 error finding rate-based rule with Action Allow - finding two rules with the same Priority - finding a rule without VisibilityConfig error - managed rule group with a vendor that does not exist - finding apex record becomes a CNAME - finding alias record that also sets a TTL error finding alias hosted zone ID is not CloudFront's - finding health check interval 20 s - finding health check FailureThreshold 'three' error - health check type 'HTTP3' error - weighted record without a SetIdentifier - finding weighted Weight 'heavy' error - 32 changes: both tools 3; cfn-lint only 12; edgecheck only 17; neither 0 neither: []

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:

py cost10.py
== lines I can price (per month) == Route 53 hosted zone $ 0.50 Route 53 standard queries, 20 million (api records) $ 8.00 Route 53 alias queries for the site (A and AAAA) $ 0.00 (free for CloudFront targets) Route 53 health check, HTTPS, endpoint outside AWS $ 2.75 (0.75 basic + 2.00 HTTPS feature) Route 53 health check, HTTPS, endpoint inside AWS $ 1.50 (0.50 basic + 1.00 HTTPS feature) WAF: 1 web ACL + 2 rules + 10M requests $ 13.00 (5.00 + 2.00 + 6.00) ------- sum of the priced lines (CloudFront delivery not included) $ 24.25 == CloudFront flat-rate plans (pricing page; one price per distribution, no overage charges) == Free $ 0.00 a month 1M requests 100 GB data transfer Pro $ 15.00 a month 10M requests 50 TB data transfer Business $200.00 a month 125M requests 50 TB data transfer Premium $1,000.00 a month 500M requests 50 TB data transfer The WAF page lists what the plans include: CloudFront, AWS WAF and DDoS protection, bot management and analytics, Route 53 DNS, CloudWatch Logs ingestion, a TLS certificate, serverless edge compute and S3 storage credits; a web ACL must stay associated. I did not read the plan page, so I cannot say whether the WAF and Route 53 lines above would disappear under a plan, or how a plan treats a web ACL's own charges, health checks or weighted records. Check that before choosing between pay-as-you-go and a plan. == invalidations == first 1,000 paths a month are free across the account; a /* wildcard is one path; the price per path above that is not on the page text

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.

What was and was not checked
Run here: a cache duration model (19 table cases, 9 stale and error cases, 3 invalidation cases) and a cache-key experiment on a synthetic log; 12 origin-access-control policy cases; 8 weighted-routing rows and the 18% rule; dnspython on 6 zone files; a WAF evaluation model (priority order, actions, default action, 4 validation rules, a rate-based demonstration); 16 scored emulator checks (and 3 unscored); 32 template mutations against cfn-lint and a cross-property checker; a cost sketch. From the documentation, not run: everything the real services do. Not read: CloudFront pay-as-you-go prices, the price of invalidation paths above the free 1,000, the flat-rate plan details, CloudFront Functions and Lambda@Edge restrictions, signed URLs and cookies, field-level encryption, continuous deployment, origin groups and failover, Route 53 alias target values and failover and latency routing details, DNSSEC, query logging, resolver features, how real rate limiting counts blocked requests, managed rule group contents, Shield, which Region a CloudFront web ACL must live in, how long a distribution takes to deploy. Not checked at all: any real request through any of these services.

Practice

Exercise 1

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 solution
Exercise 2

Model 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 solution
Exercise 3

Check 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 solution

Chapter 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 (or s-maxage for 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 overrides no-store and private (CachingOptimized keeps 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.com with an AWS:SourceArn condition for your distribution and Resource of bucket/*; 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
Coming next
Chapter 11 is delivery and day-2 operations: pipelines (CodePipeline, CodeBuild and CodeDeploy against GitHub Actions with OIDC to assume a role), deployment strategies, Systems Manager, CloudTrail, Config and the security services. Local checks will focus on workflow files, trust policies for OIDC and deployment-strategy arithmetic; a Docker-based check of workflows may be possible and Docker is working. Start with AWS Intermediate/Advanced 11.