Home/Learn/Full-Stack Integration/Configuration Across Two Deployables

Configuration Across Two Deployables

Intermediate
Environment & Config

Two 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.

The prefix is a publication decision
# 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 it

Validate at Startup

Fail loudly on boot rather than quietly at 2am.

Crash on boot, not on the first request
# 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 string

Where They Live

Per environment, and what must never be committed.

.env.example committed, secrets rotated not hidden
# 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 Aria
1

Why can a frontend environment variable never hold a secret?

Easy
2

Why validate environment variables at startup rather than where they are used?

Medium
3

An API key was committed and then removed in a later commit. Is that sufficient?

Medium

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.

Loading discussion…