DEV Community

Dutchskull
Dutchskull

Posted on • Edited on

Setting Up Dynamic Environment Variables with Vite and Docker

Introduction

Stop me if this sounds familiar…

You’ve built a slick Vite frontend, bundled it into a Docker container, and you’re ready to deploy it across environments. Staging. QA. Production. You spin up a few containers with Docker Compose, pass in some environment variables, and… nothing. Your frontend still shows the old values. Or worse, hardcoded ones from build time.

Why? Because Vite bakes your environment variables into the JavaScript at build time. That means by the time your app hits the container, those variables are already locked in. If you want to change them, you’ve got to rebuild your app from scratch—for each environment. Which defeats the entire point of using Docker in the first place.

Here's the fix:
In this guide, you’ll learn how to build your Vite app once with environment placeholders, and inject the actual values at runtime using a simple script inside Docker. No more rebuilding for every deploy. Just plug in your config at startup, and let your container do the rest.

1. The Problem with Vite and Environment Variables

Vite makes development fast—but once you hit deployment, it locks you into a limitation that trips up a lot of devs:

Environment variables are statically replaced at build time, not dynamically read at runtime.

That means when you run npm run build, Vite crawls through your code and literally replaces any reference like import.meta.env.VITE_API_URL with the value in .env.production at that moment. It becomes a hardcoded string in your final JS bundle.

If you want different values for staging or production? You’re stuck rebuilding your app for each environment. That’s time-consuming, error-prone, and kills the point of Docker’s "build once, run anywhere" philosophy.


2. The Concept: Build Once, Inject Later

Here’s the idea: instead of hardcoding real values into your build, you bake in placeholders—clearly identifiable markers like PREFIX_API_URL.

Then, when the container starts up, a small shell script scans the final JavaScript files and swaps those placeholders for actual values from your environment—using plain old sed. Just before Nginx serves the files.

Why this works:

  • Vite doesn’t know your values at build time—it just drops in what you give it.
  • At runtime, Docker does know your values, and you can use them to patch the final files.
  • You still get a fully static, cacheable frontend bundle—but with runtime flexibility.

And no, it’s not a hack—it’s a pragmatic workaround that’s safe, production-ready, and used in many real-world pipelines.


🔁 How It Works (Visual Recap)

Here’s what’s happening behind the scenes:

  1. Build phase
  • Vite replaces env references with placeholder strings like "PREFIX_API_URL" in your compiled JS.
  1. Runtime phase
  • Your Docker container starts with the real environment variables (e.g. PREFIX_API_URL=https://api.myapp.com).
  • A shell script replaces every placeholder in the final JS/HTML files.
  • Nginx serves the updated files.

How it works


3. Step-by-Step Setup

Let’s walk through how to set this up from scratch.

Step 1: Define Placeholders in .env.production

Create a .env.production file in the root of your project. The .env.production file will be used by vite build for its environment variables. Inside your project, add this line to .env.production:

VITE_API_URL=PREFIX_API_URL
Enter fullscreen mode Exit fullscreen mode

The value PREFIX_API_URL is a placeholder that Vite will statically bake into your code. It doesn’t need to be real—just something unique and easy to find later.

Step 2: Add the Runtime Script (env.sh)

This is the important part

Drop this env.sh script into your project root. Make sure it’s executable (chmod +x env.sh) and converted to Unix line endings (dos2unix env.sh if needed). If you don't want to do this every time you can also add those commands to your Dockerfile.


Step 3: Set Up Docker

🐳 Dockerfile – Explained

Here’s a Dockerfile that builds your Vite app and prepares it for runtime replacement:

# Step 1: Build the app using Node
FROM node:23-alpine AS builder

WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Step 2: Use a lightweight Nginx container to serve the files
FROM nginx:alpine

# Copy Nginx config if you have custom routing
COPY ./nginx.conf /etc/nginx/nginx.conf

# Copy the built app from the builder stage
COPY --from=builder /app/dist /var/www/html/

# Copy the runtime injection script into the container
COPY env.sh /docker-entrypoint.d/env.sh
RUN dos2unix /docker-entrypoint.d/env.sh
RUN chmod +x /docker-entrypoint.d/env.sh

# Let Docker run your script before starting Nginx
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["nginx", "-g", "daemon off;"]
Enter fullscreen mode Exit fullscreen mode

🧩 docker-compose.yml – Explained

This Compose config shows how to inject the real values during container startup:

services:
  app:
    image: app:latest
    build:
      context: app
    ports:
      - "8080:80"
    environment:
      # Where your built files live in the container
      ASSET_DIR: /var/www/html

      # Prefix used for placeholder detection
      APP_PREFIX: PREFIX_

      # Real values for your frontend to consume
      PREFIX_API_URL: "https://api.myapp.com"
Enter fullscreen mode Exit fullscreen mode

Key things happening here:

  • ASSET_DIR tells env.sh where to search for files.
  • APP_PREFIX ensures we only replace the values we want.
  • PREFIX_API_URL is the real value your app needs—matched to the placeholder in .env.production.

4. Advantages and Gotchas

✅ Pros

  • Single build for all environments
    Build once. Deploy many times.

  • Static file friendly
    Keeps your assets cacheable and CDN-ready.

  • Framework agnostic
    Works with any static frontend—not just Vite.

⚠️ Gotchas

  • Pick a unique prefix
    Avoid accidental replacements. PREFIX_ is a good default.

  • Beware of quotes and special characters
    Escape them properly if needed in shell scripts.


🔗 Resources


✅ Final Thoughts

This setup gives you flexibility without sacrificing speed or simplicity. You keep your frontend static, lightweight, and easy to cache—while still injecting environment-specific config at runtime.

It’s a clean bridge between how Vite works and how Docker deployments need to work.

You no longer have to rebuild for every little config change. Just build once, inject on start, and move on.

Top comments (1)

Collapse
 
wblondel profile image
William Blondel

Thank you! This was very useful. I implemented your solution in several projects.