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 subroutine chain for a fingerprinted asset request A request passes through vcl_recv where query strings are normalised and a backend is chosen, vcl_hash where the cache key is built, vcl_fetch where TTL and Surrogate-Key are written onto the stored object, and vcl_deliver where surrogate headers are stripped before the response reaches the browser. Where each responsibility lives in the VCL chain vcl_recv strip tracking params keep real version arg pick req.backend vcl_hash key = host + path no cookies on assets one entry per hash vcl_fetch set beresp.ttl tag Surrogate-Key set stale_if_error vcl_deliver unset Surrogate-* emit Cache-Control add debug header on cache miss Backend fetch static bucket or app origin Stored with the object: TTL and Surrogate-Key Rewritten per response: Cache-Control and debug
Tagging and TTL are written once in 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-ext served from a font pipeline is a different file from font.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.1 is versioned by parameter rather than filename. Strip v and 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.

Query-string normalisation on immutable asset paths Left column shows four distinct cache entries created by tracking parameters appended to one hashed asset URL. Right column shows a single entry after querystring.regfilter. A full-width band below warns that a real version parameter must never be filtered. Unfiltered: four cache keys After regfilter: one key /assets/app.a1b2c3d4.js …app.a1b2c3d4.js?utm_source=news …app.a1b2c3d4.js?gclid=Cj0KEQ …app.a1b2c3d4.js?fbclid=IwAR2 4 objects, 4 origin fetches /assets/app.a1b2c3d4.js every variant collapses here 1 object, 1 origin fetch, full hit ratio 1 entry for the release purge key to hold Sorted parameters keep order irrelevant Exception: a real version parameter is part of the identity /assets/vendor/player.js?v=4.2.1 and ?v=4.3.0 are different files Filter a named list of junk parameters — never the whole query string
Normalising away tracking parameters collapses four cache entries into one; filtering a genuine version parameter would serve the wrong file with a 200.

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.

TTL split between hashed assets and HTML Horizontal bars comparing the values written in vcl_fetch: a one-year edge and browser TTL for hashed assets, sixty seconds at the edge and zero in the browser for HTML, and a seven-day stale-if-error window on both. What vcl_fetch writes onto each object Hashed asset · edge 31536000 s (one year) Hashed asset · browser 31536000 s, immutable HTML · edge 60 s beresp.ttl HTML · browser 0 s, must-revalidate Both · stale_if_error 604800 s (seven days) Bar lengths are illustrative; a purge by key resets the edge TTL only.
The whole point of the fetch-side split: hashed bytes live for a year in both caches, HTML expires immediately in the browser, and both fall back to a week of stale on origin failure.

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.