Build Once, Deploy Everywhere: Replacing Vite's Build-Time Env Vars with Runtime Config
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.
Build Once, Deploy Everywhere: How We Cut Production Deploys from 12 Minutes to 90 Seconds
The Spreadsheet That Started It
I made a spreadsheet. Not a fun spreadsheet — a timing spreadsheet for our deployment pipeline.
Our full pipeline from merging code to it running in production took about 42 minutes. We were doing 10+ deploys a day. That’s over two hours of watching progress bars, every day, for a small team.
The culprit, when I dug into it: we were building the same frontend code three times for every release. Once when checking a pull request. Once for our staging environment. Once for production. The output was byte-for-byte identical between staging and production — same code, same logic, same everything. The only differences were a handful of configuration values: which server to talk to, which authentication service to use, which error-reporting key to send data to.
And yet we rebuilt from scratch every time. Because of a decision Vite makes by default.
Why Vite Made Us Rebuild Constantly
Vite is the tool we use to compile our frontend code from the components developers write into the compressed JavaScript files that browsers actually run. It has a very convenient feature: you can define configuration values with a special prefix, and Vite makes them available throughout your code automatically.
The catch: Vite doesn’t inject these values at runtime, when someone opens the app in their browser. It bakes them in at build time, when the code is compiled. Your built files don’t contain a reference to “whatever the API URL is” — they contain the literal text "https://api.staging.example.co.uk" stamped into the minified output.
This means a staging build and a production build produce different files, even when the underlying source code is identical. You can’t take the staging build and promote it to production. You have to rebuild.
We had more than 30 of these baked-in values across five portals — server addresses, authentication settings, error reporting keys, feature flag configurations, mapping API credentials. Every single one stamped into the bundle at compile time.
Two Ideas We Ruled Out
Before settling on a solution, we considered two alternatives.
The first was trying to promote our built files through our infrastructure tooling. Our infrastructure is defined in code, and those definitions get compiled into deployment packages. The problem: the compilation process bakes in account-specific details. A staging package can’t be reused for production without recompiling it, which defeats the point.
The second was a “find and replace at deploy time” approach: build with placeholder text like __API_URL__, then substitute the real values at deploy time. This works in principle but breaks source maps — the files that let you trace an error in minified production code back to the original line in your source. Replacing text after compilation changes the byte positions that source maps rely on. We didn’t want to maintain that complexity, or risk a failed substitution shipping placeholders into production.
The Obvious Answer
The solution is almost embarrassingly simple: don’t bake configuration into the build at all. Fetch it at runtime instead.
Instead of reading configuration from build-time variables, each app fetches a small JSON file called config.json when it starts up, before anything renders. That file contains all the environment-specific values. The file is generated fresh at deploy time, not at build time.
The build itself contains no configuration. It’s environment-agnostic. The same compiled files can run anywhere — you just point them at a different config.json.
We built a small shared package with two functions. loadConfig() fetches the JSON file once when the app starts. getConfig() returns the cached result whenever any part of the app needs a configuration value. If you call getConfig() before loadConfig() has finished, it throws an error immediately rather than silently returning nothing — a deliberate design choice that makes mistakes obvious.
The config.json file is generated by a short script that runs at deploy time. It pulls values from our secure parameter storage in AWS and assembles them into a JSON file, which gets uploaded alongside the app. Because it’s served with a header telling browsers not to cache it, every user always gets the latest version.
During local development, we didn’t want developers to have to create or manage config.json files manually. A small plugin intercepts any request to /config.json in the local development server and returns the developer’s local settings instead. From the app’s perspective, it works identically in all three environments.
The Pipeline After
Before: every deployment was checkout, install dependencies, compile code, upload to storage, refresh the CDN. Five portals, two environments. Ten full builds per release cycle.
After:
Staging deploys the same way it always did — you still need to compile the code. But because the compiled output contains no environment-specific values, the CDN cache from the last staging build is still valid if the source code hasn’t changed.
Production is completely different. There’s no checkout. No installing dependencies. No compilation. We copy the staging files to the production storage location and generate a fresh config.json with production values. That’s it.
Production deploys went from 12 minutes to about 90 seconds.
The overall pipeline from merge to production went from 42 minutes to 29-31 minutes. Across 10+ deploys a day, that’s 30-40 minutes back every day.
The Bug We Didn’t See Coming
The migration wasn’t painless. Five portals, 30+ configuration references scattered through the code — some centralised, some scattered across dozens of files. We migrated in waves, starting with the portals that had cleaner code organisation.
The most instructive mistake came from a subtle difference between the old world and the new.
In the old world, configuration values were string literals stamped into the code at build time. Any code that ran when a file was first loaded could read them immediately.
In the new world, configuration comes from a network request that hasn’t finished yet when the app’s files first load. We had several places in the code that initialised key services at file-load time — services that needed configuration values to set themselves up.
Those services crashed, silently or noisily depending on where they lived, because they were calling getConfig() before loadConfig() had returned.
The fix was converting those eager initialisations to lazy ones: instead of creating the service immediately when the file loads, create it the first time it’s actually needed — which is after the app has started, which is after loadConfig() has finished.
We also had to update every automated test that touched code using configuration, because the test environment doesn’t serve a config.json file. Each test module that needed configuration got a standard mock telling it to use test values.
Neither fix was complicated. But the scope of “replace environment variables with a fetch call” turned out to be much larger than it initially appeared. Configuration flows through an application like water through soil — it gets into everything.
An Unexpected Bonus
There was a side effect we didn’t anticipate.
Our build system caches compiled output and skips rebuilding things that haven’t changed. Previously, it had to treat staging and production builds as different, because their outputs were different — different configuration values meant different compiled files. Every production deploy invalidated the cache.
With configuration removed from the build, the compiled output is identical regardless of target environment. Builds that used to miss the cache now hit it consistently. We removed all the configuration-related entries from the list of things our build system uses to determine whether a cache entry is still valid.
The Broader Point
This pattern isn’t new. Applications packaged as containers have worked this way for years: build the container once, inject configuration at runtime, run the same image in every environment. We just weren’t applying it to our frontend.
Vite’s built-in configuration mechanism is genuinely convenient during development. The problem is that convenience during development creates an invisible tax on your deployment pipeline — one that compounds every time you add a new environment or increase your deploy frequency.
If you’re running a frontend application that deploys to multiple environments, it’s worth asking: how much of your build time is spent on identical code with different values stamped in? The migration has a real upfront cost, but it pays back quickly and keeps paying back with every deploy after that.
Build once. Generate configuration per environment. Promote the same artifact rather than rebuilding it. Your pipeline will thank you.
← Back to posts