(32),
EXTERNAL_API_TOKEN: z.string().regex(/^sk_live_[a-zA-Z0-9]+$/),
LOG_LEVEL: z.enum(['error', 'warn', 'info', 'debug']).default('info'),
});
export type RuntimeConfig = z.infer<typeof RuntimeConfigSchema>;
export function loadConfig(): RuntimeConfig {
const parsed = RuntimeConfigSchema.safeParse(process.env);
if (!parsed.success) {
const missingFields = parsed.error.issues.map((issue) => issue.path.join('.'));
throw new Error(Configuration validation failed. Missing or invalid: ${missingFields.join(', ')});
}
return parsed.data;
}
**Why this choice:** Validation at boot prevents partial initialization states. By defining explicit types and constraints, we catch misconfigurations before they reach database drivers or authentication middleware. This also enables IDE autocompletion and compile-time safety across the codebase.
### Step 2: Runtime Injection & Provider Abstraction
Hardcoding secrets manager SDKs creates vendor lock-in and complicates local development. We abstract the retrieval mechanism behind a unified interface that falls back to environment variables in non-production environments.
```typescript
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
interface SecretProvider {
retrieve(key: string): Promise<string>;
}
class AwsSecretsProvider implements SecretProvider {
private client: SecretsManagerClient;
constructor() {
this.client = new SecretsManagerClient({ region: process.env.AWS_REGION || 'us-east-1' });
}
async retrieve(secretId: string): Promise<string> {
const command = new GetSecretValueCommand({ SecretId: secretId });
const response = await this.client.send(command);
if (!response.SecretString) {
throw new Error(`Secret ${secretId} returned empty payload`);
}
return response.SecretString;
}
}
class EnvFallbackProvider implements SecretProvider {
async retrieve(key: string): Promise<string> {
const value = process.env[key];
if (!value) throw new Error(`Environment variable ${key} is not set`);
return value;
}
}
export function createSecretProvider(): SecretProvider {
return process.env.NODE_ENV === 'production'
? new AwsSecretsProvider()
: new EnvFallbackProvider();
}
Why this choice: Decoupling the retrieval logic allows seamless switching between local development and cloud orchestration. The provider pattern enables future migration to HashiCorp Vault or GCP Secret Manager without touching business logic. It also centralizes error handling and retry strategies for transient network failures.
Step 3: Build-Time Isolation
Container images must never contain configuration files. Multi-stage builds and explicit COPY instructions prevent accidental inclusion of .env, swap files, or dependency artifacts.
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY src/ ./src/
COPY tsconfig.json ./
RUN pnpm build
# Stage 2: Production
FROM node:20-alpine AS runner
WORKDIR /app
RUN addgroup --system --gid 1001 appgroup && adduser --system --uid 1001 appuser
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package.json ./
USER appuser
EXPOSE 3000
CMD ["node", "dist/index.js"]
Why this choice: Explicit file copying eliminates the risk of bundling .env, .git, or editor artifacts. Running as a non-root user reduces container escape impact. Multi-stage builds strip development dependencies and source maps, reducing image size and attack surface.
Step 4: Automated Rotation & Revocation
Static credentials create long-lived attack windows. Rotation must be automated, graceful, and auditable. The pipeline should generate new secrets, update dependent services, verify functionality, and revoke old credentials without downtime.
Why this choice: Manual rotation is error-prone and often deferred. Automated rotation enforces compliance policies, limits blast radius during breaches, and integrates with CI/CD for zero-downtime deployments. Provider-native rotation features (AWS Secrets Manager, Vault dynamic secrets) handle cryptographic key generation and access policy updates atomically.
Pitfall Guide
1. The History Ghost
Explanation: Adding .env to .gitignore after an initial commit does not remove the file from repository history. Git tracks snapshots, not diffs, meaning the secret remains accessible via git log or git show indefinitely.
Fix: Use git filter-repo (not the deprecated filter-branch) to rewrite history, or rotate the compromised credential immediately. For new repositories, install gitleaks or detect-secrets as a pre-commit hook to block accidental commits before they reach the index.
2. Container Layer Contamination
Explanation: Using COPY . . in a Dockerfile silently includes .env, .git, node_modules, and editor swap files into the image. Anyone with pull access to the registry can extract credentials by running the container or inspecting layers.
Fix: Replace wildcard copies with explicit file lists. Use .dockerignore to exclude sensitive patterns, but treat it as a secondary defense. Always verify image contents with docker history or trivy before pushing to registries.
3. Client-Side Variable Bleed
Explanation: Modern bundlers statically analyze process.env references and inline them into client JavaScript. If a frontend framework exposes environment variables (e.g., Vite's VITE_ prefix, Next.js NEXT_PUBLIC_), secrets become publicly accessible via browser dev tools.
Fix: Never inject server-only credentials into client bundles. Use a backend proxy or API gateway to handle sensitive operations. For frontend-required values, explicitly namespace them and validate that no production secrets match the public prefix pattern.
4. Unsanitized Diagnostic Dumps
Explanation: Error handlers and debug endpoints that serialize process.env or stack traces leak credentials to logging platforms (Sentry, Dataduty, CloudWatch). Automated log aggregation tools rarely redact sensitive patterns by default.
Fix: Implement a log redaction middleware that strips known secret patterns before transmission. Use structured logging with explicit field allowlists instead of dumping entire objects. Disable debug endpoints in production via feature flags or build-time compilation.
5. Editor Artifact Leakage
Explanation: Text editors create temporary files (.swp, ~, .bak) alongside configuration files. If these patterns are omitted from .gitignore or .dockerignore, they can be committed or baked into deployment artifacts.
Fix: Maintain a comprehensive ignore list that covers editor artifacts, OS metadata, and backup patterns. Run periodic repository scans with trufflehog to catch historical leaks. Configure editor settings to store swap files in isolated temporary directories.
6. Static Key Stagnation
Explanation: Credentials that remain unchanged for months or years increase the probability of successful exploitation. Breached keys often go undetected until automated monitoring flags anomalous API usage.
Fix: Implement automated rotation schedules aligned with compliance requirements (typically 30-90 days). Use provider-native rotation features that handle atomic updates and access policy transitions. Maintain a revocation playbook for immediate credential invalidation during incidents.
7. Provider Lock-in
Explanation: Hardcoding AWS Secrets Manager, Vault, or Azure Key Vault SDKs directly into application code creates migration friction and complicates local testing.
Fix: Abstract secret retrieval behind a provider interface. Use dependency injection to swap implementations based on environment. This enables seamless provider migration, simplifies unit testing with mock providers, and centralizes retry/backoff logic.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Solo developer / local prototyping | .env + strict validation + pre-commit hooks | Minimal overhead, fast iteration, acceptable risk for non-production | Near-zero |
| Small team / early-stage product | Runtime env vars + CI/CD secret injection + .dockerignore | Eliminates build artifacts, enables team collaboration, reduces leak vectors | Low (CI/CD platform fees) |
| Enterprise / compliance-bound | Dedicated secrets orchestrator (Vault/AWS SM) + automated rotation + audit logging | Meets SOC2/ISO requirements, provides fine-grained IAM, enables zero-downtime rotation | Medium-High (provider licensing, engineering overhead) |
| Multi-cloud / vendor-agnostic | Provider abstraction layer + Kubernetes External Secrets | Prevents lock-in, centralizes rotation logic, simplifies migration | Medium (abstraction maintenance, sync controller overhead) |
Configuration Template
# .gitignore
.env
.env.*
!.env.example
*.swp
*~
*.bak
*.pem
*.key
credentials/
secrets/
.DS_Store
Thumbs.db
# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.0
hooks:
- id: gitleaks
name: Detect secrets in staged files
stages: [commit]
// src/config/loader.ts
import { z } from 'zod';
import { createSecretProvider } from './providers';
const schema = z.object({
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
});
export async function initializeConfig() {
const provider = createSecretProvider();
const rawEnv = { ...process.env };
// Override with runtime secrets in production
if (process.env.NODE_ENV === 'production') {
rawEnv.DATABASE_URL = await provider.retrieve('prod/db/connection');
rawEnv.JWT_SECRET = await provider.retrieve('prod/auth/jwt-signing-key');
}
const validated = schema.parse(rawEnv);
return validated;
}
Quick Start Guide
- Initialize validation: Add
zod to your project and define a strict schema matching your required environment variables. Import and run the validator at application bootstrap.
- Isolate builds: Replace
COPY . . in your Dockerfile with explicit file lists. Create a .dockerignore file excluding .env, node_modules, .git, and editor artifacts.
- Hook pre-commit scanning: Install
gitleaks or detect-secrets and configure it as a pre-commit hook. Run gitleaks detect --source . to scan existing history for accidental commits.
- Route to runtime: Implement a secrets provider abstraction. In production, point it to your cloud orchestrator (AWS Secrets Manager, Vault, etc.). In development, fall back to validated environment variables.
- Verify & deploy: Run
docker build and inspect layers with docker history. Confirm no configuration files are present. Deploy and monitor startup logs for validation failures or provider connection errors.