qko@coffee:~$

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.

2 min read #vite #aws

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.

← Back to blog Reply by email