How to deploy a Vite React SPA without a Dockerfile
A Vite build is not an application in the way a Next.js or Express build is. It is a folder — usually dist — containing an index.html, a few hashed JavaScript and CSS files, and your assets. Nothing in it runs on a server. Once you have internalised that, deployment becomes almost trivial, and the three things that reliably go wrong become obvious.
This walks through deploying one properly: the build, the routing fallback, environment variables, and the caching headers. I build PandaStack so the platform commands are ours, but the first three sections apply wherever you host it.
Step 1: confirm what your build actually produces
Before deploying anything, run the build locally and look at the output. This takes thirty seconds and prevents the most common category of deployment confusion.
npm ci
npm run build
# Look at what came out. For a default Vite config this is dist/.
ls -la dist/
# index.html
# assets/index-a1b2c3d4.js
# assets/index-e5f6g7h8.css
# vite.svg
# Serve it exactly as production will, and click around:
npx --yes serve -s dist -l 4173That -s flag is not decoration. It is single-page-application mode, and it is the thing that makes client-side routing work. Serve the same folder without it and every URL except the root returns a 404.
Step 2: deploy from the repo
Vite is a recognised framework, so the install command, the build command, and the static serve are inferred from your package.json. You point at the repo and nothing else is required.
pandastack app create \
--name storefront \
--git-url https://github.com/acme/storefront \
--git-branch main
# Watch the build. Failures show up here, not in a support ticket.
pandastack app deploy <app-id> --followIf your build needs a non-default output directory or a different package manager, override just that piece — everything you do not specify stays inferred.
pandastack app create \
--name storefront \
--git-url https://github.com/acme/storefront \
--install-command "pnpm install --frozen-lockfile" \
--build-command "pnpm build" \
--start-command "npx --yes serve -s build -l \$PORT"
# For a monorepo, point at the package rather than the repo root:
# --root-directory apps/storefrontStep 3: the deep-link 404, and why it happens
This is the single most reported SPA deployment problem, and it is not a bug in your code. Your app loads at the root, you navigate to /orders/1234, everything works. Then you press refresh and get a 404.
The reason is that React Router never told the server anything. Client-side navigation rewrites the URL in the browser with the History API; no request is made. When you refresh, the browser asks the server for /orders/1234 for the first time, and there is no file at that path — because there never was one.
The fix is a fallback: any path that does not match a real file should return index.html, and let the router sort it out. Every static host has a way to express this.
serve -> the -s / --single flag (this is what -s means)
nginx -> try_files $uri $uri/ /index.html;
Caddy -> try_files {path} /index.html
Apache -> a RewriteRule to index.html for non-existent files
S3 + CloudFront -> error document = index.html, or a 404 -> 200 rewrite
If you use HashRouter instead, none of this applies, because the part
after the # is never sent to the server. That is a legitimate choice
and it costs you clean URLs.Step 4: environment variables are baked in, not read at runtime
This one causes real security incidents, so it is worth being blunt. Vite replaces import.meta.env references at build time by substituting them into the JavaScript bundle. Only variables prefixed VITE_ are included, and once included they are in a file that anyone can download and read.
- There is no such thing as a secret in a Vite build. Not obfuscated, not minified beyond recognition — a string in a public file. API keys, database URLs, and service tokens do not belong in one.
- Changing a variable requires a rebuild. Setting it on the platform and restarting does nothing, because the value is already compiled into the bundle. This is a common source of confused debugging.
- The build environment is where the variable must exist. If your platform distinguishes build-time from runtime environment variables, VITE_ ones must be set for the build.
# Fine to bake in — these are public facts about your deployment.
pandastack app create \
--name storefront \
--git-url https://github.com/acme/storefront \
--env VITE_API_URL=https://api.acme.com,VITE_SENTRY_DSN=https://...
# NOT fine. This ends up in a JavaScript file served to every visitor:
# --env VITE_STRIPE_SECRET_KEY=sk_live_...
# Anything secret goes in a backend the SPA calls, never in the bundle.Step 5: caching, which Vite has already solved for you
Vite hashes asset filenames — index-a1b2c3d4.js changes its name whenever its contents change. That gives you a clean two-tier caching rule, and getting it right is the difference between an instant repeat visit and a user stuck on a stale build.
- Hashed assets in assets/ can be cached forever. A new build produces new filenames, so a cached old file is never wrong, just unused. Cache-Control: public, max-age=31536000, immutable.
- index.html must never be cached hard. It is the file that points at the current hashed assets, so a stale copy points at a build that no longer exists. Cache-Control: no-cache, and let the CDN revalidate.
- Everything else — favicons, robots.txt, manifest files — sits in between. A few minutes to an hour is fine.
The pre-launch checklist
- Deep link to a nested route, then refresh. It must load, not 404.
- Open the network tab and confirm the hashed assets are being served with a long cache lifetime and index.html is not.
- Search your built bundle for anything secret: grep -r 'sk_live' dist/ and the equivalent for your own key prefixes. Do it before the first deploy, not after.
- Deploy twice in a row and hard-refresh in between. If the second deploy leaves users on stale assets, your index.html caching is wrong.
- Check the build size. Vite will warn about chunks over 500 KB; that warning is usually pointing at a real problem for users on slow connections.
The short version
A Vite SPA is a folder of files, and deploying it well means getting four things right: build to the directory your host expects, serve with a single-page fallback so deep links work, treat every VITE_ variable as public and rebuild when it changes, and cache hashed assets hard while never caching index.html.
Get those four right and there is genuinely nothing else to it. No server, no container, no runtime to keep patched — which is also why a static SPA is the cheapest thing you will ever host.
Frequently asked questions
Why does my Vite app 404 when I refresh on a nested route?
Because that request is the first time the server has heard about the path. Client-side routers change the URL using the browser's History API without making a network request, so navigating to /orders/1234 inside the app works fine. Refreshing asks the server directly for /orders/1234, and there is no file at that path — there never was, since your build only produced index.html and some hashed assets. The fix is a fallback rule telling the server to return index.html for any path that does not match a real file, after which the router reads the URL and renders the right view. Every static host spells this differently: it is the -s flag for serve, try_files for nginx and Caddy, and an error-document or rewrite rule on S3 with CloudFront.
Are Vite environment variables secret?
No, and treating them as if they were is a genuine source of leaked credentials. Vite substitutes VITE_-prefixed variables into your JavaScript at build time, which means the value ends up as a literal string inside a file that is downloaded by every visitor and readable with any text editor. Minification does not hide it; anyone can search the bundle for it in seconds. Only public configuration belongs there — your API's base URL, a public analytics key, a feature flag. Anything with authority over data or money must live in a backend service that the SPA calls, with the SPA authenticating as the user. The second, less dangerous consequence of build-time substitution is that changing a value requires a rebuild, since restarting with a new environment variable cannot alter a string already compiled into the bundle.
Do I need a Dockerfile to deploy a Vite app?
No, and for a purely static build you do not need a container at all. Most Git-driven platforms detect Vite from your package.json, run the install and build commands, and serve the output directory directly. A Dockerfile becomes useful when your build needs something unusual — a system library for an image-processing step, a specific toolchain version your platform's runtime detection cannot express, or a multi-stage build you already maintain for other reasons. For the ordinary case it is pure overhead: you would be writing and maintaining a container definition whose only job is to produce a folder of files that a CDN could have served for free.
Should I serve my SPA from a CDN or from a server?
From a CDN or object storage, essentially always. The output of a Vite build is immutable static files, so there is nothing for a server process to compute per request — running one only adds latency, cost, and a process to keep patched. Serving from object storage behind a CDN means files are cached near your users, there is no cold start because there is nothing to start, and an idle application costs nothing. The one case for a server in front of a SPA is when you need per-request logic before the HTML is returned: server-side rendering, authentication checks that must not happen in the browser, or request-time header injection. If you find yourself wanting those, the honest answer is usually that you have outgrown a pure SPA and want a framework with a server component.
How do I handle API calls from a Vite SPA in production?
Set your API's base URL as a VITE_-prefixed environment variable at build time and call it directly, with CORS configured on the API to allow your app's origin. The development proxy in vite.config only exists for the dev server and does nothing in a production build, which surprises people whose local setup used a relative /api path that suddenly 404s once deployed. Two things are worth getting right early: never put an API secret in the bundle, so the browser must authenticate as the user rather than as your application; and if you want same-origin requests to avoid CORS entirely, put the API and the SPA behind the same hostname at the CDN layer with a path-based route, rather than trying to solve it inside the SPA.
Keep reading
49ms p50 cold start. Fork, snapshot, and scale to zero.