2025-11-25 | PreviewProof Team

Environment Variables for Ephemeral Preview Environments: A Pattern Guide

environment variablespreview environmentssecrets managementconfiguration

Environment variables in a long-lived environment are boring — set them once, rotate quarterly, move on. In an ephemeral preview environment, they become one of the most quietly painful parts of the system. Some values are the same across every preview. Some are unique per preview. Some are inherited but overridable per branch. Some are secrets with their own rotation lifecycle. Some are public values baked in at build time. Treating all of them as a single flat .env file is how teams end up with previews that “almost work” for months.

The pattern that holds up under load is to think of preview environment variables as four layers, each with a different source of truth and a different lifecycle.

The Four Layers

Layer 1: Base configuration. The defaults that apply to every preview environment of a given service. NODE_ENV=production, LOG_LEVEL=info, FEATURE_FLAG_PROVIDER=launchdarkly. These rarely change and live in the repo, often as a checked-in .env.preview file or in a preview block in your platform config.

Layer 2: Preview-class secrets. Sandbox or test-mode credentials shared across all previews — the Stripe test key, the Twilio test SID, the SendGrid sandbox key, the Auth0 development tenant. These are secrets, but they’re identical for every preview. They live in your secrets manager scoped to the preview environment class, not per-PR.

Layer 3: Per-preview computed values. The ones that don’t exist until the preview is created. DATABASE_URL for that preview’s database, PUBLIC_APP_URL matching the assigned hostname, SERVICE_API_URL pointing at the matching backend preview, BRANCH_NAME for cache busting. These are generated at provision time by whatever creates the preview, and they’re often the values that go wrong silently.

Layer 4: Per-PR overrides. The escape hatch. A developer working on a PR needs LOG_LEVEL=debug for that preview only, or wants to point at a non-default OAuth tenant to test a flow. These should be possible — without code changes — but they shouldn’t be the default path.

The mistake most teams make is conflating layers 2 and 3. They put DATABASE_URL in their secrets manager as a single value and then wonder why every preview shares a database. Or they put their Stripe test key in per-preview env vars and end up with 40 copies of the same secret rotating out of sync.

Build-Time vs Runtime: The Next.js Trap

The most common pitfall in modern stacks is the build-time/runtime distinction. Next.js bakes any variable prefixed with NEXT_PUBLIC_ into the JavaScript bundle at build time. Vite does the same with VITE_. CRA did it with REACT_APP_.

This matters in previews because per-preview values — NEXT_PUBLIC_API_URL, NEXT_PUBLIC_SITE_URL — need to be known at build time. If you build the image once and inject env vars at deploy time, the public values reflect whatever they were when the image was built, not what the preview was assigned.

Three options:

  1. Build per preview. Slow, but correct. Per-preview values become build args.
  2. Runtime injection via a config endpoint. The server exposes a /config.json the client fetches on boot. Build is portable, values are accurate. The cost is one extra request and a bit of hydration choreography.
  3. Edge-rewriting placeholders. Build with __PUBLIC_API_URL__ and rewrite at the edge. Fast, ugly, effective.

Option 2 is the right default. It pairs with database migrations for preview environments — boot reads runtime config the same way it reads connection strings.

URLs Inside Env Vars

The other recurring problem is env var values that contain URLs. WEBHOOK_BASE_URL, OAUTH_REDIRECT_URI, CORS_ALLOWED_ORIGINS, CALLBACK_URL, CSP_CONNECT_SRC. In a preview, these all need to point at the dynamic preview hostname — not at production, not at staging, not at localhost.

The cleanest pattern is to derive them from a single canonical PREVIEW_BASE_URL per service, set by whatever provisions the preview, and compute everything else from it at runtime:

config.ts
const baseUrl = process.env.PREVIEW_BASE_URL ?? process.env.PUBLIC_APP_URL!;
export const config = {
baseUrl,
oauthRedirect: `${baseUrl}/auth/callback`,
webhookEndpoint: `${baseUrl}/webhooks/stripe`,
corsOrigins: [baseUrl],
};

Don’t store the derived URLs as separate env vars. They go stale the moment someone rebuilds the preview with a different hostname. Compute them. (For OAuth specifically, the dynamic-URL problem has its own ugly corners — see OAuth callback URLs and ephemeral previews.)

Where the Values Live: Tooling

Doppler and Infisical support hierarchical configs (root, environment, branch overrides) and integrate with preview platforms via CLI or webhook. Good fit when you want layers 2 and 4 in one place with an audit log.

Platform-native solutions — Vercel env vars, Render env groups, Railway shared variables, AWS Parameter Store with hierarchical paths — get you most of the way for free if you’re already on those platforms. The limitation is that “preview-scoped” usually means “all previews,” so you still need a separate mechanism for per-PR overrides.

HashiCorp Vault with dynamic secrets is the right answer for service-to-service credentials where each preview needs a real, short-lived database user. Heavier setup, much better security posture.

Plain .env files in the repo are fine for layer 1, not for layers 2-4. If your secrets are checked into git “but only the dev tenant ones, it’s fine,” they’re checked into git.

Per-Preview Secrets: A Security Note

A tempting pattern is to inject different secret values per preview — fresh API keys, per-preview JWT signing secrets, isolated tenant credentials. Good reasons exist, especially for service-to-service auth in multi-service previews.

There’s also a real cost. Per-preview secrets mean per-preview rotation, revocation, and audit. If you generate them, you must clean them up — every torn-down preview should revoke its credentials. Otherwise you accumulate orphaned credentials indefinitely. Make the teardown step real and verify it.

The honest default: layer 2 secrets (sandbox keys for third parties) are shared across previews. Layer 3 secrets (database credentials, internal service tokens) are per-preview, generated at provision and revoked at teardown. Don’t mix them.

What Good Looks Like

A working setup has a few visible properties: any developer can spin up a preview without manually configuring values; per-preview values are computed from a single source of truth; secrets are scoped to the right level and rotate without manual edits; teardown revokes whatever credentials it was issued.

If you’re squinting at a .env.preview.example with 40 lines and a comment saying “ask Slack channel for actual values,” you’re not there yet.


The pattern above is what we ended up with after building PreviewProof, where every PR gets its own preview with its own database, its own URLs, and its own scoped credentials — and where teardown actually revokes what it provisioned. If you’d rather not build the four-layer model yourself, that’s the layer we handle. If you would, the model above is the one we’d recommend regardless of what you use to deploy.