ring().url(),
AUTH_SECRET: z.string().min(32),
REDIS_URL: z.string().url(),
NODE_ENV: z.enum(['development', 'staging', 'production']).default('development'),
});
export type EnvConfig = z.infer<typeof envSchema>;
export function loadConfig(): EnvConfig {
try {
const validatedEnv = envSchema.parse(process.env);
return validatedEnv;
} catch (error) {
if (error instanceof z.ZodError) {
console.error('Configuration validation failed:', error.errors);
process.exit(1);
}
throw error;
}
}
**Rationale:** Using a schema library like Zod ensures that the application crashes immediately upon startup if critical configuration is missing or invalid. This is superior to lazy evaluation, which can cause obscure errors deep in the request lifecycle.
#### 2. Build and Deploy: Docker Hygiene
Docker images are often shared across teams and registries. Baking secrets into images creates persistent leaks. Use multi-stage builds and runtime injection to keep images clean.
**Implementation: Secure Dockerfile Pattern**
```dockerfile
# Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY src ./src
COPY tsconfig.json ./
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
# CRITICAL: Do not COPY .env files.
# Secrets must be injected at runtime via docker run -e or orchestration.
EXPOSE 3000
CMD ["node", "dist/index.js"]
Rationale: The builder stage compiles code without access to secrets. The runner stage contains only the compiled artifact and dependencies. Secrets are injected via the container runtime, ensuring they never exist in the image layers.
3. Client-Side Isolation: Backend Proxy Pattern
Frontend frameworks often expose environment variables prefixed with NEXT_PUBLIC_ or REACT_APP_ to the browser bundle. Never expose sensitive keys (API secrets, database credentials) this way. Use a backend proxy to mediate access.
Implementation: Secure API Proxy
// src/api/stripe-proxy.ts
import { Request, Response } from 'express';
import Stripe from 'stripe';
// Server-side only configuration
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2023-10-16',
});
export async function createCheckoutSession(req: Request, res: Response) {
try {
const { lineItems, successUrl, cancelUrl } = req.body;
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: lineItems,
success_url: successUrl,
cancel_url: cancelUrl,
});
// Return only the session ID to the client
res.json({ sessionId: session.id });
} catch (error) {
// Handle error without leaking stack traces
res.status(500).json({ error: 'Checkout initialization failed' });
}
}
Rationale: The frontend never handles the Stripe secret key. It requests a checkout session from your backend, which holds the key securely. This prevents key extraction via browser developer tools.
4. Runtime Safety: Error and Log Sanitization
Secrets often leak through diagnostic output. Implement middleware to sanitize error responses and log streams.
Implementation: Sanitization Middleware
// src/middleware/sanitize.ts
const SENSITIVE_KEYS = ['password', 'token', 'secret', 'key', 'authorization', 'cookie'];
export function sanitizeObject(obj: any): any {
if (!obj || typeof obj !== 'object') return obj;
const sanitized = { ...obj };
for (const key of Object.keys(sanitized)) {
if (SENSITIVE_KEYS.some(s => key.toLowerCase().includes(s))) {
sanitized[key] = '[REDACTED]';
} else if (typeof sanitized[key] === 'object') {
sanitized[key] = sanitizeObject(sanitized[key]);
}
}
return sanitized;
}
export function errorHandler(err: Error, req: Request, res: Response, next: any) {
const requestId = req.headers['x-request-id'] as string || crypto.randomUUID();
// Log full details internally for debugging
console.error(`[${requestId}] Error:`, err.message, err.stack);
// Return sanitized response to client
res.status(500).json({
status: 'error',
message: 'Internal Server Error',
requestId,
});
}
Rationale: Internal logs retain full context for debugging, while client responses contain only a request ID. This allows support teams to correlate issues without exposing sensitive data to end-users.
Pitfall Guide
| Pitfall | Explanation | Fix |
|---|
| Git History Persistence | Removing .env via git rm does not erase it from repository history. Clones or forks retain access to all historical commits. | Use git filter-repo or BFG Repo Cleaner to purge history. Immediately rotate all compromised keys. |
| Docker Layer Archaeology | Using COPY .env or COPY . includes secrets in image layers. Anyone with image access can extract secrets using docker history or layer inspection. | Never copy .env into images. Use .dockerignore and inject secrets at runtime via orchestration tools. |
| Client-Side Prefix Leaks | Build tools automatically expose variables with specific prefixes (e.g., NEXT_PUBLIC_) to the browser bundle. | Audit build configuration. Ensure sensitive keys lack public prefixes. Use backend proxies for sensitive operations. |
| Verbose Error Dumping | Returning full stack traces or process.env objects in error responses exposes configuration and internal paths. | Implement global error handlers that return generic messages and request IDs. Log details server-side only. |
| Log Pollution | Logging request bodies or headers without sanitization captures tokens, passwords, and PII in log aggregation systems. | Implement structured logging with automatic redaction of sensitive fields. Review log schemas regularly. |
| Social Engineering via Screenshots | Developers share terminal screenshots or chat logs containing .env contents for debugging help. | Enforce team policies against sharing raw config. Use placeholders in shared examples. Train on secure debugging practices. |
| Environment Parity Failure | Using production keys in development or staging increases blast radius if a lower-security environment is compromised. | Generate distinct keys for each environment. Use environment-specific secrets managers or platform variables. |
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Solo Developer / MVP | Platform Environment Variables | Zero infrastructure overhead; integrated with deployment workflow. | Free (included with platform). |
| Team / Compliance Required | Cloud Secrets Manager (AWS SSM, GCP Secret Manager) | Provides audit trails, IAM controls, and versioning. | Low (per-secret pricing). |
| Multi-Cloud / Hybrid | HashiCorp Vault | Abstracts secret storage; supports dynamic secrets and encryption-as-a-service. | Medium (operational complexity). |
| High-Security / Regulated | Hardware Security Module (HSM) Integration | Keys never leave hardware; meets strict compliance standards (FIPS 140-2). | High (hardware and licensing). |
Configuration Template
.gitignore Snippet
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Keep example file for documentation
!.env.example
TypeScript Config Template with Zod
// src/config/index.ts
import { z } from 'zod';
import { loadConfig } from './env-validator';
// Load and validate configuration
const config = loadConfig();
export const dbConfig = {
url: config.DATABASE_URL,
poolSize: parseInt(process.env.DB_POOL_SIZE || '10', 10),
};
export const authConfig = {
secret: config.AUTH_SECRET,
expiresIn: '24h',
};
export const isProduction = config.NODE_ENV === 'production';
export default {
db: dbConfig,
auth: authConfig,
isProduction,
};
Docker Compose for Local Development
# docker-compose.yml
version: '3.8'
services:
app:
build: .
ports:
- "3000:3000"
env_file:
- .env.development
environment:
- NODE_ENV=development
volumes:
- ./src:/app/src
depends_on:
- db
- redis
db:
image: postgres:15-alpine
environment:
POSTGRES_USER: dev_user
POSTGRES_PASSWORD: dev_pass
POSTGRES_DB: myapp_dev
volumes:
- pgdata:/var/lib/postgresql/data
redis:
image: redis:7-alpine
volumes:
pgdata:
Quick Start Guide
- Initialize Configuration: Create
.env.example with required variable names. Add .env to .gitignore.
- Add Validation: Install a schema validation library (e.g., Zod) and implement a config loader that validates
process.env at startup.
- Secure Docker: Update
Dockerfile to remove any COPY of .env files. Ensure secrets are injected via orchestration or docker run -e.
- Audit Client Build: Check frontend configuration to ensure no sensitive variables are prefixed for client exposure. Implement backend proxies for sensitive API calls.
- Deploy to Platform: Configure secrets in your deployment platform's environment variables or secrets manager. Verify the application starts successfully without a
.env file in the production environment.