← Back to posts

Build Once, Deploy Everywhere: Replacing Vite's Build-Time Env Vars with Runtime Config

· 9 min read

The Spreadsheet Moment

I was staring at a spreadsheet. Not a fun spreadsheet - a CI timing spreadsheet. Our full-stack deploy pipeline from PR merge to production took about 42 minutes. We were doing 10+ deploys a day. That’s north of two hours per day watching progress bars, which is a spectacular waste of engineering time for a small team.

The pipeline looked like this: PR checks build everything, staging rebuilds everything, production rebuilds everything again. Three full builds of the same code. The JavaScript output was byte-for-byte identical across environments - same components, same logic, same bundle structure. The only differences were a handful of configuration values: API URLs, Cognito pool IDs, Sentry DSNs, feature flag keys.

And yet we rebuilt from scratch every time, because Vite told us to.

The Vite Env Var Trap

Vite has a beautifully simple environment variable system. Prefix any variable with VITE_ and it becomes available in your client code via import.meta.env.VITE_WHATEVER. During development, Vite reads from .env files. During build, it statically replaces every import.meta.env.VITE_* reference with the literal value from the environment.

This is great for developer experience. It’s terrible for CI/CD.

The problem is that word “statically.” Vite doesn’t inject these values at runtime - it bakes them into the JavaScript bundle at build time using define replacements. Your built code doesn’t contain a reference to import.meta.env.VITE_API_URL. It contains the string "https://api.staging.example.com" hardcoded into the minified output.

This means a staging build and a production build produce different files even when the source code is identical. Which means you can’t promote a staging artifact to production. Which means you build twice. For us, with five frontend portals, it meant building ten times across two environments - plus however many times PR checks ran.

We had 30+ VITE_* variables across our portals. API base URLs, Cognito user pool IDs, client IDs, auth domains, Sentry DSNs, Sentry environments, GrowthBook SDK keys, location API keys, AppSync endpoints. Every single one baked into the bundle at compile time.

The Alternatives We Rejected

Before I settled on the approach, we evaluated two alternatives.

CDK artifact promotion. Our infrastructure is CDK. Could we build the frontend once, package it as a CDK asset, and deploy the same asset to both environments? No - CDK bakes AWS account IDs and regions into cdk.out during synthesis. A staging cdk.out can’t be reused for production without re-synthesizing, which defeats the purpose.

Placeholder sed replacement. Build with placeholder strings like __API_URL__, then sed-replace them at deploy time. This works in theory but falls apart with source maps. Replacing strings changes byte offsets, corrupting source map mappings. You’d need to regenerate source maps after replacement, which requires the original build toolchain. We also didn’t love the idea of shipping bundles full of __PLACEHOLDER__ strings that could leak if the replacement step failed silently.

The Runtime Config Pattern

The solution is almost embarrassingly simple: fetch configuration at runtime before the app mounts.

Instead of reading env vars from import.meta.env, the app fetches a /config.json file and reads values from that. The JSON file is generated at deploy time from AWS SSM parameters - not baked into the build.

We built a small shared package with two exports that matter:

// loadConfig - call once before React renders
export async function loadConfig(): Promise<AppConfig> {
  if (config) return config;
  const res = await fetch('/config.json');
  if (!res.ok) {
    throw new Error(`Failed to load config: ${res.status}`);
  }
  config = await res.json();
  return config;
}

// getConfig - call anywhere after loadConfig completes
export function getConfig(): AppConfig {
  if (!config) {
    throw new Error(
      'Config not loaded - call loadConfig() before rendering.'
    );
  }
  return config;
}

The AppConfig interface is typed:

interface AppConfig {
  environment: string;
  auth: {
    userPoolId: string;
    clientId: string;
    region: string;
    domain: string;
  };
  api: {
    baseUrl: string;
    appsyncUrl?: string;
    authUrl?: string;
  };
  sentry: {
    dsn: string;
    environment: string;
    release: string;
  };
  growthbook: {
    clientKey: string;
  };
  aws?: {
    locationApiKey?: string;
  };
}

Every portal’s main.tsx now does:

import { loadConfig } from '@our-org/runtime-config';

async function boot() {
  const config = await loadConfig();
  // Sentry, GrowthBook, React root - all use config
  createRoot(document.getElementById('root')!).render(<App />);
}

boot();

The Deploy-Time Config Generator

The config.json file is generated by a shell script that runs at deploy time. It takes three arguments - app name, environment, and commit SHA - and pulls values from AWS SSM Parameter Store:

#!/usr/bin/env bash
APP_NAME="$1"
ENVIRONMENT="$2"
COMMIT_SHA="$3"

ssm() {
  aws ssm get-parameter --name "$1" \
    --query "Parameter.Value" --output text 2>/dev/null || echo "${2:-}"
}

USER_POOL_ID=$(ssm "/platform/$APP_NAME/auth/user-pool-id")
CLIENT_ID=$(ssm "/platform/$APP_NAME/auth/client-id")
# ... remaining SSM reads ...

jq -n \
  --arg environment "$ENVIRONMENT" \
  --arg userPoolId "$USER_POOL_ID" \
  # ... remaining args ...
  '{
    environment: $environment,
    auth: { userPoolId: $userPoolId, clientId: $clientId, ... },
    sentry: { release: $commitSha, ... },
    ...
  }'

The script uses jq to build a valid JSON object. The commit SHA becomes the Sentry release identifier, so source maps (uploaded once during the staging build) align correctly across environments.

The Pipeline Transformation

Here’s where the real savings kick in.

Before (per environment): checkout -> install -> build (with env vars) -> S3 sync -> CloudFront invalidate. For five portals, two environments.

After:

Staging: checkout -> install -> build (no env vars at all) -> generate config.json per portal -> S3 sync. One build, environment-agnostic.

Production: S3 copy staging bundle to production bucket -> generate production config.json -> CloudFront invalidate. No checkout. No install. No build. No node_modules. No Vite. Just an S3-to-S3 copy and a tiny config generation step.

The production deploy went from a full rebuild (~12 minutes for five portals) to an S3 copy operation (~90 seconds). The config.json file is served with Cache-Control: no-cache so CloudFront always fetches the latest version.

The Local Dev Story

We didn’t want developers to manually create config.json files or change their workflow. A Vite plugin handles it:

export function runtimeConfigPlugin(config: AppConfig): Plugin {
  return {
    name: 'runtime-config',
    configureServer(server) {
      server.middlewares.use('/config.json', (_req, res) => {
        res.setHeader('Content-Type', 'application/json');
        res.setHeader('Cache-Control', 'no-cache');
        res.end(JSON.stringify(config, null, 2));
      });
    },
  };
}

Each portal’s vite.config.ts reads from .env.local and feeds the values into the plugin. During development, fetch('/config.json') hits the Vite dev server middleware and returns the local config. No behaviour difference between local and production.

I deliberately kept import.meta.env.MODE, import.meta.env.DEV, and import.meta.env.PROD - these are Vite built-ins for dev tooling and conditional compilation, not environment configuration. The rule is simple: if it changes between staging and production, it goes in config.json. If it only distinguishes development from production builds, it stays in import.meta.env.

The Migration Minefield

Five portals, 30+ env var references scattered across the codebase. Some portals had a centralised lib/env.ts that re-exported all env vars - those were easy. Others had import.meta.env.VITE_* scattered across dozens of files - those were tedious.

We migrated in phases. Hub and admin portal first (they had the cleanest env.ts pattern), then developer, partner, and ops portals.

The migration itself was mechanical: find every import.meta.env.VITE_* reference, replace it with getConfig().whatever. But there was a trap waiting for us.

The Module-Level Execution Bug

This was the most instructive bug of the entire migration. Several modules had code like this:

// cognito.ts - runs at module load time
const config = getConfig();
const cognitoClient = new CognitoIdentityProviderClient({
  region: config.auth.region,
});

In the old world, import.meta.env.VITE_* values were available immediately - they were string literals compiled into the bundle. Code at module scope could read them freely.

In the new world, getConfig() throws if loadConfig() hasn’t completed yet. And loadConfig() is async - it fetches from the network. Module-level code executes synchronously during import, which happens before our async boot() function runs.

The hub crashed on load. Admin portal had silent failures where config values were undefined. The fix was to convert eager singletons to lazy ones:

// Before: executes at import time (crashes)
const cognitoClient = new CognitoIdentityProviderClient({
  region: getConfig().auth.region,
});

// After: executes on first call (config is loaded by then)
let _cognitoClient: CognitoIdentityProviderClient | null = null;
function getCognitoClient() {
  if (!_cognitoClient) {
    _cognitoClient = new CognitoIdentityProviderClient({
      region: getConfig().auth.region,
    });
  }
  return _cognitoClient;
}

This is the kind of bug that doesn’t show up in unit tests - those mock the config module and never exercise the real load sequence. We caught it in staging smoke tests, which now explicitly validate that /config.json exists and contains all required fields before the deploy is considered successful.

The Test Mock Situation

Every unit test that touched code using getConfig() broke, because the test runner doesn’t serve a config.json file. The fix was straightforward:

vi.mock('@our-org/runtime-config', () => ({
  getConfig: () => ({
    environment: 'test',
    auth: { userPoolId: 'test', clientId: 'test', region: 'eu-west-2', domain: 'test' },
    api: { baseUrl: 'http://localhost' },
    sentry: { dsn: '', environment: 'test', release: 'test' },
    growthbook: { clientKey: '' },
  }),
}));

But it was a reminder: when you change how configuration flows through an application, you’re touching a load-bearing wall. Every test that referenced env vars needed updating. Every module that read config at the top level needed restructuring. The scope of “replace env vars with a fetch call” was much larger than it looked.

The Turbo Cache Bonus

There was an unexpected benefit. With VITE_* variables gone from the build, Turbo’s cache became truly environment-agnostic. Previously, Turbo had to include VITE_* variables in its environment hash - a staging build and a production build were different cache keys even with identical source code. Now the build step produces the same output regardless of target environment, which means cache hits across environments.

We removed every VITE_* entry from our Turbo configuration’s globalEnv and env fields. Builds that used to miss cache now hit it consistently.

The Results

For a five-portal monorepo doing 10+ deploys per day:

  • Staging deploy: unchanged in duration (still needs a real build), but builds are now cacheable across environments
  • Production deploy: dropped from ~12 minutes to ~90 seconds. No checkout, no install, no build. Just S3 copy + config generation + CloudFront invalidation
  • Full pipeline (merge to production): ~42 minutes down to ~29-31 minutes
  • Daily time savings: roughly 30-40 minutes across all deploys

The approach is well-established in the industry - this is how most container-based deployments handle configuration. But Vite’s import.meta.env pattern is so convenient during development that it’s easy to never question it for production. The ergonomic default creates an invisible tax on your pipeline.

If you’re running Vite SPAs and deploying to multiple environments, the migration is worth it. Build once. Generate config per environment. Promote artifacts instead of rebuilding them. Your CI bill will thank you.