Serve Laravel's Vite Assets from a CDN with Immutable Cache Headers

Serve Laravel Vite assets from a CDN: set ASSET_URL, get the immutable cache headers right, and stop every deploy breaking sessions with chunk load errors.

Steven Richardson
Steven Richardson
· 16 min read

Your app is on Forge, the JS bundle is 400 KB, and every first-time visitor in Sydney pulls it from one box in London. Moving that to a CDN is a genuinely small change — and it fails in two ways that look identical from the outside: a chunk-load error for anyone who was mid-session during a deploy, and an icon font rendering as empty boxes. Both get blamed on the CDN. Both are configuration.

Here is the whole thing, end to end, with the verification commands.

Choose between a pull-through CDN and pushing assets to object storage#

Two architectures work, and they fail differently. A pull-through CDN points CloudFront or Cloudflare at your existing origin, so a cache miss fetches /build/assets/app-DNxiirP_.js from your web server and caches it at the edge. A push model uploads public/build to S3 or R2 in CI, and the origin never serves assets at all.

Pull-through CDN Push to object storage
Pipeline change None Upload step, ordered before release
Assets in sync with deploy Always Only if upload precedes the cutover
Rollback Automatic Needs the old objects still present
Read-only containers Fine Fine
Serverless (Vapor, Cloud) Usually already handled Usually already handled
Stale-chunk risk Low — origin holds the files Higher — lifecycle rules can delete them

Pull-through is the right default. It has no pipeline to get wrong, it inherits your deploy's atomicity, and your rollback story is whatever it already was. Reach for the push model when the origin genuinely cannot serve static files — a read-only container image, or a deploy target where the web tier scales to zero. If you are already building assets inside a multi-stage Docker image, the files are in the image and pull-through costs you nothing.

Set ASSET_URL in the build and the runtime environment#

It is one environment variable, but two programs read it, and that is where most of the wasted afternoons come from. Laravel reads ASSET_URL into config('app.asset_url') at runtime, which is what asset() — and therefore @vite — prefixes onto the tags in your <head>. Separately, laravel-vite-plugin calls Vite's loadEnv() at build time and uses the same value to compute Vite's base, which is what lazily imported chunks and url() references inside your compiled CSS resolve against.

# .env — must be present when `npm run build` runs AND when the app serves requests
ASSET_URL=https://cdn.example.com

Set it only at runtime and your entry bundle loads from the CDN while every dynamic import still points at /build/assets/… on your own box. Set it only in CI and you get the mirror image. In a pipeline, exporting it as an ordinary environment variable before the build is enough — loadEnv is called with an empty prefix, so it picks up process.env as well as .env files.

Your Blade stays as it was:

<head>
    @vite(['resources/css/app.css', 'resources/js/app.js'])
</head>

Before, the rendered <head> points at your own domain:

<link rel="preload" as="style" href="https://example.com/build/assets/app-oql0qHl9.css" />
<link rel="stylesheet" href="https://example.com/build/assets/app-oql0qHl9.css" />
<script type="module" src="https://example.com/build/assets/app-DNxiirP_.js"></script>

After, every URL moves:

<link rel="preload" as="style" href="https://cdn.example.com/build/assets/app-oql0qHl9.css" />
<link rel="stylesheet" href="https://cdn.example.com/build/assets/app-oql0qHl9.css" />
<script type="module" src="https://cdn.example.com/build/assets/app-DNxiirP_.js"></script>

Two things to know before you ship it.

ASSET_URL is not APP_URL. They are separate variables and setting only APP_URL does nothing to Vite's output. I have watched more than one person change APP_URL, see no difference, and conclude the CDN is broken.

And env() is frozen by config:cache. On a production box running php artisan config:cache, changing ASSET_URL in .env has zero effect until the cache is rebuilt. Your deploy almost certainly already does this — the same discipline that makes OPcache and preloading safe in production applies here — but if you are testing by hand, it is a five-minute debugging session you can skip:

php artisan config:clear && php artisan config:cache

ASSET_URL also moves asset(). Anything you reference with asset('images/og.png') now resolves to the CDN host, which is usually what you want, but it means files in public/ that you never uploaded will 404 if you are on the push model. Absolute URLs written into your templates by hand are not rewritten at all.

If you need more control than a single host prefix — a different CDN per environment, or a signed URL scheme — reach for createAssetPathsUsing() rather than fighting the env variable:

use Illuminate\Support\Facades\Vite;

Vite::createAssetPathsUsing(function (string $path, ?bool $secure) {
    return "https://cdn.example.com/{$path}";
});

Send the right Cache-Control header for each path#

Vite writes content-hashed filenames: app-DNxiirP_.js. The hash is derived from the file's contents, so that URL can never mean anything different than it does right now. That is precisely the precondition immutable asks for, and it is why a one-year max-age on a bundle is not reckless — it is the correct answer.

The manifest is not the opposite — it is simply not in the picture. This is worth being blunt about, because a lot of advice tells you to set no-cache on /build/manifest.json at the edge. Laravel never fetches the manifest over HTTP. Illuminate\Foundation\Vite::manifestPath() resolves to public_path('build/manifest.json') and the file is read with file_get_contents() inside your PHP process. No browser ever requests that URL, so whatever header it carries is decoration.

What does matter about the manifest is that it exists on the application server. On the push model it is tempting to upload public/build wholesale and move on — but if the manifest is not also on local disk where PHP can read it, you get Vite manifest not found in production regardless of how healthy the bucket looks.

Path Header
/build/assets/* public, max-age=31536000, immutable
Non-hashed files in public/ public, max-age=86400 plus ETag
HTML responses no-store, or a short max-age with revalidation

The HTML row is the one to get right, because it is the only thing standing between a user and a stale document. no-store means never write it down anywhere; a short max-age with revalidation is fine if your pages are genuinely public. What you must not do is let an over-broad cache rule catch an authenticated response.

One caveat on immutable: it is honoured by Firefox and Safari, and Chrome largely treats a long max-age the same way in practice. It is a hint that stops a reload from issuing a revalidation request. It costs nothing when ignored, so set it.

Set the headers at the origin, not only at the CDN#

Headers can come from three places, and the precedence bites people. The origin sends Cache-Control; the CDN can respect it, replace it, or add to it; and on the push model the header comes from object metadata written at upload time. Set them at the origin first, because that is the source of truth your CDN falls back to and the thing that still works when you put a second CDN in front of it later.

Nginx, for the pull-through model:

# Hashed bundles — safe to cache forever.
location ^~ /build/assets/ {
    access_log off;
    add_header Cache-Control "public, max-age=31536000, immutable" always;
    try_files $uri =404;
}

# Everything else under /build/ — short-lived, revalidated, never immutable.
location ^~ /build/ {
    add_header Cache-Control "public, max-age=60" always;
    try_files $uri =404;
}

The always flag matters — without it nginx skips add_header on non-2xx responses, so a 304 from a conditional request loses the header entirely. So does the nesting: add_header in a location block discards every header inherited from server level, so any security headers you set globally need repeating here or they quietly disappear for your assets.

On CloudFront, create one cache behaviour for the hashed assets and attach a response headers policy. Order it ahead of your default behaviour:

{
  "PathPattern": "/build/assets/*",
  "TargetOriginId": "app-origin",
  "ViewerProtocolPolicy": "redirect-to-https",
  "CachePolicyId": "658327ea-f89d-4fab-a63d-7e88639e58f6",
  "ResponseHeadersPolicyId": "<your-immutable-policy-id>",
  "Compress": true
}

That CachePolicyId is the AWS-managed CachingOptimized policy — no cookies or query strings in the cache key, normalised Accept-Encoding, which is exactly right for static files. Know its TTLs before you attach it anywhere else: minimum 1 second, default 24 hours, maximum 365 days. That default is the trap. A path where your origin sends no Cache-Control at all does not fall through uncached; CloudFront invents a day of caching for it. Harmless on a hashed bundle, genuinely dangerous on anything dynamic that lands in the wrong behaviour.

On Cloudflare, a Cache Rule matching starts_with(http.request.uri.path, "/build/assets/") with Edge TTL set to "Use cache-control header if present" gives you the same result. Scope it to the build path — a rule broad enough to catch your HTML will happily cache a Set-Cookie response and serve one user's session to the next.

For the push model, the headers are metadata on the object, set per pattern at upload. This runs in CI right after the build step, alongside whatever Composer and npm caching you already have:

- name: Upload hashed assets (immutable)
  run: |
    aws s3 sync public/build/assets s3://$BUCKET/build/assets \
      --cache-control "public, max-age=31536000, immutable" \
      --exclude "*.map" \
      --size-only \
      --no-progress

Note what is absent: --delete. Leaving old objects in place is the entire point, and a lifecycle rule expiring them after a week or two stops the prefix growing forever. The manifest is not uploaded here at all — it stays in the deployed application, because that is where PHP reads it from. And this step must finish before the new HTML goes live, or the first visitor after cutover asks the bucket for a file that has not landed yet.

Keep the previous build so mid-session deploys do not break#

This is the failure most articles skip, and it is the one that generates support tickets.

A user loaded your app ten minutes ago. Their browser holds HTML referencing app-DNxiirP_.js. You deploy. The build directory is wiped and rewritten with new hashes. They click a link, the router requests a lazily-imported chunk, and it is gone — Failed to fetch dynamically imported module. Nothing about the CDN caused this; the CDN just makes it more visible, because the edge is now also caching a 404.

Fix it in two places.

On disk, keep the old build. The mistake is a deploy step that deletes public/build before running npm run build. Merge instead:

# Build into a temp directory, then merge over the live one.
npm run build -- --outDir public/build-next --emptyOutDir
cp -r public/build-next/assets/. public/build/assets/
cp public/build-next/manifest.json public/build/manifest.json
rm -rf public/build-next

# Prune bundles older than a week, long after any session has ended.
find public/build/assets -type f -mtime +7 -delete

Hashed filenames never collide, so the merge is safe and the old chunks stay reachable. If you deploy with atomic releases, this is the one directory that should be shared between them rather than rebuilt per release — the same reasoning that makes a blue-green deployment keep both colours live during the cutover. On the push model, replace the find with an S3 lifecycle rule expiring non-current versions after seven days, and never run aws s3 sync --delete against the assets prefix.

In the browser, handle the failure. Vite emits a vite:preloadError event when a dynamic import fails. One handler, registered once in your entry point:

// resources/js/app.js
let reloading = false;

window.addEventListener('vite:preloadError', (event) => {
    // Stop Vite throwing, then fetch the current HTML once.
    event.preventDefault();

    if (reloading) {
        return;
    }

    reloading = true;
    window.location.reload();
});

The reloading guard is not optional. Two handlers reloading independently, or a reload that hits the same stale HTML again, is how you build a refresh loop that pins a user's CPU. Keep exactly one, and make sure your HTML responses are not cached — if the reload serves the same stale document, the loop never terminates.

Both mitigations together: the old chunk is still on disk for a week, and if it somehow is not, the user gets one reload instead of a white screen.

Add CORS headers for fonts loaded from the CDN#

The moment your assets live on cdn.example.com and your page is on example.com, fonts become cross-origin requests. Browsers enforce CORS on font loads specifically — a stylesheet or an image will load happily from another origin, a @font-face file will not. The symptom is an icon font rendering as blank boxes or fallback glyphs, with a CORS error in the console that nobody scrolled down far enough to see.

Same-origin assets never hit this, which is why it appears the day you move to a CDN and gets blamed on the CDN.

On CloudFront, attach the managed SimpleCORS response headers policy (60669652-455b-4ae9-85a4-c4c02393f86c) to the assets behaviour — or CORS-With-Preflight (5cc3b908-e619-4b99-88e5-2cf7f45965bd) if anything you serve triggers a preflight. Both defer to the origin when it already sent the header. Build your own if you want a specific origin rather than *:

{
  "CorsConfig": {
    "AccessControlAllowOrigins": { "Quantity": 1, "Items": ["https://example.com"] },
    "AccessControlAllowMethods": { "Quantity": 2, "Items": ["GET", "HEAD"] },
    "AccessControlAllowHeaders": { "Quantity": 1, "Items": ["*"] },
    "AccessControlAllowCredentials": false,
    "OriginOverride": true
  }
}

There is a caching trap here worth naming. If CloudFront receives the first request for a font without an Origin header, it caches that response without CORS headers and serves it to everyone until it expires — including the browsers that need the header. Either include Origin in the cache key via an origin request policy, or send Access-Control-Allow-Origin: * unconditionally from the origin.

At the origin, one directive covers it:

location ~* \.(woff2?|ttf|otf|eot)$ {
    add_header Access-Control-Allow-Origin "*" always;
    add_header Cache-Control "public, max-age=31536000, immutable" always;
}

If you use crossorigin on a font preload tag, the fetch becomes a CORS request too, so the header is required either way.

Preconnect to the CDN and check the preload tags#

@vite emits <link rel="preload"> and <link rel="modulepreload"> alongside the script and style tags, and those are prefixed by ASSET_URL like everything else. Verify it in the rendered HTML rather than the network tab, because a preload pointing at the wrong origin still works — it just races the real request instead of warming it.

Add a preconnect so the DNS lookup and TLS handshake to the CDN start before the parser reaches the first asset:

<head>
    <link rel="preconnect" href="https://cdn.example.com" crossorigin>
    @vite(['resources/css/app.css', 'resources/js/app.js'])
</head>

The crossorigin attribute is needed for the warmed connection to be reused by the font and module fetches, which are themselves CORS requests. Without it the browser opens a second connection and you have paid for the hint twice.

If you are running an SPA with code splitting, Vite::prefetch() is worth pairing with this — it eagerly pulls the lazily-imported chunks after load, so navigation does not stall on a cold CDN edge:

// app/Providers/AppServiceProvider.php
public function boot(): void
{
    Vite::prefetch(concurrency: 3);
}

Verify it with curl before you believe it#

A CDN in front of an origin caches whatever the origin sends, including mistakes. If Laravel is serving assets through PHP because the public path is wrong, the edge will cache that too, and your dashboard will show a healthy hit rate for a broken setup. Four commands settle it.

A hashed bundle should be immutable, compressed, and served by the CDN, not PHP:

curl -sSI https://cdn.example.com/build/assets/app-DNxiirP_.js | grep -iE 'cache-control|x-cache|server|age|content-encoding'
cache-control: public, max-age=31536000, immutable
server: AmazonS3
age: 4213
x-cache: Hit from cloudfront
content-encoding: br

server: AmazonS3 or server: nginx is the tell. A server header naming PHP, or an x-powered-by, means the request fell through to your application and you are about to cache a framework boot for a year.

The second request for the same URL should be a hit — if every request is a miss, something variable is in your cache key, usually cookies or a query string inherited from a default behaviour you forgot to override:

curl -sSI https://cdn.example.com/build/assets/app-DNxiirP_.js | grep -i x-cache
x-cache: Hit from cloudfront

A font must carry the CORS header, and it only appears if you ask as a browser would:

curl -sSI -H 'Origin: https://example.com' \
  https://cdn.example.com/build/assets/icons-B7kQ2mP1.woff2 | grep -i access-control
access-control-allow-origin: https://example.com

And your HTML must not be cached at the edge:

curl -sSI https://example.com/ | grep -iE 'cache-control|set-cookie|x-cache'

If that shows x-cache: Hit on a page with a Set-Cookie, stop and fix your cache rule before anything else — you are one request away from serving someone else's session.

Stop invalidating on every deploy#

If your pipeline runs aws cloudfront create-invalidation --paths "/*" after each release, delete it. Hashed assets never need invalidating: a new build produces new URLs, and the old ones should stay cached precisely so users mid-session can still fetch them. A wildcard invalidation throws away a warm edge cache, costs money past the free tier, and papers over a header that is wrong somewhere.

There are exactly two cases where you do need one. A bad response got cached — a 404 or a PHP error page that the edge picked up while your origin config was wrong — in which case fix the origin first, then invalidate that single path. Or you replaced a non-hashed file in public/ under the same name, like favicon.ico or an OG image, in which case invalidate that path and consider hashing it instead.

aws cloudfront create-invalidation \
  --distribution-id E1234567890ABC \
  --paths "/favicon.ico"

And do not reach for ?v=123 on an already-hashed URL. The hash is the version; a query string just fragments the cache key and lowers your hit ratio.

Two more things to check before you call it done. On Vapor and Laravel Cloud, asset distribution is already managed for you — setting ASSET_URL by hand can point at a distribution the platform is also rewriting, and you end up debugging a conflict you created. Read what the platform sets before you change anything; the Vapor and Forge trade-offs cover which parts each one owns. And source maps: a map referenced from a CDN-hosted bundle either 404s noisily or, if you shipped it, hands your unminified source to anyone who opens devtools. Decide deliberately, and if you do ship maps, restrict them at the CDN rather than hoping nobody looks.

Once the headers are right, the rest is deploy discipline. If your release process still wipes the build directory, fix that next — it is the same class of problem as ordering migrations and cutover in a zero-downtime deploy, and it fails for the same reason: two versions of your application are live at once, and only one of them knows it.

FAQ#

How do I serve Laravel Vite assets from a CDN?

Set ASSET_URL=https://cdn.example.com in the environment used to run npm run build and in the environment the application runs in, then rebuild the config cache. Laravel's Vite helper prefixes every generated tag with that value, and laravel-vite-plugin uses the same variable to set Vite's base so lazily imported chunks resolve to the CDN too. Point the CDN at your existing origin as a pull-through distribution, or upload public/build to object storage in CI if your origin cannot serve static files. No change to vite.config.js is needed for the standard case.

What does ASSET_URL do in Laravel?

ASSET_URL populates config('app.asset_url'), which Laravel uses as the base for asset() and for every URL the Vite helper generates. It also does a second job you cannot see from PHP: laravel-vite-plugin reads it at build time and uses it to compute Vite's base, which governs how dynamic imports and compiled CSS url() references resolve. It is a different variable from APP_URL — changing APP_URL alone has no effect on asset URLs. Because it is read through env(), a cached config must be rebuilt with php artisan config:cache before the runtime half of the change takes effect.

What Cache-Control header should Laravel's built assets use?

Use public, max-age=31536000, immutable on the hashed files in /build/assets/, because Vite's filenames are content hashes and a given URL can never refer to different bytes. Apply it there and nowhere else. Non-hashed files in public/ want a moderate max-age with an ETag, and HTML wants no-store or a short max-age with revalidation. The manifest needs no header at all, because Laravel reads it from local disk rather than over HTTP.

Why do users get a chunk load error after I deploy?

Because their browser is holding HTML from the previous build that references chunk filenames your deploy just deleted. When the app lazily imports one of those chunks, the request 404s and Vite throws Failed to fetch dynamically imported module. Fix it by keeping the previous build's files on disk rather than wiping public/build on deploy, and by listening for Vite's vite:preloadError event to trigger a single guarded page reload as a fallback. Both together, not either alone.

Do I need CORS headers for fonts served from a CDN?

Yes. Browsers enforce CORS on @font-face requests specifically, so a font served from a different origin than the page needs Access-Control-Allow-Origin or it silently fails to render. Images and stylesheets do not have this restriction, which is why fonts are usually the only thing that breaks. On CloudFront, attach the managed SimpleCORS response headers policy to the assets behaviour, and make sure Origin is part of the cache key so a headerless first request does not get cached and served to everyone.

Should I upload Vite assets to S3 or put a CDN in front of my server?

Put a CDN in front of your server unless you have a specific reason not to. Pull-through needs no pipeline changes, keeps assets automatically in sync with whatever you deployed, and inherits your existing rollback behaviour. The push-to-S3 model is worth the extra upload step and ordering constraints when the origin genuinely cannot serve static files — a read-only container, or a web tier that scales to zero. On managed platforms like Vapor and Laravel Cloud, check what the platform already does before choosing either.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.