Astro Static Asset Optimisation and Fingerprinting

You deployed a new Astro build and users are reporting a blank page, JavaScript errors, or CSS that looks like the old design. The symptoms point to one of two failure modes: the CDN is serving stale HTML that references hash filenames from the previous build, or the CDN is serving the new HTML but the referenced _astro/ files have not yet propagated from origin. This page diagnoses both conditions, clarifies how Astro’s content hashing pipeline interacts with CDN edge behaviour, and gives you the specific commands and config to resolve them.

Symptom Identification

Before touching any configuration, pinpoint which layer is misbehaving. Open the browser DevTools Network tab immediately after a fresh deploy and note:

Symptom What the network shows Probable layer
Blank page, no console errors 200 OK for HTML, but 404 for _astro/*.js CDN serving new HTML before _astro/ files reached origin
Old styles, new JS 200 OK for all files but content is stale CDN cached the HTML; assets updated but HTML still old
Hydration mismatch error 200 OK for all files; console: Hydration mismatch Mixed deploy — some edges have new HTML, some have old
304 Not Modified loop 304 on HTML even after hard-reload Missing or wrong Cache-Control on HTML entry points

Run this curl command to inspect headers from the CDN edge directly:

curl -sI https://your-domain.example.com/ \
  | grep -iE "(cache-control|age|etag|x-cache|cf-cache-status)"

A cf-cache-status: HIT with a high age value on the HTML document is the smoking gun for stale-HTML syndrome.

How Astro’s Hash Pipeline Actually Works

Astro delegates asset processing to Vite, which in turn uses Rollup’s output pipeline. The sequence is:

  1. Vite reads every file imported in .astro components and collects a dependency graph
  2. Rollup bundles and tree-shakes JS/TS; PostCSS processes stylesheets
  3. Each output file is hashed — the hash is derived from the file’s content bytes using a truncated content hash
  4. Files are written to dist/_astro/ with the hash embedded in the name: index-BKdj3a1f.js
  5. Astro writes HTML files to dist/ with <script> and <link> tags pointing to the hashed paths
  6. dist/.vite/manifest.json maps source paths to hashed output paths for SSR and tooling

Critically, step 5 means the HTML is the only file that changes its references. The _astro/ files themselves are immutable: once written, a given hash URL will always serve the same bytes.

The practical consequence: CDN cache-key architecture must treat HTML and _astro/ files with opposite policies. HTML must always be revalidated; _astro/ files can be cached permanently.

Post-deploy CDN state: correct vs stale Two columns compare the correct state after deploy (CDN has new HTML pointing to new _astro files) versus the stale state (CDN still serves old HTML pointing to old hash URLs while the new _astro files have replaced the old ones at origin, causing 404s). Correct state CDN: index.html (new) references _astro/index-NEW.js CDN: _astro/index-NEW.js 200 OK — immutable Page loads correctly Stale HTML state CDN: index.html (OLD) references _astro/index-OLD.js _astro/index-OLD.js 404 — replaced by NEW on origin Blank page / JS error
After deploy, the CDN must serve the new HTML. If the CDN still holds a cached copy of the old HTML, it requests the old hashed asset — which is no longer at origin.

Side-by-Side: Filename Hashing vs Query-String Versioning

Astro uses filename hashing exclusively. Understanding why helps when diagnosing CDN behaviour and when evaluating the implementing cache keys with query parameters vs filenames tradeoff.

Dimension Filename hash (/index-BKdj3a1f.js) Query string (/index.js?v=BKdj3a1f)
CDN cache key Unique per content by default Varies by CDN — many strip query strings
Cache-Control strategy immutable safe — URL never reused Must use no-cache or short TTL to avoid stale hits
Rollback Re-deploy old HTML; old hash URLs still valid on CDN Old URL still valid; must invalidate or purge
Nginx/Varnish default Caches as distinct objects May serve same cached object ignoring ?v=
Cloudflare default Caches distinct objects Caches distinct objects (Cloudflare respects query strings)
SEO impact Negligible (crawlers follow canonical) Negligible

Astro does not provide a built-in query-string versioning mode. If a downstream system requires fixed filenames with version suffixes, you would need a post-build transform script — but this negates most of the immutable-caching benefits.

Enforcing the Correct Cache-Control Split

The single most important operational configuration is applying opposite Cache-Control policies to HTML and hashed assets. Without this split, the deploy sequence cannot be atomic from the CDN’s perspective.

Nginx

# Apply to the Nginx server block serving your Astro dist/
location /_astro/ {
    add_header Cache-Control "public, max-age=31536000, immutable";
    add_header Vary "Accept-Encoding";
    access_log off;
    gzip_static on;
}

location ~* \.(html)$ {
    add_header Cache-Control "no-cache, must-revalidate";
    add_header Vary "Accept-Encoding";
}

location / {
    try_files $uri $uri/ /index.html;
    add_header Cache-Control "no-cache, must-revalidate";
}

If you changed build.assets to something other than _astro, update the location /_astro/ path to match your custom prefix.

Cloudflare Cache Rules

In the Cloudflare dashboard under Cache Rules, create two rules evaluated top-to-bottom:

Rule 1 — Immutable assets:

  • Expression: http.request.uri.path contains "/_astro/"
  • Cache eligibility: Eligible for cache
  • Edge TTL: Respect origin, or set Custom with 365 days
  • Browser TTL: Override — 31536000 seconds
  • Add response header: Cache-Control: public, max-age=31536000, immutable

Rule 2 — HTML revalidation:

  • Expression: http.request.uri.path matches "\.html$" OR ends with "/"
  • Browser TTL: Override — 0 seconds
  • Add response header: Cache-Control: no-cache, must-revalidate

After a deploy, purge only the HTML paths at the CDN edge:

# Purge only HTML — never purge _astro/ paths
curl -X POST "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/purge_cache" \
  -H "Authorization: Bearer ${CF_API_TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{"files":["https://your-domain.example.com/","https://your-domain.example.com/about/","https://your-domain.example.com/blog/"]}'

For background on CDN-level purge strategies, see the Cloudflare cache rules and purge reference.

Runnable Verification Commands

Check headers on a hashed asset

ASSET_URL="https://your-domain.example.com/_astro/index-BKdj3a1f.js"

curl -sI "$ASSET_URL" | grep -iE "(http/|cache-control|age|etag|cf-cache-status)"

Expected:

HTTP/2 200
cache-control: public, max-age=31536000, immutable
age: 14521
etag: "BKdj3a1f"
cf-cache-status: HIT

The age header should climb on repeated requests. If it resets to 0 each time, the CDN is bypassing cache — check that Cloudflare is not set to “Development Mode” and that the rule expression matches.

Validate all manifest entries exist in dist/

#!/bin/bash
set -euo pipefail

MANIFEST="dist/.vite/manifest.json"
if [ ! -f "$MANIFEST" ]; then
  echo "ERROR: manifest not found at $MANIFEST" >&2
  exit 1
fi

MISSING=0
while IFS= read -r asset_path; do
  target="dist/${asset_path}"
  if [ ! -f "$target" ]; then
    echo "MISSING: $target"
    MISSING=$((MISSING + 1))
  fi
done < <(python3 -c "
import json, sys
with open('$MANIFEST') as f:
    m = json.load(f)
for v in m.values():
    if 'file' in v:
        print(v['file'])
    for imp in v.get('imports', []):
        chunk = m.get(imp, {})
        if 'file' in chunk:
            print(chunk['file'])
")

if [ "$MISSING" -gt 0 ]; then
  echo "FATAL: $MISSING assets missing from dist/. Aborting deploy." >&2
  exit 1
fi

echo "OK: all manifest assets present."

Run this script in CI after npx astro build and before uploading to your CDN or object store.

When to Reconsider This Approach

Filename hashing with immutable caching is the right default for Astro projects. Consider alternatives in these specific scenarios:

You need fixed asset URLs. Some integrations — payment SDKs, analytics snippets, A/B testing tools — require assets at predictable paths. Place those in public/ deliberately and document that they are intentionally unversioned. Do not attempt to work around this with symlinks or server rewrite rules.

You are behind a CDN that strips query strings and mangles filenames. A small number of legacy CDN appliances apply URL normalisation that strips characters like [ or ] from filenames, breaking Vite’s pattern. In that case, custom assetFileNames patterns using only alphanumeric characters and hyphens are necessary.

Your deploy pipeline cannot tolerate the two-file-type atomic sequence. Some platforms atomically swap entire directories (Netlify, Cloudflare Pages, Vercel). On those platforms, stale-HTML syndrome cannot occur because old and new versions of the site are served atomically. Astro’s default hashing requires no extra configuration on these platforms — the CDN purge step is handled for you.

You need public/ files versioned. If legacy constraints force critical assets into public/, write a pre-build script that computes a content hash for each file, renames it, and injects the hashed path into a lookup table your templates consume. This is the same pattern used by the rollup asset manifest for CDN deploys approach.

Frequently Asked Questions

Why does Astro serve 404s for assets immediately after a successful deploy?

The deploy completed but the CDN is still serving the previous version of index.html, which references old hash filenames. Those old files have been replaced on the origin by the new build. The fix: purge only the HTML URLs at the CDN edge immediately after deploying. The old _astro/ files are gone from origin but will not be requested once the HTML is updated.

Can I use query-string versioning instead of filename hashing?

Astro does not support query-string versioning natively. Vite’s output pipeline always produces filename hashes. Implementing query-string versioning would require a post-build transform that renames files back to their original names and rewrites all references — this eliminates the immutable-caching benefit and is not recommended.

How do I confirm that immutable headers are actually preventing revalidation requests?

Run curl -sI on a known hashed asset twice within a few minutes. The second response should show age greater than 0 and cf-cache-status: HIT (Cloudflare) or x-cache: Hit (other CDNs). If age is always 0, the CDN is not caching the asset — check for Vary headers that may be fragmenting the cache key beyond Accept-Encoding.