Containers
A multi-stage Dockerfile builds with Node and serves with nginx, so the shipped image holds the dist folder and no toolchain.
COPY --from=build /app/dist /usr/share/nginx/htmlA container image for a Vue application holds two things: the files npm run build produced, and a web server to hand them out. The build tools do not need to ship. A multi-stage Dockerfile is how you keep them out.
Two stages
The first stage starts from a Node image, installs dependencies with npm ci, and runs npm run build. That leaves /app/dist inside that stage. The second stage starts from nginx:alpine and copies only that folder to /usr/share/nginx/html, the document root the official nginx image serves. Nothing else crosses over. No node_modules, no source, no lockfile. A small Vue build lands in the low tens of megabytes, and almost all of that is nginx.
The server config is part of the image
The nginx default site serves files and returns 404 for anything missing, so /invoices/42 fails inside a container exactly as it fails on a static host. Copy your own config to /etc/nginx/conf.d/default.conf, with the single-page-application fallback in it. The image is then self-contained: whoever runs it does not have to know it is an SPA.
What trips people up
Copy package.json and package-lock.json on their own line before the rest of the source. Docker then reuses the cached npm ci layer whenever only application code changed, which is most of the time.
And write a .dockerignore. Without one, COPY . . drags your local node_modules into the build stage, where it shadows the install and may hold native binaries compiled for your laptop's CPU rather than the image's.
Example
<script setup lang="ts">
// Both values are substituted into the bundle at build time, which is
// correct here: a version identifies one build, not one environment.
const version: string = import.meta.env.VITE_APP_VERSION ?? 'dev'
const mode: string = import.meta.env.MODE
</script>
<template>
<p class="build-badge">
build <strong>{{ version }}</strong>
<em v-if="mode !== 'production'">({{ mode }})</em>
</p>
</template>
<style scoped>
.build-badge {
font: 0.75rem/1.4 ui-monospace, monospace;
color: #6b7280;
}
.build-badge em {
margin-left: 0.25rem;
font-style: normal;
color: #b45309;
}
</style>When to use it
- Shipping a Vue frontend as an image a platform team can run without knowing it is a Vue app.
- Building the app in CI once and promoting the same image through staging and production.
- Serving the SPA behind an existing reverse proxy that expects every service to be a container on a port.
More examples
Multi-stage Dockerfile
Build it with `docker build --build-arg VITE_APP_VERSION=1.4.2 -t web:1.4.2 .`. The `build` stage is discarded once its `dist` has been copied, so the toolchain never reaches the registry. Note that `VITE_APP_VERSION` has to be a build argument rather than a container environment variable, because Vite substitutes `import.meta.env` values when it bundles; a `docker run -e` would arrive far too late.
# Dockerfile
FROM node:22-alpine AS build
WORKDIR /app
# Dependencies first, so this layer is cached across source-only changes.
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
# A build argument, not a runtime variable: Vite bakes it into the bundle.
ARG VITE_APP_VERSION=dev
ENV VITE_APP_VERSION=$VITE_APP_VERSION
RUN npm run build
FROM nginx:alpine AS serve
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80docker/nginx.conf
The config that makes the image usable on its own: compression, the two cache rules, and the SPA fallback. `try_files $uri =404` inside the assets block is deliberate -- a missing hashed chunk should fail as 404, not quietly return HTML, because HTML served as JavaScript produces a confusing syntax error instead of a clear one.
# docker/nginx.conf
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
# Hashed filenames change on every build, so they can be cached hard.
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
try_files $uri =404;
}
# The shell names the current chunks, so it must be revalidated.
location = /index.html {
add_header Cache-Control "no-cache" always;
}
location / {
try_files $uri $uri/ /index.html;
}
}.dockerignore
Every path here is one that would otherwise be copied into the build stage by `COPY . .`. `node_modules` and `dist` are the two that cause real bugs: the first shadows `npm ci`, the second can leave a stale build in the image if the fresh build fails in a way the Dockerfile does not catch. The `.env` entries keep local secrets out of an image layer, where they would stay readable even after a later `RUN rm`.
# .dockerignore
node_modules
dist
.git
.env
.env.*
*.log
coverage
playwright-report
.vite
Discussion