Skip to main content
DevOps, Deployment & Operations
🚀 DevOps & OpsLesson 3 of 13

Config & Secrets in Production (12-Factor)

The 12-factor way: configuration and secrets that are safe in production.

Config & Secrets in Production (12-Factor)

Stage 3 · DevOps, Deployment & Operations · B.U.I.L.D. letter: D

You shipped it. The site is live. Then you get paged at 2 a.m. because someone hard-coded the database URL, pushed it to GitHub, and the credentials rotated. Or worse — the app silently connected to the dev database from the production server all weekend. Config discipline isn't glamour work. It's the difference between a stable system and a liability. This lesson gives you the ops mindset to do it right from the start.


⚠️ The vibe trap

You built the front end fast, you got the app deployed, and the config "works." The vibe coder's first config sin is baking values into code: const DB_URL = "postgres://prod-host/mydb". The second is branching on environment in the app itself: if (process.env.NODE_ENV === 'production') { ... }. That spreads your ops concerns through every file. The third is skipping validation — your app boots fine locally, crashes in prod with Cannot read properties of undefined because API_KEY is blank, and it takes 20 minutes of log archaeology to find out why. These patterns scale badly. Let's fix all three.


🌍 12-Factor Rule: Config Lives in the Environment

The 12-Factor App defines config as anything that varies between deploys — development, staging, production. The rule is simple: store that config in environment variables, not in code.

What counts as config?

  • Database connection strings
  • External API URLs and credentials
  • Feature flag values
  • Port numbers, base URLs, timeout thresholds
  • Any value that would change if you open-sourced the repo tomorrow

What is not config?

  • Business logic
  • UI layout decisions
  • Anything that is the same across every environment
12-Factor Config Table — What Goes Where

| Value                          | In code? | In .env? | In platform env? |
|--------------------------------|----------|----------|------------------|
| DATABASE_URL                   | NO       | dev only | YES (prod)       |
| STRIPE_SECRET_KEY              | NO       | dev only | YES (prod)       |
| NEXT_PUBLIC_SITE_URL           | NO       | dev only | YES (prod)       |
| API base URL (varies per env)  | NO       | dev only | YES (prod)       |
| Pagination page size (fixed)   | YES      | —        | —                |
| UI color theme                 | YES      | —        | —                |
| App version string             | YES      | —        | —                |

Mental model: Your code repo is the same artifact in every environment. Only the environment variables around it change. If you need to change a value to switch environments, that value is config — move it out of the code.

Why it matters: A single deployable artifact means your staging deploy is identical code to your production deploy. You've proven the code in staging. If config is the only difference, you trust the promotion.

Common mistake: Committing .env.production to the repo. A .env file is a local developer convenience. Production config lives in the platform — never in version control.


🔐 Platform Secret Stores & Runtime Injection

On your laptop, a .env file is fine. In production, you need something that:

  • Is not in git
  • Has access controls (not everyone can read prod DB creds)
  • Has an audit trail (who changed DATABASE_URL and when?)
  • Can rotate credentials without a redeploy

Every major platform has this:

PlatformSecret storeHow env vars land in your process
VercelProject → Settings → Environment VariablesInjected at build and runtime
RailwayVariables tab per serviceInjected at container start
AWSSSM Parameter Store / Secrets ManagerIAM role + SDK fetch, or ECS injection
KubernetesSecrets → mounted as env or volumeInjected into pod spec
RenderEnvironment tab per serviceInjected at runtime

The platform injects the values into your process's environment at start time. Your code reads them with process.env.DATABASE_URL. The values are never in your filesystem and never in your repo.

# Setting production env vars — Vercel CLI example
vercel env add DATABASE_URL production
# paste the value when prompted — it never touches your terminal history in plain text

# Railway CLI example
railway variables set DATABASE_URL="postgres://..." --environment production

# Confirm what's set (values are redacted in most CLIs)
vercel env ls production

Mental model: Think of the platform as a lockbox that hands your app a sealed envelope of config at boot. Your code only reads the envelope — it never knows or cares where the values came from.

Why it matters: Rotating a credential becomes a 30-second ops action (update the secret in the platform, restart the service) instead of a code change, PR, review, and deploy.

Common mistake: Setting env vars on the build container and expecting them to appear at runtime, or vice versa. Know your platform's distinction. On Vercel, most variables are available both at build and runtime unless scoped otherwise. On AWS ECS, task-definition env vars are runtime-only.


🚦 Config Validation at Boot — Fail Fast

A missing env var is a deployment bug. The worst time to discover it is when a user hits a 500 error at 3 a.m. The best time is the moment the process starts. Validate all required config at boot, throw loudly, and refuse to start if anything is missing.

// config.js — validate and export all config at boot
// Pattern works in Node, Next.js API routes, Express, etc.

function requireEnv(name) {
  const value = process.env[name];
  if (!value || value.trim() === '') {
    // This intentionally crashes the process at startup.
    // A noisy crash now beats a silent failure later.
    throw new Error(
      `[config] Missing required environment variable: ${name}\n` +
      `  Set it in your platform's environment settings before deploying.`
    );
  }
  return value;
}

function optionalEnv(name, defaultValue) {
  return process.env[name] ?? defaultValue;
}

export const config = {
  // Required — will throw at boot if absent
  databaseUrl:      requireEnv('DATABASE_URL'),
  stripeSecretKey:  requireEnv('STRIPE_SECRET_KEY'),
  jwtSecret:        requireEnv('JWT_SECRET'),

  // Optional with safe defaults
  port:             parseInt(optionalEnv('PORT', '3000'), 10),
  logLevel:         optionalEnv('LOG_LEVEL', 'info'),
  maxUploadMb:      parseInt(optionalEnv('MAX_UPLOAD_MB', '10'), 10),

  // Derived — computed once from raw values
  isProduction:     process.env.NODE_ENV === 'production',
};

// Usage everywhere else:
// import { config } from './config.js';
// const db = new Client({ connectionString: config.databaseUrl });

Mental model: Your config.js is the single front door for all configuration. Nothing in your app reaches into process.env directly — it goes through the validated config object. If the front door can't open, the building doesn't admit anyone.

Why it matters: A deployment with a missing env var fails immediately at startup with a clear error message. Your monitoring sees a crash loop, you get an alert, you fix it in minutes — before any user sees a broken page.

Common mistake: Validating config inside individual modules (const db = process.env.DATABASE_URL ?? crash()). If that module is lazily loaded, the crash happens at the worst possible time. Validate everything at startup, in one place.


🏗️ Build-Time vs Runtime Config — The Next.js Gotcha

This one bites almost every vibe coder who deploys a Next.js app for the first time.

Next.js (and most bundlers) have two config moments:

  1. Build time — when next build runs. Any process.env.X used in a Client Component gets baked into the JavaScript bundle. The value is frozen. If you change it later, you must rebuild.
  2. Runtime — when the Node.js server process starts. Server Components and API routes can read process.env.X live at request time.

The convention: any env var prefixed NEXT_PUBLIC_ is inlined at build time and available on the client. Everything else is server-only and read at runtime.

# .env.local (dev only — never committed)
DATABASE_URL=postgres://localhost:5432/myapp_dev        # runtime, server-only
NEXT_PUBLIC_SITE_URL=http://localhost:3000              # build-time, client-visible
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_abc123      # build-time, client-visible
STRIPE_SECRET_KEY=sk_test_xyz789                       # runtime, server-only — NEVER prefix with NEXT_PUBLIC_

# In production (set in Vercel/platform settings):
# NEXT_PUBLIC_SITE_URL=https://yourapp.com
# NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
# DATABASE_URL=postgres://...prod-host.../myapp
# STRIPE_SECRET_KEY=sk_live_...

Mental model: NEXT_PUBLIC_ means "stamp this value into every user's browser bundle at build time." It's public by definition — do not put anything secret there. Server-side vars stay on the server and are read fresh each deploy.

Why it matters: If you set NEXT_PUBLIC_API_URL in your platform after an existing build, nothing changes — the old URL is frozen in the bundle. You must trigger a new build. Many support tickets are born here.

Common mistake: Prefixing a secret key with NEXT_PUBLIC_ to "make it work on the client." That key is now in every user's browser. Reference D3 Lesson 8 (sec-08-secrets-management) for the full security depth on this. Short answer: move the logic to a Server Component or an API route.


🚩 Feature Flags — Config That Turns Behavior On and Off

Feature flags are boolean (or graduated) config values that enable or disable behavior at runtime — without a code deploy. They are a natural extension of 12-factor config.

// Simple in-process feature flags from env vars
// Set FEATURE_NEW_CHECKOUT=true in staging; leave unset in prod
export const flags = {
  newCheckout:     process.env.FEATURE_NEW_CHECKOUT === 'true',
  betaDashboard:   process.env.FEATURE_BETA_DASHBOARD === 'true',
  maintenanceMode: process.env.MAINTENANCE_MODE === 'true',
};

// Usage
if (flags.maintenanceMode) {
  return <MaintenancePage />;
}

For sophisticated rollout (% of users, per-account, A/B testing), use a dedicated service like LaunchDarkly, PostHog feature flags, or Growthbook. Those services expose the same env-var-friendly config pattern but add targeting rules, analytics, and instant toggles without redeploys.

Mental model: Feature flags let you separate deploy (push new code) from release (turn on behavior). You can ship every day and release on your schedule.

Why it matters: You can disable a broken feature in 30 seconds from a dashboard without a rollback. This is the operational superpower behind continuous delivery.

Common mistake: if (process.env.NODE_ENV === 'production') as a feature flag. That couples feature availability to the deployment environment, not to an intentional decision. If you want something off in dev and on in prod, use an explicit flag.


🛠️ Your Mission

Take an existing app (a vibe-coded project or a Stage 2 project you built) and give it production-grade config:

  1. Create a config.js (or config.ts) module that imports nothing from process.env except through requireEnv / optionalEnv helpers — validate every required variable at boot.
  2. Audit your codebase for any process.env.X calls outside that module and replace them with imports from config.
  3. Move every per-environment value out of code and into environment variables. Delete any if (process.env.NODE_ENV === 'production') config branches.
  4. In your deployment platform (Vercel, Railway, or wherever you ship), set the production values in the platform's secret store — not in a committed file.
  5. Confirm that starting the app locally with a missing required var produces a clear, immediate error message naming the missing variable.
  6. Identify any NEXT_PUBLIC_ vars (or your framework's equivalent) and document which are baked at build time and which are runtime-only.

✅ You're done when…

  • Your app passes the Production-Readiness Checklist: no process.env calls outside config.js, all required vars validated at boot, no secrets in version control
  • Removing any single required env var causes the app to refuse to start with a named error (not a silent crash 10 requests later)
  • Every per-environment value is set in the platform's environment settings, not in a committed file
  • You can explain to a teammate which of your env vars are baked at build time and which are read at runtime — and why that distinction matters
  • No if (prod) config branches remain in your application code

➡️ Next: CI/CD Pipelines. Build It Right, Or Don't Build It At All. 🏛️

Always-on rigor toolkit

🏛️ Build It Right, Or Don't Build It At All.