Astro Build-Time Hashing
Astro 4 produces content-addressed assets automatically through its embedded Vite pipeline, writing every processed file into a _astro/ subdirectory with an 8-character content hash fused into the filename. This guide covers the complete configuration surface: the default _astro/ output, build.assetsPrefix for CDN origin offloading, build.assets directory overrides, the image service hash path, Vite passthrough options, and the one hard exception — the public/ directory — where fingerprinting never applies.
When to Use Astro’s Built-In Hashing vs. a Custom Pipeline
Astro’s integrated approach suits the majority of projects. Use the following decision points to confirm it is the right fit before diving into configuration.
| Signal | Built-in Astro hashing | Custom post-build script |
|---|---|---|
| Framework | Astro 4+ | Astro 3 or below, custom SSR adapters |
| Asset sources | src/ imports and src/assets/ images |
Large public/ trees that cannot be moved |
| CDN prefix | Single origin or single CDN bucket | Multi-region, per-environment CDN prefixes set at deploy time |
| Image service | @astrojs/image or built-in Image component |
Sharp pipeline outside Astro |
| Manifest needed | dist/.vite/manifest.json (automatic) |
Custom JSON manifest keyed to deploy ID |
If your public/ directory holds more than a handful of files that must be versioned, you will need a pre-build or post-build script — Astro does not process that directory through Vite. Everything else is handled for you.
What Astro will and will not fingerprint
Three routes exist for a byte to reach dist/, and only two of them end in a hashed filename. A file imported from .astro, .ts, .jsx, or a <style> block enters Vite’s module graph and is emitted through Rollup with a hash. A dependency pulled from node_modules takes the same route but usually lands in a shared chunk rather than a file of its own. A file dropped into public/ skips the graph entirely and is copied byte-for-byte, keeping the URL it had in source control.
The distinction matters operationally because each route needs a different cache policy. Hashed output can carry max-age=31536000, immutable forever; verbatim public/ output must carry a short TTL because its URL is reused on every deploy. Mixing the two under one CDN rule is the most common cause of a stale favicon or an out-of-date robots.txt surviving a release.
Prerequisites
- Astro 4.0+ (
astro@^4.0.0) - Node 18 or 20 (LTS — avoids platform-specific hash drift caused by V8 differences)
package-lock.jsonorpnpm-lock.yamlcommitted and locked — floatingviteorrollupversions cause non-deterministic build outputsNODE_ENV=productionset during CI builds
Check installed versions:
node --version
npx astro --version
npx vite --version
Configuration Reference
The table below documents every astro.config.mjs key that affects asset fingerprinting.
| Key | Type | Default | Effect |
|---|---|---|---|
build.assets |
string |
"_astro" |
Directory name inside dist/ where hashed assets are written |
build.assetsPrefix |
string | object |
undefined |
Prepended to all hashed asset URLs in emitted HTML (CDN origin) |
build.inlineStylesheets |
"auto" | "always" | "never" |
"auto" |
Controls whether small CSS files are inlined (inlined files are not hashed) |
vite.build.assetsInlineLimit |
number |
4096 |
Files smaller than this byte limit are inlined as base64 (no hash) |
vite.build.rollupOptions.output.assetFileNames |
string |
"_astro/[name].[hash][extname]" |
Full filename pattern for non-JS assets |
vite.build.rollupOptions.output.chunkFileNames |
string |
"_astro/[name].[hash].js" |
Filename pattern for JS code-split chunks |
vite.build.rollupOptions.output.entryFileNames |
string |
"_astro/[name].[hash].js" |
Filename pattern for JS entry points |
image.service |
object |
Sharp (built-in) | Image optimisation service; processed images receive content hashes |
Anatomy of an emitted filename
Every key in that table changes one segment of the same string. Reading _astro/index-BKdj3a1f.js left to right: _astro/ comes from build.assets, index is the [name] token Rollup fills from the entry or chunk basename, BKdj3a1f is the [hash] token, and .js is [extname]. Once you can point at which config key owns which segment, debugging a mismatched CDN rule takes seconds rather than a bisect.
Step-by-Step Implementation
1. Confirm Default Output
Run a production build and inspect the output directory:
npx astro build
ls -la dist/_astro/
Expected output resembles:
index-BKdj3a1f.css
index-Cp7kXzQW.js
hoisted-DwR2mNp1.js
hero-Bn8vQtXR.webp
Each filename embeds 8 hex characters derived from the file’s content. Changing a single byte in index.css produces a completely different hash, while all other filenames remain stable — this is the core mechanism behind cache-key architecture for immutable assets.
2. Override the Output Directory with build.assets
The default _astro prefix is recognised by most hosting platforms. If your CDN or server config applies rules based on a different path prefix:
// astro.config.mjs
import { defineConfig } from 'astro/config';
export default defineConfig({
build: {
assets: 'static' // hashed files go to dist/static/ instead of dist/_astro/
}
});
Update your server rules to apply Cache-Control: public, max-age=31536000, immutable to the new path prefix (/static/ in this example). Keep the prefix consistent across all environments to avoid cache misses after promote.
3. Set build.assetsPrefix for CDN Origin Offloading
build.assetsPrefix rewrites every asset URL emitted in HTML and JS from a relative path to an absolute CDN URL. This is required when your static assets live on a different origin than your HTML (e.g., an S3 bucket behind Cloudflare).
// astro.config.mjs
import { defineConfig } from 'astro/config';
export default defineConfig({
build: {
assetsPrefix: 'https://assets.example.com'
}
});
After building, open dist/index.html and confirm script/link tags reference https://assets.example.com/_astro/index-BKdj3a1f.js rather than /_astro/index-BKdj3a1f.js.
For multi-environment deployments, pass the prefix through an environment variable:
// astro.config.mjs
import { defineConfig } from 'astro/config';
const assetsPrefix = process.env.ASSETS_PREFIX ?? undefined;
export default defineConfig({
build: {
assetsPrefix
}
});
Set ASSETS_PREFIX=https://assets-staging.example.com in your staging CI job and ASSETS_PREFIX=https://assets.example.com in production. The build artefacts remain identical byte-for-byte; only the prefix differs — which is safe as long as both CDN origins serve the same files.
4. Fine-Tune Hash Length via Vite Passthrough
Astro exposes the full Vite config via the vite key. The default hash length Rollup uses is 8 hex characters. For repositories with thousands of chunks where the theoretical collision probability matters, increase the length to 12–16:
// astro.config.mjs
import { defineConfig } from 'astro/config';
export default defineConfig({
vite: {
build: {
rollupOptions: {
output: {
// 8 chars = default, safe for most projects
// 12–16 chars recommended for monorepos with thousands of chunks
assetFileNames: '_astro/[name]-[hash:8][extname]',
chunkFileNames: '_astro/[name]-[hash:8].js',
entryFileNames: '_astro/[name]-[hash:8].js'
}
}
}
}
});
Do not change assetFileNames unless you have also updated build.assets to match — misaligned patterns can result in assets written to the correct path but referenced by an incorrect URL in the HTML.
5. Image Service Hashing
Astro’s built-in image service (using Sharp) processes <Image /> component sources and writes optimised, hashed copies to dist/_astro/. The hash is derived from the image content plus the transform parameters (width, height, format, quality). Changing any parameter produces a different output file.
---
// src/components/Hero.astro
import { Image } from 'astro:assets';
import heroSrc from '../assets/hero.png';
---
<Image src={heroSrc} width={1200} height={600} format="webp" alt="Hero" />
The emitted HTML will reference a path like /_astro/hero-Bn8vQtXR.webp. No additional configuration is needed — the image service integrates directly with the Vite asset pipeline and honours the build.assets prefix.
If you use a custom image service (image.service in astro.config.mjs), verify the service writes output through Vite’s asset plugin chain; services that write files directly to public/ bypass fingerprinting.
6. Client Directives Decide Whether JS Is Emitted at All
An Astro component that carries no client:* directive renders to HTML at build time and contributes zero JavaScript to dist/_astro/. Add client:load, client:idle, client:visible, client:media, or client:only and the same component becomes an island: Rollup now has a browser-side entry for it, emits a hashed chunk, and pulls in the framework runtime as a shared dependency.
---
// src/pages/index.astro
import Counter from '../components/Counter.jsx';
import Chart from '../components/Chart.jsx';
---
<Counter client:load />
<Chart client:visible />
That single file turns into at least three hashed outputs: a chunk for Counter, a chunk for Chart, and a shared runtime chunk both import. Because the runtime is shared, adding a fourth island of the same framework costs one new chunk, not a duplicated runtime. The corollary is that reorganising components between islands re-partitions the chunk graph and re-hashes more files than the diff suggests — the client directive hashing reference walks through the blast radius of each directive and how to audit which island produced which file.
7. public/ Caveats — the One Hard Exception
Files in the public/ directory are copied verbatim to dist/ with no Vite processing, no hashing, and no URL rewriting. This is intentional — public/ exists for assets that must retain a fixed URL (favicons, robots.txt, sitemap.xml, Open Graph images referenced by external services).
The implication: never place versioned JS, CSS, or application images in public/. If you do:
- The file URL never changes across deploys
- CDN caches cannot be atomically invalidated
- Rolling back requires a manual CDN purge of a specific path
To check for accidentally unhashed JS or CSS in public/:
find dist/public -type f \( -name "*.js" -o -name "*.css" \) 2>/dev/null || \
find dist -maxdepth 1 -type f \( -name "*.js" -o -name "*.css" \) 2>/dev/null
Any matches are candidates to move into src/assets/ so Vite can hash them.
src/ imports are hashed through Vite; public/ files are copied verbatim and never receive a content hash.Verification
After every build, run these commands to confirm fingerprinting is working as expected.
Confirm hashed filenames exist
npx astro build 2>&1 | tail -5
find dist/_astro -type f | sort
Every file should follow the name-XXXXXXXX.ext pattern where the hash segment is 8 hex characters.
Diff hashes between two builds
Make a trivial change to src/styles/global.css (add a comment), rebuild, then compare:
# First build
npx astro build
find dist/_astro -name "*.css" | sort > /tmp/hashes-before.txt
# Touch CSS and rebuild
echo "/* bump */" >> src/styles/global.css
npx astro build
find dist/_astro -name "*.css" | sort > /tmp/hashes-after.txt
diff /tmp/hashes-before.txt /tmp/hashes-after.txt
The CSS filename changes; JS filenames that do not import the CSS should remain stable. If JS hashes change when only CSS changed, your JS bundles are importing CSS in a way that creates a dependency — investigate with --reporter verbose.
Verify Cache-Control header on a deployed asset
curl -sI https://your-cdn.example.com/_astro/index-BKdj3a1f.js \
| grep -iE "(cache-control|age|etag)"
Expected response headers:
cache-control: public, max-age=31536000, immutable
age: 3842
etag: "BKdj3a1f"
The age value confirms the asset is being served from CDN cache rather than origin.
Read the Vite manifest
cat dist/.vite/manifest.json | python3 -m json.tool | head -40
Each entry maps a source path to its hashed output path. This file is consumed by SSR frameworks, server-side template helpers, and deployment scripts to build signed asset URLs.
CI/CD Integration
A correct Astro deploy sequence must upload _astro/ files to the CDN origin before updating HTML. Reversing the order — or doing both simultaneously without an atomic swap — produces the stale-HTML failure mode: users receive new HTML referencing hash filenames that do not yet exist on the CDN.
Recommended deploy order
- Run
npx astro build - Verify all manifest entries are present in
dist/(use the validation script below) - Upload
dist/_astro/**to the CDN origin or object store - Wait for the upload to complete (verify with a
HEADrequest to at least one hashed asset) - Upload the remaining
dist/files (HTML,sitemap.xml,robots.txt) - Purge only the HTML paths from the CDN edge cache
Platforms that perform atomic directory swaps (Cloudflare Pages, Netlify, Vercel) handle steps 3–5 internally — you only need the build and the optional purge.
GitHub Actions workflow
# .github/workflows/deploy.yml
name: Build and Deploy
on:
push:
branches: [main]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
env:
NODE_ENV: production
ASSETS_PREFIX: ${{ secrets.ASSETS_CDN_URL }}
CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
SITE_DOMAIN: ${{ secrets.SITE_DOMAIN }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Build
run: npx astro build
env:
ASSETS_PREFIX: ${{ env.ASSETS_PREFIX }}
- name: Validate manifest
run: |
MANIFEST="dist/.vite/manifest.json"
if [ ! -f "$MANIFEST" ]; then
echo "ERROR: manifest.json missing" && exit 1
fi
MISSING=0
while IFS= read -r f; do
[ -f "dist/$f" ] || { echo "MISSING: $f"; MISSING=$((MISSING+1)); }
done < <(python3 -c "
import json
with open('$MANIFEST') as fh:
m = json.load(fh)
for v in m.values():
if 'file' in v: print(v['file'])
")
[ "$MISSING" -eq 0 ] || exit 1
echo "All manifest assets verified."
- name: Upload hashed assets to R2
run: |
# Upload _astro/ directory FIRST — assets must exist before HTML references them
aws s3 sync dist/_astro/ s3://${{ secrets.S3_BUCKET }}/_astro/ \
--cache-control "public, max-age=31536000, immutable" \
--delete \
--endpoint-url ${{ secrets.S3_ENDPOINT }}
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
- name: Upload HTML and static files
run: |
# HTML last — only after _astro/ is fully uploaded
aws s3 sync dist/ s3://${{ secrets.S3_BUCKET }}/ \
--exclude "_astro/*" \
--cache-control "no-cache, must-revalidate" \
--delete \
--endpoint-url ${{ secrets.S3_ENDPOINT }}
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
- name: Purge HTML from Cloudflare edge
run: |
curl -s -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\":[\"${SITE_DOMAIN}/\",\"${SITE_DOMAIN}/about/\",\"${SITE_DOMAIN}/blog/\"]}"
Replace S3_BUCKET, S3_ENDPOINT, and the files list in the purge step with your actual values. The critical ordering — _astro/ before HTML before purge — is encoded in the step dependency chain.
Preserving old hash files during a rolling deploy
When traffic is split between old and new versions (blue/green or canary), some requests will be served by the old HTML pointing to old hash filenames. Those old files must remain on the CDN origin until all old HTML is fully purged and replaced. Avoid using --delete on the _astro/ upload step while a rolling deploy is in progress. Instead, delete old _astro/ files in a separate cleanup job that runs after the CDN purge is confirmed complete:
# Run after purge is confirmed — lists _astro/ objects older than 24h and removes them
aws s3 ls s3://$S3_BUCKET/_astro/ \
--endpoint-url "$S3_ENDPOINT" \
| awk '{print $4}' \
| xargs -I{} aws s3 rm "s3://$S3_BUCKET/_astro/{}" \
--endpoint-url "$S3_ENDPOINT"
Adapt the timestamp filter for your retention window. A 24-hour window is conservative and safe for most deploy cadences.
Edge Cases and Known Issues
Hash drift in CI despite locked dependencies. If build hashes differ between local and CI despite identical node_modules, the most common cause is OS-level file ordering fed into Rollup’s chunk graph. Pin ROLLUP_IGNORE_FORCE_CLOSURE=true (Rollup 4+) and ensure the build always runs with NODE_ENV=production. See debugging phantom hash changes in CI for a complete checklist.
Vite assetsInlineLimit silently removes hashes. The default 4 096-byte inline limit causes small SVGs, tiny images, and short CSS files to be embedded as base64 data URIs in HTML or JS. These inlined assets are not versioned as separate files. If you need every asset to have a standalone URL:
export default defineConfig({
vite: {
build: {
assetsInlineLimit: 0 // disable inlining entirely
}
}
});
SSR adapters and build.assetsPrefix. When using @astrojs/node, @astrojs/cloudflare, or any SSR adapter, the assetsPrefix is embedded in server-rendered HTML at build time. If you change the CDN URL between environments without rebuilding, server-rendered pages will reference the wrong origin. Always rebuild for each environment or use a runtime-configurable prefix.
Image service writes to a non-hashed path. This can happen when a third-party image service calls fs.writeFile directly to dist/ rather than returning an asset through Vite’s plugin emit API. Symptoms: image files in dist/_astro/ have no hash segment. Solution: check the service’s documentation for a useVitePlugin or viteIntegration option.
Content collections change hashes without a source edit. Astro’s content collections are compiled into the page bundles that render them. Adding a single MDX entry to a collection changes the module that enumerates the collection, which changes the chunk importing it, which changes that chunk’s hash. The source files you edited were Markdown, so a reviewer looking at the diff will not expect JS hashes to move. This is correct behaviour — the emitted bytes genuinely differ — but it means a content-only release still invalidates JS chunks and should be treated as a code deploy for cache-warming purposes.
Trailing-slash config changes every emitted HTML path. trailingSlash: 'always' versus 'never' changes the directory layout Astro writes into dist/, and therefore the relative URLs from each HTML file to _astro/. With build.assetsPrefix set to an absolute origin the emitted URLs are absolute and unaffected; without it, flipping this option after a deploy produces HTML whose relative asset paths resolve one directory level away. Set it once, early, and never change it on a live site without a full HTML purge.
Hashes are stable across astro build reruns but not across Astro minor upgrades. Rollup’s chunking heuristics and Astro’s compiler output both change between minor releases. Upgrading astro from 4.5 to 4.8 with no source change will still re-hash most of _astro/. Schedule framework upgrades as their own release so the cache churn is attributable, and never bundle one with an urgent content fix.
public/ files referenced in <head>. A common mistake is placing og-image.png in public/ and referencing it in Open Graph tags from a component. The URL is stable (/og-image.png), which is correct for Open Graph. But if you later move the file into src/assets/ and update the reference, the hash will be embedded in the path — external scrapers and social platforms cache the original URL and break. Keep Open Graph images in public/ by design; do not hash them.
Performance Impact
Astro’s hashing adds negligible build-time overhead for projects with fewer than 1 000 assets. For larger builds:
| Scenario | Build overhead | Cache benefit |
|---|---|---|
| < 500 assets | < 1 s | 100% CDN hit rate on unchanged files |
| 500–5 000 assets | 2–8 s | Same — hashes only recompute for changed files |
| 5 000+ assets (image-heavy) | 15–60 s | Consider image.service caching or pre-built image CDN |
| Monorepo with shared packages | Potential hash drift | Pin package versions; use build.rollupOptions.external for shared deps |
The immutable caching benefit — serving all assets directly from CDN edge without an origin round-trip — typically reduces time-to-first-byte for returning visitors by 40–80% compared to query-string versioning, which many CDNs do not cache by default.
Reducing unnecessary hash churn
Each Rollup chunk contains an import of every module it depends on, including transitive ones. A change to a deeply imported utility can cascade through the chunk graph, updating hashes for files whose content has not changed at a source level but whose dependency fingerprint has. This is normal Rollup behaviour but can inflate the number of files that change hash on each build.
The cost shows up on the CDN, not in the build log: every file whose hash moved is a cold object at the edge and a fresh origin fetch for the first visitor in each region. Measuring the width of that cascade before and after a chunking change tells you exactly how much re-fetch traffic a release costs.
Mitigation strategies:
Manual chunks. Group stable third-party libraries into a vendor chunk that changes only when you update package.json dependencies:
// astro.config.mjs
import { defineConfig } from 'astro/config';
export default defineConfig({
vite: {
build: {
rollupOptions: {
output: {
manualChunks: (id) => {
if (id.includes('node_modules')) {
return 'vendor';
}
},
assetFileNames: '_astro/[name]-[hash:8][extname]',
chunkFileNames: '_astro/[name]-[hash:8].js',
entryFileNames: '_astro/[name]-[hash:8].js'
}
}
}
}
});
After this change, a CSS update does not invalidate the vendor chunk. The vendor bundle receives a new hash only when its content — the third-party code — changes.
build.inlineStylesheets: "never". Astro’s default "auto" mode inlines CSS that is below Vite’s assetsInlineLimit. This can cause the inline CSS to shift between inline and file-based depending on how your stylesheets grow. Setting "never" ensures CSS is always emitted as a separate file with a stable hash:
export default defineConfig({
build: {
inlineStylesheets: 'never'
}
});
Measure cascade width. After a code change, run two builds and compare the set of changed filenames to understand how wide the hash cascade is:
npx astro build
find dist/_astro -type f | sed 's/.*\///' | sort > /tmp/before.txt
# Make a targeted source change, then rebuild
npx astro build
find dist/_astro -type f | sed 's/.*\///' | sort > /tmp/after.txt
diff /tmp/before.txt /tmp/after.txt | grep "^[<>]"
Files appearing only on the left (<) were in the old build; files appearing only on the right (>) are new. The union of changed filenames is what the CDN must re-fetch from origin after the next deploy.
Pre-Deploy Checklist
Frequently Asked Questions
Does Astro hash assets in the public/ directory?
No. The public/ directory is a deliberate escape hatch for files that must keep a fixed URL. Astro copies those files verbatim to dist/ with no Vite processing and no content hash. If you need a file in public/ to be versioned, move it to src/assets/ and import it in a component or layout.
What is the Vite manifest and do I need it?
The file dist/.vite/manifest.json maps every source asset path to its hashed output path. It is written automatically when Astro builds for production. SSR pages and server-side scripts use it to look up correct hashed URLs at request time. You can also parse it in deployment scripts to verify all expected assets were emitted before promoting to production.
Can I use a different hash algorithm — SHA-256 instead of the default truncated MD5?
Vite uses a truncated content hash derived from the file bytes. The algorithm is not user-configurable in Vite 5/Rollup 4 without a custom plugin. For most CDN caching purposes, collision resistance at 8 characters is sufficient. If you require cryptographically strong hashes for subresource integrity validation purposes, generate SRI hashes as a separate post-build step — do not rely on the filename hash for that.
How do I roll back if a bad build ships with incorrect asset hashes?
Because each build produces uniquely named files, the previous build’s assets are still present on the CDN (they were never purged — only the HTML was purged). Restore the previous dist/ and re-deploy. The old hashed asset URLs remain valid. See content hashing vs semantic versioning for a detailed comparison of rollback strategies.
Related
- Build Tool & Framework Asset Pipeline Integration — parent reference covering all build tool hashing approaches
- Vite asset pipeline configuration — Vite-specific config that Astro exposes through its
vitepassthrough key - Cache-key architecture — how CDN cache keys interact with hashed filenames
- Astro static asset optimisation and fingerprinting — diagnosing stale assets and CDN cache misses post-deploy
- Island client directives and asset hashes — which
client:*directives emit hashed chunks and how far a change propagates