Shipping It
The production build, the container image, the SPA rewrite and cache headers that do not strand a browser on the old index.
vite build --mode production writes hashed assets and an index.html into dist/npm run build runs vite build, which writes a dist folder: an index.html and, under dist/assets, JavaScript and CSS files whose names contain a content hash. Vite 8 bundles with Rolldown, minifies with Oxc by default, and targets baseline-widely-available browsers. Options that used to sit under build.rollupOptions now belong under build.rolldownOptions. Vite 8 needs Node 20.19 or later, or 22.12 or later.
The two kinds of file
The hashed assets can be cached forever, because a change to their contents changes their name. index.html cannot be cached at all, because it is the only file that names the current hashes. That is the whole of the cache policy, and getting it backwards is the most common deploy failure in a single-page application.
The SPA rewrite
Every route is served by the same index.html, so the web server must answer an unknown path with that file instead of 404. In nginx that is try_files $uri /index.html. Without it, /tasks/7 works when the user clicks to it and 404s when they reload.
The image and the rollback
A two-stage container build keeps Node out of the runtime image: stage one installs and builds, stage two copies dist into nginx. Because the image contains the built files, rolling back is deploying the previous tag. Test that once, before you need it.
Example
<script setup lang="ts">
import { computed } from 'vue'
const version = computed(() => import.meta.env.VITE_COMMIT_SHA ?? 'dev')
const isProduction = import.meta.env.PROD
</script>
<template>
<footer>
<small data-testid="build-info">build {{ version }}</small>
<small v-if="!isProduction">development build</small>
</footer>
</template>
<style scoped>
footer {
display: flex;
gap: 0.5rem;
padding: 0.5rem 1rem;
opacity: 0.7;
}
</style>When to use it
- Making a reload of /tasks/7 work instead of returning 404.
- Caching hashed bundles for a year without ever serving a stale index.html.
- Shipping an image that contains the built files and no build toolchain.
- Rolling back by redeploying the previous image tag, and having tested that path.
More examples
The build configuration
The alias is what makes every @/ import in the project resolve. sourcemap: 'hidden' emits source maps for the error tracker without referencing them from the shipped bundles, so browsers do not fetch them. Lowering chunkSizeWarningLimit turns an accidental large chunk into a build warning you will actually notice.
// vite.config.ts
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
build: {
sourcemap: 'hidden',
chunkSizeWarningLimit: 400,
},
})The container image
Stage one does the install and the build; stage two is nginx with only dist and a config file in it, so the shipped image has no Node, no node_modules and no source. Copying package files before the rest of the source lets the dependency layer be reused when only application code changed.
# Dockerfile
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ARG VITE_COMMIT_SHA
ENV VITE_COMMIT_SHA=$VITE_COMMIT_SHA
RUN npm run build
FROM nginx:1.27-alpine
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80The rewrite and the cache headers
Two location blocks and nothing else. Hashed files under /assets/ are immutable for a year and 404 if they are missing, which is the signal you want rather than a silent fallback to HTML. Everything else falls through to index.html with no-store, so a returning browser always learns the current hashes.
# docker/nginx.conf
server {
listen 80;
root /usr/share/nginx/html;
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
location / {
add_header Cache-Control "no-store";
try_files $uri /index.html;
}
}
Discussion