Environment Variables and Secrets: Build Time vs Run Time

Why a VITE_ variable is public the moment you deploy, why changing it needs a rebuild, and where a real secret belongs instead.

Environment Variables and Secrets: Build Time vs Run Time
On this pageShow
  1. What you will build
  2. Build time or run time?
  3. Before you start
  4. Step 1: Create a Vite site that shows its settings
  5. Step 2: Set the values for your laptop
  6. Step 3: Push and deploy
  7. Step 4: Add the variables in the console
  8. Step 5: Change a value
  9. Step 6: Prove what is public
  10. Where secrets belong
  11. Troubleshooting

On Light Cloud, environment variables reach a static site at build time and a container at run time. A Vite site reads VITE_* variables while it is built and writes their values into its JavaScript, so they are public and a new value needs a rebuild. A server app, such as Express, FastAPI or Django, reads its variables when it starts, so they stay on the server. That is why secrets belong in a server app's variables, never in a VITE_ name.

This guide shows all of it on a live site, including a check that a secret did not leak.

What you will build

A small Vite site that prints the settings baked into its build:

The live site with the banner Autumn sale: 20% off until Sunday and a table: VITE_API_URL and VITE_BANNER with their values, PAYMENT_SECRET undefined

Live demo: main-tutorial-env-vars-examples.light-cloud.io. Source code: github.com/light-cloud-com/tutorial-env-vars.

Build time or run time?

Static site (React, Vue, Svelte, plain Vite)Server app (Express, FastAPI, Django)
When variables are readonce, during the buildevery time the app starts
Who can see the valuesevery visitor, in the JavaScriptonly the server
Changing a valuerebuilds the siterestarts the app
Good forAPI addresses, feature flags, public keyspasswords, API keys, database URLs

Before you start

Step 1: Create a Vite site that shows its settings

terminal
$ npm create vite@latest tutorial-env-vars -- --template vanilla --no-interactive
$ cd tutorial-env-vars
$ npm install
found 0 vulnerabilities

Replace the starter's src/main.js:

src/main.js
javascript
import './style.css'

// Vite replaces import.meta.env.VITE_* with the values it finds when the
// site is BUILT. Only names starting with VITE_ are included; anything
// else stays undefined in the browser.
const settings = {
  VITE_API_URL: import.meta.env.VITE_API_URL,
  VITE_BANNER: import.meta.env.VITE_BANNER,
  PAYMENT_SECRET: import.meta.env.PAYMENT_SECRET,
}

const rows = Object.entries(settings)
  .map(([name, value]) => `<tr><th>${name}</th><td>${value ?? '<em>undefined</em>'}</td></tr>`)
  .join('')

document.querySelector('#app').innerHTML = `
  ${settings.VITE_BANNER ? `<p class="banner">${settings.VITE_BANNER}</p>` : ''}
  <h1>Build-time settings</h1>
  <p>Values baked into this build:</p>
  <table>${rows}</table>
  <p class="note">Built in <strong>${import.meta.env.MODE}</strong> mode.</p>
`

The styles are in the repository; any CSS works. Delete src/counter.js and the files in src/assets, which the new page does not use.

Step 2: Set the values for your laptop

For local development, Vite reads a .env.local file:

.env.local
text
VITE_API_URL=http://localhost:8080
VITE_BANNER=Running on my laptop
PAYMENT_SECRET=sk_test_local_only

The starter's .gitignore already contains *.local, so this file never reaches Git. Commit a .env.example without real values instead, so others know which variables exist.

Build once and look at what ended up in the JavaScript:

terminal
$ npm run build
dist/assets/index-CTRkmqE9.css  0.70 kB │ gzip: 0.35 kB
dist/assets/index-CZudv4-9.js   1.14 kB │ gzip: 0.68 kB
✓ built in 273ms

Open the .js file from dist/assets in your editor (your file name will differ) and search for the three values. localhost:8080 and Running on my laptop are there, in the file anyone can download. sk_test_local_only is not: without the prefix, Vite leaves it out.

Step 3: Push and deploy

terminal
$ git init -b main
$ git add -A
$ git commit -m "Vite site that shows its build-time settings"
# Create an empty repository named tutorial-env-vars on github.com/new first.
$ git remote add origin https://github.com/YOUR-USERNAME/tutorial-env-vars.git
$ git push -u origin main
To https://github.com/YOUR-USERNAME/tutorial-env-vars.git
 * [new branch]      main -> main
branch 'main' set up to track 'origin/main'.

In the Light Cloud console, click New..., Deploy from GitHub, pick tutorial-env-vars and click Deploy. It is served from the edge like any static site. The first build runs with no variables, so the page shows undefined three times.

Step 4: Add the variables in the console

  1. Open Production, then the Settings tab.
  2. Next to Environment Variables, click Edit, then Add, and add two variables:
NameValue
VITE_API_URLhttps://main-catalog-api-examples.light-cloud.io
VITE_BANNERAutumn sale: 20% off until Sunday

The Environment Variables editor with VITE_API_URL and VITE_BANNER and the Save button highlighted

  1. Click Save. Light Cloud rebuilds the site with the new values; 16 seconds in my run. The page now shows them (the screenshot at the top).

You can also add variables in the deploy form, under Advanced, before the first build.

Step 5: Change a value

Change VITE_BANNER to Autumn sale ends tonight and click Save. There is no code change and no push, but the site still has to be rebuilt, because the old value is written into the old JavaScript. Saving starts that rebuild; in my run the new banner was live about 34 seconds later:

The site after the change, with the banner Autumn sale ends tonight

Step 6: Prove what is public

In the same edit, I also added PAYMENT_SECRET with a fake key, sk_test_example_not_a_real_key, to see what happens to a variable without the VITE_ prefix. Look at what the live site sends to every visitor:

  1. Open the site in your browser, right-click the page and choose View Page Source.
  2. Near the top is a line with src="/assets/index-BHMD3_X_.js" (your file name will differ). Click the file name to open it.
  3. Search the file (Cmd+F or Ctrl+F) for VITE_, then for PAYMENT_SECRET.

In my file the search found:

text
VITE_API_URL:`https://main-catalog-api-examples.light-cloud.io`
VITE_BANNER:`Autumn sale ends tonight`
PAYMENT_SECRET:void 0

The two VITE_ values are there in plain text for anyone. PAYMENT_SECRET was compiled to void 0, JavaScript for undefined, and a search for sk_test finds nothing.

So the prefix is a safety catch, not a vault: anything you name VITE_ is published, and anything else is simply not available in the browser.

Where secrets belong

A browser can never keep a secret, so code that needs one must run on a server:

  • Put the key in an environment variable of a server app, for example an Express API or a FastAPI app. It reads process.env or os.environ when it starts, and the value never leaves the server.
  • Let the static site call that API. The API's address is fine as a VITE_ variable; the key is not.

Troubleshooting

My variable is undefined in the browser

Its name does not start with VITE_, or the site was built before you added it. Check the name, then Save again (or push a commit) to rebuild.

I put a secret in a VITE_ variable by mistake

It is public from the first deploy that included it. Removing the variable is not enough: revoke the key with whoever issued it, create a new one, and keep the new one on a server.

More tutorials