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:
-
envsubston 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
}
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
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);
// ...
}
// 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>
);
}
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"]
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
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
TypeScript types from your manifest
npx rep typegen
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>;
}
}
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"]
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
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
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
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:
- Gateway reads all
REP_*environment variables - Classifies them into PUBLIC / SENSITIVE / SERVER (by prefix)
- Runs guardrails — entropy scan + known secret format detection — on PUBLIC vars
- Generates an ephemeral AES-256 key and HMAC secret (in-memory only, never persisted)
- Encrypts SENSITIVE vars into a base64 blob
- Computes an HMAC-SHA256 integrity token over canonical JSON
- 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);
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
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
// After
import { rep } from '@rep-protocol/sdk';
rep.get('API_URL')
rep.get('FEATURE_FLAGS')
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_URL → REP_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
- const apiUrl = import.meta.env.VITE_API_URL;
+ import { rep } from '@rep-protocol/sdk';
+ const apiUrl = rep.get('API_URL');
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"
One image. Two services. Config lives in the environment, where it belongs.
The Payoff
Before REP:
- Build
myapp:staging, buildmyapp: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)