Configuration Across Two Deployables
IntermediateTwo apps, two sets of variables, and one hard rule: anything the browser can read is public forever, because it is compiled into the bundle.
Overview
Configuration stops being trivial the moment there are two deployables, because the same conceptual setting now exists in two places and can disagree. The distinction that actually matters is not frontend versus backend but public versus secret: a frontend variable is substituted into the JavaScript at build time and shipped to every visitor, so a key placed there is published, not configured. The second recurring surprise is build-time versus runtime — changing a frontend variable requires a rebuild, while a backend one takes effect on restart.
Public and Secret
The prefix rule, and why it exists.
# Frontend (.env.local) — NEXT_PUBLIC_ is inlined into the bundle
NEXT_PUBLIC_API_URL=https://api.aicancode.org
NEXT_PUBLIC_RAZORPAY_KEY_ID=rzp_live_xxx # publishable by design
NEXT_PUBLIC_POSTHOG_KEY=phc_xxx
# Backend (Fly secrets) — never leaves the server
DATABASE_URL=postgres://...
JWT_SECRET=...
RAZORPAY_KEY_SECRET=...
OPENAI_API_KEY=...
// The rule: NEXT_PUBLIC_ (or VITE_) is a declaration that this value
// is public. It is substituted at BUILD time and lives in the shipped
// JavaScript forever — including in every previously deployed bundle.
// So a secret placed there is not "leaked if someone looks", it is
// published. Rotate it; you cannot unpublish it.
// A server-only variable is simply unprefixed, and reading it from a
// client component returns undefined by design:
process.env.JWT_SECRET // undefined in the browser
// The dangerous middle: Next.js server components and route handlers
// CAN read secrets — and importing such a module into a client
// component is how a secret ends up in the bundle. Mark them:
import 'server-only' // build fails if a client imports itValidate at Startup
Fail loudly on boot rather than quietly at 2am.
# Backend — parse the whole environment once, at import time
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
DATABASE_URL: PostgresDsn
JWT_SECRET: str = Field(min_length=32)
ALLOWED_ORIGINS: list[str]
ENVIRONMENT: Literal["development", "staging", "production"]
RAZORPAY_KEY_ID: str
LOG_LEVEL: str = "INFO" # a sane default
settings = Settings() # missing or malformed -> crash on boot
# Without this, a missing JWT_SECRET is discovered by the first user
# who tries to log in, hours after the deploy.
// Frontend — the same idea
const env = z.object({
NEXT_PUBLIC_API_URL: z.string().url(),
}).parse({ NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL })
// Run it at build time so a missing variable fails the build.
// Every process.env value is a string. Coerce and validate:
PORT: int = 8000 # not "8000"
DEBUG: bool = False # "false" is truthy as a stringWhere They Live
Per environment, and what must never be committed.
# Local
.env.local # gitignored, real values
.env.example # COMMITTED, every key with a placeholder —
# this is the onboarding document
# Vercel: per-environment values in the dashboard or CLI
vercel env add NEXT_PUBLIC_API_URL production
# Production, Preview and Development are separate sets. Preview
# builds must point at a staging API, not production.
# Fly: secrets are encrypted and injected as env vars
flyctl secrets set JWT_SECRET=... -a tool-hub-api # triggers a restart
flyctl secrets list # names only, never values
# Rules:
# - .env* in .gitignore, except .env.example
# - never paste a secret into chat, a ticket or a log line
# - a leaked secret is rotated, not deleted from history — assume
# it was captured the moment it was exposed
# - separate credentials per environment, so a staging leak is not
# a production incident
# Detection, because this happens to everyone eventually:
gitleaks detect # in CI, and as a pre-commit hook
# And be careful with logging: a URL containing ?key=... appears in
# access logs, exception traces and error trackers. Put credentials
# in headers, never in query strings.Key Points to Remember
- 1A NEXT_PUBLIC_ or VITE_ variable is inlined at build time and published in every shipped bundle
- 2Server-only modules should import "server-only" so a client import fails the build instead of leaking a secret
- 3Validate and coerce the whole environment at startup so a missing value crashes on boot, not on the first request
- 4Commit .env.example as the onboarding document and keep real values per environment in Vercel and Fly
- 5A leaked secret must be rotated, not scrubbed — and credentials in a query string end up in access logs
Interview Questions
Sign in to ask AriaWhy can a frontend environment variable never hold a secret?
Why validate environment variables at startup rather than where they are used?
An API key was committed and then removed in a later commit. Is that sufficient?
Ask Aria about Configuration Across Two Deployables
Your personal AI tutor — ask anything about this concept
Revision Status
Personal Notes
Sign in to save personal notes for this topic.
Discussion
Sign in to join the discussion.