DevOps ·

Secrets Management for Startup Engineering Teams: From .env Files to Production Vault

A practical guide to secrets management maturity for engineering teams. Covers why .env files break down at scale, the full maturity ladder from encrypted dotenv through cloud-native secret stores, TypeScript access patterns, rotation without downtime, CI/CD injection, and Cloudflare Workers secrets.

Secrets Management for Startup Engineering Teams: From .env Files to Production Vault

Most teams start with .env files because they work on day one.

You copy .env.example, fill in values, and the app runs. That approach scales to a point and then becomes a liability. The moment a contractor leaves with a copy of the file, an intern pushes .env to a public repo, or your production database password has not changed in three years because nobody knows how to rotate it without an outage, you have crossed that point.

This guide covers the full progression: why .env files eventually break, what to use instead at each growth stage, and concrete TypeScript patterns for every tier.

Why .env files break down

.env files are plaintext. Every person who needs to run the app locally gets every secret, whether they need them or not. Common failure modes:

Accidental git commits. .gitignore is not a security control. A git add . from the wrong directory, a misconfigured IDE plugin, or a developer who forgot the rule once is enough. Tools like git-secrets and trufflehog catch some of these, but they are second lines of defense, not architectural guarantees.

No audit trail. When your production database is compromised, you need to know whether the credentials were in the repository, who had access, and when the secret was last rotated. A file on developer laptops gives you none of that.

No rotation. Rotating a secret stored in .env means distributing a new file to every developer and every deployment environment. Teams avoid it, so secrets never rotate.

Shared secrets across environments. When DATABASE_URL in staging.env points to production because someone forgot to update it, debugging data corruption becomes interesting.

Principle of least privilege is impossible. Every developer has access to every secret. Your billing intern should not have your Stripe webhook signing key.

None of these are hypothetical. Every growing engineering team hits at least two of them.

The maturity ladder

There is no one right answer. The right tier depends on team size, compliance requirements, and operational tolerance.

TierWhat it isBest forMain limitation
Plain .envPlaintext local filesSolo dev, prototypesNo audit trail, no rotation, accidental leaks
Encrypted .env (sops/age)Encrypted secrets committed to gitSmall teams, no dedicated infraStill no rotation, manual key management
Cloud provider secretsAWS Secrets Manager, GCP Secret ManagerTeams already on AWS/GCPVendor lock-in, per-secret pricing
Dedicated secret managerHashiCorp Vault, Doppler, InfisicalTeams needing rotation, audit, RBACOperational overhead (self-hosted Vault)

Move up when the pain of your current tier outweighs the cost of the next one. Jumping straight to Vault as a three-person team is overengineering. Running plain .env with a 40-person team that has SOC 2 aspirations is a liability.

Tier 1: Encrypted .env with sops and age

sops encrypts values in structured files while leaving keys readable. age is a simple, modern encryption tool you can use as the sops backend.

The encrypted file can live in git. The decryption key stays out of git and is distributed separately (or stored in your cloud KMS).

A sops-encrypted file looks like this once encrypted:

# .env.enc.yaml
DATABASE_URL: ENC[AES256_GCM,data:abc123...,type:str]
STRIPE_SECRET_KEY: ENC[AES256_GCM,data:xyz789...,type:str]
sops:
    age:
        - recipient: age1...
    lastmodified: "2026-03-24T10:00:00Z"

To use the decrypted values in a Node.js app, decrypt at startup or build time:

// lib/load-secrets.ts
import { execSync } from "child_process";
import { config } from "dotenv";
import { writeFileSync, unlinkSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";

export function loadEncryptedSecrets(encryptedPath: string): void {
  if (process.env.NODE_ENV === "production") return; // handled separately in prod

  const tmpPath = join(tmpdir(), `.env.decrypted.${process.pid}`);
  try {
    const decrypted = execSync(`sops --decrypt --output-type dotenv ${encryptedPath}`, {
      encoding: "utf8",
    });
    writeFileSync(tmpPath, decrypted, { mode: 0o600 });
    config({ path: tmpPath });
  } finally {
    try { unlinkSync(tmpPath); } catch {}
  }
}

This tier gets you version-controlled secrets and keeps plaintext off developer laptops, but it does not solve rotation or fine-grained access control.

Tier 2: AWS Secrets Manager

Once your team is on AWS, Secrets Manager gives you rotation, versioning, audit logs via CloudTrail, and IAM-based access control without running extra infrastructure.

Secrets are fetched at runtime. Cache them aggressively to avoid per-request API costs and latency.

// lib/secrets.ts
import {
  SecretsManagerClient,
  GetSecretValueCommand,
} from "@aws-sdk/client-secrets-manager";

const client = new SecretsManagerClient({ region: process.env.AWS_REGION ?? "us-east-1" });

type SecretCache = {
  value: string;
  fetchedAt: number;
};

const cache = new Map<string, SecretCache>();
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes

export async function getSecret(secretId: string): Promise<string> {
  const cached = cache.get(secretId);
  if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
    return cached.value;
  }

  const response = await client.send(
    new GetSecretValueCommand({ SecretId: secretId }),
  );

  const value = response.SecretString;
  if (!value) throw new Error(`Secret ${secretId} has no string value`);

  cache.set(secretId, { value, fetchedAt: Date.now() });
  return value;
}

export async function getSecretJson<T>(secretId: string): Promise<T> {
  const raw = await getSecret(secretId);
  return JSON.parse(raw) as T;
}

Usage with typed secrets:

// app startup
type DbSecrets = { username: string; password: string; host: string };

const dbSecrets = await getSecretJson<DbSecrets>("prod/app/database");
const pool = new Pool({
  user: dbSecrets.username,
  password: dbSecrets.password,
  host: dbSecrets.host,
});

The 5-minute cache is intentional. During rotation, AWS Secrets Manager keeps two versions: AWSCURRENT and AWSPREVIOUS. Your app may serve requests with either version briefly. That is acceptable if your application code can authenticate with both (the rotation window). The cache reduces calls and makes rotation transitions smooth.

GCP equivalent: The pattern is nearly identical with @google-cloud/secret-manager. Fetch by secret ID and version (latest), cache in memory with TTL.

Tier 3: Dedicated secret manager (Vault)

HashiCorp Vault gives you fine-grained policies, dynamic secrets, multiple authentication backends, and full audit logs. The cost is operational complexity if you self-host. A small team running Vault on a VM with no HA, no backups, and no runbook is worse than no Vault at all.

When Vault makes sense: you need dynamic secrets (short-lived DB credentials issued per deployment), multi-cloud, or strict audit requirements. Self-hosted is reasonable for a dedicated platform team. Hosted (HCP Vault) reduces the operational surface.

// lib/vault-client.ts
type VaultSecretResponse = {
  data: { data: Record<string, string> };
};

export class VaultClient {
  constructor(
    private readonly addr: string,
    private readonly token: string,
  ) {}

  async readSecret(path: string): Promise<Record<string, string>> {
    const url = `${this.addr}/v1/${path}`;
    const response = await fetch(url, {
      headers: { "X-Vault-Token": this.token },
    });

    if (!response.ok) {
      throw new Error(`Vault read failed: ${response.status} ${path}`);
    }

    const body = (await response.json()) as VaultSecretResponse;
    return body.data.data;
  }
}

// Usage
const vault = new VaultClient(
  process.env.VAULT_ADDR ?? "http://127.0.0.1:8200",
  process.env.VAULT_TOKEN ?? "",
);

const dbCreds = await vault.readSecret("secret/data/prod/database");

In production, replace static VAULT_TOKEN with AppRole authentication or Kubernetes service account token injection. Static tokens are how Vault installations become just a different flavor of .env files.

Secret rotation without downtime

Rotation is the most commonly skipped practice and the most valuable one during a breach response.

The pattern that works for connection-based secrets (database passwords, API keys):

  1. Create new credentials (new DB user or new API key).
  2. Store new credentials as the next version.
  3. Deploy the application while it can still authenticate with old credentials.
  4. Switch active version to new credentials.
  5. Verify zero errors for a soak period.
  6. Delete old credentials.

Step 3 is the hard part. Your caching layer must be able to pick up the new secret when AWSCURRENT changes.

// Extended cache with rotation awareness
export async function getSecretWithRefresh(secretId: string): Promise<string> {
  const cached = cache.get(secretId);
  if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
    return cached.value;
  }
  // Cache miss or expired: fetch fresh from Secrets Manager
  return getSecret(secretId);
}

// Call this when a connection fails due to auth error
export function invalidateSecret(secretId: string): void {
  cache.delete(secretId);
}

When your database driver throws an auth error, call invalidateSecret and retry once. This handles the window where some instances have the new secret and some still have the cached old one.

CI/CD secret injection

Secrets should never be stored in CI YAML files. Use your CI platform’s native secret storage.

GitHub Actions

# .github/workflows/deploy.yml
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Deploy
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
          STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
        run: |
          npm ci
          npm run deploy

For AWS deployments, prefer OIDC over long-lived access keys:

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
          aws-region: us-east-1

The IAM role has a trust policy scoped to the exact repository and branch. No secret to rotate, no secret to leak.

GitLab CI

# .gitlab-ci.yml
deploy:
  stage: deploy
  script:
    - npm ci
    - npm run deploy
  variables:
    DATABASE_URL: $DATABASE_URL   # injected from GitLab CI/CD Variables

Mark CI/CD variables as “masked” in GitLab settings. This prevents the raw value from appearing in job logs even if your script accidentally prints it.

Cloudflare Workers: wrangler secret

Workers do not have a filesystem and do not read .env files at runtime. Secrets are bound as environment variables via the Wrangler CLI.

# Set a secret for production
wrangler secret put DATABASE_URL

# Set for a specific environment
wrangler secret put STRIPE_SECRET_KEY --env production

Access in Worker code:

// worker/src/index.ts
export interface Env {
  DATABASE_URL: string;
  STRIPE_SECRET_KEY: string;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    // env.DATABASE_URL is available, never in source code
    const db = buildDb(env.DATABASE_URL);
    return handleRequest(request, db);
  },
};

Secrets set via wrangler secret put are encrypted at rest, scoped to the Worker, and never visible in wrangler.toml. They do not appear in wrangler secret list output either. For local development, use a .dev.vars file (excluded from git via .gitignore) instead of .env.

# .dev.vars (not committed)
DATABASE_URL=postgres://localhost:5432/myapp
STRIPE_SECRET_KEY=sk_test_...

Preventing secrets from appearing in logs

Even with a proper secret manager, secrets can leak through logs. Structured logs that include request bodies, error objects with stack traces, or poorly scoped debug output are common vectors.

Build a redaction layer:

// lib/safe-log.ts
const SECRET_PATTERNS = [
  /password/i,
  /secret/i,
  /token/i,
  /api[_-]?key/i,
  /authorization/i,
  /credential/i,
];

function redactObject(obj: unknown, depth = 0): unknown {
  if (depth > 8) return "[truncated]";
  if (obj === null || typeof obj !== "object") return obj;
  if (Array.isArray(obj)) return obj.map((item) => redactObject(item, depth + 1));

  const out: Record<string, unknown> = {};
  for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
    if (SECRET_PATTERNS.some((re) => re.test(key))) {
      out[key] = "[REDACTED]";
    } else {
      out[key] = redactObject(value, depth + 1);
    }
  }
  return out;
}

export function safeLog(level: "info" | "warn" | "error", event: string, fields: Record<string, unknown>) {
  console.log(
    JSON.stringify({
      level,
      event,
      ...redactObject(fields),
      ts: new Date().toISOString(),
    }),
  );
}

Apply this in your HTTP middleware to redact request and response bodies before logging. Also ensure your error serializer does not dump raw Error objects that might include secret values in their cause chain.

Least-privilege access patterns

Every service should only be able to read the secrets it needs for the specific environment it runs in.

A naming convention that maps cleanly to IAM policies:

/{environment}/{service}/{secret-name}

/prod/payment-service/stripe-secret-key
/prod/payment-service/database-url
/prod/notification-service/sendgrid-api-key
/staging/payment-service/stripe-secret-key

With this structure, an IAM policy for payment-service in production:

{
  "Effect": "Allow",
  "Action": ["secretsmanager:GetSecretValue"],
  "Resource": "arn:aws:secretsmanager:us-east-1:*:secret:/prod/payment-service/*"
}

notification-service cannot read payment-service secrets. If a notification-service deployment is compromised, the blast radius is contained.

Audit logging

Cloud provider secret managers emit audit events natively. AWS CloudTrail records every GetSecretValue call with the IAM identity, timestamp, and source IP. Enable these logs and route them to a durable store separate from your application logs.

For self-hosted Vault, enable the audit device on startup:

vault audit enable file file_path=/var/log/vault/audit.log

A minimal query to detect anomalies: any GetSecretValue call from an identity that has not made that call before, or from an IP outside your known compute ranges.

Maturity checklist

Use this to assess where your team is today.

Tier 1: Basic hygiene (every team, no excuses)

  • .env files are in .gitignore
  • .env.example contains no real values
  • Automated secret scanning runs on every pull request (trufflehog, gitleaks, or equivalent)
  • CI secrets are in platform native secret storage, not hardcoded in YAML

Tier 2: Controlled access

  • Secrets are not shared in plain text over Slack or email
  • Staging and production have distinct secrets with no overlap
  • Each service has its own credentials, not shared credentials
  • Departing team members trigger a rotation of any secrets they had access to

Tier 3: Operational security

  • Secrets are stored in a managed secret store with audit logs
  • IAM policies follow least-privilege by service and environment
  • Rotation is automated or documented and practiced
  • Logs redact secret-like fields before writing

Tier 4: Full maturity

  • Rotation is automated and tested quarterly with a soak-period rollback procedure
  • Dynamic short-lived secrets used for database access (Vault database engine or RDS IAM auth)
  • Anomaly detection on secret access patterns
  • Incident response runbook includes “rotate all secrets” as a documented step with estimated time

Most teams at seed-to-Series-A should target Tier 2 across the board and Tier 3 for production. Tier 4 is appropriate when you have dedicated security resources or compliance requirements.

Closing

The move from .env to a managed secret store is not a single big-bang migration. It is a series of small decisions: encrypt before committing, use IAM roles instead of long-lived keys, route CI secrets through platform storage, add a redaction wrapper around your logger.

Each step independently reduces blast radius. The maturity checklist gives you concrete progress rather than abstract security posture talk. Pick the tier that matches your current operational capacity, implement it fully, and then move up.

A secret that never rotated is a secret you will eventually regret.

More in DevOps

How Terraform Works Internally: HCL Parsing, Dependency Graph Execution, and the Provider Protocol Behind Infrastructure as Code
DevOps ·

How Terraform Works Internally: HCL Parsing, Dependency Graph Execution, and the Provider Protocol Behind Infrastructure as Code

A deep dive into Terraform's internal architecture covering HCL parsing, the expression evaluation engine, DAG-based dependency resolution, the gRPC provider plugin protocol, state mechanics, and the plan/apply lifecycle.

How Docker Works Internally: Namespaces, Cgroups, Union Filesystems, and the OCI Runtime Behind Every Container
DevOps ·

How Docker Works Internally: Namespaces, Cgroups, Union Filesystems, and the OCI Runtime Behind Every Container

A deep dive into what happens when you run docker run: Linux namespaces for process isolation, cgroups v2 for resource enforcement, overlay2 union filesystem for layered images, the OCI runtime spec and how runc spawns containers, content-addressable storage, the containerd shim architecture, and networking with veth pairs.

How Kubernetes Works: Pods, Scheduling, and the Reconciliation Loop
DevOps ·

How Kubernetes Works: Pods, Scheduling, and the Reconciliation Loop

A deep dive into Kubernetes internals covering the control plane architecture, etcd watch mechanics, the scheduler's filter and score phases, controller manager reconciliation loops, kubelet pod assignment, the CRI and CNI plugin models, and production considerations for resource requests, pod disruption budgets, and cluster autoscaler behavior.

How Docker Works: Namespaces, Cgroups, Union Filesystems, and the Container Runtime from Build to Execution
DevOps ·

How Docker Works: Namespaces, Cgroups, Union Filesystems, and the Container Runtime from Build to Execution

A deep dive into Docker internals covering Linux namespace isolation, cgroup resource constraints, OverlayFS copy-on-write layer mechanics, Dockerfile build cache invalidation rules, the containerd and runc OCI runtime stack, and production considerations for image hardening and startup latency.