In lesson 02-03 we did a calculation that left Marta speechless: of the monthly S3 bill for the photo catalogue, 97 % was not storage, it was outbound transfer. Every time a customer in Seville opens the vine tomato's page, the photo travels whole from a data centre in Ireland to their phone, and MercadoFresco pays for every byte. If the customer reloads, it is paid for again. If ten thousand customers look at the same photo, the same file is paid for ten thousand times.

And now, after building the load balancer in 03-03, there is a second charge per GB: the ALB also bills for the traffic it processes.

Amazon CloudFront is AWS's content delivery network (CDN): some 700 points of presence spread around the world that keep a copy of your content close to whoever asks for it. The customer in Seville gets the photo from Madrid instead of from Dublin; the request reaches neither S3 nor the ALB; and CloudFront's outbound transfer has a permanent free tier of 1 TB a month that covers MercadoFresco's entire current demand.

In this lesson Marta puts CloudFront in front of everything and attacks the cost problem head on, without losing sight of the other thing a CDN brings, which often matters more: latency.

Contents

  1. What a CDN is and how delivery from the edge works
  2. Cache hits and misses
  3. Distributions and origins
  4. Origin groups and origin failover
  5. Cache behaviours and path patterns
  6. The cache key: cache policies and origin request policies
  7. TTL: minimum, maximum and default
  8. Compression, HTTP/2 and HTTP/3
  9. Origin Access Control: closing the bucket to the world
  10. Invalidations versus file name versioning
  11. Custom domains and the us-east-1 certificate trap
  12. Dynamic content through CloudFront to the ALB
  13. CloudFront Functions and Lambda@Edge
  14. Logs, metrics and hit rate
  15. Price classes and MercadoFresco's saving calculation
  16. Creation from the CLI, invalidation and clean-up

What a CDN is and how delivery from the edge works

A CDN (Content Delivery Network) is a geographically distributed network of cache servers that sit between the user and the origin server. CloudFront has three levels:

Level How many What it does
Edge locations ~700 in more than 100 cities Serve the user and store the most popular content
Regional edge caches ~13, larger Second cache level: if the edge misses, this is asked before the origin
Origin Your S3 bucket, your ALB, or any HTTP server The source of truth

The edge locations closest to MercadoFresco's customers are Madrid and Marseille, with capacity also through London and Paris. A customer in Seville who today waits for the photo to cross 2,000 km to Dublin will start receiving it from Madrid, 400 km away.

The latency gain is half the argument and should not be underestimated: a shop that loads half a second slower loses measurable conversions. The other half is money, and we look at it with numbers at the end of the lesson.

Cache hits and misses

Everything CloudFront does boils down to this diagram:

sequenceDiagram
    participant C as Client<br/>(Seville)
    participant E as Edge location<br/>(Madrid)
    participant R as Regional edge cache<br/>(Frankfurt)
    participant O as Origin<br/>mercadofresco-catalogo-fotos

    Note over C,O: CACHE MISS (first request)
    C->>E: GET /productos/tomate-rama.jpg
    E->>E: Do I have it? No (Miss)
    E->>R: GET to the second level
    R->>R: Do I have it? No (Miss)
    R->>O: GET to the origin
    O-->>R: 200 + image (transfer FREE to CloudFront)
    R-->>E: image (stored)
    E-->>C: image (stored) · ~350 ms

    Note over C,O: CACHE HIT (subsequent requests)
    C->>E: GET /productos/tomate-rama.jpg
    E->>E: Do I have it? Yes (Hit)
    E-->>C: image from Madrid · ~25 ms

Three things this diagram teaches that are worth fixing in your mind:

  1. Only the first user pays the long wait. Everyone after that gets the answer from the edge.
  2. Transfer from the origin to CloudFront is free when the origin is S3 (and also from an ALB or EC2 in the same account). This is the economic key to the whole lesson.
  3. There is a second level. Even if the Madrid edge does not have it, the regional edge cache may, and the origin is spared the request all the same. This improves the real hit rate far more than you would work out by eye.

Every response carries the X-Cache header, which says exactly what happened:

X-Cache value Meaning
Hit from cloudfront Served from the edge, without touching the origin
Miss from cloudfront It was not there: it was requested from the origin
RefreshHit from cloudfront It had expired, the origin answered 304 Not Modified
Error from cloudfront The origin failed

Distributions and origins

A distribution is CloudFront's unit of configuration. When you create it, AWS assigns a domain name of its own, of the form d111111abcdef8.cloudfront.net, and rolls the configuration out to every edge location in a few minutes.

Inside a distribution you declare one or more origins. MercadoFresco needs two:

Origin (Id) Type Domain What it serves
origen-fotos-s3 S3 with OAC mercadofresco-catalogo-fotos.s3.eu-west-1.amazonaws.com Product photos and thumbnails
origen-tienda-alb Custom alb-mercadofresco-tienda-1234567890.eu-west-1.elb.amazonaws.com HTML, API, everything dynamic

The difference between the two origin types is not cosmetic:

S3 origin Custom origin (ALB, EC2, external server)
Protocol to the origin AWS internal HTTP or HTTPS, configurable
Restricted access Origin Access Control (OAC) Secret header + security group
Header forwarding Limited Complete
Allowed methods Only GET, HEAD, OPTIONS All, including POST and PUT
Timeouts Fixed Adjustable (OriginReadTimeout)

A nuance that confuses people: if you configure an S3 bucket as a static website (the mode we saw in 02-03, with its own .s3-website- domain), CloudFront treats it as a custom origin, not as an S3 origin, and then you cannot use OAC. For MercadoFresco we use the S3 REST endpoint with OAC, which is the right thing to do.

Origin groups and origin failover

An origin group pairs two origins: a primary one and a secondary one. If the primary returns one of the configured error codes, CloudFront automatically retries against the secondary, transparently as far as the client is concerned.

{
  "Quantity": 1,
  "Items": [
    {
      "Id": "grupo-fotos-con-respaldo",
      "FailoverCriteria": {
        "StatusCodes": { "Quantity": 4, "Items": [500, 502, 503, 504] }
      },
      "Members": {
        "Quantity": 2,
        "Items": [
          { "OriginId": "origen-fotos-s3" },
          { "OriginId": "origen-fotos-s3-respaldo" }
        ]
      }
    }
  ]
}

It only works with GET, HEAD and OPTIONS, and the criteria can also include 403 and 404. For MercadoFresco it makes sense the day the catalogue is replicated to a bucket in another region; today it is a configuration that is ready but not active. Failover at the DNS level, which is a different and complementary mechanism, is studied in lesson 03-05.

Cache behaviours and path patterns

A cache behaviour associates a path pattern with an origin and with a set of rules. It is the equivalent of the ALB's rules, but at the edge.

MercadoFresco defines four:

Prec. Pattern Origin Methods Cache policy Why
0 /productos/* origen-fotos-s3 GET, HEAD Long cache (1 year) The photos do not change: the name carries a version
1 /miniaturas/* origen-fotos-s3 GET, HEAD Long cache (1 year) Generated by Lambda, immutable
2 /api/* origen-tienda-alb All CachingDisabled Order data: never cached
(default) * origen-tienda-alb GET, HEAD, OPTIONS Short cache (60 s) Catalogue HTML

Evaluation rules, very much like the ALB's:

  • Behaviours are evaluated in order of precedence and the first one that matches wins.
  • The default behaviour (*) is always evaluated last, without exception.
  • Patterns accept * and ?, and they are case sensitive.

The /api/* behaviour deserves attention: you have to allow every HTTP method (GET, HEAD, OPTIONS, PUT, POST, PATCH, DELETE), because otherwise a client confirming an order with POST would get a 403 from CloudFront. And you have to disable the cache explicitly: caching a response for /api/pedidos/4711 and serving it to another customer would be a serious data leak.

The cache key: cache policies and origin request policies

This section is what separates a CDN that saves money from one that is of no use at all.

The cache key is the set of data CloudFront uses to identify a stored object. By default it is just the URL path. But headers, cookies and query strings can be added to it. And here is the trap:

Every distinct value of any element of the cache key creates a distinct entry in the cache.

An arithmetic example that makes it clear. Suppose you include in the cache key the User-Agent header, which has thousands of distinct values:

Elements in the cache key Entries for ONE photo Hit rate
Path only 1 ~95 %
Path + Accept-Encoding 2 (gzip, br) ~93 %
Path + CloudFront-Viewer-Country ~30 ~70 %
Path + User-Agent thousands ~5 %
Path + every cookie Practically one per user ~0 %

With User-Agent in the key, CloudFront stores a different copy of the same photo for every version of every browser. Almost every request is a miss, they all go to the origin, and you have paid for a CDN that caches nothing. It is the most expensive and most frequent CloudFront mistake.

To tell the two apart properly, AWS separates two policies:

Cache policy Origin request policy
What it controls What makes up the cache key What is forwarded to the origin
Effect on the cache Every value creates a new entry None
When to use it Only if the content changes with that value If the origin needs that data to work

The case that clarifies the difference: the origin wants to record the visitor's country in its logs. If you put CloudFront-Viewer-Country in the cache policy, you multiply the entries by 30. If you put it in the origin request policy, the origin receives the data on every cache miss and the cache still has a single entry. The second is the right one.

The policies managed by AWS cover almost everything:

Managed policy Cache key When
CachingOptimized Path only Static content: MercadoFresco's photos
CachingOptimizedForUncompressedObjects Path only, no compression Already compressed files (JPEG, video)
CachingDisabled Nothing is cached /api/*
Elemental-MediaPackage Video specific Streaming

And a policy of your own, when you really do need to vary by language:

{
  "Name": "pol-cache-catalogo-mercadofresco",
  "Comment": "Catalogue HTML: varies by language and by page",
  "DefaultTTL": 60,
  "MinTTL": 0,
  "MaxTTL": 300,
  "ParametersInCacheKeyAndForwardedToOrigin": {
    "EnableAcceptEncodingGzip": true,
    "EnableAcceptEncodingBrotli": true,
    "HeadersConfig": {
      "HeaderBehavior": "whitelist",
      "Headers": { "Quantity": 1, "Items": ["Accept-Language"] }
    },
    "CookiesConfig": { "CookieBehavior": "none" },
    "QueryStringsConfig": {
      "QueryStringBehavior": "whitelist",
      "QueryStrings": { "Quantity": 2, "Items": ["pagina", "categoria"] }
    }
  }
}

The decisions behind this policy, one by one:

  • Accept-Language on the allow list: the catalogue is served in Spanish and in Catalan, so the content does change. It multiplies entries by 2, not by thousands.
  • CookieBehavior: none: session cookies do not change the catalogue's HTML. Including them would destroy the cache completely.
  • Only two query parameters on the allow list: pagina and categoria do change the content. Marketing campaign parameters (utm_source, utm_medium…) do not, and if they were included, every link of every campaign would be a different cache entry for the same HTML.
  • EnableAcceptEncodingGzip and Brotli: CloudFront normalises the header and stores the compressed variants intelligently, without them counting as arbitrary values.

TTL: minimum, maximum and default

The TTL (Time To Live) is how long CloudFront keeps an object before asking again. There are three values, and their interaction with the origin's headers throws people:

Parameter What it does
MinTTL Minimum time an object is kept, even if the origin asks for less
DefaultTTL Time used if the origin sends no Cache-Control
MaxTTL Maximum time, even if the origin asks for more

The full logic:

  1. If the origin sends no Cache-Control or ExpiresDefaultTTL applies.
  2. If the origin sends Cache-Control: max-age=NN is used, clamped between MinTTL and MaxTTL.
  3. Cache-Control: no-store or private → not cached (unless MinTTL is greater than 0, in which case it is cached anyway: a classic source of surprises).

MercadoFresco's values:

Content MinTTL DefaultTTL MaxTTL Origin's Cache-Control
Photos /productos/* 0 31,536,000 (1 year) 31,536,000 public, max-age=31536000, immutable
Thumbnails /miniaturas/* 0 31,536,000 31,536,000 public, max-age=31536000, immutable
Catalogue HTML 0 60 300 public, max-age=60
/api/* 0 0 0 no-store

A year of caching for the photos sounds reckless until you understand the name versioning technique, which we come to two sections from now. The right thing is to put the header on the S3 object when uploading it, because browsers take advantage of it there too:

aws s3 cp foto-nueva.jpg s3://mercadofresco-catalogo-fotos/productos/tomate-rama-v3.jpg \
  --profile mercadofresco-dev --region eu-west-1 \
  --cache-control "public, max-age=31536000, immutable" \
  --content-type "image/jpeg"

immutable is a directive that tells the browser not even to ask whether it has changed. It removes even the 304 revalidation requests.

Compression, HTTP/2 and HTTP/3

Three settings that you switch on with a tick box and that cut bytes and latency:

Automatic compression. With Compress: true, CloudFront compresses responses of compressible content types (HTML, CSS, JavaScript, JSON, SVG) with Gzip or Brotli when the client accepts it, provided the object weighs between 1 KB and 10 MB. Brotli reduces 15-20 % more than Gzip. A warning: it does not compress JPEG, PNG or MP4, which are compressed already; trying would only burn CPU.

HTTP/2 and HTTP/3. They are switched on per distribution:

Protocol Advantage Status
HTTP/1.1 Universal Compatibility
HTTP/2 Multiplexing: many requests over one connection By default
HTTP/3 (QUIC) Over UDP; survives a network change and starts faster Has to be switched on

HTTP/3 matters especially for MercadoFresco: a good share of its customers browse from a phone, and on a phone connections switch between masts and wi-fi constantly. QUIC keeps the session alive when that happens; TCP has to reconnect.

# In the distribution's configuration
"HttpVersion": "http2and3"

Origin Access Control: closing the bucket to the world

Today mercadofresco-catalogo-fotos has a policy that allows public s3:GetObject under productos/. That means anyone can bypass CloudFront and download straight from S3, and MercadoFresco pays the expensive transfer. It also makes hotlinking possible: another website can link MercadoFresco's photos and put the bill on its account.

Origin Access Control (OAC) solves both things: it makes only CloudFront able to read from the bucket, signing every request to the origin with SigV4.

OAC replaces the old OAI (Origin Access Identity). If you find documentation with OAI, it is out of date: OAI does not support KMS encryption (our alias/mercadofresco-datos key), does not work in every region and does not allow POST/PUT. For anything new, OAC.

Creating the access control:

OAC_ID=$(aws cloudfront create-origin-access-control \
  --profile mercadofresco-dev \
  --origin-access-control-config '{
    "Name": "oac-mercadofresco-catalogo",
    "Description": "CloudFront access to MercadoFresco photo bucket",
    "SigningProtocol": "sigv4",
    "SigningBehavior": "always",
    "OriginAccessControlOriginType": "s3"
  }' \
  --query 'OriginAccessControl.Id' --output text)

And the bucket policy, which is the essential piece:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "SoloCloudFrontPuedeLeerLasFotos",
      "Effect": "Allow",
      "Principal": {
        "Service": "cloudfront.amazonaws.com"
      },
      "Action": "s3:GetObject",
      "Resource": [
        "arn:aws:s3:::mercadofresco-catalogo-fotos/productos/*",
        "arn:aws:s3:::mercadofresco-catalogo-fotos/miniaturas/*"
      ],
      "Condition": {
        "StringEquals": {
          "AWS:SourceArn": "arn:aws:cloudfront::111122223333:distribution/E2QWERTY123ABC"
        }
      }
    },
    {
      "Sid": "DenegarTodoLoQueNoVayaCifrado",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:*",
      "Resource": [
        "arn:aws:s3:::mercadofresco-catalogo-fotos",
        "arn:aws:s3:::mercadofresco-catalogo-fotos/*"
      ],
      "Condition": {
        "Bool": { "aws:SecureTransport": "false" }
      }
    }
  ]
}

What matters in this policy, clause by clause:

  • The principal is the service cloudfront.amazonaws.com, not a user identity.
  • The AWS:SourceArn condition is the critical part: without it, any CloudFront distribution in any AWS account could read the bucket. It restricts the permission to the specific distribution E2QWERTY123ABC in account 111122223333.
  • Only s3:GetObject is granted, and only under the catalogue's two prefixes. CloudFront cannot list the bucket or write anything.
  • The second clause, with Deny and aws:SecureTransport: false, rejects any access over unencrypted HTTP. It is good practice worth having on every bucket.

The result, comparing before and after:

flowchart LR
    subgraph BEFORE["BEFORE: bucket with public read"]
        C1["Client"] -->|"pays expensive transfer"| S1[("mercadofresco-catalogo-fotos")]
        C2["Someone else's site<br/>(hotlinking)"] -->|"and MercadoFresco pays too"| S1
    end

    subgraph AFTER["AFTER: bucket closed with OAC"]
        C3["Client"] --> CF["CloudFront<br/>E2QWERTY123ABC"]
        CF -->|"SigV4 signature<br/>free transfer"| S2[("mercadofresco-catalogo-fotos")]
        C4["Direct access to S3"] -.->|"403 Forbidden"| S2
    end

Afterwards you have to remove the old public policy and check that the public access block (seen in 02-03) is still on:

aws s3api put-bucket-policy --profile mercadofresco-dev --region eu-west-1 \
  --bucket mercadofresco-catalogo-fotos \
  --policy file://politica-oac-catalogo.json

# Check: direct access must now fail with 403
curl -s -o /dev/null -w "S3 direct: %{http_code}\n" \
  https://mercadofresco-catalogo-fotos.s3.eu-west-1.amazonaws.com/productos/tomate-rama-v3.jpg

curl -s -o /dev/null -w "Through CloudFront: %{http_code}\n" \
  https://d111111abcdef8.cloudfront.net/productos/tomate-rama-v3.jpg

The expected result is 403 on the first and 200 on the second. That pair of commands is the complete verification that OAC is properly set up.

A practical detail: the photos are encrypted with the KMS key alias/mercadofresco-datos (02-03). That key's policy must allow kms:Decrypt to the CloudFront service; if it does not, every request will return 403 and the message will not be very clear. KMS and its key policies are studied in 04-02.

Invalidations versus file name versioning

Luis replaces the tomato photo with a better one and uploads it under the same name. CloudFront carries on serving the old one for a year, because that is what the TTL says. There are two ways to fix it, and one is clearly better.

Option A: invalidation. You tell CloudFront to forget an object in every edge location.

aws cloudfront create-invalidation --profile mercadofresco-dev \
  --distribution-id E2QWERTY123ABC \
  --paths "/productos/tomate-rama.jpg"

# Invalidate everything (slow and expensive; emergencies only)
aws cloudfront create-invalidation --profile mercadofresco-dev \
  --distribution-id E2QWERTY123ABC --paths "/*"

Option B: name versioning. Every version of the photo is uploaded under a different name: tomate-rama-v1.jpg, tomate-rama-v2.jpg, tomate-rama-v3.jpg. The HTML points at the latest, and since the URL is new, it is a clean cache miss. The old one expires on its own.

Invalidation Name versioning
Time until the change is visible 1-5 minutes (propagating to 700 edges) Immediate
Cost 1,000 paths free a month; after that 0.005 USD per path Zero
Rolling back Impossible: the old one has been deleted Change the HTML back to v2
Objects already downloaded by browsers Stay the old ones Requested again, different URL
Risk during a deployment A window of inconsistency None
Scalability Saturates with frequent deployments Unlimited

The fourth row is the decisive one and almost nobody sees it coming: an invalidation clears CloudFront's cache, but not the customer's browser cache. If the browser stored the photo with max-age=31536000, it will carry on showing the old one for a year whatever happens. Name versioning solves that as well, because the URL is different.

That is why MercadoFresco adopts versioning, and invalidations are kept for emergencies —a photo uploaded by mistake, a wrong price in cached HTML.

Custom domains and the us-east-1 certificate trap

By default the distribution answers on d111111abcdef8.cloudfront.net. To use cdn.mercadofresco.example two things are needed: declaring it as an alternate domain name (CNAME) and providing a TLS certificate that covers it.

⚠️ The classic CloudFront trap

The ACM certificate for CloudFront must ALWAYS be in the us-east-1 region (North Virginia), whatever the region your resources live in.

That is so because CloudFront is a global service and its control plane is in us-east-1 (we saw this when talking about global, regional and zonal services in 01-03). A certificate issued in eu-west-1 is perfectly valid for the ALB, but CloudFront will not even show it in the drop-down list, and the error message does not explain why.

Practical consequence for MercadoFresco: two certificates must be requested for the same domain.

# Certificate for the ALB: in the ALB's region
aws acm request-certificate --profile mercadofresco-dev --region eu-west-1 \
  --domain-name mercadofresco.example \
  --subject-alternative-names "*.mercadofresco.example" \
  --validation-method DNS

# Certificate for CloudFront: COMPULSORILY in us-east-1
aws acm request-certificate --profile mercadofresco-dev --region us-east-1 \
  --domain-name mercadofresco.example \
  --subject-alternative-names "*.mercadofresco.example" \
  --validation-method DNS

Both are free and both renew themselves. Both stay in PENDING_VALIDATION until the validation CNAME records are created, which is what we will do in lesson 03-05.

Besides that, every distribution should force HTTPS:

Setting Value Effect
ViewerProtocolPolicy redirect-to-https Anyone arriving over HTTP gets a 301 to HTTPS
MinimumProtocolVersion TLSv1.2_2021 Old TLS versions are not accepted
OriginProtocolPolicy (ALB) https-only The CloudFront→ALB leg is encrypted too

Dynamic content through CloudFront to the ALB

A CDN is good for more than static files. Passing the HTML and the API through CloudFront as well brings things even when nothing is cached:

Advantage Why it happens
A closer TLS connection The TLS handshake completes in Madrid, not in Dublin: several round trips are saved
Persistent connections to the origin CloudFront keeps connections open to the ALB and reuses them
AWS's backbone network From the edge to the origin you travel over AWS's private network, more stable than the internet
A single place for WAF and Shield Protection is applied at the edge, before reaching the VPC — 04-04 and 04-05
Compression and HTTP/3 for free Even if the origin does not support them
Less traffic processed by the ALB Everything cacheable stops counting as LCUs

When it does not pay off: if the application is internal and every user is in the same region as the origin, CloudFront adds a hop with no gain. For MercadoFresco, with customers all over Spain and plans to open in Portugal, it clearly pays off.

The only thing to be careful with: for the /api/* behaviour you have to allow every method and forward to the origin the headers the application needs (Authorization, Host, Content-Type) through the origin request policy AllViewerExceptHostHeader, without putting them in the cache key.

CloudFront Functions and Lambda@Edge

Sometimes you need to run logic at the edge: rewriting a URL, adding security headers, redirecting by country. CloudFront offers two very different mechanisms.

CloudFront Functions Lambda@Edge
Where it runs At the edge location (all ~700) At the regional edge caches (~13)
Language JavaScript (strict ECMAScript 5.1) Node.js or Python
Maximum time < 1 ms 5 s (viewer) / 30 s (origin)
Memory 2 MB 128-10,240 MB
Network access No Yes
Access to the request body No Yes
Can call other AWS services No Yes
Events Viewer request/response Viewer and origin request/response
Cost 0.10 USD per million 0.60 USD per million + compute time
Typical case URL rewriting, headers, simple redirects Authentication, database calls, image transformation

The rule: if it fits in a CloudFront Function, it goes in a CloudFront Function. It is six times cheaper, it runs in more places and it is orders of magnitude faster.

A real MercadoFresco example: adding security headers to every response and normalising the catalogue URLs.

// funcion-cabeceras-seguridad.js
// Event: viewer-response (runs just before the response is returned to the client)
function handler(event) {
    var response = event.response;
    var headers = response.headers;

    // Force HTTPS for a year on every subdomain
    headers['strict-transport-security'] = {
        value: 'max-age=31536000; includeSubDomains; preload'
    };
    // Stop the browser guessing the content type
    headers['x-content-type-options'] = { value: 'nosniff' };
    // Stop the shop being loaded inside someone else's iframe (clickjacking)
    headers['x-frame-options'] = { value: 'DENY' };
    // Do not send the full URL as the referrer to external sites
    headers['referrer-policy'] = { value: 'strict-origin-when-cross-origin' };
    // Switch off browser APIs the shop does not use
    headers['permissions-policy'] = {
        value: 'geolocation=(), microphone=(), camera=()'
    };

    return response;
}

And the URL rewriting, on the viewer request event:

// funcion-normalizar-url.js
// Event: viewer-request (before looking at the cache)
function handler(event) {
    var request = event.request;
    var uri = request.uri;

    // /categoria/verduras  ->  /categoria/verduras/index.html
    if (uri.endsWith('/')) {
        request.uri = uri + 'index.html';
    } else if (!uri.includes('.')) {
        request.uri = uri + '/index.html';
    }

    // Remove campaign parameters from the cache key.
    // Without this, every link of every marketing campaign would be a different
    // entry for the SAME page, and the hit rate would collapse.
    var qs = request.querystring;
    ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'fbclid', 'gclid']
        .forEach(function (p) { delete qs[p]; });

    return request;
}

That second example has a direct economic effect: it is a function with four useful lines that recovers the hit rate Sara's campaigns were destroying without anyone knowing.

An important detail about when it runs: on viewer-request the function runs before the cache is consulted, so the change to the URI affects the cache key. If it ran on origin-request, it would only fire on cache misses and would change nothing.

# Publish and associate the function
aws cloudfront create-function --profile mercadofresco-dev \
  --name mercadofresco-normalizar-url \
  --function-config '{"Comment":"Normalise URL and strip campaign parameters","Runtime":"cloudfront-js-2.0"}' \
  --function-code fileb://funcion-normalizar-url.js

aws cloudfront publish-function --profile mercadofresco-dev \
  --name mercadofresco-normalizar-url --if-match "$ETAG"

Logs, metrics and hit rate

Standard logs. CloudFront dumps into S3 one line per request, with the cache result included:

# They are configured inside the distribution
"Logging": {
  "Enabled": true,
  "IncludeCookies": false,
  "Bucket": "mercadofresco-registros-web.s3.amazonaws.com",
  "Prefix": "cloudfront/"
}

Metrics in CloudWatch (always in us-east-1, because it is a global service):

Metric What it measures Target at MercadoFresco
Requests Total requests
CacheHitRate % served from cache > 90 % on photos
OriginLatency Origin response time < 300 ms
4xxErrorRate Client errors < 1 %
5xxErrorRate Server errors < 0.1 %
TotalErrorRate The sum of both < 1 %
BytesDownloaded Bytes served The basis of the cost calculation

CacheHitRate is the metric to watch: if it drops, the saving disappears and the origin takes the load. The most frequent causes of a low rate are the ones we have already seen: too many elements in the cache key, too short a TTL, or Cache-Control set wrongly at the origin.

aws cloudwatch get-metric-statistics --profile mercadofresco-dev --region us-east-1 \
  --namespace AWS/CloudFront --metric-name CacheHitRate \
  --dimensions Name=DistributionId,Value=E2QWERTY123ABC Name=Region,Value=Global \
  --start-time "$(date -u -d '7 days ago' +%Y-%m-%dT%H:%M:%SZ)" \
  --end-time "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  --period 86400 --statistics Average \
  --query 'sort_by(Datapoints,&Timestamp)[].{Day:Timestamp,Hits:Average}' --output table

Dashboards and alarms on these metrics are built in lesson 05-01.

Price classes and MercadoFresco's saving calculation

Price classes limit which edge locations content is served from, in exchange for a lower price:

Class Regions included Relative cost For MercadoFresco
PriceClass_All All, including South America, Australia, India Most expensive Unnecessary today
PriceClass_200 All except South America, Australia and New Zealand Intermediate Unnecessary today
PriceClass_100 North America, Europe and Israel Cheapest The right choice

MercadoFresco's customers are in Spain and, shortly, in Portugal: both in Europe. With PriceClass_100 you pay less and the experience of the real customers is identical. Anyone outside will still be served, just from a more distant edge.

The calculation, with the numbers from 02-03

Starting data for MercadoFresco's catalogue:

  • 40 GB of photos and thumbnails in S3.
  • 1,200,000 photo views a month.
  • Average weight per photo served: 250 KB → 300 GB of transfer a month.

The current situation, serving straight from S3:

Item Calculation Cost
Storage 40 GB × 0.023 USD/GB 0.92 USD
GET requests 1,200,000 × 0.0004 USD/1,000 0.48 USD
Outbound transfer 300 GB × 0.09 USD/GB 27.00 USD
Total 28.40 USD

And there is the 97 % from 02-03: 27.00 ÷ 28.40 = 95 %, which with the traffic of the campaign months goes above 97 %.

With CloudFront in front, assuming a 90 % cache hit rate:

Item Calculation Cost
Storage in S3 40 GB × 0.023 USD/GB 0.92 USD
Transfer S3 → CloudFront Always free 0.00 USD
GET requests to S3 (only the 10 % of misses) 120,000 × 0.0004/1,000 0.05 USD
CloudFront outbound transfer 300 GB, within the permanent free 1 TB 0.00 USD
CloudFront HTTPS requests 1,200,000, within the free 10 M 0.00 USD
Total 0.97 USD

Saving: 27.43 USD a month, 96.6 %. And the photos are served in about 25 ms instead of 350 ms.

CloudFront's permanent free tier (1 TB of outbound transfer and 10 million requests a month, with no expiry, not to be confused with the 12-month free tier from 01-01) is what makes the result so emphatic. It is also worth seeing what happens when MercadoFresco grows tenfold:

Scenario S3 only With CloudFront Saving
Today: 300 GB, 1.2 M requests 28.40 USD 0.97 USD 96.6 %
×10: 3 TB, 12 M requests 277 USD 176 USD 36.5 %
×50: 15 TB, 60 M requests 1,383 USD ~880 USD 36 %

The percentage saving falls as you grow, because the free tier stops being significant, but the absolute saving rises. And to that figure you have to add what you stop paying in ALB LCUs for the traffic that no longer goes through it, plus the conversion gained by serving five times faster.

Creation from the CLI, invalidation and clean-up

A distribution's configuration is a long JSON document. We write it in a file:

{
  "CallerReference": "mercadofresco-catalogo-2026-08-02",
  "Comment": "CDN for MercadoFresco catalogue and shop",
  "Enabled": true,
  "PriceClass": "PriceClass_100",
  "HttpVersion": "http2and3",
  "IsIPV6Enabled": true,
  "DefaultRootObject": "index.html",
  "Origins": {
    "Quantity": 2,
    "Items": [
      {
        "Id": "origen-fotos-s3",
        "DomainName": "mercadofresco-catalogo-fotos.s3.eu-west-1.amazonaws.com",
        "OriginAccessControlId": "E1OACMERCADOFRESCO",
        "S3OriginConfig": { "OriginAccessIdentity": "" }
      },
      {
        "Id": "origen-tienda-alb",
        "DomainName": "alb-mercadofresco-tienda-1234567890.eu-west-1.elb.amazonaws.com",
        "CustomOriginConfig": {
          "HTTPPort": 80,
          "HTTPSPort": 443,
          "OriginProtocolPolicy": "https-only",
          "OriginSslProtocols": { "Quantity": 1, "Items": ["TLSv1.2"] },
          "OriginReadTimeout": 30,
          "OriginKeepaliveTimeout": 5
        }
      }
    ]
  },
  "DefaultCacheBehavior": {
    "TargetOriginId": "origen-tienda-alb",
    "ViewerProtocolPolicy": "redirect-to-https",
    "AllowedMethods": {
      "Quantity": 3,
      "Items": ["GET", "HEAD", "OPTIONS"],
      "CachedMethods": { "Quantity": 2, "Items": ["GET", "HEAD"] }
    },
    "Compress": true,
    "CachePolicyId": "658327ea-f89d-4fab-a63d-7e88639e58f6",
    "OriginRequestPolicyId": "b689b0a8-53d0-40ab-baf2-68738e2966ac"
  },
  "CacheBehaviors": {
    "Quantity": 2,
    "Items": [
      {
        "PathPattern": "/productos/*",
        "TargetOriginId": "origen-fotos-s3",
        "ViewerProtocolPolicy": "redirect-to-https",
        "AllowedMethods": {
          "Quantity": 2,
          "Items": ["GET", "HEAD"],
          "CachedMethods": { "Quantity": 2, "Items": ["GET", "HEAD"] }
        },
        "Compress": false,
        "CachePolicyId": "658327ea-f89d-4fab-a63d-7e88639e58f6"
      },
      {
        "PathPattern": "/api/*",
        "TargetOriginId": "origen-tienda-alb",
        "ViewerProtocolPolicy": "https-only",
        "AllowedMethods": {
          "Quantity": 7,
          "Items": ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"],
          "CachedMethods": { "Quantity": 2, "Items": ["GET", "HEAD"] }
        },
        "Compress": true,
        "CachePolicyId": "4135ea2d-6df8-44a3-9df3-4b5a84be39ad"
      }
    ]
  },
  "Logging": {
    "Enabled": true,
    "IncludeCookies": false,
    "Bucket": "mercadofresco-registros-web.s3.amazonaws.com",
    "Prefix": "cloudfront/"
  }
}

The long identifiers are policies managed by AWS, the same in every account: 658327ea-... is CachingOptimized, 4135ea2d-... is CachingDisabled and b689b0a8-... is the origin request policy AllViewer.

# Create the distribution
DIST_ID=$(aws cloudfront create-distribution --profile mercadofresco-dev \
  --distribution-config file://distribucion-mercadofresco.json \
  --query 'Distribution.Id' --output text)

# Tag it (CloudFront uses a separate command, with the ARN)
aws cloudfront tag-resource --profile mercadofresco-dev \
  --resource "arn:aws:cloudfront::111122223333:distribution/$DIST_ID" \
  --tags 'Items=[{Key=Proyecto,Value=mercadofresco},{Key=Entorno,Value=produccion},
                 {Key=Componente,Value=catalogo},{Key=Propietario,Value=marta},
                 {Key=CentroCoste,Value=marketing}]'

# Rolling out to the edge locations takes a few minutes
aws cloudfront wait distribution-deployed --profile mercadofresco-dev --id "$DIST_ID"

aws cloudfront get-distribution --profile mercadofresco-dev --id "$DIST_ID" \
  --query 'Distribution.DomainName' --output text

A note about the profile: CloudFront is global, so its commands take no --region; the CLI uses us-east-1 internally.

Invalidation and tracking:

INV_ID=$(aws cloudfront create-invalidation --profile mercadofresco-dev \
  --distribution-id "$DIST_ID" --paths "/productos/tomate-rama.jpg" \
  --query 'Invalidation.Id' --output text)

aws cloudfront get-invalidation --profile mercadofresco-dev \
  --distribution-id "$DIST_ID" --id "$INV_ID" --query 'Invalidation.Status'

⚠️ Cost and clean-up

CloudFront does not charge for having a distribution: only for transfer and requests, and the first 1 TB and 10 M a month are free permanently. It is by far the cheapest service in this module, and a practice distribution with lab traffic costs zero.

The only thing that can catch you out is invalidations: 1,000 paths free a month and 0.005 USD per path after that. Invalidating /* counts as a single path, so not even that is expensive; the bad case is invalidating 5,000 files one by one on every deployment.

To delete it you have to disable it first and wait for the change to roll out:

ETAG=$(aws cloudfront get-distribution-config --profile mercadofresco-dev \\
  --id "$DIST_ID" --query 'ETag' --output text)
# Edit the JSON setting "Enabled": false and apply it again
aws cloudfront update-distribution --profile mercadofresco-dev --id "$DIST_ID" \\
  --distribution-config file://distribucion-desactivada.json --if-match "$ETAG"
aws cloudfront wait distribution-deployed --profile mercadofresco-dev --id "$DIST_ID"
aws cloudfront delete-distribution --profile mercadofresco-dev --id "$DIST_ID" --if-match "$NEW_ETAG"

And if you delete the distribution, remember to restore access to the bucket, or the photos will stop being served altogether.

One final mention: in front of CloudFront you can place AWS Shield, which protects against denial-of-service attacks (lesson 04-04), and AWS WAF, which filters malicious requests by rules and by rate (lesson 04-05). Both are applied at the edge, before the traffic reaches the VPC, and that is precisely the reason why it is worth having all of MercadoFresco's traffic, static and dynamic, go through CloudFront.

Common Mistakes and Tips

Putting too much in the cache key. This is the expensive mistake. User-Agent or every cookie sinks the hit rate to almost zero and you pay for a CDN that caches nothing. If the origin needs a piece of data but the content does not change with it, it goes in the origin request policy.

Requesting the CloudFront certificate in eu-west-1. It has to be in us-east-1. It will not even appear in the list and you will lose half an hour looking for the reason.

Invalidating on every deployment instead of versioning names. It is slower, it costs money, it does not clear browser caches and it does not let you roll back. Version the file name.

Forgetting AWS:SourceArn in the OAC policy. Without that condition, any CloudFront distribution in the world can read your bucket. It is a real and silent security hole.

Caching /api/*. Serving one customer the cached response of another's order is a data leak. Use CachingDisabled and allow every HTTP method.

Setting long TTLs without Cache-Control at the origin. CloudFront's TTL does not reach the browser. If you want the client to cache too, the header has to be on the S3 object.

Not reviewing CacheHitRate. A distribution with a 20 % hit rate is misconfigured and nobody finds out until the bill arrives. Look at it every week.

Leaving campaign parameters in the cache key. Every different utm_source creates a new entry for the same HTML. Strip them with a CloudFront Function on viewer-request.

Using Lambda@Edge for something a CloudFront Function solves. Six times more expensive, fewer execution points and far more latency, for nothing.

The golden tip: after every configuration change, check the X-Cache header with curl -I twice in a row. The first should say Miss; the second, Hit. If the second still says Miss, there is something in the cache key that should not be there.

Exercises

Exercise 1: design the cache behaviours of a complete shop

MercadoFresco adds three new kinds of content: /static/* (CSS and JavaScript with versioned names), /pedidos/* (HTML pages personalised for the authenticated user) and /buscar (a search with a q parameter, whose results are the same for every user). Design the cache behaviour of each one: pattern, origin, methods, cache policy, TTL and justification.

Exercise 2: diagnose a 12 % hit rate

MercadoFresco has been on CloudFront for a month and CacheHitRate is at 12 %. The S3 bill has not come down. List at least five possible causes, the command or check that confirms each one, and the correction.

Exercise 3: calculate the break-even point

Sara wants to know at what traffic volume CloudFront stops being "free" and how much would be saved if MercadoFresco opened in Portugal and France and the traffic doubled. Calculate the monthly cost of serving 2 TB with 4 million requests, with and without CloudFront, and explain what changes when the free tier is exceeded.

Solutions

Solution 1

Pattern Origin Methods Cache policy TTL Justification
/static/* origen-fotos-s3 or the ALB GET, HEAD CachingOptimized + Compress: true Min 0 / Def 1 year / Max 1 year The name carries a version (app.a3f9c1.js), so the content is immutable. Compression is the key part: CSS and JS shrink by 70-80 % with Brotli
/pedidos/* origen-tienda-alb All CachingDisabled 0 / 0 / 0 Content personalised for the authenticated user. Caching it would show one customer's order to another. You also have to forward Authorization and the session cookies with the origin request policy AllViewer, without putting them in the cache key (which does not exist here)
/buscar origen-tienda-alb GET, HEAD A policy of your own: key = path + the q parameter only Min 0 / Def 300 / Max 3600 The result is the same for everyone, so it can be cached: hundreds of customers search for "tomato". Only q in the key; no cookies and no utm_*. Five minutes is enough to absorb the peak without showing stale prices

The most instructive case is /buscar: it is dynamic content that can be cached because it does not depend on the user. The correct distinction is not "static versus dynamic", but "the same for everyone versus personalised". A popular search served from cache saves one RDS query per hit, which takes load off the database as well as off the network.

Precedence: /static/*, /pedidos/* and /buscar before the default behaviour *. The order among them makes no difference because the patterns do not overlap.

Solution 2

Cause 1: unnecessary elements in the cache key. By far the most likely.

aws cloudfront get-cache-policy --profile mercadofresco-dev --id "$POLICY_ID" \
  --query 'CachePolicy.CachePolicyConfig.ParametersInCacheKeyAndForwardedToOrigin'

Look for HeaderBehavior: whitelist with highly variable headers, CookieBehavior: all or QueryStringBehavior: all. Correction: move everything the origin needs but that does not change the content to the origin request policy.

Cause 2: campaign parameters. Sara has launched a campaign and every email carries a different utm_content. It shows up in the access logs, with thousands of nearly identical URLs. Correction: the parameter-stripping CloudFront Function.

Cause 3: the origin sends Cache-Control: no-cache or max-age=0.

curl -I https://mercadofresco-catalogo-fotos.s3.eu-west-1.amazonaws.com/productos/tomate-rama-v3.jpg

If no Cache-Control appears, the object was uploaded without the header. Correction: upload it again with --cache-control, or aws s3 cp onto itself with --metadata-directive REPLACE.

Cause 4: too short a TTL. A DefaultTTL of 60 s on the photos makes the cache expire constantly. It shows up in get-cache-policy. Correction: 1 year for versioned content.

Cause 5: traffic spread very widely with content that is not very popular. Every edge location has its own cache; if an object is requested once a month from each city, it is always a miss. It is confirmed by cross-referencing the access logs by x-edge-location. Correction: PriceClass_100 concentrates traffic in fewer edges and improves the hit rate as well as being cheaper.

Cause 6, less obvious: the default behaviour * is capturing the photos because the pattern /productos/* is written wrongly (for example, productos/* without the leading slash). It is checked with X-Cache and with x-edge-result-type in the logs.

Solution 3

Without CloudFront, 2 TB (2,048 GB) and 4 M requests from S3:

Item Calculation Cost
Outbound transfer 2,048 GB × 0.09 USD/GB 184.32 USD
GET requests 4,000,000 × 0.0004/1,000 1.60 USD
Storage 40 GB × 0.023 0.92 USD
Total 186.84 USD

With CloudFront (PriceClass_100, 90 % hit rate):

Item Calculation Cost
CloudFront transfer First 1 TB free; the other 1,024 GB × 0.085 87.04 USD
HTTPS requests 4 M, within the free 10 M 0.00 USD
Transfer S3 → CloudFront Free 0.00 USD
GET requests to S3 (10 % of misses) 400,000 × 0.0004/1,000 0.16 USD
Storage 40 GB × 0.023 0.92 USD
Total 88.12 USD

Saving: 98.72 USD a month, 53 %.

What changes once the free tier is exceeded. Below 1 TB, CloudFront is essentially free and the saving is close to 100 %. Above it, the saving comes to depend on two structural factors that always hold:

  1. Transfer from the origin to CloudFront is free, so S3 stops billing outbound transfer altogether, whatever the volume.
  2. CloudFront's price per GB is lower than S3's (0.085 against 0.09 USD/GB in Europe) and it also falls in tiers with volume: from 10 TB, 50 TB and 150 TB the price drops step by step, whereas S3's is practically flat.

The break-even point, therefore, does not exist: CloudFront is cheaper than S3 direct at any volume, and the only reason not to put it in front would be content that was impossible to cache and that was consumed by a single client in the same region. That is not the case for an online shop.

Conclusion

MercadoFresco has attacked the cost problem at its root. You know what a CDN is, how CloudFront's three levels work —edge locations, regional edge caches and origin— and exactly what happens on a cache hit and on a cache miss, including the fact that changes everything: transfer from the origin to CloudFront is free. You can read the X-Cache header to know which situation you are in.

You have configured a distribution with two origins —the mercadofresco-catalogo-fotos bucket and the alb-mercadofresco-tienda from the previous lesson—, you know about origin groups for failover, and you have split the traffic into cache behaviours by path pattern, with the rule that the first match wins and * always comes last. Above all, you have mastered the concept that decides whether a CDN is of any use: the cache key. You know that every distinct value of every element creates a new entry, that putting in User-Agent or every cookie sinks the hit rate to 5 %, and that the correct distinction is between the cache policy (what makes up the key) and the origin request policy (what the origin needs to receive without fragmenting the cache). You know how MinTTL, DefaultTTL and MaxTTL interact with the origin's Cache-Control, and why the header has to be put on the S3 object.

You have closed the bucket with Origin Access Control, with a policy that authorises the service cloudfront.amazonaws.com only from the specific distribution thanks to the AWS:SourceArn condition —without which any distribution in the world could read it—, and you know that OAC replaces the old OAI. You have checked with two curl calls that direct access returns 403 and access through CloudFront returns 200. You know why file name versioning is better than invalidations: it is immediate, free, reversible and it also clears the browser's cache. You know about the trap of the certificate in us-east-1 and that two certificates are needed, one per region. You have seen when it pays off to send the dynamic content through CloudFront as well, and you have distinguished CloudFront Functions from Lambda@Edge, writing two real functions: the security headers and the campaign parameter stripping that recovers the hit rate Sara's campaigns were destroying.

And the numbers: with PriceClass_100, the permanent free tier of 1 TB and a 90 % hit rate, the catalogue's monthly cost goes from 28.40 USD to 0.97 USD, 96.6 % less, and the photos are served in 25 ms instead of 350 ms. The 97 % outbound transfer problem spotted in 02-03 is solved, and the ALB from 03-03 now only processes what genuinely cannot be cached.

Only one thing is missing, and it is the one that makes none of this visible to a real customer: the domain. Right now the shop answers on d111111abcdef8.cloudfront.net and the load balancer on alb-mercadofresco-tienda-1234567890.eu-west-1.elb.amazonaws.com. Nobody is going to type that. On top of that, the two ACM certificates you requested are still in PENDING_VALIDATION, waiting for DNS records that do not yet exist. In lesson 03-05, "Route 53", we will build MercadoFresco's DNS: we will validate the certificates, we will learn what alias records are and why they are the only way to point the apex mercadofresco.example at CloudFront, we will go through the routing policies —weighted for the 10 % canary, geolocation to open in Portugal, failover to a maintenance page— and we will close the module with the complete network architecture.

© Copyright 2026. All rights reserved