Skip to content
TutorialProjects & tooling

Environment variables and deployment: dev, preview, production

Your app needs an API URL, maybe a key, and three environments to run in. Where you put each value decides who can read it: VITE_ variables and Nuxt's public config reach the browser, server-only config stays on the server.

vuejs.school team11 Nov 20257 min readVite 7.2 · Nuxt 4.2

The API address your app calls, its publishable key and any secret key that only a server should see all change between your laptop, a preview deployment and production. Vite and Nuxt each have their own way to supply these values, and one rule tells you where each belongs: anything the browser receives is public. By the end you can put each value in the right place, keep secrets on the server, and deploy a Vite or Nuxt build to any host.

Vite env files and the VITE_ prefix

A Vue project built with Vite reads environment variables from files in the project root. Which files load depends on the mode, a name for the environment: development when you run vite, production when you run vite build.

  • .env is loaded in every mode.
  • .env.local is loaded in every mode and is meant to stay out of git.
  • .env.[mode] and .env.[mode].local are loaded only in that mode.

A more specific file wins over a general one, and a variable already set in the shell wins over all of them. Vite reads the files when it starts, and the dev server restarts itself when you save one of them.

Only variables whose name starts with VITE_ reach your code. Vite replaces import.meta.env.VITE_API_URL with the string from the file at build time, and leaves every other variable out of the bundle. That prefix is a declaration: "this value is allowed in the browser".

Here .env holds two public values, and src/api/client.ts reads the API address:

.env
VITE_API_URL=http://localhost:8787
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_123
src/api/client.ts
export const apiUrl = import.meta.env.VITE_API_URL

export async function getProducts() {
  const response = await fetch(`${apiUrl}/products`)
  return response.json()
}

Vite also provides import.meta.env.MODE, BASE_URL, DEV, PROD and SSR without any file; the last three are booleans. Values from your env files are always strings, so a flag written as VITE_FEATURE_X=false is the truthy string 'false' until you compare it: import.meta.env.VITE_FEATURE_X === 'true'.

Keep secret keys out of VITE_ variables

Here is the mistake the prefix exists to prevent:

.envBreaks
VITE_STRIPE_SECRET_KEY=sk_live_abc

Read it anywhere in your code and it works in development, it builds, and the secret key is now in dist/assets/index-*.js, readable by anyone who opens the network tab. Vite's documentation is plain about it: VITE_* variables should not hold sensitive data.

The fix is a question, not a rename: who needs the value? The browser needs the publishable key; the secret key belongs to a server. If the project has no server, a serverless function on the host is one.

.envWorks
VITE_STRIPE_PUBLISHABLE_KEY=pk_live_123

Keep *.local in .gitignore (create-vue adds it), and commit .env only when it contains nothing you would mind seeing on a public page.

Type import.meta.env and catch typos

Without a declaration, every import.meta.env.VITE_* property is typed any, and a typo in a variable name becomes undefined at runtime. Declare the ones you use in the env.d.ts file that create-vue puts in the project root (or any .d.ts file your tsconfig includes):

env.d.ts
/// <reference types="vite/client" />

interface ViteTypeOptions {
  strictImportMetaEnv: unknown
}

interface ImportMetaEnv {
  readonly VITE_API_URL: string
  readonly VITE_STRIPE_PUBLISHABLE_KEY: string
}

interface ImportMeta {
  readonly env: ImportMetaEnv
}

The ViteTypeOptions lines are what catch typos. Without them, Vite's own types accept any other name as any, so import.meta.env.VITE_API_URl still compiles. With them it is a type error, and a create-vue project's npm run build, which runs vue-tsc, fails on it.

Keep this file free of import statements; an import turns it into a module and the augmentation stops applying.

Modes: development, staging, production

A mode can have any name, so a third environment is one flag away: vite build --mode staging reads .env.staging and still produces an optimised build.

That works because the mode and NODE_ENV are separate settings. The mode picks the env files; NODE_ENV picks a development or a production bundle. vite build makes a production bundle in every mode, --mode development included, unless you set NODE_ENV=development yourself, in the command or in an env file.

After a build, vite preview serves dist on port 4173 so you can click through the real bundle. It is a local check, not a server to deploy (the documentation says so), and not the preview deployments of the last section.

Nuxt: runtimeConfig instead of VITE_

Nuxt has a server, which changes two things: a value can stay on the server, and configuration is read when the server runs instead of being baked into the bundle. Both work through runtimeConfig in nuxt.config.ts:

nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    stripeSecretKey: '',
    public: {
      apiBase: '/api',
    },
  },
})

Keys outside public exist only on the server; keys inside public are sent to the browser with the page. The values here are defaults, replaced by environment variables named after the keys:

  • NUXT_STRIPE_SECRET_KEY overrides stripeSecretKey.
  • NUXT_PUBLIC_API_BASE overrides public.apiBase.

Only keys declared in the config can be overridden, and an override is read when the server starts, so one build serves every environment. Values that look like numbers or booleans are parsed: 123456 arrives as a number unless the quotes are part of the value (NUXT_PIN='"123456"' in .env).

Read the config with useRuntimeConfig(). In a server route, pass the event. This route creates a Stripe Checkout session for a price the browser sends:

server/api/checkout.post.ts
export default defineEventHandler(async (event) => {
  const { stripeSecretKey } = useRuntimeConfig(event)
  const { priceId } = await readBody<{ priceId: string }>(event)

  return $fetch('https://api.stripe.com/v1/checkout/sessions', {
    method: 'POST',
    headers: { Authorization: `Bearer ${stripeSecretKey}` },
    body: new URLSearchParams({
      mode: 'payment',
      'line_items[0][price]': priceId,
      'line_items[0][quantity]': '1',
      success_url: 'https://example.com/thanks',
    }),
  })
})

The body is a URLSearchParams because Stripe's API takes form-encoded bodies; $fetch sends a plain object as JSON.

In a component, useRuntimeConfig().public.apiBase works on the server and in the browser. The private keys are there only during server rendering. Never render one into the template or put it in useState: both end up in the HTML.

The Nuxt CLI reads your .env file in development and for nuxt build and nuxt generate, so NUXT_STRIPE_SECRET_KEY=sk_test_123 in .env works on your machine. The built server does not read .env: on a host, set the variables in its environment settings.

Deploying a static build to any host

A Vite app is static files: vite build writes them to dist/, which any host that serves files can take. One thing must be configured on that host: with Vue Router's HTML5 history mode, a direct visit to /products/42 asks the server for a file that does not exist. The server must answer every unknown path with index.html so the router can take over. On nginx that is one line, and most static hosts have an equivalent rewrite rule:

location / {
  try_files $uri $uri/ /index.html;
}

Once the server never returns a 404, your app has to: add a catch-all route, { path: '/:pathMatch(.*)', component: NotFound }, so a wrong URL shows a not-found page instead of an empty one.

A Nuxt site can be static too. nuxt generate starts at /, follows the links on each page and renders every page it reaches to HTML at build time. It writes the result to .output/public together with 200.html and 404.html fallback pages for routes it did not render. Pages that need server code at request time cannot be generated. Everything else (a blog, a docs site, a marketing site) works this way and needs only file hosting.

A static Nuxt site has no server to read runtime config later: its NUXT_PUBLIC_* values are fixed when nuxt generate runs, so set them in the build environment and build again after changing one.

Nuxt on a Node or serverless host

When pages need a server, nuxt build produces .output/, a self-contained server plus the client assets. Run it with Node. Set NODE_ENV=production so Vue Router stops warning about unmatched routes, and PORT or HOST to change the defaults of 3000 and 0.0.0.0:

npx nuxt build
NODE_ENV=production node .output/server/index.mjs

The server inside .output is built by Nitro, and Nitro has a preset for each kind of host: a plain Node server, a cluster of Node processes, and the serverless and edge platforms. Several hosts, the ones Nitro lists as zero-config providers, are detected from their environment variables at build time, so a build there already targets the platform. Anywhere else the default is the plain Node server.

To choose explicitly, set nitro.preset in nuxt.config.ts or NITRO_PRESET=node-server in the build environment. The NUXT_ overrides from the runtimeConfig section go in the host's environment settings, one set per environment.

Previews per branch and a build step in CI

A preview deployment is a build of a branch at its own URL, so a reviewer can click through a pull request before it merges. Many hosts offer it through their git integration, and the part you own is the environment: give previews their own NUXT_PUBLIC_API_BASE or VITE_API_URL pointing at a staging API, and never the production secret key.

Whatever the host, run your tests and the build in CI on every push and pull request. A minimal workflow for GitHub Actions, which any CI system can mirror:

.github/workflows/ci.yml
name: CI
on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm test
      - run: npm run build
        env:
          VITE_API_URL: ${{ vars.VITE_API_URL }}
  • node-version: 24: Node 24 is the current long-term support release.
  • npm ci: installs exactly what the lockfile says.
  • npm test: runs the test script in package.json; in a create-vue project with Vitest, write npm run test:unit.

Public values come from the CI's variables (vars), secrets from its secrets store. A VITE_ value may be a variable because it is public anyway. A NUXT_ server key belongs in the secrets store, and for a Nuxt server build it is only needed where the server runs, not where it is built.

Check it stuck3 questions from the Environment variables, builds and deployment quiz.
Practice now
Was this helpful?

Keep reading