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 throughimport(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
baseprefix (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?
| Symptom | Most likely cause | Jump to |
|---|---|---|
File missing from dist/ entirely | Wrong folder / not imported | Cause 1 |
Site deploys under a subpath like /blog/ | base path offset | Cause 2 |
| Works on Mac/Windows, 404s only on the live Linux host | Filename case mismatch | Cause 3 |
Requested name has no hash, dist/_astro/ name does | Content-hash mismatch | Cause 4 |
File exists in dist/, 200 with a cache-buster, 404 without | Stale CDN edge | Cause 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 wrote | Result |
|---|---|
File at public/images/foo.png, HTML uses /images/foo.png | OK |
File at src/assets/foo.png, HTML uses /src/assets/foo.png | 404 |
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 toimport - Match but with a hash → you’re referencing the unhashed name, fix the reference
Step 2: Use the framework’s recommended asset 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 --forcefrom 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,
importassets so the framework manages hashing — never hardcode/src/... - If you use
base, build all paths withimport.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 preview — preview 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.
Related
Tags: #Hosting #Debug #Troubleshooting