A Vite app can look perfectly healthy in development and still fail in production for a boring reason: direct navigation to real routes starts returning 404.

The homepage works. Maybe a few assets work. But refresh /settings, /projects/123, or any non-root URL, and nginx serves a file-system miss instead of your app shell.

This is one of the most common SPA deployment mistakes, and it is completely avoidable.

The deployment pattern that worked cleanly here was simple:

  • build the app with Bun,
  • serve the built dist/ output with nginx,
  • configure nginx with a history-route fallback using try_files.

That is the whole game.


The actual problem is not Vite

Vite is usually not the thing that is broken.

The real mismatch is between:

  • how a client-side router thinks about routes, and
  • how a static file server resolves paths.

Your Vue router sees /projects/123 as “render the projects page inside the SPA.”

nginx sees /projects/123 as “find a file or directory at that path.”

If you do not teach nginx to fall back to index.html, the server does the reasonable thing for a static host and returns 404.

That is why the fix belongs in the runtime web server config, not in wishful thinking about the frontend build.


The deployment shape that worked

The successful setup was:

Build stage

  • use Bun in the image build stage
  • install deps
  • run the production build
  • produce a clean dist/ directory

Runtime stage

  • use nginx as the runtime image
  • copy dist/ into nginx’s served directory
  • configure a fallback so unknown paths return index.html

This is a very boring shape.

That is a compliment.

For a normal Vite SPA on CapRover, boring is exactly what you want.


The nginx rule that matters

The key runtime behavior is the history fallback.

In practice, that means try_files should resolve like this:

location / {
  try_files $uri $uri/ /index.html;
}

That line is the difference between:

  • deep links working, and
  • every refreshed client-side route breaking in production.

If you only remember one thing from this post, remember that.


Why nginx is a good fit here

People sometimes overcomplicate SPA hosting on CapRover.

For a plain frontend app, nginx gives you almost everything you need:

  • fast static serving
  • predictable routing behavior
  • easy fallback rules
  • a small and well-understood runtime image

You do not need a Node server just because the project was built with modern frontend tooling.

If the app is a static SPA, treat it like a static SPA.


Why Bun made sense for the build stage

The build stage used Bun rather than a heavier Node-based toolchain.

That worked well because the problem was not runtime JavaScript semantics. The problem was just producing the built frontend assets cleanly and quickly.

A Bun build stage is a good fit when:

  • the project already builds correctly under Bun,
  • you want a leaner/faster build container,
  • and the runtime is static anyway.

But Bun is not the core lesson here.

You could swap in npm or pnpm and the routing truth would stay the same.

The important part is still the nginx fallback.


The mistake I would avoid

A common bad deploy path looks like this:

  1. build the SPA,
  2. copy dist/ somewhere public,
  3. assume the hosting layer will “just know” how to handle frontend routes.

It will not.

CapRover can deploy the container successfully while the app is still broken for any real deep link.

That is why a homepage-only smoke test is not enough.


The smoke test that actually matters

After deploying a Vite SPA, check at least these cases:

  • /
  • one real nested route
  • a hard refresh on that nested route
  • direct browser open on that nested route

If those are not part of your validation, you can ship a broken SPA while still thinking the deployment succeeded.


When this pattern is the right default

I would use this exact deployment shape by default when all of these are true:

  • the app is a client-rendered SPA,
  • the production artifact is static files,
  • you do not need SSR,
  • and your routing is handled in the browser.

In that case, the default answer should usually be:

  • build statically,
  • serve with nginx,
  • add the history fallback,
  • keep the runtime simple.

When not to use this pattern

Do not force this if the app actually needs:

  • server rendering,
  • API routes inside the same runtime,
  • authenticated edge/server logic at request time,
  • or framework-specific server behavior.

This pattern is for a real SPA, not for every JavaScript app with a dist/ folder.


The practical takeaway

Deploying a Vite SPA on CapRover should not be complicated.

If the app is truly static, the winning pattern is simple:

  • build with the tool that works,
  • serve with nginx,
  • and make try_files $uri $uri/ /index.html; non-negotiable.

Most SPA 404 incidents on self-hosted setups are not mysterious. They are just missing history fallbacks.

Fix that once, bake it into your default container pattern, and move on.