Skip to content

Managing Environment Variables and Secrets

Aman Kumar Singh7 min read
Part 8 of 40From the Full Stack SaaS Masterclass series
Managing Environment Variables and Secrets — article by Aman Kumar Singh

Last time, in Setting Up Docker for Local Development, we got Postgres and Redis running the same way on every machine on the team. That solved "works on my machine" for the infrastructure. It didn't solve the other half of the problem: every one of those services needs credentials, and those credentials need to be different in local dev, staging, and production without anyone copy-pasting a database password into a Slack message.

This is part of the Full Stack SaaS Masterclass, the series where we're building a multi-tenant SaaS from an empty repo to something production-ready. Environment variables feel like the most boring topic in the whole series, and that's exactly why teams get them wrong. Nobody schedules time to think about config until a secret leaks into a git history or a staging database gets pointed at by a production deploy.

You don't need a giant secrets-management platform on day one. A handful of habits, applied consistently, scale fine from a solo project to a team of engineers, and they keep you from being one careless git add . away from a real incident.

Why config deserves its own discipline

Every real app has values that change between environments: database URLs, API keys, JWT secrets, third-party credentials, feature flags. The twelve-factor app methodology calls this out directly: config should live in the environment, not in the code, because code is the same across every deploy and config is not. If your DATABASE_URL is hardcoded, you can't run the same build against staging and production without editing source files, which defeats the entire point of having a build artifact.

The second reason is a security one. Code gets reviewed, diffed, and often made public. Secrets don't get that scrutiny in the same way, and they shouldn't be visible in the same places code is. Once a secret is committed to git, it's in history forever unless you rewrite it. By then it's usually already been rotated in panic, not by plan.

The third reason is less obvious: config bugs are some of the hardest to debug precisely because the code is correct. A missing REDIS_URL in staging doesn't throw a compile error. It throws a runtime error three requests deep into a job queue, at 2am, after the person who set up staging has moved teams. Treating configuration as a first-class, validated part of your app instead of an afterthought is what prevents that class of bug entirely.

Validate config at startup, not at first use

The single highest-leverage habit here is validating every environment variable when the app boots, not the first time some deep service tries to read process.env.STRIPE_SECRET_KEY and gets undefined. Fail fast, with a clear error, before the app accepts a single request.

In NestJS this slots naturally into ConfigModule. Instead of forRoot({ isGlobal: true }) with no validation, define a schema and pass it in:

// src/config/env.validation.ts
import { z } from 'zod';

export const envSchema = z.object({
  NODE_ENV: z.enum(['development', 'test', 'staging', 'production']),
  PORT: z.coerce.number().default(3000),
  DATABASE_URL: z.string().url(),
  REDIS_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
  STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
});

export type EnvConfig = z.infer<typeof envSchema>;

export function validateEnv(config: Record<string, unknown>): EnvConfig {
  const result = envSchema.safeParse(config);

  if (!result.success) {
    const issues = result.error.issues
      .map((issue) => `${issue.path.join('.')}: ${issue.message}`)
      .join('\n');
    throw new Error(`Invalid environment configuration:\n${issues}`);
  }

  return result.data;
}
// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { validateEnv } from './config/env.validation';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      validate: validateEnv,
    }),
  ],
})
export class AppModule {}

With this in place, a missing JWT_SECRET or a malformed DATABASE_URL crashes the app on boot with a message that names the exact variable, not a stack trace from a random service two weeks later. That trade is almost always worth it: a loud failure in CI or on deploy beats a silent one in production.

The same idea applies on the frontend, though Next.js handles it differently since env vars there get baked into the client bundle at build time if you're not careful. Anything prefixed NEXT_PUBLIC_ ships to the browser. Everything else stays server-only. Keep a small, explicit list of what's actually public:

// apps/web/src/env.ts
const publicEnv = {
  apiUrl: process.env.NEXT_PUBLIC_API_URL,
  stripePublishableKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
} as const;

for (const [key, value] of Object.entries(publicEnv)) {
  if (!value) {
    throw new Error(`Missing required public env var for: ${key}`);
  }
}

export { publicEnv };

Naming the boundary explicitly like this means nobody accidentally prefixes a real secret with NEXT_PUBLIC_ just because it was convenient during a late-night fix. That single prefix decides whether a value ends up in a browser's network tab.

Local development: .env files done right

For local dev, a .env file loaded by dotenv (or NestJS's built-in loader) is still the right tool. The discipline is in what goes in git and what doesn't.

Commit an .env.example with every variable name and a placeholder or safe default, never a real value:

# .env.example
NODE_ENV=development
PORT=3000
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/saas_dev
REDIS_URL=redis://localhost:6379
JWT_SECRET=replace-with-a-long-random-string
STRIPE_SECRET_KEY=sk_test_replace_me

Ignore the real one:

# .gitignore
.env
.env.local
.env.*.local

When a new engineer joins, the onboarding step is cp .env.example .env and fill in real local values, most of which come straight from the Docker Compose file we set up previously. .env.example doubles as documentation: anyone can read it and know exactly what the app needs to run, without grepping the codebase for every process.env reference.

One habit worth adding here: run a pre-commit check (a simple secret-scanning tool works fine) that blocks a commit containing anything that looks like a live key. It catches the case where .env gets accidentally un-ignored, or someone pastes a real key into a config file while debugging and forgets to remove it before committing.

Secrets in staging and production

Local .env files don't belong anywhere near a deployed environment. Once you're off a laptop, the secret needs to live somewhere with access control, an audit trail, and rotation support. A plaintext file gives you none of that.

On AWS, that's Secrets Manager or SSM Parameter Store, both of which integrate cleanly with ECS, Lambda, and EC2. The pattern is the same regardless of which one you pick: the secret's value never sits in your deploy config or CI logs, only its identifier does.

// src/config/secrets.ts
import {
  SecretsManagerClient,
  GetSecretValueCommand,
} from '@aws-sdk/client-secrets-manager';

const client = new SecretsManagerClient({ region: process.env.AWS_REGION });

export async function loadSecret(secretId: string): Promise<string> {
  const response = await client.send(
    new GetSecretValueCommand({ SecretId: secretId }),
  );

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

  return response.SecretString;
}

In practice, you'd call this once during bootstrap, before ConfigModule validates anything, and inject the resolved values into process.env or directly into your config object. The app code never knows or cares whether a value came from a local .env file or Secrets Manager. That's the point: the same validated config shape works in both places, only the source changes.

For CI/CD, GitHub Actions repository and environment secrets cover most needs. Scope secrets to the environment that needs them (production secrets shouldn't be readable from a PR build), and never echo a secret in a workflow step for debugging. GitHub masks known secret values in logs, but that masking only works for exact string matches, so a secret that gets base64-encoded or partially transformed before printing slips right through the mask.

Common pitfalls worth naming directly

A few mistakes show up often enough to call out on their own.

Logging the entire process.env object "just to check config" is a common one, usually left in during debugging and forgotten. It's an easy way to leak every secret the process holds into a log aggregator that a much wider set of people can read than the codebase itself.

Reusing the same secret across environments is another. If staging and production share a JWT_SECRET, a token issued by one is valid on the other, which turns a staging compromise into a production one.

Long-lived secrets that never rotate are a slow accumulation of risk. Every engineer who's ever had access to a secret, and every system that's ever logged it by accident, remains a potential exposure point for as long as that secret stays valid. Rotation doesn't need to be constant, but it should be possible without a redeploy freeze, which is itself a good test of whether your secrets architecture is sound.

And a subtler one: config drift between environments, where staging quietly ends up with a different flag setting than production because someone made a one-off change and never wrote it down. A validated schema, checked into source control even though the values aren't, keeps every environment honest about what it needs.

Key takeaways

  • Config belongs in the environment, not in code, per the twelve-factor model, and secrets deserve stricter handling than ordinary config.
  • Validate every environment variable at startup with a schema (Zod, Joi, or similar) so missing or malformed config fails loudly before the app serves traffic.
  • Commit `.env.example` with placeholder values; never commit a real `.env`. Add it to `.gitignore` from day one.
  • On the frontend, treat the `NEXT_PUBLIC_` prefix as a hard security boundary between server-only and browser-visible values.
  • Use a managed secrets store (Secrets Manager, SSM Parameter Store) in staging and production instead of plaintext files or CI variables alone.
  • Never reuse secrets across environments, and design for rotation from the start rather than bolting it on after an incident.

Frequently asked questions

What's the difference between an environment variable and a secret?

An environment variable is any config value that changes between environments, like a port number or a feature flag. A secret is a specific kind of environment variable whose exposure causes harm, such as an API key or database password. Every secret is an environment variable, but not every environment variable needs secret-level handling.

Should I use dotenv in production?

`dotenv` and similar loaders are fine for local development, where the file lives only on your machine. In staging and production, prefer a managed secrets store or your platform's native secret injection (ECS task definitions, Kubernetes secrets, GitHub Actions environment secrets) so values are access-controlled and auditable rather than sitting in a plaintext file on a server.

How do I stop secrets from leaking into Next.js client bundles?

Only prefix a variable with `NEXT_PUBLIC_` if it's genuinely safe to expose in the browser, such as a publishable API key. Keep everything else unprefixed and read it only in server components, API routes, or server actions. Treat the prefix as an explicit, reviewed decision rather than a naming convention you apply casually.

What should happen when a secret leaks into git history?

Rotate the secret immediately; assume it's compromised the moment it's committed, regardless of whether the repo is public. Removing it from git history (with tools like `git filter-repo`) matters for hygiene, but it doesn't undo the exposure, since the value may already be cached or cloned elsewhere. Rotation is the step that actually closes the risk.

How often should secrets be rotated?

There's no universal schedule; it depends on the sensitivity of the secret and how it's used. What matters more than a fixed cadence is that rotation is possible without downtime or a redeploy freeze. If rotating a database password or JWT signing key requires a maintenance window, that's a sign the architecture needs work, independent of how often you actually do it.

Do I need a secrets manager for a small side project?

Probably not on day one. A well-ignored `.env` file and platform-native secrets (like your host's environment variable settings) are enough until you have a team, multiple environments, or compliance requirements. Add a dedicated secrets manager when the coordination cost of plaintext values across people and environments starts to outweigh the setup cost of the tool.

Related articles

Deploying NestJS to Production — Aman Kumar Singh
Security Best Practices — Aman Kumar Singh
Configuration and Environment — Aman Kumar Singh
Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.