Vue – Building and Deploying to Production

December 26, 20255 min readUpdated 8/24/2026

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 output
dist/
  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 only

The 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;
}

Netlifypublic/_redirects:

/*    /index.html   200

Vercelvercel.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 --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf

The 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.