DEV Community

Olamide Adebayo
Olamide Adebayo

Posted on

The Frontend Environment Variable Problem No One Really Solved

If you've shipped a React, Vue, or Angular app inside a Docker container, you've lived through this:

VITE_API_URL=https://api.staging.example.com npm run build
Enter fullscreen mode Exit fullscreen mode

That npm run build bakes the URL into the JavaScript bundle. Literally — the bundler finds every import.meta.env.VITE_API_URL reference and replaces it with the string "https://api.staging.example.com". Static string replacement. The resulting JS file has no concept of an environment variable. It's just a hardcoded string now.

Which means your Docker image is environment-specific. You can't promote it to production. You need a separate build with the production URL. The image you tested in staging is a different binary than what goes to prod.

This violates the entire point of containers.

The Hack Everyone Writes

Eventually, someone on the team writes a shell script. It looks roughly like this:

#!/bin/sh
# env.sh — runs at container startup
cat <<EOF > /usr/share/nginx/html/config.js
window.__ENV__ = {
  API_URL: "${API_URL}",
  FEATURE_FLAGS: "${FEATURE_FLAGS}",
  ANALYTICS_KEY: "${ANALYTICS_KEY}"
};
EOF
nginx -g 'daemon off;'
Enter fullscreen mode Exit fullscreen mode

Then somewhere in the app:

const apiUrl = window.__ENV__?.API_URL || 'http://localhost:3000';
Enter fullscreen mode Exit fullscreen mode

It works. Thousands of teams use exactly this pattern. But it has problems that compound silently:

No security model. Every variable is plaintext in the page source. Someone puts ANALYTICS_KEY, OAUTH_CLIENT_ID, or occasionally an actual secret behind window.__ENV__ and it's visible to anyone who hits View Source. There's no classification, no distinction between "safe to expose" and "maybe shouldn't be in the HTML."

No integrity. If something modifies that config (a browser extension, a CDN compromise, a supply chain attack), the app has no way to detect it.

No standard. Every team reinvents the wheel with slightly different naming conventions, different variable formats, different injection methods. There's no tooling ecosystem because there's no protocol.

Fragile. Some teams use envsubst or sed directly on the minified JS bundle. One escaped character away from a production incident.

What If There Was a Protocol?

This is why we built REP — the Runtime Environment Protocol.

REP is an open specification (RFC-style, CC BY 4.0) and a reference implementation (Go gateway + TypeScript SDK, Apache 2.0). It replaces the ad-hoc env.sh pattern with a standardised, security-first approach.

The core idea: a lightweight Go binary (~6MB) sits in front of your static file server. At container boot, it reads environment variables, classifies them by security tier, and injects a signed JSON payload into every HTML response. Your application code reads these values through a tiny SDK.

Three Security Tiers

REP classifies variables by their prefix:

# PUBLIC — plaintext in the HTML, synchronous access
REP_PUBLIC_API_URL=https://api.example.com
REP_PUBLIC_FEATURE_FLAGS=dark-mode,new-checkout

# SENSITIVE — encrypted with AES-256-GCM, decrypted on demand
REP_SENSITIVE_ANALYTICS_KEY=UA-12345-1

# SERVER — never sent to the browser, period
REP_SERVER_DB_PASSWORD=supersecret
Enter fullscreen mode Exit fullscreen mode

The prefix IS the classification. REP_PUBLIC_* appears in the page source (like API URLs and feature flags — things that are inherently visible via the network tab anyway). REP_SENSITIVE_* is encrypted in the HTML and requires a short-lived session key to decrypt. REP_SERVER_* never leaves the gateway process — it's the only tier suitable for true secrets.

This is the key difference from every existing solution: you make an explicit security decision for each variable at the naming level. No ambiguity.

Automatic Secret Detection

At startup, the gateway scans your REP_PUBLIC_* values for things that look like they shouldn't be public:

  • Shannon entropy > 4.5 bits/char? That string looks random — probably a secret.
  • Starts with AKIA? That's an AWS access key.
  • Starts with eyJ? That's a JWT.
  • Starts with ghp_, sk_live_, sk-, xoxb-? GitHub token, Stripe key, OpenAI key, Slack token.

If any heuristic trips, the gateway logs a warning. In --strict mode, it refuses to start. This catches the most common misconfiguration — accidentally putting a secret under the PUBLIC prefix.

HMAC Integrity

Every injected payload carries an HMAC-SHA256 signature and an SRI hash. The SDK verifies these on page load. If something tampered with the config in transit (CDN, proxy, browser extension), the integrity check fails and the SDK flags it.

Hot Config Reload

Optional Server-Sent Events endpoint. Update a Kubernetes ConfigMap, rotate a feature flag, the gateway detects the change and pushes it to every connected browser. The SDK fires onChange callbacks. No page reload needed.

Try It in 5 Minutes

Here's how to go from zero to working demo with Docker.

1. Create a minimal frontend

Any static HTML will do. Create an index.html:

<!DOCTYPE html>
<html>
<head>
  <title>REP Demo</title>
  <script type="module">
    import { rep } from 'https://esm.sh/@rep-protocol/sdk@latest';

    document.getElementById('api-url').textContent = rep.get('API_URL', 'not set');
    document.getElementById('env-name').textContent = rep.get('ENV_NAME', 'not set');
    document.getElementById('flags').textContent = rep.get('FEATURE_FLAGS', 'none');
  </script>
</head>
<body>
  <h1>REP Demo</h1>
  <p>API URL: <strong id="api-url"></strong></p>
  <p>Environment: <strong id="env-name"></strong></p>
  <p>Feature Flags: <strong id="flags"></strong></p>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

2. Download the gateway binary

Grab the latest release from GitHub:

# Linux/macOS
curl -L https://github.com/ruachtech/rep/releases/latest/download/rep-gateway-$(uname -s | tr '[:upper:]' '[:lower:]')-$(uname -m) -o rep-gateway
chmod +x rep-gateway
Enter fullscreen mode Exit fullscreen mode

Or pull the Docker image:

docker pull ghcr.io/ruachtech/rep/gateway:latest
Enter fullscreen mode Exit fullscreen mode

3. Run it

Without Docker:

REP_PUBLIC_API_URL="https://api.example.com" \
REP_PUBLIC_ENV_NAME="local" \
REP_PUBLIC_FEATURE_FLAGS="dark-mode,beta-checkout" \
./rep-gateway --mode embedded --static-dir . --port 8080 --log-format text
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:8080 — you'll see the values displayed.

With Docker Compose:

# docker-compose.yml
services:
  staging:
    image: ghcr.io/ruachtech/rep/gateway:latest
    command: ["--mode", "embedded", "--static-dir", "/static", "--port", "8080"]
    ports:
      - "8080:8080"
    environment:
      REP_PUBLIC_API_URL: "https://api.staging.example.com"
      REP_PUBLIC_ENV_NAME: "staging"
      REP_PUBLIC_FEATURE_FLAGS: "dark-mode,beta-checkout,debug"
    volumes:
      - ./index.html:/static/index.html:ro

  production:
    image: ghcr.io/ruachtech/rep/gateway:latest
    command: ["--mode", "embedded", "--static-dir", "/static", "--port", "8080"]
    ports:
      - "8081:8080"
    environment:
      REP_PUBLIC_API_URL: "https://api.example.com"
      REP_PUBLIC_ENV_NAME: "production"
      REP_PUBLIC_FEATURE_FLAGS: "dark-mode"
    volumes:
      - ./index.html:/static/index.html:ro
Enter fullscreen mode Exit fullscreen mode
docker compose up
Enter fullscreen mode Exit fullscreen mode

Now visit localhost:8080 (staging) and localhost:8081 (production). Same HTML file. Same gateway image. Different configuration. That's the entire value proposition.

4. View Source and inspect

Open View Source on either page. You'll see the injected payload:

<script id="__rep__" type="application/json" data-rep-version="0.1.0"
        data-rep-integrity="sha256-...">
{
  "public": {
    "API_URL": "https://api.staging.example.com",
    "ENV_NAME": "staging",
    "FEATURE_FLAGS": "dark-mode,beta-checkout,debug"
  },
  "_meta": {
    "version": "0.1.0",
    "injected_at": "2026-03-02T10:30:00Z",
    "integrity": "hmac-sha256:...",
    "ttl": 0
  }
}
</script>
Enter fullscreen mode Exit fullscreen mode

Note: type="application/json" means the browser does not execute this script. It's inert data. The SDK reads it via document.getElementById('__rep__').

5. Check the health endpoint

curl http://localhost:8080/rep/health | jq .
Enter fullscreen mode Exit fullscreen mode
{
  "status": "healthy",
  "version": "0.1.0",
  "variables": {
    "public": 3,
    "sensitive": 0,
    "server": 0
  },
  "guardrails": {
    "warnings": 0,
    "blocked": 0
  },
  "uptime_seconds": 42
}
Enter fullscreen mode Exit fullscreen mode

Using the SDK in a Real App

For a real React/Vue/Svelte app, install the SDK:

npm install @rep-protocol/sdk
Enter fullscreen mode Exit fullscreen mode

Then replace your build-time env var references:

// Before (build-time, environment-specific)
const apiUrl = import.meta.env.VITE_API_URL;

// After (runtime, environment-agnostic)
import { rep } from '@rep-protocol/sdk';
const apiUrl = rep.get('API_URL');
Enter fullscreen mode Exit fullscreen mode

rep.get() is synchronous. No async. No loading states. No Suspense wrapper needed. The value is available the instant the module loads because the SDK reads the <script> tag from the DOM — which is already there before your JavaScript executes.

For encrypted values:

// Fetches a session key, decrypts, caches in memory
const analyticsKey = await rep.getSecure('ANALYTICS_KEY');
Enter fullscreen mode Exit fullscreen mode

For hot reload:

rep.onChange('FEATURE_FLAGS', (newValue, oldValue) => {
  console.log(`Flags changed: ${oldValue}${newValue}`);
  // Re-render, toggle UI, whatever you need
});
Enter fullscreen mode Exit fullscreen mode

The SDK is 1.5KB gzipped with zero runtime dependencies.

Adding It to Your Existing Dockerfile

You don't need to rewrite anything. Just add two lines to your existing multi-stage Dockerfile:

FROM node:22-alpine AS build
WORKDIR /app
COPY . .
RUN npm ci && npm run build

# Add REP gateway
FROM ghcr.io/ruachtech/rep-gateway:latest
COPY --from=build /app/dist /static
EXPOSE 8080
ENTRYPOINT ["rep-gateway", "--mode", "embedded", "--static-dir", "/static"]
Enter fullscreen mode Exit fullscreen mode

Your build step stays identical. No --build-arg for API URLs. No .env.production. The image is environment-agnostic. Configure it at runtime via environment variables — exactly how containers are supposed to work.

The Security Model Is Honest

I want to be upfront about what REP protects and what it doesn't.

PUBLIC vars are in the page source. By design. Don't put secrets here. These are for API URLs, feature flags, app versions — things that are visible in the network tab regardless.

SENSITIVE vars raise the bar, but aren't a vault. A sophisticated attacker with XSS can call rep.getSecure() and exfiltrate the result. But the encryption prevents casual exposure (View Source, DOM scrapers, browser extensions scanning the page), and the session key mechanism makes intentional access auditable — every key request is logged with IP, origin, and timestamp. Session keys are single-use and expire in 30 seconds.

SERVER vars never reach the browser. This is the only tier for true secrets. If you need a value client-side that would be catastrophic if leaked, reconsider whether it should be client-side at all.

The full threat model covers 7 specific threats with mitigations and residual risks. We deliberately don't claim browser-side encryption is bulletproof — because it isn't.

Compared to What's Out There

The most common question: "How is this different from X?"

vs. shell scripts / envsubst / window.ENV: Same injection concept, but REP adds security classification, encryption, integrity verification, secret detection, and a standard SDK. The injection is 10% of REP. The security layer is 90%.

vs. @import-meta-env/unplugin: The most sophisticated existing tool. But it's a build-tool plugin — you install it into Vite or Webpack and it modifies your build pipeline. REP doesn't touch your build at all. It operates on already-built artifacts at the infrastructure layer. They're complementary, not competing.

vs. SSR frameworks (Next.js, Nuxt): SSR solves this for frameworks that support it. But not all apps need SSR, it couples you to a specific framework, and many organisations have existing SPAs they can't migrate. REP works with any SPA.

vs. "just fetch /config.json at startup": Works, but adds a network dependency at app init (loading delay, race conditions, requires error handling). rep.get() is synchronous — no fetch, no loading state.

What's in the Box

  • Gateway: Go binary (~6MB). Zero external dependencies. Proxy mode (in front of nginx/caddy) or embedded mode (serves files directly). FROM scratch compatible.
  • SDK: TypeScript. Zero runtime deps. ~1.5KB gzipped. Synchronous public access, async encrypted access.
  • CLI: rep validate, rep typegen, rep lint, rep dev. Full local dev workflow.
  • Adapters: First-party React (useRep), Vue (useRep), and Svelte (repStore) with hot-reload-aware hooks.
  • Specification: Full RFC-style document. 14 sections. CC BY 4.0.
  • Security Model: 7 threat analyses with honest residual risk assessments.

Full docs at rep-protocol.dev. Source at github.com/ruachtech/rep.

We'd Love Feedback

REP is early and opinionated. We're looking for feedback on:

  • The security model — are we being honest enough about the limitations? Are there threats we've missed?
  • The SDK API — is rep.get() / rep.getSecure() the right developer experience?
  • Edge cases — what happens with your specific framework, bundler, or deployment setup?
  • The spec — is anything ambiguous or under-specified?

File issues, open PRs, or just tell us we're wrong about something. The best outcome is that this becomes a community standard rather than one team's opinion.


REP is open source under Apache 2.0, built by Ruach Tech. The specification is licensed under CC BY 4.0.

Top comments (0)