Static Assets 404 After Deploy (Astro/Vite Fix)

Images, CSS, and fonts 404 in production but work locally. Five web-verified causes and fixes: missing dist/ copy, base path, case mismatch, content hash, CDN cache.

The page loads fine after deploy, but images are broken, CSS is missing, and fonts disappear — the DevTools Network tab is a wall of 404s. Locally npm run dev is perfect, which makes it tempting to blame the host. Nine times out of ten the real cause is on your side: the file was never copied into dist/, your hardcoded path ignores the base setting, the filename case differs between your Mac and the Linux server, or Vite/Astro added a content hash you didn’t reference.

Fastest fix (covers ~80% of cases): open DevTools → Network, click the red 404 request, and copy its exact path (for example images/foo.png). Run a clean local build and search for that path:

rm -rf dist/
npm run build
find dist -path "*images/foo*"
  • No match → the file isn’t in your build output. Move it to public/ or reference it through import (Cause 1).
  • Match, but the filename has a hash like foo.a3f7b9.png → you hardcoded the unhashed name. Use the framework’s asset reference (Cause 4).
  • Match, exact name → the file builds fine, so the problem is the request path: a base prefix (Cause 2), a case mismatch (Cause 3), or a stale CDN edge (Cause 5).

The rest of this article walks each of those five buckets with a one-line check you can run against dist/ or in DevTools.

Which bucket are you in?

SymptomMost likely causeJump to
File missing from dist/ entirelyWrong folder / not importedCause 1
Site deploys under a subpath like /blog/base path offsetCause 2
Works on Mac/Windows, 404s only on the live Linux hostFilename case mismatchCause 3
Requested name has no hash, dist/_astro/ name doesContent-hash mismatchCause 4
File exists in dist/, 200 with a cache-buster, 404 withoutStale CDN edgeCause 5

Common causes

Ordered by hit rate, highest first.

1. Asset in the wrong place — not in the build output

Only files in public/ are copied as-is to dist/ (path preserved). Files in src/assets/ must be referenced via import so Vite processes them; a raw /src/assets/foo.png in HTML 404s in the browser.

You wroteResult
File at public/images/foo.png, HTML uses /images/foo.pngOK
File at src/assets/foo.png, HTML uses /src/assets/foo.png404
File at src/assets/foo.png, component does import foo from './foo.png'OK, Vite emits dist/_astro/foo.<hash>.png

How to spot it:

npm run build
find dist -name "foo.png" -o -name "foo.*.png"

If nothing matches, it wasn’t bundled.

2. Base path offset

astro.config.mjs with base: '/blog' deploys everything under /blog/.... A hardcoded /images/foo.png in your template hits https://yourdomain.com/images/foo.png (404) instead of https://yourdomain.com/blog/images/foo.png. This is the single most common GitHub-Pages-project-site footgun: it works in npm run dev because the dev server happily serves both paths, then breaks the moment you deploy under a subpath.

Astro does not rewrite raw <img src="/..."> or public/ URLs for you — per the Astro docs, when base is set, all of your static asset URLs must add the base as a prefix yourself. Even public/favicon.svg must be requested as /blog/favicon.svg.

How to spot it:

grep -n "base:" astro.config.mjs

If base is set, prefix every path with import.meta.env.BASE_URL (see the fix below), or use framework helpers like Astro’s <Image> component — don’t hardcode the leading-slash path.

3. Case-sensitivity mismatch

/Images/Foo.PNG and /images/foo.png are the same file on macOS and Windows (case-insensitive by default) and two different files on a Linux deploy server (case-sensitive). Local always works, production always 404s. Both Netlify and Vercel run Linux build/serve environments, so this is one of the most common “builds locally, 404s in production” reports in their support forums.

How to spot it:

ls public/images/ | grep -i foo

Compare the on-disk casing against the exact URL DevTools requested. The HTML reference must match the on-disk filename byte-for-byte, including case and extension (.JPG is not .jpg). A reliable catch is git config core.ignorecase false plus a clean clone on a case-sensitive filesystem — if the file vanishes, your reference is mis-cased.

4. Vite/Astro added a content hash, you referenced the original name

Vite hashes assets imported from src/, emitting foo.a3f7b9.png. If your .mdx / .astro hardcodes /src/assets/foo.png or /foo.png, the file at that path doesn’t exist post-build.

How to spot it: DevTools → Network → click the 404 → check the requested URL, then ls dist/_astro/ to see what the real hashed filename looks like.

5. CDN hasn’t synced or is serving a stale version

Right after deploy the CDN edges may not have the new file yet. Or the previous deploy’s HTML is cached and still references an old hashed filename that the new build replaced — a returning visitor on a stale HTML page requests app.OLD.js, which no longer exists, and 404s. (Vercel’s Skew Protection, on Pro and Enterprise as of June 2026, exists specifically to keep clients pinned to the assets of the deploy they loaded for a configurable window; without it, hard-refresh fixes the visitor but not the cache.)

How to spot it:

curl -I "https://yourdomain.com/images/foo.png"
curl -I "https://yourdomain.com/images/foo.png?cb=$(date +%s)"

With buster 200, without 404 → CDN cache. Both 404 → the file genuinely didn’t upload.

Shortest path to fix

Ordered by ROI. The first three usually solve 80% of cases.

Step 1: Confirm the file is in dist/ after a local build

rm -rf dist/
npm run build
find dist -type f \( -name "*.png" -o -name "*.jpg" -o -name "*.svg" -o -name "*.css" -o -name "*.woff*" \) | head -20

Take the 404 URL path from DevTools (e.g. images/foo.png) and search:

find dist -path "*images/foo*"
  • Nothing → file isn’t in the build, check public/ placement or switch to import
  • Match but with a hash → you’re referencing the unhashed name, fix the reference

Astro recommended:

---
import { Image } from 'astro:assets';
import foo from '../assets/foo.png';
---
<Image src={foo} alt="foo" />

Vite guarantees the reference matches the emitted filename, hash and all.

If the asset can’t be imported (e.g. images in MDX), put it in public/:

public/images/foo.png   →  HTML uses /images/foo.png

Note: files in public/ are not processed — no compression, no hashing, no optimization. If you rename one, browsers may cache the old version; add a manual cache buster.

Step 3: Handle the base path

If you have base: '/blog', every hardcoded path needs a prefix. Astro pattern:

<img src={`${import.meta.env.BASE_URL}images/foo.png`} alt="" />

or:

<img src={new URL('images/foo.png', Astro.url).pathname} alt="" />

Don’t write src="/images/foo.png" — that only works when base: '/'.

Step 4: Purge CDN per-URL

# verify origin
curl -I "https://yourdomain.com/images/foo.png?cb=$(date +%s)"
# check CDN state
curl -I "https://yourdomain.com/images/foo.png"

If with buster you see 200 but without you see 404, purge that specific URL (menu paths current as of June 2026):

  • Cloudflare: dashboard → your domain → Caching → Configuration → Purge Cache → Custom Purge, choose URL, paste the full URLs. Single-file (instant) purge is available on every plan now. Note the path is case-sensitive, so purge the exact casing you serve.
  • Vercel: vercel --prod --force from the CLI, or in the dashboard open the deployment’s ⋯ menu → Redeploy and untick Use existing Build Cache.
  • Netlify: Deploys → Trigger deploy → Clear cache and deploy site.

Step 5: Add a CI smoke test for key assets

Post-deploy:

#!/usr/bin/env bash
set -e
BASE="https://yourdomain.com"
# key asset list
ASSETS=(
  "/favicon.ico"
  "/images/og-default.png"
  "/fonts/inter.woff2"
)
for a in "${ASSETS[@]}"; do
  code=$(curl -s -o /dev/null -w "%{http_code}" "$BASE$a")
  [[ "$code" == "200" ]] || { echo "FAIL $a$code"; exit 1; }
done
echo "All key assets OK"

Any missing asset gets caught within 30 seconds of deploy.

How to confirm it’s fixed

Don’t trust a browser reload — your local cache lies. Confirm from a clean state:

# 1. The asset returns 200 from the live origin (note the -L to follow base-path redirects)
curl -sIL "https://yourdomain.com/images/foo.png" | head -1

# 2. No 404s remain on the page (grep the served HTML for the asset, then re-check Network)

Then reload the page in a DevTools session with Disable cache ticked (Network tab) or a private window, and confirm the Network tab shows 200 (not 200 (from disk cache)) for every previously-red request. If you purged a CDN URL, re-run the cache-buster comparison from Cause 5: both with and without the buster should now be 200.

Prevention

  • Lowercase + kebab-case all asset filenames to dodge case-sensitivity bugs
  • In components, import assets so the framework manages hashing — never hardcode /src/...
  • If you use base, build all paths with import.meta.env.BASE_URL
  • Post-deploy, smoke-test key assets (favicon, OG image, primary font) for 200
  • Host very large assets on a dedicated CDN — keeps the build artifact small and deploy fast

FAQ

Why does it work in npm run dev but 404 in production? The dev server serves files straight from disk on a case-insensitive Mac/Windows filesystem and ignores your base setting, so mis-cased names and missing base prefixes both “work” locally. Production runs a real build on a case-sensitive Linux host under your actual base. Reproduce locally with npm run build && npm run previewpreview honors base and serves only what’s in dist/.

Where do I put images — public/ or src/assets/? Put it in src/assets/ and import it whenever you can: the framework hashes the filename, optimizes the image, and guarantees the reference matches. Use public/ only for files that must keep a fixed, predictable URL (favicon, robots.txt, OG image referenced by absolute URL, site.webmanifest) — those are copied as-is with no processing.

The CSS/JS file is app.a3f7b9.css in dist/ but the browser asks for app.css. What broke? You referenced an unhashed name that Vite renamed at build time. Don’t hardcode the path — import the asset (or link the stylesheet through the framework) so the emitted hash is always in sync. Hardcoding a hashed name also breaks on the next build, because the hash changes whenever the file content changes.

I purged the CDN and it still 404s. Now what? Re-run the Cause 5 check. If the cache-buster URL (?cb=...) is also 404, the file genuinely isn’t on the origin — this is a build/upload problem, not a cache problem, so go back to Step 1 and confirm it’s in dist/. CDN purging only helps when the buster URL returns 200.

Does adding a base path require me to change every image tag? Yes, for any path you hardcode with a leading slash. Prefix them with import.meta.env.BASE_URL, or add a single <base href={import.meta.env.BASE_URL}> in your <head> and use root-relative paths without the leading slash. Note that BASE_URL’s trailing slash follows your trailingSlash config, so build paths by concatenation rather than assuming a slash is or isn’t there.

Tags: #Hosting #Debug #Troubleshooting