Deploying a Vite + React SPA — The Fallback Rule and the Env Var Trap
A Vite build doesn't produce an app you can run — it produces a dist/ folder of static files, so the container needs a web server to hand them out. Once that's in place, two things go wrong in exactly the same way for everyone. Navigating within the app works and refreshing the page 404s, because your server looks for a file at /dashboard that doesn't exist. And VITE_ environment variables you set on the app have no effect, because Vite replaces them during the build and the build never sees them. Both are quick fixes, and both are worth knowing before you spend an evening on them.
Your build output needs a server
npm run build gives you HTML, JavaScript, CSS, and assets. Nothing in that folder listens on a port. So the container's job is to serve a directory, and the smallest sensible way to do that is a static web server — nginx is the usual pick, and nginx:alpine keeps the image small, which is deploy downtime.
That's the whole runtime. There's no Node process in production for a pure SPA, which is why these images are among the lightest things you can host.
The fallback rule
Here's the bug everyone hits. Your router handles /dashboard in the browser, so clicking a link works. Then you refresh, and the browser asks the server for /dashboard — a path with no file behind it — and gets a 404.
The fix is one directive: serve index.html for anything that isn't a real file, and let the router take it from there.
server {
listen 8080;
root /usr/share/nginx/html;
location / {
try_files $uri $uri/ /index.html;
}
}
try_files checks for the file, then the directory, then falls back to index.html. Deep links and refreshes now work.
Two things worth adding while you're in there:
- Don't cache
index.html. Vite fingerprints asset filenames, so those can be cached hard — but ifindex.htmlis cached, browsers keep loading the old bundle references after a deploy. - Listen on the port you'll bind. Set it explicitly in the config and bind that container port as HTTPS in the panel. Nothing probes your app, so a mismatch here deploys "successfully" and never answers.
VITE_ variables are frozen at build time
This is the same trap as NEXT_PUBLIC_ in an AI-built Next.js app, and it catches people harder in an SPA because there's no server side to fall back on.
Vite's documentation is direct: variables are "statically replaced at build time to make tree-shaking effective", and "variables prefixed with VITE_ will be exposed in client-side source code after Vite bundling" (Vite docs).
Now add the platform fact: the image build receives neither your app's environment variables nor build arguments. So if the platform builds your Dockerfile, VITE_API_URL is not defined when vite build runs. It's replaced with undefined, your app calls undefined/api/..., and the variable sits in the panel looking perfectly correct.
There are two honest ways out.
Build in CI and push the image. Set the variables in your workflow, where they are available at build time, then push the built image. Simple, and it's the right answer when the values differ per environment and rarely change.
Or make configuration a runtime fetch. This is the pattern that actually keeps one image portable across environments. Write a small config.json into the served directory when the container starts, generated from environment variables, and have the app fetch it before it renders:
#!/bin/sh
cat > /usr/share/nginx/html/config.json <<EOF
{ "apiUrl": "${API_URL}" }
EOF
exec nginx -g 'daemon off;'
Point your image's ENTRYPOINT at that script. Now API_URL is an ordinary environment variable you can change in the panel, and a restart picks it up — no rebuild.
One caution specific to this platform: your image's ENTRYPOINT is preserved and the app's run command is applied as the command, so leave the app's run command empty if your entrypoint script already starts nginx. Setting both makes them combine, and the failure looks like a broken image.
Never put a secret behind VITE_
Worth stating on its own, because AI-generated projects do this constantly. Vite spells out the consequence: "VITE_* variables should not contain sensitive information such as API keys. The values of these variables are bundled into your source code at build time."
Anything with that prefix ships to the browser. VITE_OPENAI_API_KEY is not a configuration mistake, it's a published key — rotate it and move the call behind a small backend. That backend can be a second app here, reading the real key from its own environment where it stays on the server.
A Dockerfile that does all of it
FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
Dependencies installed before source is copied, so a code change reuses the install layer. The final image is a web server plus your dist/ — usually tens of megabytes, which makes redeploys quick.
Keep an eye on the build stage: it runs on the platform with around 0.75 GB of RAM unless you build in CI, and a large SPA with heavy dependencies can reach that. What to do if it doesn't fit.
Does this belong in a container at all?
Fair question, and the answer isn't always yes. A purely static site with no backend is well served by any static host.
A container earns its place when:
- You also run an API, and you'd rather have the frontend and backend in one place with one bill.
- You need that backend to hold secrets your SPA can't — which, per the section above, is most apps that talk to a paid API.
- You want one artifact per environment, configured at runtime rather than rebuilt.
If none of those apply, a static host is simpler. If any do, the SPA is the cheap half of a two-app setup, and the number of apps doesn't change your bill — only the resources they use.
FAQ
Why does refreshing a page in my SPA give a 404?
The server is looking for a file at that path. Add a fallback so unknown paths serve index.html and let your router handle them.
Why is my VITE_ variable undefined in production?
Vite replaces those values at build time, and the platform's build doesn't receive your app's environment variables. Build in CI, or fetch config at runtime.
Can I change a VITE_ value without rebuilding?
No. It's compiled into the bundle. Use a runtime config.json written by your entrypoint if you need it to vary.
Is VITE_ safe for API keys?
No. Those values are bundled into client-side code. Put the key in a backend and call that instead.
What server should I use in the container?
nginx on Alpine is a good default — small, fast, and one directive away from correct SPA routing.
Do I need Node in the production image?
No. Build with Node in one stage, then serve the static output from a web server image.
Wrapping up
An SPA is one of the easiest things to host and one of the easiest to get subtly wrong, because both classic mistakes produce an app that looks deployed. Add the index.html fallback so refreshes work, decide deliberately whether your configuration is baked at build time or fetched at runtime, and keep anything secret behind a backend rather than a VITE_ prefix. After that it's a few megabytes of static files and a web server, which is about as low-maintenance as hosting gets.