How to manage secrets in a Node.js app (the right way)
Every Node.js app has secrets. Database URLs, API keys, Stripe credentials, JWT signing keys — they all need to live somewhere. Where you put them and how you manage them determines whether your app stays secure or ends up on a breach disclosure page.
Here’s a practical guide to doing it right.
The wrong way — and why everyone does it
The default approach is dotenv. Install the package, create a .env file,
call require('dotenv').config() at the top of your entry file, and you’re
done. It works, it’s simple, and it’s what every tutorial teaches.
The problems start when your app grows:
- The
.envfile lives on your machine. Your teammate has a different one. Which one is right? - You have staging and production. Now you have
.env.stagingand.env.productionscattered across machines. - Someone commits
.envto git. It happens to everyone eventually. - A new developer joins. How do they get the values? Slack? Email? A Notion doc that’s three months out of date?
None of these are catastrophic on their own. Together they create a mess that gets worse as your team grows.
The basics — what you should always do
Regardless of what secrets management approach you use, these are non-negotiable:
Never commit secrets to git.
# .gitignore
.env
.env.*
!.env.example
The !.env.example line keeps your example file tracked so teammates
know what variables are needed.
Use a .env.example file.
# .env.example — commit this, leave values empty
DATABASE_URL=
STRIPE_SECRET_KEY=
JWT_SECRET=
NODE_ENV=development
PORT=3000
Validate your environment on startup.
Don’t let your app start with missing secrets and crash in production with a confusing error. Fail fast with a clear message:
const required = ['DATABASE_URL', 'STRIPE_SECRET_KEY', 'JWT_SECRET'];
for (const key of required) {
if (!process.env[key]) {
console.error(`Missing required environment variable: ${key}`);
process.exit(1);
}
}
Or use a validation library like zod:
import { z } from 'zod';
const env = z.object({
DATABASE_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
JWT_SECRET: z.string().min(32),
PORT: z.coerce.number().default(3000),
NODE_ENV: z.enum(['development', 'staging', 'production']).default('development'),
}).parse(process.env);
export default env;
This gives you type safety, validation, and a clear error if something is missing. Worth adding to every project.
The dotenv approach — fine for solo projects
For a solo developer on a single project, dotenv is perfectly acceptable:
npm install dotenv
import 'dotenv/config';
// or
require('dotenv').config();
Just be disciplined about .gitignore and never share your .env file
over insecure channels.
The moment you add a second developer or a second environment, the cracks start showing.
The cloud secrets approach — for teams
When you have a team, secrets need to live somewhere central that everyone can access without emailing files around. The options:
AWS Secrets Manager — solid if you’re already on AWS. Integrates well with Lambda and ECS. Overkill if you’re not in the AWS ecosystem.
import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
const client = new SecretsManagerClient({ region: "us-east-1" });
const response = await client.send(
new GetSecretValueCommand({ SecretId: "my-app/production" })
);
const secrets = JSON.parse(response.SecretString);
HashiCorp Vault — powerful, open source, self-hostable. Requires dedicated infrastructure to run properly. Worth it at scale, overkill for small teams.
Doppler — good developer experience, solid integrations. Free tier is limited to one project and one environment which is frustrating for anyone juggling multiple projects.
EnvMaster — what we built to solve this exact problem. Stores variables encrypted in the cloud, injects them directly into any process at runtime. Free tier covers 3 projects with 5 environments each.
# Install the CLI
curl -fsSL https://raw.githubusercontent.com/Atlantis-Services/envmaster-cli/master/install.sh | sh
# Link your project
envmaster project my-api
envmaster environment production
# Run your app with variables injected
envmaster run -- node server.js
No dotenv package, no .env file, no require('dotenv').config().
process.env just works because the variables are injected before
your process starts.
CI/CD — the part everyone forgets
Local development is only half the problem. Your CI/CD pipeline needs secrets too, and “just add them as GitHub Actions secrets” works until you have 40 variables across 6 environments and you’re manually syncing them every time something changes.
The cleanest approach for GitHub Actions with EnvMaster:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install EnvMaster CLI
run: curl -fsSL https://raw.githubusercontent.com/Atlantis-Services/envmaster-cli/master/install.sh | sh
- name: Build with injected variables
run: envmaster run -- npm run build
env:
ENVMASTER_TOKEN: ${{ secrets.ENVMASTER_TOKEN }}
You only need one secret in GitHub — ENVMASTER_TOKEN. Everything else
comes from EnvMaster automatically.
Production — the most important environment
A few extra rules for production secrets:
Use different values in every environment. Your production database should never be accessible with development credentials. Separate keys, separate databases, separate everything.
Rotate secrets regularly. API keys that never change are a liability. If a key has been sitting unchanged for two years, you have no idea who has seen it. Set a reminder to rotate critical credentials every 90 days.
Audit who has access. When a developer leaves your team, audit what secrets they had access to and rotate anything they could have copied. This is easy to forget and expensive when you don’t do it.
Never log secrets. It sounds obvious but it happens constantly —
error handlers that log the full request object, debug statements that
dump process.env, stack traces that include connection strings.
Audit your logging code.
The right setup in practice
Here’s what a well-configured Node.js app looks like:
my-app/
├── .env.example # committed — variable names, no values
├── .gitignore # .env and .env.* ignored
├── src/
│ ├── config/
│ │ └── env.ts # zod validation, single source of truth
│ └── index.ts # imports config/env.ts first
// src/config/env.ts
import { z } from 'zod';
import 'dotenv/config'; // only for local dev
const env = z.object({
DATABASE_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string(),
JWT_SECRET: z.string().min(32),
PORT: z.coerce.number().default(3000),
NODE_ENV: z.enum(['development', 'staging', 'production']),
}).parse(process.env);
export default env;
In production, swap dotenv for your secrets manager of choice. The
rest of your app just imports from config/env.ts and never touches
process.env directly.
Summary
- Always add
.envto.gitignore - Use
.env.exampleto document variable names - Validate your environment on startup — fail fast with clear errors
- For solo projects: dotenv is fine
- For teams: use a centralized secrets manager
- For CI/CD: one token, everything else comes from the secrets manager
- Rotate production secrets regularly and audit access when people leave
The goal is getting to a state where your secrets live in exactly one place, everyone on your team can access what they need, and you know exactly what changed and when.
EnvMaster is free to start with a 14-day Pro trial on every new account. Try it here — no credit card required.