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
- What a CDN is and how delivery from the edge works
- Cache hits and misses
- Distributions and origins
- Origin groups and origin failover
- Cache behaviours and path patterns
- The cache key: cache policies and origin request policies
- TTL: minimum, maximum and default
- Compression, HTTP/2 and HTTP/3
- Origin Access Control: closing the bucket to the world
- Invalidations versus file name versioning
- Custom domains and the
us-east-1certificate trap - Dynamic content through CloudFront to the ALB
- CloudFront Functions and Lambda@Edge
- Logs, metrics and hit rate
- Price classes and MercadoFresco's saving calculation
- 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:
- Only the first user pays the long wait. Everyone after that gets the answer from the edge.
- 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.
- 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-Languageon 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:
paginaandcategoriado 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. EnableAcceptEncodingGzipandBrotli: 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:
- If the origin sends no
Cache-ControlorExpires→DefaultTTLapplies. - If the origin sends
Cache-Control: max-age=N→Nis used, clamped betweenMinTTLandMaxTTL. Cache-Control: no-storeorprivate→ not cached (unlessMinTTLis 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.
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-datoskey), does not work in every region and does not allowPOST/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:SourceArncondition is the critical part: without it, any CloudFront distribution in any AWS account could read the bucket. It restricts the permission to the specific distributionE2QWERTY123ABCin account111122223333. - Only
s3:GetObjectis granted, and only under the catalogue's two prefixes. CloudFront cannot list the bucket or write anything. - The second clause, with
Denyandaws: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.jpgThe 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-1region (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 ineu-west-1is 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 DNSBoth 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 tableDashboards 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 textA 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.jpgIf 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:
- Transfer from the origin to CloudFront is free, so S3 stops billing outbound transfer altogether, whatever the volume.
- 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.
AWS Course
Module 1: Introduction to AWS
- What Is AWS?
- Setting Up Your AWS Account
- AWS Global Infrastructure
- The AWS Management Console
- AWS CLI and SDKs
Module 2: Core AWS Services
Module 3: Networking and Content Delivery
Module 4: Security and Identity
- AWS Identity and Access Management (IAM)
- AWS Key Management Service (KMS)
- Secrets Manager and Parameter Store
- AWS Shield
- AWS WAF
Module 5: Monitoring and Management
Module 6: Databases
Module 7: Application Integration
- Amazon SQS
- Amazon SNS
- Amazon EventBridge
- AWS Step Functions
- Integration Patterns: Idempotency, Retries and Dead-Letter Queues
