CloudFront invalidations without nuking your AWS bill
If your deploy pipeline ends every push with `aws cloudfront create-invalidation
–paths “/*”`, it works, but it’s the CDN equivalent of clearing your entire
browser cache because one tab was stale. It’s slow to propagate, it costs
money past a fairly small free tier, and — the part people miss — it doesn’t
actually need to exist for most of your assets if you name your files right.
This site’s own deploy (deploy.yml) does a scoped invalidation of exactly
two paths, not a wildcard, and here’s the reasoning and the mechanics behind
that choice.
How invalidation pricing actually works
CloudFront gives you **1,000 free invalidation path requests per month, per account**. Past that, each additional path costs $0.005. The part that trips people up is what counts as “one path”:
/*— a single wildcard path — counts as one path, no matter how many objects it matches at the edge. Nuking your entire distribution with one wildcard call costs the same as invalidating one specific file.- An explicit list of paths in a single
create-invalidationcall is billed per path in the list.aws cloudfront create-invalidation --paths"/index.html" "/blog/index.html" "/rss.xml"bills three paths, not one call.
So a single /* is actually the cheapest way to invalidate by path-count —
the trap isn’t cost from the wildcard itself, it’s what a wildcard does
operationally: it forces CloudFront to revalidate every object in the
distribution against the origin on the next request, which means a burst of
origin requests to S3 right after every deploy, and it invalidates objects
that never changed. On a low-traffic personal site that’s harmless. On
anything with real traffic or a slow/rate-limited origin, a full-distribution
wildcard after every deploy is the thing that actually costs you — in origin
load and in cache-miss latency for visitors who hit the edge in the seconds
after the invalidation lands, not in the $0.005-per-path line item.
The free tier resets monthly and is shared across all distributions in the account, so if you run several sites or a multi-tenant setup off one AWS account, a chatty pipeline on one distribution eats the free allowance for all of them.
The real fix: stop invalidating, start fingerprinting
Invalidation is a workaround for a caching mistake: telling CloudFront to
cache a URL for a long time when the content behind that URL can change.
Fingerprinted (content-hashed) filenames remove the mistake instead of
compensating for it. Astro’s build already does this for you — run npm run
build and look at dist/_astro/:
_astro/client.a1b2c3d4.js
_astro/index.e5f6a7b8.css
The hash is derived from the file’s content. Change one character of source, the hash changes, the URL changes. Because the URL is different, there’s nothing to invalidate — the old URL still exists at the edge serving old content (fine, nothing references it anymore), and the new URL is a cache miss exactly once, everywhere, the first time each edge location requests it. This is why you can safely set:
Cache-Control: public, max-age=31536000, immutable
on everything under _astro/ (or /assets/, however your bundler names it).
A year-long max-age plus immutable tells both browsers and CloudFront
“never revalidate this, ever” — and that’s true, because the filename itself
guarantees the content can’t change out from under that URL. This is the
single biggest lever for cutting invalidation traffic to near zero: the
majority of a static site’s bytes (JS, CSS, hashed images) never need an
invalidation call in their entire lifetime.
Set this at the S3 origin via object metadata (Astro/most bundlers don’t set
Cache-Control on upload themselves — your sync step has to), or override it
at the CloudFront cache behavior level with a policy scoped to the hashed
asset path pattern:
aws s3 sync ./dist s3://my-bucket/ \
--exclude "*" --include "_astro/*" \
--cache-control "public, max-age=31536000, immutable" \
--metadata-directive REPLACE
Run this pass before the pass that uploads everything else, so the
hashed-assets rule doesn’t get clobbered by a broader default Cache-Control
applied later in the sync.
Where you still need surgical invalidation
Fingerprinting only works for files whose name changes when their content changes. Two categories of file don’t get that treatment, and those are the only things worth invalidating:
index.htmland any other non-hashed HTML entrypoint. The URL/blog/index.html(or/blog/via the pretty-URL rewrite) has to stay stable — it’s what’s in every bookmark, backlink, and search index entry — but its content changes every time you publish. Give these a shortmax-age(orno-cacheso CloudFront/browsers always revalidate against origin) and invalidate them explicitly on deploy.- Non-hashed static assets you can’t rename, like
favicon.ico,robots.txt,sitemap-index.xml, orrss.xml— anything a spec or a client expects at a fixed path.
For this site, that means the deploy step invalidates a short, explicit list, not a wildcard:
aws cloudfront create-invalidation \
--distribution-id "$CF_DISTRIBUTION_ID" \
--paths "/index.html" "/blog/*" "/rss.xml" "/sitemap-index.xml"
/blog/* is doing real work here — every post and tag page under /blog/
is HTML with a stable, non-hashed URL, so a scoped wildcard on just that
subtree is the right call. It’s still one billed path (wildcards always are),
but more importantly it only forces revalidation on the part of the site that
actually changes on every deploy, leaving the immutable hashed assets alone.
The gotcha: invalidations aren’t instant, and they queue
create-invalidation returns immediately with a status of InProgress — the
CLI call succeeding does not mean the content is gone from every edge
location yet. Full propagation across all of CloudFront’s edge locations
typically completes within a few minutes, but there’s no SLA guaranteeing a
specific time, and it’s not uncommon to see stale content served from one
edge location after another has already updated. If your deploy pipeline
runs a post-deploy smoke test that curls the live URL immediately after
create-invalidation returns, don’t assert on content freshness right away —
poll get-invalidation for Status: Completed first, or just accept some
propagation lag in the check:
aws cloudfront get-invalidation \
--distribution-id "$CF_DISTRIBUTION_ID" \
--id "$INVALIDATION_ID" \
--query 'Invalidation.Status'
The second, less obvious gotcha: **invalidation requests queue per
distribution**, and each request can list at most 3,000 paths (or 15 with
wildcards). If a script fires invalidations in a tight loop — say, a bulk
content migration that invalidates per-file instead of batching — later
requests sit InProgress behind earlier ones rather than running in
parallel, so a burst of small invalidations can take noticeably longer to
fully clear than one batched call with the same total path count. Batch your
paths into as few create-invalidation calls as the file-list limits allow,
rather than looping a call per file.
What to actually do
Fingerprint everything your bundler can fingerprint and set max-age=31536000,
immutable on it — that’s most of your bytes and it needs zero invalidation
calls, ever, for the life of the file. For the small set of non-hashed
entrypoints (HTML, robots.txt, feeds), use a short max-age and a scoped
explicit-path invalidation on deploy, not /*. You’ll stay comfortably
inside the free 1,000-path monthly allowance even with several deploys a day,
and — the bigger win — your origin only gets hit for the handful of objects
that actually changed, not your entire distribution.
Join the discussion
Comments for this post live on social — reply to the thread.
Related posts
Cloud roundup: macOS Screen Sharing bug now under attack
A patched macOS Screen Sharing flaw is being exploited to plant crypto miners, a Windows Defender bypass has no fix yet, and EC2 gets built-in app health checks.
Cutting NAT gateway costs with VPC endpoints that actually help
How gateway and interface VPC endpoints replace NAT gateway traffic for AWS API calls, what they cost instead, and which traffic still has to go through NAT.
Cloud roundup: S3 finally names the policy that denied you
AWS S3 access-denied errors now name the exact policy ARN, Client VPN gets a scriptable CLI, and OpenAI ships authorized offensive-security models on Bedrock.