Config management is not glamorous but it is the foundation everything else sits on. This guide covers the full system: validated environment variables, environment-specific config, secrets that never touch your codebase, and the patterns that work from a single VPS to a multi-environment deployment.
The Problem With Unmanaged Config
Most teams have at minimum four environments: local development, CI, staging, and production. Each has different values for dozens of variables. Without a system:
Variables that exist in production but not in .env.example cause 3 AM surprises
A typo in an environment variable name silently falls back to undefined
Secrets rotate but old values persist on servers nobody remembers to update
A new developer clones the repo and cannot start the app without asking four people for values
The solution is three things: a schema that documents and validates every variable, a clear hierarchy for where values come from in each environment, and a secrets strategy that does not involve passing plaintext passwords in Slack.
Step 1 — Validated Config with Zod
Define every environment variable your app needs in one place. Validate at startup so the app crashes immediately with a clear error rather than failing mysteriously at runtime when a missing variable is first accessed.
// src/config/env.ts
import { z } from 'zod';
const EnvSchema = z.object({
// ── Server ──────────────────────────────────────────────
NODE_ENV: z.enum(['development', 'test', 'production', 'staging']),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error']).default('info'),
APP_VERSION: z.string().default('unknown'),
APP_URL: z.string().url(),
// ── Database ─────────────────────────────────────────────
DATABASE_URL: z
.string()
.url('DATABASE_URL must be a valid connection string')
.refine(
url => url.startsWith('postgresql://') || url.startsWith('postgres://'),
'DATABASE_URL must be a PostgreSQL connection string'
),
DATABASE_POOL_SIZE: z.coerce.number().int().min(1).max(100).default(10),
// ── Redis ─────────────────────────────────────────────────
REDIS_URL: z.string().url().optional(),
REDIS_HOST: z.string().default('localhost'),
REDIS_PORT: z.coerce.number().int().default(6379),
REDIS_PASSWORD: z.string().optional(),
// ── Auth ──────────────────────────────────────────────────
JWT_PRIVATE_KEY: z.string().min(1),
JWT_PUBLIC_KEY: z.string().min(1),
JWT_REFRESH_SECRET: z.string().min(32, 'JWT_REFRESH_SECRET must be at least 32 characters'),
// ── AWS ───────────────────────────────────────────────────
AWS_REGION: z.string().default('us-east-1'),
S3_BUCKET: z.string().optional(),
AWS_ACCESS_KEY_ID: z.string().optional(),
AWS_SECRET_ACCESS_KEY: z.string().optional(),
// ── Email ─────────────────────────────────────────────────
RESEND_API_KEY: z.string().optional(),
// ── Payments ──────────────────────────────────────────────
STRIPE_SECRET_KEY: z.string().optional(),
STRIPE_WEBHOOK_SECRET: z.string().optional(),
// ── Observability ─────────────────────────────────────────
SENTRY_DSN: z.string().url().optional(),
});
// Validate immediately — crash at startup if config is invalid
const result = EnvSchema.safeParse(process.env);
if (!result.success) {
console.error('\n❌ Invalid environment configuration:\n');
const errors = result.error.flatten().fieldErrors;
for (const [field, messages] of Object.entries(errors)) {
console.error(` ${field}: ${messages?.join(', ')}`);
}
console.error('\nCheck your .env file against .env.example\n');
process.exit(1);
}
export const env = result.data;
// Type-safe access everywhere — no more process.env.PORT as string
// env.PORT is number, env.DATABASE_URL is guaranteed non-null
Step 2 — The .env.example File
The .env.example file is your documentation. It is committed to git, contains no real values, and shows every variable the app needs with a comment explaining what it is for.
# .env.example — copy to .env and fill in real values
# DO NOT commit .env — it is in .gitignore
# ── Server ──────────────────────────────────────────────────
NODE_ENV=development
PORT=3000
LOG_LEVEL=info
APP_URL=http://localhost:3000
# ── Database ────────────────────────────────────────────────
# PostgreSQL connection string
# Format: postgresql://USER:PASSWORD@HOST:PORT/DATABASE
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/myapp_dev
DATABASE_POOL_SIZE=10
# ── Redis ───────────────────────────────────────────────────
REDIS_HOST=localhost
REDIS_PORT=6379
# REDIS_PASSWORD= # Not required in local dev
# ── Auth ────────────────────────────────────────────────────
# Generate with: openssl genrsa -out private.pem 2048 && openssl rsa -in private.pem -pubout -out public.pem
JWT_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n..."
JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n..."
# Generate with: openssl rand -hex 64
JWT_REFRESH_SECRET=generate_with_openssl_rand_hex_64
# ── AWS ─────────────────────────────────────────────────────
AWS_REGION=us-east-1
# S3_BUCKET=your-bucket-name # Only needed for file uploads
# AWS_ACCESS_KEY_ID= # Use IAM roles in production instead
# AWS_SECRET_ACCESS_KEY=
# ── Email ────────────────────────────────────────────────────
# Get from resend.com
# RESEND_API_KEY=re_...
# ── Payments ─────────────────────────────────────────────────
# Get from Stripe dashboard
# STRIPE_SECRET_KEY=sk_test_...
# STRIPE_WEBHOOK_SECRET=whsec_... # From: stripe listen --print-secret
Step 3 — Environment-Specific Config Objects
Some config is not secret — it is just different per environment. Build rates, feature defaults, timeouts. Put these in code, not in environment variables:
// src/config/index.ts
import { env } from './env';
const configs = {
development: {
rateLimits: {
loginAttempts: 100, // Relaxed for development
apiRequests: 10000,
},
email: {
sendReal: false, // Log emails instead of sending in dev
},
cache: {
ttl: 10, // Short TTL for development iteration
},
},
test: {
rateLimits: {
loginAttempts: 1000, // High limit so tests don't hit rate limits
apiRequests: 100000,
},
email: {
sendReal: false,
},
cache: {
ttl: 1,
},
},
staging: {
rateLimits: {
loginAttempts: 10,
apiRequests: 500,
},
email: {
sendReal: true, // Send real emails in staging
},
cache: {
ttl: 60,
},
},
production: {
rateLimits: {
loginAttempts: 5,
apiRequests: 200,
},
email: {
sendReal: true,
},
cache: {
ttl: 300,
},
},
};
export const config = {
env: env.NODE_ENV,
port: env.PORT,
isDev: env.NODE_ENV === 'development',
isTest: env.NODE_ENV === 'test',
isProd: env.NODE_ENV === 'production',
...configs[env.NODE_ENV],
};
Step 4 — Secrets That Never Touch Your Codebase
For a VPS deployment, secrets belong on the server — not in CI environment variables, not in your .env.example, not in Slack.
Generating secrets correctly:
# JWT RSA key pair
openssl genrsa -out /opt/app/secrets/jwt_private.pem 2048
openssl rsa -in /opt/app/secrets/jwt_private.pem \
-pubout -out /opt/app/secrets/jwt_public.pem
# Random secrets
openssl rand -hex 64 > /opt/app/secrets/jwt_refresh_secret
openssl rand -hex 32 > /opt/app/secrets/session_secret
# Set permissions — readable only by the app user
chmod 600 /opt/app/secrets/*
chown deploy:deploy /opt/app/secrets/*
The production .env file — set once via SSH, never synced through CI:
# On the VPS — set manually or via an encrypted secret store
cat > /opt/app/.env.production << 'EOF'
NODE_ENV=production
PORT=3000
APP_URL=https://yourapp.com
DATABASE_URL=postgresql://appuser:$(cat /opt/app/secrets/db_password)@localhost:5432/appdb
JWT_PRIVATE_KEY="$(cat /opt/app/secrets/jwt_private.pem | tr '\n' '\n')"
JWT_PUBLIC_KEY="$(cat /opt/app/secrets/jwt_public.pem | tr '\n' '\n')"
JWT_REFRESH_SECRET=$(cat /opt/app/secrets/jwt_refresh_secret)
RESEND_API_KEY=$(cat /opt/app/secrets/resend_api_key)
STRIPE_SECRET_KEY=$(cat /opt/app/secrets/stripe_secret_key)EOF
chmod 600 /opt/app/.env.production
chown deploy:deploy /opt/app/.env.production
Step 5 — Config Access Patterns
With the validated env and config objects, accessing configuration is consistent everywhere:
import { env } from '../config/env';
import { config } from '../config';
// Environment variables — all typed and validated
const db = new Pool({
connectionString: env.DATABASE_URL,
max: env.DATABASE_POOL_SIZE,
});
// Derived config — environment-appropriate values
const rateLimiter = new RateLimiter({
maxRequests: config.rateLimits.apiRequests,
});
// Guard against features that need optional config
if (env.STRIPE_SECRET_KEY) {
initializeStripe(env.STRIPE_SECRET_KEY);
} else if (config.isProd) {
throw new Error('STRIPE_SECRET_KEY is required in production');
}
Step 6 — CI Config
In GitHub Actions, secrets go in repository secrets (Settings → Secrets), not in env: blocks directly in the YAML file where they appear in logs.
# .github/workflows/deploy.yml
jobs:
deploy:
steps:
- name: Deploy to VPS
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
# Reload app — env file is already on the server
cd /opt/app
docker compose pull
docker compose up -d
Notice what is not passed via CI: DATABASE_URL, JWT keys, API keys. These live on the server in /opt/app/.env.production. CI only needs SSH access.
The Config Validation Script
Add a script that developers run after cloning the repo to verify their .env is complete:
// scripts/check-env.ts
import { EnvSchema } from '../src/config/env';
import * as dotenv from 'dotenv';
dotenv.config({ path: '.env' });
const result = EnvSchema.safeParse(process.env);
if (result.success) {
console.log('✅ Environment configuration is valid');
} else {
console.error('❌ Missing or invalid environment variables:\n');
const errors = result.error.flatten().fieldErrors;
for (const [field, messages] of Object.entries(errors)) {
console.error(` ${field}: ${messages?.join(', ')}`);
}
console.error('\nCopy .env.example to .env and fill in the required values.');
process.exit(1);
}
//package.json{"scripts":{"check:env":"tsx scripts/check-env.ts"}}
Add it to the README setup steps. A new developer runs pnpm check:env and immediately knows exactly which variables they are missing.
The Rules That Prevent Config Incidents
✅ Every variable in the app is in .env.example — no undocumented variables
✅ Zod schema validates all variables at startup — no silent undefined falls
✅ .env is in .gitignore — never committed
✅ Secrets generated with openssl — not typed by hand
✅ Production secrets set on the server via SSH — never through CI env vars
✅ Different config objects per environment — not ternary chains in code
✅ check:env script runs on new developer setup
✅ Secrets rotated by updating the file on the server, not redeploying
Originally published on ZyVOP
💡 For more articles like this, subscribe to the ZyVOP newsletter!