DEV Community

Cover image for Stop Baking Config Into Your React Builds — Runtime Env Vars for Containerised Frontends
Olamide Adebayo
Olamide Adebayo

Posted on

Stop Baking Config Into Your React Builds — Runtime Env Vars for Containerised Frontends

You know the drill. Your React app needs an API URL. So you reach for import.meta.env.VITE_API_URL, run npm run build, and ship it. Works great — until you need to deploy the same image to staging and production with different config.

Now you're building myapp:staging and myapp:prod. Two images. Two builds. The image that passed your tests is a different binary than the one going to production. Your CD pipeline is lying to you.

This is the dirty secret of React containerisation: every VITE_* / REACT_APP_* / NEXT_PUBLIC_* variable is dead-end string-replaced into your bundle at build time. The browser has no process object. There is no runtime.

The workarounds are all terrible:

  • envsubst on minified JS — fragile, mutates your container filesystem
  • fetch('/config.json') at startup — loading delays, race conditions, you're just moving the problem
  • window.__ENV__ set via a shell script — no standard, no security model, requires Node.js or bash in your prod image

There's a better way.


Introducing REP Protocol

REP (Runtime Environment Protocol) is a lightweight open protocol that injects environment variables into your React app at container startup, not at build time. A tiny Go binary (the gateway) reads your REP_* environment variables when the container boots, encrypts the sensitive ones, and injects them as an inert <script> tag into your HTML before the browser ever sees it.

Your React code stays clean:

import { useRep, useRepSecure } from '@rep-protocol/react';

function App() {
  const apiUrl = useRep('API_URL');                         // synchronous, no loading state
  const { value: analyticsKey } = useRepSecure('ANALYTICS_KEY'); // encrypted, async
}
Enter fullscreen mode Exit fullscreen mode

Same image. Different docker run -e flags. Done.


The Security Tier System

REP uses a prefix convention that forces you to make an explicit security decision for every variable:

Prefix Tier Behaviour
REP_PUBLIC_* PUBLIC Plaintext in page source. Sync access via useRep().
REP_SENSITIVE_* SENSITIVE AES-256-GCM encrypted. Async access via useRepSecure().
REP_SERVER_* SERVER Never sent to the browser. Gateway-only.

The prefixes are stripped in your app: REP_PUBLIC_API_URL becomes rep.get('API_URL'). This is key — it means you can't accidentally grab a SERVER variable from client code. The namespace just doesn't exist on the client.

SENSITIVE vars are encrypted with AES-256-GCM at gateway startup using an ephemeral in-memory key. When useRepSecure() is called, the SDK fetches a single-use session key from /rep/session-key (30s TTL, rate-limited, origin-validated) and decrypts the blob in the browser. The plaintext is never stored anywhere.


Building the Todo App

Let's walk through a real example. Here's the full structure of a containerised React todo app using REP.

1. Install the packages

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

2. Write your React components

// App.tsx
import { useRep } from '@rep-protocol/react';

export default function App() {
  // useRep() is synchronous — no loading state, no Suspense, no useEffect
  const appTitle    = useRep('APP_TITLE', 'My App');
  const envName     = useRep('ENV_NAME',  'development');
  const maxTodosStr = useRep('MAX_TODOS', '10');
  const maxTodos    = parseInt(maxTodosStr ?? '10', 10);

  // ...
}
Enter fullscreen mode Exit fullscreen mode
// RepConfigPanel.tsx — shows both tiers in action
import { useRep, useRepSecure } from '@rep-protocol/react';
import { meta } from '@rep-protocol/sdk';

export function RepConfigPanel() {
  // PUBLIC tier — synchronous, available before first render
  const apiUrl = useRep('API_URL', '');

  // SENSITIVE tier — encrypted in HTML, decrypted on demand
  const { value: analyticsKey, loading, error } = useRepSecure('ANALYTICS_KEY');

  const repMeta = meta(); // injected_at, integrity valid?, hot_reload enabled?

  return (
    <div>
      <p>API URL: {apiUrl}</p>
      <p>
        Analytics key:{' '}
        {loading ? 'fetching…' : error ? 'unavailable' : analyticsKey}
      </p>
      {repMeta && (
        <p>Integrity: {repMeta.integrityValid ? '✓ valid' : '✗ tampered'}</p>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

useRep() re-renders automatically on hot reload — if the gateway detects a config change via SSE, subscribed components update in the browser without a page refresh.

3. Write the Dockerfile

This is where it gets interesting. The production image is built from scratch — no OS, no shell, no Node.js. Just the Go gateway binary and your static files.

# Stage 1: Download the REP gateway binary
FROM alpine:3.21 AS gateway

ARG GATEWAY_VERSION=0.1.3
ARG TARGETARCH=amd64

RUN apk add --no-cache curl ca-certificates && \
    ARCHIVE="rep-gateway_${GATEWAY_VERSION}_linux_${TARGETARCH}.tar.gz" && \
    curl -fsSL \
      "https://github.com/RuachTech/rep/releases/download/v${GATEWAY_VERSION}/${ARCHIVE}" \
      -o /tmp/gateway.tar.gz && \
    tar -xzf /tmp/gateway.tar.gz -C /tmp && \
    mv /tmp/rep-gateway /rep-gateway && \
    chmod +x /rep-gateway

# Stage 2: Build the React app
FROM node:20-alpine AS app

WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build

# Stage 3: Minimal runtime image
FROM scratch

COPY --from=gateway /rep-gateway /rep-gateway
COPY --from=app /app/dist /static
COPY --from=gateway /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

USER 65534:65534
EXPOSE 8080

ENTRYPOINT ["/rep-gateway", "--mode", "embedded", "--static-dir", "/static"]
Enter fullscreen mode Exit fullscreen mode

The final image contains:

  • One Go binary (~5MB)
  • Your dist/ folder
  • CA certificates

No Node.js. No nginx. No bash. FROM scratch means the attack surface is essentially zero.

4. Build once, deploy everywhere

docker build -t rep-todo .

# Development
docker run --rm -p 8080:8080 \
  -e REP_PUBLIC_APP_TITLE="REP Todo" \
  -e REP_PUBLIC_ENV_NAME=development \
  -e REP_PUBLIC_API_URL=http://localhost:3001 \
  -e REP_PUBLIC_MAX_TODOS=10 \
  -e REP_SENSITIVE_ANALYTICS_KEY=ak_demo_abc123 \
  rep-todo

# Staging — same image, different flags
docker run --rm -p 8080:8080 \
  -e REP_PUBLIC_APP_TITLE="REP Todo (Staging)" \
  -e REP_PUBLIC_ENV_NAME=staging \
  -e REP_PUBLIC_API_URL=https://api.staging.example.com \
  -e REP_PUBLIC_MAX_TODOS=20 \
  -e REP_SENSITIVE_ANALYTICS_KEY=ak_staging_xyz789 \
  rep-todo
Enter fullscreen mode Exit fullscreen mode

The exact same rep-todo:latest image runs in both environments. The image that passed your CI tests is literally the binary that goes to production.


Going Further: The Optional .rep.yaml Manifest

The gateway works without any manifest — you can stop at step 4 and ship. But if you want typed variables, runtime validation, and bundle scanning, add a .rep.yaml to your project.

# .rep.yaml
version: "0.1.0"

variables:
  APP_TITLE:
    tier: public
    type: string
    default: "REP Todo"

  ENV_NAME:
    tier: public
    type: enum
    required: true
    values: ["development", "staging", "production"]

  API_URL:
    tier: public
    type: url
    required: true

  MAX_TODOS:
    tier: public
    type: number
    default: "10"

  ANALYTICS_KEY:
    tier: sensitive
    type: string

settings:
  strict_guardrails: false  # set to true in production
  hot_reload: true
Enter fullscreen mode Exit fullscreen mode

TypeScript types from your manifest

npx rep typegen
Enter fullscreen mode Exit fullscreen mode

This generates src/rep.d.ts, augmenting the SDK with typed overloads derived directly from your variable declarations:

// src/rep.d.ts — auto-generated, do not edit
declare module "@rep-protocol/sdk" {
  export interface REP {
    // Only declared PUBLIC keys are valid — anything else is a compile-time error
    get(key: "APP_TITLE" | "ENV_NAME" | "API_URL" | "MAX_TODOS"): string | undefined;

    // Only declared SENSITIVE keys are valid here
    getSecure(key: "ANALYTICS_KEY"): Promise<string>;
  }
}
Enter fullscreen mode Exit fullscreen mode

Rename a variable in .rep.yaml, re-run typegen, and TypeScript marks every call site that broke. Wire it into prebuild and types stay in sync automatically.

Runtime validation at container startup

Point the gateway at your manifest and it validates the full variable set before serving a single request:

ENTRYPOINT ["/rep-gateway", "--mode", "embedded", "--static-dir", "/static", "--manifest", "/static/.rep.yaml"]
Enter fullscreen mode Exit fullscreen mode

Or via env var: REP_GATEWAY_MANIFEST=/static/.rep.yaml.

Missing a required variable? The container refuses to start:

manifest validation: manifest validation failed:
  - required variable "ENV_NAME" is not set
  - required variable "API_URL" is not set
Enter fullscreen mode Exit fullscreen mode

Type mismatches, invalid enum values, and regex pattern failures are all caught the same way — hard failures at container startup, before any traffic reaches your app.


Local Development

You don't need Docker to work locally. The CLI wraps the gateway binary and gives you a dev server:

# Copy the example env file
cp .env.example .env.local

# Terminal 1: start Vite as usual
npm run dev

# Terminal 2: start the REP gateway (proxies Vite at :5173)
npx rep dev --port 3000 --env .env.local --proxy http://localhost:5173 --hot-reload
Enter fullscreen mode Exit fullscreen mode

Now open http://localhost:3000. Your app runs through the gateway — config is injected, encryption works, hot reload is live.

Edit .env.local, change REP_PUBLIC_APP_TITLE to something else. The browser updates instantly, no page refresh.

The CLI also includes rep lint, which scans your built bundle for accidentally leaked secrets:

npx rep lint ./dist
Enter fullscreen mode Exit fullscreen mode

It runs Shannon entropy analysis and pattern matching (AWS keys, JWTs, GitHub tokens, Stripe keys, etc.) against your build output. Useful even without a manifest — it catches secrets that slipped into the wrong tier regardless.


What Happens at Runtime (Under the Hood)

When the container starts:

  1. Gateway reads all REP_* environment variables
  2. Classifies them into PUBLIC / SENSITIVE / SERVER (by prefix)
  3. Runs guardrails — entropy scan + known secret format detection — on PUBLIC vars
  4. Generates an ephemeral AES-256 key and HMAC secret (in-memory only, never persisted)
  5. Encrypts SENSITIVE vars into a base64 blob
  6. Computes an HMAC-SHA256 integrity token over canonical JSON
  7. Pre-renders the <script id="__rep__" type="application/json"> tag

On each browser request, the gateway serves your index.html with the script tag injected before </head>. The script type is application/json — the browser does not execute it. It's inert data.

The SDK reads it synchronously on module load:

// This happens on import, before your component tree renders
const script = document.getElementById('__rep__');
const payload = JSON.parse(script.textContent);
Enter fullscreen mode Exit fullscreen mode

useRep() reads from the parsed payload. Zero network calls. Zero loading states.


Migrating an Existing Project

You can adopt REP gradually — it works alongside your existing build-time vars while you migrate component by component. But if you want to move fast, the codemod handles the bulk of the transformation automatically.

Option A — Automated (recommended for larger codebases)

# Vite
npx @rep-protocol/codemod --framework vite --src ./src

# Create React App
npx @rep-protocol/codemod --framework cra --src ./src

# Next.js
npx @rep-protocol/codemod --framework next --src ./src
Enter fullscreen mode Exit fullscreen mode

The codemod rewrites your source files in place. For a Vite project it transforms:

// Before
import.meta.env.VITE_API_URL
import.meta.env.VITE_FEATURE_FLAGS
Enter fullscreen mode Exit fullscreen mode
// After
import { rep } from '@rep-protocol/sdk';

rep.get('API_URL')
rep.get('FEATURE_FLAGS')
Enter fullscreen mode Exit fullscreen mode

It also adds the SDK import where missing and strips the VITE_ / REACT_APP_ / NEXT_PUBLIC_ prefixes from your variable names throughout. After running it, rename your env vars in .env files and CI config accordingly (VITE_API_URLREP_PUBLIC_API_URL), then wire up the gateway.

Option B — Manual (better for small projects or incremental adoption)

Install the packages and replace calls one file at a time:

npm install @rep-protocol/sdk @rep-protocol/react
npm install -D @rep-protocol/cli
Enter fullscreen mode Exit fullscreen mode
- const apiUrl = import.meta.env.VITE_API_URL;
+ import { rep } from '@rep-protocol/sdk';
+ const apiUrl = rep.get('API_URL');
Enter fullscreen mode Exit fullscreen mode

Either way, rep lint ./dist is worth running before shipping to make sure nothing sensitive slipped into the wrong tier. If you add a .rep.yaml, rep typegen gives you typed overloads on top.


Docker Compose — The Twelve-Factor Way

services:
  frontend-staging:
    image: myapp:latest
    environment:
      REP_PUBLIC_API_URL: "https://api.staging.example.com"
      REP_PUBLIC_FEATURE_FLAGS: "dark-mode,beta-checkout"
      REP_SENSITIVE_ANALYTICS_KEY: "UA-XXXXX-staging"
      REP_SERVER_INTERNAL_SECRET: "never-reaches-browser"

  frontend-prod:
    image: myapp:latest  # SAME IMAGE
    environment:
      REP_PUBLIC_API_URL: "https://api.example.com"
      REP_PUBLIC_FEATURE_FLAGS: "dark-mode"
      REP_SENSITIVE_ANALYTICS_KEY: "UA-XXXXX-prod"
      REP_SERVER_INTERNAL_SECRET: "also-never-reaches-browser"
Enter fullscreen mode Exit fullscreen mode

One image. Two services. Config lives in the environment, where it belongs.


The Payoff

Before REP:

  • Build myapp:staging, build myapp:prod — two different binaries
  • Config change = rebuild = redeploy
  • Secrets in window.__ENV__ as plaintext

After REP:

  • Build once: myapp:latest
  • Config change = restart container with new env vars
  • Sensitive vars AES-encrypted, public vars integrity-verified, server vars never leave the gateway

The todo example is in the REP monorepo if you want to clone and run it. Full spec, security model, and integration guide are in spec/.


REP is open source under Apache 2.0. The spec is CC BY 4.0. Contributions welcome.

Top comments (0)