The page loads fine after deploy, but images are broken, CSS is missing, and fonts disappear — 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 one of: the file was never copied into dist/, your hardcoded path doesn’t account for the base setting, or Vite/Astro added a content hash to the filename that you didn’t update your reference to.
This article splits it into 5 typical scenarios, each with a check you can run against dist/ or in DevTools.
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.
How to spot it:
grep -n "base:" astro.config.mjs
If base is set, use import.meta.env.BASE_URL to prefix paths, or use framework helpers like Astro’s <Image> — don’t hardcode.
3. Case-sensitivity mismatch
/Images/Foo.PNG and /images/foo.png are the same file on macOS (case-insensitive) and two different files on a Linux deploy server (case-sensitive). Local always works, production always 404s.
How to spot it:
ls public/images/ | grep -i foo
The HTML reference must match the on-disk filename exactly, including case and extension.
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.
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:
- Cloudflare: Purge Custom URLs, paste the full URL
- Vercel:
vercel --prod --forceto redeploy - 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.
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
Related
Tags: #Hosting #Debug #Troubleshooting