Static Assets 404 After Deploy

Images and CSS 404 after deploy but work locally. Five typical causes — missing dist/ copy, base path, content hash, public folder, host upload step.

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 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.

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 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:

  • Cloudflare: Purge Custom URLs, paste the full URL
  • Vercel: vercel --prod --force to 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, 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

Tags: #Hosting #Debug #Troubleshooting