DEV Community

Young Gao
Young Gao

Posted on

Production Secrets Management: From .env Files to HashiCorp Vault (2026 Guide)

Your .env file has your database password, your Stripe secret key, and your JWT signing secret. It's also in your git history from that one commit six months ago. Let's fix this.

The .env Problem

Every Node.js tutorial starts the same way:

# .env
DATABASE_URL=postgres://admin:password123@localhost:5432/myapp
STRIPE_SECRET_KEY=sk_live_abc123
JWT_SECRET=my-super-secret
Enter fullscreen mode Exit fullscreen mode
import dotenv from 'dotenv';
dotenv.config();

const db = new Pool({ connectionString: process.env.DATABASE_URL });
Enter fullscreen mode Exit fullscreen mode

This works on your laptop. In production, it's a liability.

Here's why: .env files are unencrypted plaintext sitting on disk. They get copied into Docker images, committed to repos, logged by process managers, and leaked through error dumps. They have no access control, no audit trail, and no rotation mechanism.

The rule is simple: .env for development, secret managers for everything else.

Secret Managers: The Production Way

A secret manager gives you encrypted storage, access control, audit logs, and rotation — all the things a flat file doesn't.

Here's a practical wrapper that abstracts your secret source:

interface SecretProvider {
  get(key: string): Promise<string>;
  getMultiple(keys: string[]): Promise<Record<string, string>>;
}

class AWSSSMProvider implements SecretProvider {
  private client: SSMClient;
  private cache: Map<string, { value: string; expiry: number }> = new Map();
  private ttl: number;

  constructor(region: string, ttlMs = 300_000) {
    this.client = new SSMClient({ region });
    this.ttl = ttlMs;
  }

  async get(key: string): Promise<string> {
    const cached = this.cache.get(key);
    if (cached && cached.expiry > Date.now()) return cached.value;

    const command = new GetParameterCommand({
      Name: key,
      WithDecryption: true,
    });
    const result = await this.client.send(command);
    const value = result.Parameter?.Value ?? '';

    this.cache.set(key, { value, expiry: Date.now() + this.ttl });
    return value;
  }

  async getMultiple(keys: string[]): Promise<Record<string, string>> {
    const entries = await Promise.all(
      keys.map(async (k) => [k, await this.get(k)] as const)
    );
    return Object.fromEntries(entries);
  }
}
Enter fullscreen mode Exit fullscreen mode

For local development, you swap in a provider that reads .env:

class LocalEnvProvider implements SecretProvider {
  async get(key: string): Promise<string> {
    return process.env[key] ?? '';
  }

  async getMultiple(keys: string[]): Promise<Record<string, string>> {
    return Object.fromEntries(keys.map((k) => [k, process.env[k] ?? '']));
  }
}

// Factory — one line decides the strategy
function createSecretProvider(): SecretProvider {
  if (process.env.NODE_ENV === 'production') {
    return new AWSSSMProvider(process.env.AWS_REGION!);
  }
  return new LocalEnvProvider();
}
Enter fullscreen mode Exit fullscreen mode

Your application code never knows where secrets come from. That's the point.

Runtime Injection vs Build-Time

This distinction matters more than most people think.

Build-time means baking secrets into your artifact — Docker image, compiled bundle, deployed package. Anyone with access to the artifact has the secret.

Runtime means the process fetches secrets when it starts (or later). The artifact contains zero sensitive data.

# BAD: build-time injection
FROM node:20-alpine
ARG DATABASE_URL
ENV DATABASE_URL=$DATABASE_URL
COPY . .
RUN npm run build

# GOOD: runtime injection — no secrets in the image
FROM node:20-alpine
COPY . .
RUN npm run build
CMD ["node", "dist/server.js"]
# Secrets injected via ECS task definition, K8s secrets, or fetched at boot
Enter fullscreen mode Exit fullscreen mode

Your application should bootstrap secrets at startup:

async function bootstrap(): Promise<AppConfig> {
  const secrets = createSecretProvider();

  const [dbUrl, jwtSecret, stripeKey] = await Promise.all([
    secrets.get('/myapp/prod/database-url'),
    secrets.get('/myapp/prod/jwt-secret'),
    secrets.get('/myapp/prod/stripe-key'),
  ]);

  return Object.freeze({ dbUrl, jwtSecret, stripeKey });
}

// Pass config explicitly — no global process.env access
const config = await bootstrap();
const app = createApp(config);
Enter fullscreen mode Exit fullscreen mode

Object.freeze prevents accidental mutation. Passing config as an explicit dependency makes testing trivial and secrets auditable — you can grep every place config.stripeKey is used.

Secret Rotation Without Downtime

Secrets should rotate. Passwords expire, keys get compromised, compliance demands it. Your app needs to handle this without restarting.

class RotatingSecretProvider {
  private provider: SecretProvider;
  private current: Record<string, string> = {};
  private refreshInterval: NodeJS.Timeout | null = null;

  constructor(provider: SecretProvider) {
    this.provider = provider;
  }

  async start(keys: string[], intervalMs = 60_000): Promise<void> {
    await this.refresh(keys);
    this.refreshInterval = setInterval(() => this.refresh(keys), intervalMs);
  }

  private async refresh(keys: string[]): Promise<void> {
    try {
      const updated = await this.provider.getMultiple(keys);
      const changed = keys.filter((k) => updated[k] !== this.current[k]);

      if (changed.length > 0) {
        console.log(`Rotated secrets: ${changed.join(', ')}`);
        this.current = updated;
      }
    } catch (err) {
      // Keep old secrets on failure — don't crash
      console.error('Secret refresh failed, keeping current values', err);
    }
  }

  get(key: string): string {
    return this.current[key] ?? '';
  }

  stop(): void {
    if (this.refreshInterval) clearInterval(this.refreshInterval);
  }
}
Enter fullscreen mode Exit fullscreen mode

The important detail: on refresh failure, keep the old secrets. Don't crash your app because the secret manager had a blip. Log it, alert on it, but keep serving.

For database credentials specifically, the dual-credential pattern works well:

async function createResilientPool(secrets: RotatingSecretProvider) {
  // Primary connection string
  let pool = new Pool({ connectionString: secrets.get('db-url-primary') });

  // Health check with automatic failover to rotated credentials
  setInterval(async () => {
    try {
      await pool.query('SELECT 1');
    } catch {
      const freshUrl = secrets.get('db-url-primary');
      pool = new Pool({ connectionString: freshUrl });
    }
  }, 30_000);

  return { query: (...args: any[]) => pool.query(...args) };
}
Enter fullscreen mode Exit fullscreen mode

AWS RDS and Vault both support dual-credential rotation — the old credential stays valid while the new one propagates. Your app just needs to retry with fresh values on auth failure.

Common Mistakes

Committing .env to git. Even if you delete it, it's in the history. Use git-secrets or a pre-commit hook:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/awslabs/git-secrets
    hooks:
      - id: git-secrets
Enter fullscreen mode Exit fullscreen mode

If it's already in your history, git filter-repo is the fix. Rotate every secret that was ever committed — assume it's been seen.

Logging secrets accidentally. This one is insidious:

// BAD — logs the entire config including secrets
console.log('Starting with config:', config);

// BAD — error objects can contain connection strings
catch (err) {
  console.error('DB failed:', err); // err.message may include the URL
}

// GOOD — explicit allowlist of what gets logged
console.log('Starting with config:', {
  region: config.region,
  port: config.port,
  dbHost: new URL(config.dbUrl).hostname, // host only, no creds
});
Enter fullscreen mode Exit fullscreen mode

Using process.env everywhere. When secrets are scattered across 40 files as process.env.WHATEVER, you can't audit access, can't rotate, and can't test cleanly. Read secrets once at startup, pass them through explicitly.

Same secrets across environments. Your staging Stripe key should not work in production. Namespace secrets by environment: /myapp/prod/stripe-key vs /myapp/staging/stripe-key. Never share credentials between environments.

No secret access alerts. Your secret manager can log every access. Set up alerts for unusual patterns — access from new IPs, access spikes, access to secrets a service shouldn't need. This is your early warning system.

Hardcoding "temporary" secrets. There's nothing more permanent than a temporary hardcoded API key. If a secret exists, it goes in the secret manager. No exceptions, no "I'll fix it later."


Part of my Production Backend Patterns series. Follow for more practical backend engineering.


If this was useful, consider:

Top comments (0)