Runtime configuration with Vite and CloudFront
How to move from build-time to runtime environment variable injection with a custom Vite plugin and a CloudFront behavior, without any additional dependency. Build once, deploy everywhere.
Vite handles environment variables through .env files. During development, it loads .env.development and resolves import.meta.env.VITE_* references on the fly through its development server. During build, it reads .env.production and inlines the values directly into the output bundle, replacing every import.meta.env.VITE_* with the corresponding hardcoded string.
At build time, this means one artifact per environment. This breaks the build-once-deploy-everywhere principle. The fix is to move configuration injection from build time to runtime, so a single artifact can serve any environment.
How it works
The core idea is to replace Vite’s inlined values with a global window.__ENV__ object. A Vite plugin rewires the references from import.meta.env.VITE_* to window.__ENV__.VITE_* at build time. At startup, the browser loads a /runtime-config.js file that populates this object. A dedicated CloudFront behavior serves this file with the right values for the target environment.
In index.html, a script tag pulls /runtime-config.js before anything else:
<head>
<script src="/runtime-config.js"></script>
</head>
The plugin uses apply: 'build' to only run during build:
// Replaces build-time import.meta.env.VITE_* references with window.__ENV__.VITE_*
function runtimeConfigPlugin(): Plugin {
return {
name: 'runtime-config',
apply: 'build',
transform(code) {
if (!code.includes('import.meta.env.VITE_')) return
return code.replaceAll('import.meta.env.VITE_', 'window.__ENV__.VITE_')
},
}
}
It runs in the transform hook, which happens before Vite’s own environment variable replacement, so the references have already been rewritten to window.__ENV__.VITE_* when Vite would normally inline the values from .env.production. There is nothing left to substitute, which is why deleting .env.production is safe.
Locally, Vite resolves import.meta.env.VITE_* from .env.development as usual. A public/runtime-config.js with an empty window.__ENV__ = {} is needed to avoid a 404 from the script tag. The application code never reads it in development.
Infrastructure side
Now that the frontend reads from window.__ENV__ at runtime, something must serve that object with the right values per environment.
The function itself is a simple template:
function handler(event) {
return {
statusCode: 200,
statusDescription: "OK",
headers: {
"content-type": { value: "application/javascript" },
"cache-control": { value: "no-cache, no-store, must-revalidate" }
},
body: 'window.__ENV__ = ${jsonencode(config)};'
};
}
The templatefile function injects the values per environment:
resource "aws_cloudfront_function" "runtime_configuration" {
name = "${var.environment}-runtime-configuration"
runtime = "cloudfront-js-2.0"
code = templatefile("${path.module}/functions/runtime-config.js", {
config = {
VITE_API_URL = var.api_url
}
})
}
End result: the pipeline produces one artifact, configuration is managed through Terraform, developers don’t change anything in their code, and updating a variable is a terraform apply away. No rebuild required.
This feature is included in terraform-aws-cloudfront-site.