A Vue application builds to static files. That is worth saying plainly, because it decides everything else: there is no Node server to run, no process to keep alive, and hosting is a bucket behind a CDN for pennies.
The build
npm run build # -> dist/
npm run preview # serve dist/ locally, on the real built outputdist/
index.html
assets/
index-DYUHRy06.js 138.03 kB │ gzip: 46.09 kB
DashboardView-BMckYpIh.js 197.49 kB │ gzip: 69.12 kB
ExploreView-BO33kbQP.js 3.79 kB │ gzip: 1.88 kB
index-BqL3nZ8p.css ...Every filename carries a content hash. That is what makes the caching strategy below safe: a file's name changes whenever its content does, so a cached copy can never be stale.
Run npm run preview before you deploy. The dev server and the production
build differ in ways that matter — environment variables are baked in, minification can expose a
dependency on function names, and dynamic imports become real network requests. Most "it worked locally"
deployment failures are visible in preview.
Environment variables
const BASE = import.meta.env.VITE_API_BASE ?? "http://localhost:8087";Vite exposes variables from .env files on import.meta.env, and
only those prefixed VITE_. Files are read in this order, later overriding
earlier:
.env always
.env.production only for `vite build`
.env.local always, and gitignored -- your machine onlyThe prefix is a safety rail, and the rule behind it is absolute: these values are compiled
into the JavaScript bundle. Anyone can read them in devtools. A VITE_ variable is
public — an API base URL, a publishable Stripe key, a feature flag. A database password or a secret key
never gets one, and if it needs one it belongs on a server instead.
They are substituted at build time, not run time. One build cannot be promoted from
staging to production with a different API URL — it is baked in. If you need that, fetch a small
config.json at startup instead.
The rewrite rule, and why routes 404 on refresh
The failure everyone hits once. The app works, you navigate to /explore, you refresh, and
you get a 404.
The reason: navigating within the app is history.pushState — no request is made. A refresh
does make a request, for /explore, and there is no such file. Only
index.html exists.
The fix is to serve index.html for anything that is not a real file, and let the router
sort it out client-side.
nginx — this is the whole of it:
location / {
try_files $uri $uri/ /index.html;
}Netlify — public/_redirects:
/* /index.html 200Vercel — vercel.json:
{ "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] }AWS S3 + CloudFront — set the error document to index.html, or add a
CloudFront Function that rewrites unmatched paths. Note the S3 static-hosting version returns a 404
status with the HTML body, which is wrong for SEO; a CloudFront custom error response mapping 404 to
/index.html with a 200 is the correct form.
Apache — .htaccess with FallbackResource /index.html.
The status code matters. The rewrite must return 200, not a redirect — otherwise the URL changes and deep links stop working.
Caching
Hashed filenames make this simple, and getting it backwards is the second most common deployment problem:
# Hashed assets: the name changes when the content does, so cache forever.
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
# index.html is NOT hashed. It is the file that points at all the others,
# so a cached copy pins users to the previous deploy.
location = /index.html {
add_header Cache-Control "no-cache";
}no-cache does not mean "do not cache" — it means "revalidate before using", which is
exactly right for a small file that must always be current.
Get this the wrong way round and users keep loading an old index.html that references
chunks you have deleted, which surfaces as a blank page and a MODULE_NOT_FOUND in the
console.
Serving it from a container
Build in one stage, serve from a tiny image in the next:
# ---- build ----
FROM node:22-alpine AS build
WORKDIR /app
# Copy manifests first so `npm ci` is cached unless dependencies changed.
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# ---- serve ----
FROM nginx:alpine
COPY /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.confThe final image contains no Node, no node_modules and no source — just nginx and the
static files.
A deploy workflow
name: deploy
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
# `npm ci` respects the lockfile exactly. `npm install` may not.
- run: npm ci
- run: npm run test:unit:run
- run: npm run build
env:
VITE_API_BASE: ${{ secrets.API_BASE }}
- run: aws s3 sync dist/ s3://my-bucket --delete
# Without this, the CDN keeps serving the previous index.html.
- run: aws cloudfront create-invalidation --distribution-id ${{ secrets.DIST_ID }} --paths "/*"Two lines there are the ones people leave out. The invalidation — a CDN caches your
old index.html and will keep serving it for its TTL otherwise. And running the
tests before the build, so a failing suite stops the deploy rather than being noticed
afterwards.
Before you call it done
Check the console on the deployed site. Not locally — deployed. Mixed content, CORS and CSP problems only exist in production.
Refresh on a deep route. This is the rewrite-rule test, and it is the one thing that is fine in development and broken in production.
Set the page title per route — lesson 20's afterEach. A site where every
tab says "Vite App" looks unfinished.
Consider what a crawler sees. A Vue SPA serves an empty <div
id="app"> and fills it with JavaScript. Google executes JavaScript; many other crawlers and
most link-preview bots do not. If search or social previews matter, you want Nuxt — server-side
rendering or static generation — rather than a plain SPA. That is a decision to make before you build,
not after.
Next: Interview Questions.