Fastly VCL for Fingerprinted Asset Routing
A Fastly service that fronts a fingerprinted build has to do four things that the default configuration does not do for you: collapse pointless cache-key variants on immutable URLs, send /assets/* to a different backend than the HTML, give hashed files a one-year edge TTL while the HTML gets sixty seconds, and attach a Surrogate-Key so a single purge call can invalidate a release. All four live in VCL, and each one has a well-known way to get it wrong. This page walks the exact subroutines, the code that belongs in each, and the curl sequence that proves it works.
Which Subroutine Does What
Fastly’s VCL is a fork of Varnish 2.1, so the subroutine names differ from open-source Varnish 4+: there is no vcl_backend_response, and the fetch-side logic lives in vcl_fetch. Getting the placement wrong is the single most common cause of “my TTL is ignored” and “the browser can see my surrogate keys”.
| Subroutine | What belongs there | Common mistake |
|---|---|---|
vcl_recv |
Normalise the request: strip tracking query strings, sort remaining parameters, choose req.backend, force lookup for asset paths |
Setting beresp.ttl here — beresp does not exist yet, and the VCL fails to compile |
vcl_hash |
Add or remove cache-key components (host, path, a device class) | Adding req.http.Cookie to the hash on asset paths, which fragments the cache per visitor |
vcl_fetch |
Set beresp.ttl, beresp.stale_if_error, beresp.stale_while_revalidate, and beresp.http.Surrogate-Key from the path |
Setting Surrogate-Key in vcl_deliver instead — by then the object is already stored untagged, so purge-by-key misses it |
vcl_deliver |
Strip Surrogate-Control and Surrogate-Key, add debug headers, set the client-facing Cache-Control |
Assuming Fastly strips surrogate headers for you on a shielded service; on a shield-to-edge hop they are deliberately preserved |
vcl_error |
Synthesise a response, or restart onto a fallback backend |
Using it to serve stale — stale serving is driven by stale_if_error on the object, not by error handling |
The distinction that matters most for fingerprinting is vcl_fetch versus vcl_deliver. vcl_fetch runs once, when the object is being written into the cache; whatever headers you set there are stored with the object and become the thing purge-by-key indexes. vcl_deliver runs on every response, including cache hits, and only shapes what leaves the POP. Tagging belongs in the first; sanitising belongs in the second.
vcl_fetch and stored with the object; vcl_deliver only reshapes what leaves the POP.Normalising the Cache Key in vcl_recv
A fingerprinted URL already carries its identity in the filename, so every query parameter appended to it is noise that splits one cache entry into many. Share buttons, email campaigns and ad platforms all append parameters indiscriminately, and a preloaded /assets/app.a1b2c3d4.js?utm_campaign=spring is a distinct cache key from the bare URL. Each variant is a separate origin fetch, a separate object occupying POP storage, and — critically — a separate object that your purge-by-key call still catches, but that never accumulates enough hits to stay warm.
The fix is querystring.regfilter, which removes parameters matching a regular expression while leaving everything else intact:
sub vcl_recv {
#FASTLY recv
# Immutable asset paths: the filename hash is the identity, so drop
# analytics parameters that only fragment the cache key.
if (req.url.path ~ "^/assets/") {
set req.url = querystring.regfilter(req.url,
"^(utm_[a-z]+|gclid|fbclid|msclkid|mc_cid|mc_eid|ref|_ga)$");
set req.url = querystring.sort(req.url);
}
# Everything else keeps its query string verbatim.
return(lookup);
}
querystring.sort is the cheap second half of the same idea: ?a=1&b=2 and ?b=2&a=1 are semantically identical but hash differently, so sorting collapses them before the key is built. On an asset path with all tracking parameters already removed, sorting is usually a no-op — but it costs nothing and it protects you the day someone adds a legitimate two-parameter asset URL.
Why You Must Not Strip Every Parameter
The tempting one-liner is set req.url = req.url.path; — throw the query string away entirely on /assets/. Do not do this. Several real asset patterns put meaningful state in the query string, and discarding it makes Fastly serve the wrong bytes with a 200:
- Web fonts with a variant selector. A
font.woff2?subset=latin-extserved from a font pipeline is a different file fromfont.woff2?subset=cyrillic. Stripping the parameter collapses both onto whichever one filled the cache first. - Third-party or vendored files you do not control the naming of. A widget shipped as
/assets/vendor/player.js?v=4.2.1is versioned by parameter rather than filename. Stripvand every future release of the widget hits the cached 4.2.1 object forever. - Signed or scoped media. Any
?expires=/?policy=style parameter is part of the authorisation decision, not decoration.
This is precisely the trade-off covered in the query parameters versus filenames reference: a filename hash is safe to normalise around because it is content-addressed, while a query-string version is load-bearing. Write the filter as an allow-list of known junk, never as a deny-list of known-good, and the failure mode of a new unrecognised parameter is a redundant cache entry rather than a wrong response.
Routing /assets/* and HTML to Different Backends
Fingerprinted assets usually live in object storage while HTML comes from an application server or a renderer. Splitting them at the edge lets each backend have its own health check, timeout and shielding configuration, and it makes the TTL logic in vcl_fetch a simple branch on req.backend rather than a re-parse of the path.
backend F_static_assets {
.host = "assets-origin.example-internal.net";
.port = "443";
.ssl = true;
.ssl_cert_hostname = "assets-origin.example-internal.net";
.ssl_sni_hostname = "assets-origin.example-internal.net";
.connect_timeout = 2s;
.first_byte_timeout = 15s;
.between_bytes_timeout = 10s;
.max_connections = 400;
}
backend F_app_origin {
.host = "app-origin.example-internal.net";
.port = "443";
.ssl = true;
.ssl_cert_hostname = "app-origin.example-internal.net";
.ssl_sni_hostname = "app-origin.example-internal.net";
.connect_timeout = 2s;
.first_byte_timeout = 30s;
.between_bytes_timeout = 20s;
.max_connections = 200;
}
sub vcl_recv {
#FASTLY recv
if (req.url.path ~ "^/assets/") {
set req.backend = F_static_assets;
# Cookies never vary a fingerprinted asset; dropping them here keeps
# the object public and prevents accidental pass-through.
unset req.http.Cookie;
} else {
set req.backend = F_app_origin;
}
return(lookup);
}
The unset req.http.Cookie line is doing more work than it looks. Fastly’s boilerplate #FASTLY recv macro passes requests carrying cookies straight to origin for many content types; leaving a session cookie attached to an asset request turns a cacheable object into a per-user pass and quietly destroys the hit ratio on your largest files. Because the hashed filename already guarantees the response is identical for every visitor, there is nothing a cookie could legitimately vary. The same reasoning is why vcl_hash should stay untouched on asset paths — the default key of host plus normalised URL is exactly right, and every addition to it fragments the namespace described in the cache key architecture guide.
TTL, Tagging and Stale Serving in vcl_fetch
This is where the fingerprinting payoff lands. A hashed filename can be cached for a year with no risk, because a content change produces a new URL; the HTML that points at it must be short-lived, because it is the only mutable document in the deploy. The same subroutine attaches the surrogate keys that make a release purgeable in one call.
sub vcl_fetch {
#FASTLY fetch
if (req.url.path ~ "^/assets/") {
# Hashed filename => content-addressed => cache for a year at the edge.
set beresp.ttl = 31536000s;
set beresp.http.Cache-Control = "public, max-age=31536000, immutable";
# Tag by release and by asset class so purges can be coarse or narrow.
set beresp.http.Surrogate-Key = "release-" + req.http.X-Release-Tag + " static-assets";
if (req.url.path ~ "\.css$") {
set beresp.http.Surrogate-Key = beresp.http.Surrogate-Key + " css-bundle";
}
if (req.url.path ~ "\.(woff2|woff|ttf)$") {
set beresp.http.Surrogate-Key = beresp.http.Surrogate-Key + " font-assets";
}
# Immutable bytes are always better than an error page.
set beresp.stale_if_error = 604800s;
set beresp.stale_while_revalidate = 86400s;
} else {
# HTML entry points: short edge TTL, revalidated behind a grace window.
set beresp.ttl = 60s;
set beresp.http.Cache-Control = "public, max-age=0, must-revalidate";
set beresp.http.Surrogate-Key = "html-entry-points release-" + req.http.X-Release-Tag;
set beresp.stale_if_error = 604800s;
set beresp.stale_while_revalidate = 30s;
}
return(deliver);
}
req.http.X-Release-Tag is set once in vcl_recv from a Fastly edge dictionary (table.lookup(release_config, "current_tag", "unknown")), so a deploy updates one dictionary value rather than recompiling VCL. If you prefer the origin to own the tag, emit Surrogate-Key from the application and let this branch only fill in the parts the origin cannot know — the merge patterns are laid out in the surrogate key reference.
beresp.stale_if_error = 604800s is the line that earns its keep during an incident. With a year-long TTL, a hashed asset almost never revalidates — but when the origin bucket is misconfigured mid-deploy and returns 403, Fastly serves the stored object for up to a week instead of propagating the failure. On HTML the same directive means a hard origin outage degrades to slightly stale markup rather than an error page, which is the behaviour described in the stale-while-revalidate guide.
Stripping Surrogate Headers in vcl_deliver
Fastly removes Surrogate-Control and Surrogate-Key from responses it sends to a browser, but that guarantee only holds on the final hop. On a shielded service the shield POP forwards the headers to the edge POP on purpose — that is how the edge learns the tags — and any custom VCL that copies headers around, or an intermediate proxy of your own, can leak them downstream. Stripping them explicitly is one line and removes the ambiguity:
sub vcl_deliver {
#FASTLY deliver
# Never let CDN-internal metadata reach a browser.
unset resp.http.Surrogate-Control;
if (!req.http.Fastly-FF) {
unset resp.http.Surrogate-Key;
}
# Cheap cache observability for smoke tests and incident triage.
if (fastly_info.state ~ "^HIT") {
set resp.http.X-Cache = "HIT";
} else {
set resp.http.X-Cache = "MISS";
}
set resp.http.X-Cache-Hits = obj.hits;
return(deliver);
}
The req.http.Fastly-FF test is the important detail: Fastly sets that header on requests that came from another Fastly node, so the condition keeps the tags intact on the shield-to-edge hop and drops them only on the hop that faces a real client. Removing the header unconditionally works on an unshielded service and silently breaks tag propagation the day you enable shielding.
Verifying the Configuration
Two commands prove the whole chain. The first confirms normalisation, TTL and header stripping; the second confirms the object really is indexed under the key you think it is.
# 1. Normalisation and headers. The tracking parameter must not change the
# cache key, so the second request should be a HIT on the same object.
curl -sI "https://www.example.com/assets/app.a1b2c3d4.js" \
| grep -Ei "^(x-cache|x-cache-hits|cache-control|surrogate-control|surrogate-key|age):"
curl -sI "https://www.example.com/assets/app.a1b2c3d4.js?utm_source=newsletter" \
| grep -Ei "^(x-cache|x-cache-hits|age):"
# Expected on the second call:
# x-cache: HIT
# x-cache-hits: 2 <- same stored object, not a new entry
# age: 41
# Expected on both calls:
# cache-control: public, max-age=31536000, immutable
# (no surrogate-control and no surrogate-key lines at all)
# 2. Prove the tag is attached by purging it and watching the object reset.
curl -s -X POST \
"https://api.fastly.com/service/$SERVICE_ID/purge/static-assets" \
-H "Fastly-Key: $FASTLY_TOKEN" \
-H "Accept: application/json"
# => {"status":"ok","id":"1-1719000000-4f2c19"}
sleep 1
curl -sI "https://www.example.com/assets/app.a1b2c3d4.js" \
| grep -Ei "^(x-cache|age):"
# Expected: x-cache: MISS and age: 0
# If it still reports HIT with a large Age, vcl_fetch never wrote the key.
The failure signature is worth memorising: a purge that returns {"status":"ok"} and changes nothing means the API accepted a key that no cached object carries. That is almost always Surrogate-Key being set in vcl_deliver rather than vcl_fetch — the header reaches the response but never reaches the stored object, so Fastly’s tag index has no entry to invalidate. Re-check with Fastly-Debug: 1, which makes Fastly echo the object’s stored surrogate keys back in the response for debugging traffic.
When to Reconsider
Custom VCL is not free — it is a second configuration surface that has to be versioned, reviewed and rolled back alongside your application.
Your origin can already set the headers. If the application controls its own responses, emit Surrogate-Key and Cache-Control there and keep the Fastly service on stock configuration. Origin-side tagging is easier to unit-test and impossible to get out of sync with the build that produced the filenames.
You are on Compute rather than VCL. Fastly’s Compute services express the same logic in Rust or JavaScript. The subroutine boundaries disappear, but the ordering constraint does not: tags must be attached to the response before it is inserted into the cache.
Your assets are not actually fingerprinted. Every rule here assumes the filename is content-addressed. If you still serve /assets/app.js, a one-year TTL is a trap and you need purge-on-every-deploy instead, with the TTL choices from the immutable and TTL tuning guide.
The routing split buys you nothing. If HTML and assets come from the same bucket with identical timeouts, two backends is ceremony. Keep one backend and branch only on TTL and tagging inside vcl_fetch.
Frequently Asked Questions
Can I set beresp.ttl in vcl_recv to avoid a second branch?
No — beresp does not exist during vcl_recv, and referencing it fails VCL compilation before the service ever activates. The request-side and fetch-side variable sets are deliberately separate: req in vcl_recv and vcl_hash, beresp in vcl_fetch, resp in vcl_deliver, obj on a hit. If you need a decision made in vcl_recv to survive into vcl_fetch, stash it in a custom request header such as req.http.X-Asset-Class and read that header on the fetch side.
Does querystring.sort hurt anything on HTML routes?
It can. Some applications treat parameter order as significant — ordered filter chains, signed request payloads where the signature covers the raw string. Restrict querystring.sort and querystring.regfilter to the asset path prefix, as in the snippet above, and leave application routes untouched. On /assets/ the risk is nil because the filename hash, not the query string, identifies the bytes.
How does purge-by-key interact with a one-year TTL?
They are orthogonal. The TTL governs when Fastly voluntarily refetches; a purge invalidates immediately regardless of remaining TTL. That is exactly why a year-long TTL is safe on tagged objects: you never depend on expiry, because a single POST /service/$SERVICE_ID/purge/release-v2-5-0 can clear the whole release the moment you need it. In practice you should almost never need to — a new build produces new filenames — but the tag is your escape hatch during a rollback.
Why 8 hex characters in the filename patterns?
Eight hex characters is the default in these examples and is sufficient for a typical application producing tens to low hundreds of chunks. Monorepos emitting thousands of chunks per build should move to 12–16 characters; the regular expressions here use open-ended quantifiers such as [a-f0-9]{8,} so they keep matching when you lengthen the hash.
Related
- Fastly instant purge — parent guide: the purge API, soft versus hard purge, and authentication scopes
- Fastly surrogate keys for fingerprinted assets — tag naming, multi-tag objects, and partial release purges
- Cache key architecture — what a CDN actually hashes into a cache key and why normalisation matters
- Cache-Control immutable and TTL tuning — choosing the TTL values this VCL writes
- CDN purge strategies — comparing purge mechanisms across Fastly, Cloudflare, CloudFront, and Nginx