# How do I set up CDN asset versioning: hashing and a cache strategy on deploy?

> Drop query-string busting: cache content-hashed filenames as `immutable`, keep the HTML short-lived, and upload the assets before you cut over.

- Asked: 2026-05-24
- Answered: 2026-05-27
- Asked by: Aslı
- Tags: performans, ci-cd, altyapi
- Source: https://muhammetsafak.com/just-ask/cdn-asset-versioning-and-cache-strategy-on-deploy/
- Language: en-US
- Author: Muhammet Şafak

---
**Question:** When I push new code live with DeployerPHP, the UI breaks because of stale CSS/JS in the user's browser. Some CDNs ignore a query string like `mix.js?id=123` and keep serving the old version.

For a permanent fix I want to hash the asset names (`app.a8f9b2.js`) and ship them with `Cache-Control: max-age=31536000` to the CDN. How do I design the build and invalidate automation in CI/CD?


Short answer: query-string cache busting is unreliable; the durable fix is content-hashed filenames (`app.a8f9b2.js`) — the name changes only when the content changes, and the cache strategy handles the rest.

## Short answer

The root of your problem: with `app.js?id=123` the filename is always the same. Some CDNs strip or ignore the query string in the cache key, so even when `id` changes they keep serving the OLD cached file. You need to change the cache key — the name, not the query. I covered the API side of the same "who refreshes the cache" question in [the edge caching and invalidation answer](/just-ask/edge-caching-with-cloudflare-workers-kv-and-invalidation/).

## Why

1. **A query string is not a cache-key guarantee.** Behaviour varies by CDN; some ignore it entirely. The name, on the other hand, is part of the cache key at every layer.

2. **A content hash is a natural version number.** The name changes only when the content changes — and when it doesn't, there's no needless re-download either.

3. **Deploy time is a race.** If the new HTML points at a hash that hasn't been uploaded yet, the user gets a 404; hashing alone won't save you if the order is wrong.

## What to do

1. **Use content-hashed filenames.** Like `app.a8f9b2.js`, where the hash is derived from the file's contents. That lets you cache every asset with `Cache-Control: public, max-age=31536000, immutable` — the `immutable` directive tells the browser "don't even revalidate this," cutting needless conditional requests.

2. **Keep the HTML/entrypoint short-lived.** Serve the HTML that references the hashed assets with `no-cache` or a short TTL: it must always be fresh so it points to the new hashes. Cache the HTML long and the whole chain locks up.

3. **In CI/CD, upload first, then go live.** Build hashed output with the Vite manifest and upload the assets to the CDN/bucket **before** flipping the app live. In a DeployerPHP flow, wire this as an "upload before switching the symlink" step.

4. **Keep the previous release's assets around for a while.** At deploy time, a user with the page open is still running the old HTML and requesting the old hashes. DeployerPHP's `keep_releases` protects those in-flight users.

**Bottom line:** I'd set up the trio of content-hashed `immutable`-cached assets + short-lived HTML + upload-before-cutover. With true content hashing you practically never need to purge assets — since the name changes, the old file just sits there untouched and nobody requests it. The only thing you ever invalidate is the HTML, and that's already short-cached. Drop query-string busting entirely.

## Related Reading

- [How do I set up secretless AWS access in CI/CD with OIDC and IAM roles?](https://muhammetsafak.com/just-ask/secretless-ci-cd-with-aws-iam-roles-and-oidc/) — Just Ask
- [How do I shrink Docker images with multi-stage builds and distroless?](https://muhammetsafak.com/just-ask/shrinking-docker-images-with-multi-stage-builds-and-distroless/) — Just Ask
- [How do I safely manage Terraform state with S3 + DynamoDB locking?](https://muhammetsafak.com/just-ask/terraform-remote-state-and-locking-with-s3-and-dynamodb/) — Just Ask
