CodingNic

From Development to Production

Managing Secrets Across Environments

From Development to Production 15 min read

Managing Secrets Across Environments

Objectives

By the end of this lesson, you should be able to:

  • Explain why each environment needs its own, separate secrets
  • Validate required environment variables at startup, and fail loudly if any are missing
  • Explain where production secrets actually come from in a real deployment

💡 Why this matters: Course 3 (Module 6) covered keeping a single secret out of source control. A real application has multiple environments, each needing its own secrets, and a startup that silently continues with a missing or malformed secret is its own kind of production incident, this lesson covers both.

⚠️ A note on verification: every snippet and every output in this lesson was actually run.

Why Every Environment Needs Its Own Secrets

A JWT_SECRET shared between development and production means anyone with the (far less carefully guarded) development secret can forge valid tokens in production, exactly the forgery scenario Course 3, Module 3 covered, now made worse by an unnecessarily wide blast radius. A DATABASE_URL shared between staging and production means a mistake made while testing in staging can destroy real production data. Every environment gets its own secrets, generated separately, never reused across environments, this is a hard rule, not a preference.

Validating Required Environment Variables at Startup

javascript
const { z } = require('zod');

const envSchema = z.object({
  NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
  PORT: z.string().regex(/^\d+$/).default('3000'),
  JWT_SECRET: z.string().min(16, 'JWT_SECRET must be at least 16 characters'),
  DATABASE_URL: z.string().min(1, 'DATABASE_URL is required')
});

function validateEnv() {
  const result = envSchema.safeParse(process.env);
  if (!result.success) {
    console.error('Invalid environment configuration:');
    for (const issue of result.error.issues) {
      console.error(`  ${issue.path.join('.')}: ${issue.message}`);
    }
    throw new Error('Refusing to start with invalid environment configuration');
  }
  return result.data;
}

module.exports = { validateEnv };

The same Zod pattern Course 3 used for request bodies (Module 5), applied to process.env instead, envSchema declares exactly which environment variables are required, and what shape each one needs, validateEnv() is meant to be called once, at application startup, before anything else runs.

What Happens with a Valid Environment

javascript
process.env.NODE_ENV = 'development';
process.env.JWT_SECRET = 'a-long-enough-dev-secret';
process.env.DATABASE_URL = 'postgresql://localhost:5432/myapp_dev';
console.log('Valid:', validateEnv());
text
Valid: {
  NODE_ENV: 'development',
  PORT: '3000',
  JWT_SECRET: 'a-long-enough-dev-secret',
  DATABASE_URL: 'postgresql://localhost:5432/myapp_dev'
}

What Happens with a Missing Secret

javascript
process.env.NODE_ENV = 'production';
delete process.env.JWT_SECRET;
delete process.env.DATABASE_URL;
try {
  validateEnv();
} catch (err) {
  console.log('Threw as expected:', err.message);
}
text
Invalid environment configuration:
  JWT_SECRET: Invalid input: expected string, received undefined
  DATABASE_URL: Invalid input: expected string, received undefined
Threw as expected: Refusing to start with invalid environment configuration

This is the entire point: an application starting up with a missing JWT_SECRET shouldn’t run at all, quietly falling back to undefined, or an empty string, and only failing later, on the first real request, is far worse than refusing to start in the first place, with a clear message naming exactly what’s missing.

Where Production Secrets Actually Come From

Never a .env.production file sitting on a server’s disk, or worse, in a repository. Every real hosting platform, and every CI/CD system, provides its own mechanism, an environment variables panel in a platform’s dashboard, a secrets manager, or encrypted CI/CD secrets, injected into the running process’s environment at deploy time, process.env.JWT_SECRET reads the same either way, the application code never needs to know or care which mechanism supplied it. Module 5 of this course covers configuring this concretely, on a real platform.

Try It

  1. Build envSchema and validateEnv(), and confirm a valid environment passes and an invalid one throws with a clear, specific message.
  2. Add a rule requiring JWT_SECRET to be at least 32 characters in production specifically (hint: use .refine() on the whole object, checking NODE_ENV and JWT_SECRET.length together), and confirm a 20-character secret passes in development but fails in production.
  3. Explain, in one or two sentences, why failing loudly at startup is better than an application starting successfully with a missing secret and failing later, on the first request that needs it.
  4. List two secrets a real API in this track would need (beyond JWT_SECRET and DATABASE_URL), and explain briefly why each shouldn’t be shared between environments.

Recap

  • Every environment needs its own secrets, generated separately, never reused, sharing a secret across environments widens the damage a single leak can cause.
  • Validating required environment variables at startup, and refusing to run with a missing or malformed one, catches a real class of production incident before it happens, not after.
  • Real production secrets come from a hosting platform or CI/CD system’s own secrets mechanism, never a file on disk or in version control.

This is the final lesson of this module before exercises. Next lesson: exercises, building a complete, environment-aware, validated configuration module.