DEV Community

Libme
Libme

Posted on

Where Should a Small Team Store Its Secrets? Comparing .env Files, SOPS, Doppler, 1Password, and Vault

If your team is under about ten people and already runs on one cloud provider, the cheapest correct answer is your provider's own secret store plus OIDC in CI, and no long-lived keys anywhere. If you're spread across several providers or hand secrets to non-infra teammates, a hosted secrets manager with a CLI injector — Doppler or 1Password — pays for itself the first time a rotation doesn't take an afternoon. Encrypted files in git (SOPS) are the right answer for a narrow case: config that must version alongside code, reviewed in PRs.

What almost never works past the second engineer is passing .env files around in Slack. Here's why, concretely, and what to replace it with.

The failure mode that ends the .env era

The bug that finally forced this decision on me wasn't a breach. It was a deploy that came up healthy and served traffic against the wrong database for twenty minutes.

The shape is always the same:

// db.js — the line that costs you an incident
const url = process.env.DATABASE_URL || "postgres://localhost:5432/app_dev";
Enter fullscreen mode Exit fullscreen mode

A secret gets renamed in one place and not another, or a CI job runs before the env is populated, and process.env.DATABASE_URL is undefined. The || fallback is silent by design. Nothing throws, health checks pass, and the failure only surfaces when someone notices writes going nowhere.

Two fixes, and you want both. First, fail fast at boot so an absent secret is a crash, not a fallback:

// env.js — parse once, at startup, before anything connects
import { z } from "zod";

const schema = z.object({
  DATABASE_URL: z.string().url(),
  STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
  SESSION_SECRET: z.string().min(32),
});

const parsed = schema.safeParse(process.env);
if (!parsed.success) {
  console.error("Invalid environment:", parsed.error.flatten().fieldErrors);
  process.exit(1);
}

export const env = parsed.data;
Enter fullscreen mode Exit fullscreen mode

Import env everywhere instead of touching process.env directly, and add a lint rule banning process.env outside that one file. Now a missing secret is a loud, immediate, unambiguous crash.

Second, remove the drift itself by having exactly one place a secret lives. That's the actual decision below.

Fail-fast env parsing is the highest-leverage twenty lines in this entire post — do it before you pick a vendor, because it turns every secrets bug from a silent wrong answer into a stack trace.

What are the real options, and what does each cost you?

Approach Best when Real drawback
.env files, shared manually Solo, one machine, throwaway projects No rotation story, no audit trail, leaks via Slack/backups
SOPS + age/KMS, committed to git Config that must version with code and be PR-reviewed Rotation = commit + redeploy; key management is on you
Cloud-native (AWS Secrets Manager, Parameter Store, GCP Secret Manager, Azure Key Vault) Already all-in on one cloud Clumsy for local dev; per-secret and per-API-call billing adds up at high call volume
Doppler Multi-provider deploys, want a fast CLI + integrations Another vendor in your boot path; SaaS-first
1Password Secrets Automation Team already lives in 1Password; humans and machines need the same vault Service-account model takes a beat to grasp; usage-metered
Vault / OpenBao (self-hosted) Dynamic short-lived DB creds, strict compliance Genuine operational burden — unseal, HA, upgrades

A few honest notes on each, since the marketing pages won't give you these.

Cloud-native stores are the default nobody regrets on cost or reliability, and AWS Systems Manager Parameter Store's standard tier in particular is the underrated option — plain-string parameters with KMS encryption, no per-secret monthly charge on the standard tier as of mid-2026 (Secrets Manager bills per secret per month plus API calls, so check your call pattern before assuming it's cheap). The pain is local development: your laptop now needs cloud credentials to boot the app, which is exactly the long-lived key you were trying to eliminate.

Doppler is the one that handles the "same secret, five environments, three deploy targets" problem without a bespoke sync script, and its CLI injects secrets as environment variables for the duration of a process:

doppler run --project api --config dev -- node server.js
Enter fullscreen mode Exit fullscreen mode

Nothing lands on disk, and doppler run composes with whatever your app already expects. The drawback is real: you've added a network dependency to your startup path, and you should understand its cached-fallback behavior before you put it in front of production boots.

1Password Secrets Automation is the right pick when the same credential needs to be readable by a person during an incident and by a machine during a deploy, because it's one vault with one audit log for both. The CLI resolves op:// references at launch:

# .env.template — safe to commit; contains references, not values
DATABASE_URL=op://prod/postgres/url
STRIPE_SECRET_KEY=op://prod/stripe/secret_key
Enter fullscreen mode Exit fullscreen mode
op run --env-file=.env.template -- node server.js
Enter fullscreen mode Exit fullscreen mode

Committing a template of references is the part that quietly fixes onboarding — a new hire clones, runs, and gets the right values without anyone DM'ing them a file.

Vault (or OpenBao, the Linux Foundation fork created after HashiCorp's 2023 license change) earns its complexity on exactly one feature: dynamic secrets. It can mint a Postgres user valid for an hour and revoke it automatically, so a leaked credential expires on its own. If nobody on your team wants to own unseal keys and HA topology, use the managed offering or don't use Vault — a badly-run Vault is worse than Parameter Store.

Vault is a correct answer to a question most small teams don't have yet; the question it answers is "how do I make leaked credentials expire by themselves."

How do you get secrets into CI without storing secrets in CI?

This is the part teams skip, and it's where the highest-value credentials sit. A long-lived cloud access key pasted into repository secrets is the single most valuable thing an attacker can get from your CI — it survives rotation of everything else.

Use OIDC federation instead. GitHub Actions can exchange a short-lived workflow identity token for cloud credentials, so no static key exists to steal:

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/deploy
          aws-region: us-east-1
      - run: ./deploy.sh
Enter fullscreen mode Exit fullscreen mode

The trust policy on that IAM role should pin the repository and the branch or environment — a wildcard subject condition means any workflow in your org can assume it. GitLab CI, CircleCI, and Buildkite all have equivalent OIDC flows.

If your CI still holds a long-lived cloud key in 2026, fixing that beats every other item on this list.

When is an encrypted-file approach (SOPS) actually right?

SOPS encrypts only the values in a YAML/JSON file, leaving keys readable, so a diff still shows which setting changed without revealing anything:

sops --encrypt --age age1ql3z... secrets.dev.yaml > secrets.dev.enc.yaml
sops --decrypt secrets.dev.enc.yaml
Enter fullscreen mode Exit fullscreen mode

That property — reviewable diffs — is the whole argument. It fits GitOps and Kubernetes flows where config already ships through git, and it works offline with no vendor in the boot path.

The cost is that rotation means a commit, a merge, and a deploy, and every old value stays in git history forever. Once a secret has been in a repo, rotating it is the only real remediation — deleting the commit is not, since clones and forks keep the object. Treat SOPS as configuration-that-happens-to-be-sensitive, not as a credential vault.

FAQ

How should a small startup manage secrets across dev, staging, and production?
One store, three scopes, no files on laptops. Use your cloud provider's secret store if you're single-cloud, or Doppler/1Password if you aren't, and inject values into the process at launch instead of writing .env files to disk. Validate every required variable at boot so a missing secret crashes instead of falling back.

Is it safe to commit a .env file if it's encrypted?
Encrypted with SOPS or git-crypt, yes, with two caveats: the decryption key must live outside the repo, and any value that has ever been committed in plaintext must be rotated, because git history and existing clones keep it permanently.

Do I need HashiCorp Vault for a five-person team?
Almost certainly not. Vault's payoff is dynamic, short-lived credentials and fine-grained policy; below that bar, a managed secret store plus OIDC in CI gives you most of the security benefit with none of the unseal-and-HA operational burden.

Bottom line

Single-cloud teams should use their provider's secret store with OIDC in CI and stop there — it's the lowest-cost, lowest-drama option, and it removes the long-lived keys that actually get exploited. Teams deploying across several platforms, or handing credentials to people who don't touch infrastructure, get real time back from Doppler or 1Password's CLI injection. Reach for SOPS when sensitive config genuinely needs to be reviewed in pull requests, and for Vault or OpenBao only when you specifically want credentials that expire on their own. Whatever you choose, the boot-time validation and the CI key removal matter more than the vendor.

Related reading

Top comments (1)

Collapse
 
circuit profile image
Rahul S

The axis I'd add to this: whichever store you pick, they all converge to the same process.env blob at runtime, so the choice barely touches the leak path that actually bites small teams — env vars get inherited by every child process and slurped whole into the first crash report your error tracker ships off to a third party. Doppler vs native store vs SOPS is really a question about secrets at rest; the incident is almost always a secret in flight, showing up in a Sentry payload or a stray debug log line. What moves the needle there isn't the store, it's keeping the value out of the global environment — fetch it at the point of use and hand it only to the client that needs it — plus scrubbing at the telemetry boundary. Rotation's the same shape: the honest test for all of these is how fast you can kill the old value, and SOPS's forever-in-git-history problem is just the visible version of a gap every one of them has.