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.
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 |
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';
---
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. 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.
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.
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
astro@^4.0.0installed andastro.config.mjsusesdefineConfigNODE_ENV=productionset in CI environmentpackage-lock.jsonor lockfile committed with exactviteandrollupversionsdist/_astro/files confirmed present after build (ls dist/_astro/ | wc -l> 0)dist/.vite/manifest.jsonpresent and non-emptybuild.assetsandvite.build.rollupOptions.output.assetFileNamesprefix are aligned if both are customisedCache-Control: public, max-age=31536000, immutableto/_astro/(or custom prefix)/,/about/, etc.) haveCache-Control: no-cache, must-revalidatebuild.assetsPrefixset to CDN origin URL in production CI jobdist/root (would indicate accidentalpublic/placement)
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 - Content hashing vs semantic versioning — when filename hashes beat version numbers
- 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