BETAWe're in beta! If you spot a bug, let us know.
·8 min·By Nicolas Ritouet

Why New Developers Spend Day 1 Hunting for Secrets

A new dev joins your team, clones the repo, runs npm install, then npm run dev. Nothing works. The next 3 hours are spent asking for credentials on Slack. Here's how to fix this.

A new developer joins your team. They clone the repo, run npm install, then npm run dev. The app crashes. Missing DATABASE_URL. They check the README—it says "copy .env.example to .env". They do that. Still crashes. The example file has DATABASE_URL=your_database_url_here.

The next three hours look like this:

  • "Hey, where do I get the database credentials?" (Slack, #general)
  • "Check with Sarah, she set up the staging DB" (Sarah is on PTO)
  • "I think there's a Notion page somewhere..." (there is, it's 8 months out of date)
  • "Just use my .env, I'll DM it to you" (now the secret is in Slack forever)

By the time they have a working local environment, it's 4pm. Day 1 is gone.

This happens too often. The pattern is so common it feels inevitable. It's not.

Why .env.example doesn't work

The theory is simple: maintain a .env.example file with all required variables, keep it in sync with your actual .env, and new developers just fill in the values.

In practice:

It's always out of date. A developer adds SENDGRID_API_KEY to their .env, tests the feature, pushes the code. The feature works in CI because someone added the secret to GitHub Actions. Nobody updates .env.example because the feature already works.

Values are useless. STRIPE_SECRET_KEY=sk_test_xxx tells you nothing. Which Stripe account? Test or live? Where do you get it? The comment says "ask John"—John left six months ago.

No validation. The app crashes with "Cannot read property 'send' of undefined" instead of "Missing SENDGRID_API_KEY". The new dev spends an hour debugging before realizing it's a config issue.

Multiple environments, one file. Your .env.example mixes local dev values, staging references, and production patterns. A new dev doesn't know which values to use where.

The .env.example pattern assumes discipline that doesn't exist in real teams under real deadlines.

The real problem: too many secrets required locally

Before looking at tooling, ask a harder question: why does your local dev environment need 15 secrets to run?

Here's a typical .env file I see in projects:

# Database
DATABASE_URL=postgres://user:pass@db.example.com:5432/myapp

# Redis
REDIS_URL=redis://user:pass@redis.example.com:6379

# Auth
JWT_SECRET=some-long-random-string
GITHUB_CLIENT_ID=xxx
GITHUB_CLIENT_SECRET=xxx

# Payments
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx

# Email
SENDGRID_API_KEY=SG.xxx

# Storage
AWS_ACCESS_KEY_ID=AKIA...
AWS_SECRET_ACCESS_KEY=xxx
AWS_BUCKET=myapp-uploads

# Monitoring
SENTRY_DSN=https://xxx@sentry.io/xxx

# Feature flags
LAUNCHDARKLY_SDK_KEY=sdk-xxx

That's 14 secrets. Every new developer needs all of them to run npm run dev. Every secret is a potential point of failure, a thing to share, a thing to rotate, a thing to leak.

Most of these don't need to be real credentials for local development.

Reduce before you manage

1. Local infrastructure with Docker Compose

Your local Postgres doesn't need a cloud connection string. Neither does Redis.

# docker-compose.yml
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: dev
      POSTGRES_PASSWORD: dev
    ports:
      - "5432:5432"

  redis:
    image: redis:7
    ports:
      - "6379:6379"

Now your local .env is:

DATABASE_URL=postgres://dev:dev@localhost:5432/myapp
REDIS_URL=redis://localhost:6379

No secrets to share. docker compose up -d, done.

2. Mock external services

Your local dev environment doesn't need to send real emails or process real payments.

For HTTP APIs, use a tool like MSW (Mock Service Worker) or nock:

// tests/mocks/handlers.ts
import { http, HttpResponse } from 'msw'

export const handlers = [
  http.post('https://api.sendgrid.com/v3/mail/send', () => {
    return HttpResponse.json({ message: 'Mocked' })
  }),
  http.post('https://api.stripe.com/v1/charges', () => {
    return HttpResponse.json({ id: 'ch_mock_123', status: 'succeeded' })
  }),
]

Enable mocks in development, disable in production. No API keys needed locally.

For webhooks, use a local tunnel (ngrok, localtunnel) when you actually need to test the integration. That's once a month, not every npm run dev.

3. Feature flags to disable integrations

Not every feature needs to work in local dev.

// config.ts
export const config = {
  email: {
    enabled: process.env.NODE_ENV === 'production' || process.env.SENDGRID_API_KEY,
    provider: process.env.SENDGRID_API_KEY ? 'sendgrid' : 'console',
  },
  analytics: {
    enabled: process.env.NODE_ENV === 'production',
  },
}

In development, emails log to console. Analytics are disabled. The app runs without those secrets.

4. Fail fast with validation

When a secret is actually required, fail immediately with a clear message.

// config.ts
import { z } from 'zod'

const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  STRIPE_SECRET_KEY: z.string().startsWith('sk_').optional(),
  NODE_ENV: z.enum(['development', 'production', 'test']),
})

export const env = envSchema.parse(process.env)

The app fails at startup with "Invalid environment variables: STRIPE_SECRET_KEY must start with 'sk_'" instead of crashing randomly when someone tries to checkout.

After reduction: what's left?

After applying these patterns, your local dev .env might look like:

DATABASE_URL=postgres://dev:dev@localhost:5432/myapp
REDIS_URL=redis://localhost:6379
JWT_SECRET=dev-secret-not-for-production

Three values. Two are hardcoded in docker-compose.yml (no secret). One is a dev-only placeholder.

The app runs. Features that require external services gracefully degrade or mock.

But some secrets can't be eliminated:

  • Shared staging credentials: The Stripe test account the whole team uses
  • OAuth apps: GitHub/Google client IDs that actually need to work
  • Third-party APIs: Services that don't have test modes

For these, you need a real solution.

The access problem

The remaining secrets have an access problem, not a storage problem.

Most teams store secrets in 1Password, Notion, or a shared Google Doc. Storage isn't the issue—access is.

Who has the current version? The Stripe key was rotated last week. The Notion page still has the old one. Half the team has the new key, half doesn't.

Who should have access? The contractor who joined for 3 months? The designer who occasionally runs the app locally? The intern?

What happens when someone leaves? Their laptop still has the .env file. They can still access staging. Nobody remembers to rotate the keys.

The pattern of "store secrets somewhere, share access manually" doesn't scale past 3 developers.

Automation over documentation

The fix isn't better documentation. It's removing humans from the loop.

Access should be automatic. If a developer has access to the repository, they should have access to its secrets. No asking, no waiting, no Slack DMs.

Sync should be automatic. When a secret changes, everyone gets the new version. No "hey team, I updated the Stripe key, pull the new .env from Notion".

Revocation should be automatic. When someone loses repo access, they lose secret access. No manual rotation required.

This is what a secrets manager does. Not the enterprise kind with 200-page deployment guides—the developer-focused kind that takes 30 seconds to set up.

# First time: link repo to secrets
keyway init

# New dev joins: get all secrets
keyway pull

# Secret changes: everyone syncs
keyway pull

# Someone leaves: remove from GitHub
# (their access is already revoked)

The new developer's day 1 becomes: clone, npm install, keyway pull, npm run dev. Working app in 5 minutes.

The complete setup

Here's what a properly configured project looks like:

myapp/
├── docker-compose.yml      # Local Postgres, Redis
├── .env.example            # Documents what's needed (not values)
├── src/
│   ├── config.ts           # Validated, typed config
│   └── mocks/              # MSW handlers for external APIs
├── README.md
└── .gitignore              # Includes .env

.env.example documents variables, not values:

# Required for all environments
DATABASE_URL=              # Local: postgres://dev:dev@localhost:5432/myapp

# Required for payment features (optional in dev)
STRIPE_SECRET_KEY=         # Get from: dashboard.stripe.com/test/apikeys

# Auto-generated in dev, required in production
JWT_SECRET=                # Generate: openssl rand -base64 32

README.md has the actual setup:

## Setup

1. Clone the repo
2. `docker compose up -d` (starts local Postgres/Redis)
3. `keyway pull` (gets shared secrets)
4. `npm install`
5. `npm run dev`

First time? Run `keyway login` before step 3.

config.ts validates and provides defaults:

const env = {
  DATABASE_URL: process.env.DATABASE_URL || 'postgres://dev:dev@localhost:5432/myapp',
  STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY, // undefined in dev is fine
  JWT_SECRET: process.env.JWT_SECRET || 'dev-only-secret',
  NODE_ENV: process.env.NODE_ENV || 'development',
}

if (env.NODE_ENV === 'production' && !env.STRIPE_SECRET_KEY) {
  throw new Error('STRIPE_SECRET_KEY is required in production')
}

export { env }

What changes

Before:

  • New dev: 3 hours to get running
  • Secret rotation: Manual, incomplete, rarely done
  • Offboarding: Hope they deleted the .env file
  • Audit: No idea who has access to what

After:

  • New dev: 5 minutes to get running
  • Secret rotation: Update once, everyone syncs
  • Offboarding: Remove from GitHub, access revoked
  • Audit: Log of who accessed what and when

The difference isn't the tool. It's removing the human coordination that breaks every time.

Start here

  1. Audit your .env file. How many secrets are actually required? How many could be Docker Compose, mocks, or disabled features?

  2. Add validation. Fail fast with clear errors, not cryptic crashes 10 minutes into debugging.

  3. Automate the remaining secrets. Pick a tool (Keyway, Doppler, 1Password CLI, dotenvx—whatever). The important thing is removing the manual sharing.

  4. Update your README. The setup section should be copy-paste commands, not a treasure hunt.

The goal isn't perfect security. It's making the secure path the easy path. When keyway pull is faster than asking on Slack, developers will use it. When Docker Compose is easier than sharing database credentials, they'll use that instead.

Make day 1 boring. That's the goal.


Keyway automates secret sharing for teams on GitHub. Free for open source: keyway.sh