Back to Blog

Environment Variable Management SaaS Production Security

Published: June 24, 2026
Environment Variable Management SaaS Production Security

Every team I know has a story about the .env file that got committed. Usually it goes one of two ways: someone spots it in the PR review, deletes it, and everyone has a tense laugh about "how close we came." Or nobody spots it, the secrets live in git history forever, and the breach — if it comes — traces back to that moment. According to GitGuardian, over 12.8 million new secrets were exposed in public GitHub repositories in a single year — a number that makes environment variable management saas production security feel less like an optional practice and more like the thing you should have done last year.

The .env file is the most convenient way to manage environment variables. It is also the most dangerous, because convenience scales straight into production if nobody draws a line. That line — between development convenience and production security — is what environment variable management for SaaS production security is actually about. Not just where you put your secrets, but how you validate them, rotate them, audit access to them, and use them to control environment-specific behaviour without hardcoding anything.

This post covers the full pipeline: local .env discipline, validation with NestJS ConfigService, environment-specific configs, secrets rotation strategy, access auditing, and using env vars as feature flags. If you manage a SaaS on NestJS and Next.js, these are the practices that keep your secrets out of the commit log and your production config from being someone's 3am disaster.

Computer server rack with security lock representing environment variable management for SaaS production security

Categories of Environment Variables

Not every environment variable is the same thing, and treating them all as "stuff in a .env file" is how teams accidentally give production database credentials the same protection level as a log level setting. Three categories, and they belong in different buckets:

Secrets. Database URLs with passwords, API keys for third-party services, JWT signing keys, encryption salts. These are the variables that cause real damage when leaked. The average data breach costs $4.88 million (IBM, 2024), and stolen credentials are the initial action in 24% of breaches (Verizon DBIR, 2024). Secrets belong in a secrets manager — AWS Secrets Manager, Doppler, or HashiCorp Vault — not in a flat file on disk, not in CI/CD pipeline YAML that gets cached, not in the env block of a Docker Compose file that gets committed.

Configuration. Database hosts and ports (without passwords), API base URLs, log levels, timeout values, feature flags. These are not sensitive, but they must be correct per environment. A staging app pointing at the production database host is a staging app that corrupts production data. These belong in per-environment configuration files or platform env vars (Render, Vercel, Railway), validated at startup.

Environment identity. NODE_ENV, APP_ENV, SERVICE_NAME, INSTANCE_ID. These tell the application which environment it's running in and which peers to talk to. They are small, static, and almost never change between deploys. They are also the most frequently misspelled in a .env.example — trust me, I've watched a staging deployment spin up in production mode because NODE_ENV=staging was typed as NODE_ENV=stagin and silently fell through to the default.

The discipline is: know which category each variable belongs to, store it accordingly, and never let a secret leak into a category that isn't encrypted at rest and restricted by access control.

.env Files and Git: The First Line of Defense

The single most important rule of environment variable management for SaaS production security is the simplest one: add .env to .gitignore before the first commit. Not after. Before. Every project scaffold we build starts with a .env.example in version control and .env in the ignore file, before a single line of application code gets written.

The .env.example serves as documentation. It lists every variable the application expects, with a comment describing what it is and a placeholder value that makes sense — never a real secret, never a production hostname, never something that accidentally works if someone copies it.

This discipline of documenting expectations without exposing real values is a core practice of environment variable management saas production security — the .env.example serves as a contract between the application and the engineer who configures it.

ENV
1# Database
2DATABASE_HOST=localhost
3DATABASE_PORT=5432
4DATABASE_NAME=myapp_dev
5DATABASE_USER=myapp_user
6# No DATABASE_PASSWORD in .env.example — that is filled locally
7
8# Auth
9JWT_SECRET=change-me-in-production
10JWT_EXPIRATION=3600
11
12# Third-party
13STRIPE_PUBLISHABLE_KEY=pk_test_placeholder
14# STRIPE_SECRET_KEY — never put secret keys in example files

That "change-me-in-production" on JWT_SECRET is deliberate. It makes the validation fail on startup in any environment that wasn't configured properly, which is exactly the failure mode you want — loud, immediate, blocking.

For teams that need to share .env files among developers (and you should avoid this where possible), use an encrypted channel: Doppler's secrets sharing, 1Password, or a tool like send.env.dev with burn-on-read. Do not paste the contents into Slack.

Beyond the ignore file, add a pre-commit hook. Tools like git-secrets or talisman scan staged files for patterns that look like AWS keys, database URLs, or private keys. They catch the slip-ups — the ctrl+v into the wrong file, the accidental git add -A that picked up the .env you forgot to ignore. We run this on every repo we build.

If you do commit a secret — and you will, it happens — rotate the credential immediately and remove it from git history with git-filter-repo. But don't assume the git scrub fixed anything. The secret was on the wire during the push, potentially cached in CI, maybe in a collaborator's local clone. Rotate it. The git history cleanup is for the audit trail, not for security.

Local Development Environment Variables

Local dev is where .env files belong. They are convenient, they are editable in any editor, and they work with every tool in the ecosystem. The trick is keeping local dev isolated from everything else.

Every developer on the team should have their own .env with local-only values. The database URL points to a local Postgres instance. The Stripe keys are test mode. The NODE_ENV is development. We use a simple convention: cp .env.example .env, then edit the values that need to be real.

For NestJS projects, the @nestjs/config package loads .env files automatically when configured, but there is a subtle point: be explicit about which file to load in which environment.

TypeScript
1// app.module.ts
2ConfigModule.forRoot({
3  envFilePath: [`.env.${process.env.NODE_ENV || 'development'}`, '.env'],
4  isGlobal: true,
5  validationSchema: Joi.object({...}),
6})

This loads .env.development first (if it exists), then falls back to .env. That means you can have per-environment defaults and override only what changes. In practice though, we keep it simple — one .env for local, platform env vars for staging and production. The fallback chain saves you when someone deploys without setting NODE_ENV (and they will, see the typo story above).

Environment-Specific Configuration Across Deployments

Staging and production should never read their configuration from a .env file that came from a git checkout. The .env file that a CI pipeline generates from GitHub Actions secrets is better — at least the values live in a secret store — but it still means the secrets were injected at build time and persisted in the filesystem of the deployed artifact. Not ideal.

The better approach is to inject environment variables at the platform level. Every deployment platform — Render, Railway, Vercel, Fly.io, AWS ECS, Kubernetes — supports setting environment variables in its own way. The platform manages the values, the app reads them from process.env at runtime, and no file touches disk.

For Kubernetes, use a ConfigMap for non-sensitive configuration and Secrets for sensitive values. Mount them as environment variables in the pod spec. NestJS reads them the same way — through process.env or ConfigService — regardless of whether they came from a file, a platform UI, or a Kubernetes secret.

For the NestJS backend, the twelve-factor approach means the same build artifact deploys to staging and production, with only the environment variables differing. We use GitHub Actions to deploy, setting per-environment variables through the deployment platform's API rather than baking them into pipeline YAML. Our NestJS Next.js CI/CD GitHub Actions configuration post covers exactly how we inject per-environment secrets without exposing them in workflow logs.

The Docker multi-stage build we use for the NestJS service includes a runtime stage that has no .env file at all — it reads everything from the host environment. That means the same Docker image deploys to staging, production, and any review environment, with the platform handling the config.

Devops infrastructure setup for environment variable management saas production security

NestJS ConfigService: Validation and Typed Access

NestJS ships with one of the best environment variable management tools in the Node.js ecosystem: the ConfigService from @nestjs/config. It loads variables from .env files or the runtime environment, provides typed access, and — critically — supports startup validation via Joi.

The validation schema is where most teams stop too early. They validate that the variable exists, but not that it has a correct value. A DATABASE_PORT of "abc" will pass a .required() check, then crash when your connection pool tries to parseInt it.

TypeScript
1import * as Joi from 'joi';
2
3export const validationSchema = Joi.object({
4  NODE_ENV: Joi.string().valid('development', 'staging', 'production').default('development'),
5  PORT: Joi.number().default(3000),
6  DATABASE_HOST: Joi.string().required(),
7  DATABASE_PORT: Joi.number().default(5432),
8  DATABASE_NAME: Joi.string().required(),
9  DATABASE_USER: Joi.string().required(),
10  DATABASE_PASSWORD: Joi.string().required(),
11  JWT_SECRET: Joi.string().min(16).required(),
12  JWT_EXPIRATION: Joi.number().default(3600),
13  STRIPE_SECRET_KEY: Joi.string().when('NODE_ENV', {
14    is: 'production',
15    then: Joi.required(),
16    otherwise: Joi.optional(),
17  }),
18  REDIS_URL: Joi.string().uri().optional(),
19  FEATURE_NEW_CHECKOUT: Joi.boolean().default(false),
20});

A few things worth pointing out in this schema:

  • NODE_ENV is restricted to exactly three values with a sane default. This catches the "stagin" typo before the app serves a single request.
  • JWT_SECRET requires a minimum length. A two-character JWT secret is valid syntax and catastrophic security.
  • STRIPE_SECRET_KEY is required in production but optional elsewhere. This is the conditional validation that makes per-environment schemas practical.
  • FEATURE_NEW_CHECKOUT is typed as boolean. Without this, process.env.FEATURE_NEW_CHECKOUT returns the string "false" — which is truthy in JavaScript. The Joi boolean coercion handles that.

Using the ConfigService throughout the application means every module accesses configuration through a typed interface rather than raw process.env calls. We wrap the config in a dedicated class with getter methods, so if a key name changes, it changes in one place.

TypeScript
1@Injectable()
2export class AppConfig {
3  constructor(private configService: ConfigService) {}
4
5  get port(): number {
6    return this.configService.get<number>('PORT', 3000);
7  }
8
9  get databaseUrl(): string {
10    const host = this.configService.get<string>('DATABASE_HOST');
11    const port = this.configService.get<number>('DATABASE_PORT');
12    const name = this.configService.get<string>('DATABASE_NAME');
13    const user = this.configService.get<string>('DATABASE_USER');
14    const password = this.configService.get<string>('DATABASE_PASSWORD');
15    return `postgresql://${user}:${password}@${host}:${port}/${name}`;
16  }
17
18  get isProduction(): boolean {
19    return this.configService.get<string>('NODE_ENV') === 'production';
20  }
21}

This pattern means your application code never touches process.env directly. Every environment variable access goes through validation, typing, and a clearly named interface. Worth its weight when you refactor the variable name six months later and only have to touch one file.

Secrets Rotation: How and How Often

A secret that never rotates is a secret that lives forever. If it was compromised six months ago and nobody knows, it works just as well today as the day it leaked. Rotation limits that window, and automated rotation is one of the pillars of environment variable management saas production security because it removes the human forgetfulness factor.

The rotation cadence depends on the type of secret:

  • Database credentials: every 90 days. If your data is sensitive or regulated, every 30 days.
  • API keys and tokens: every 180 days, unless the provider enforces a shorter window.
  • JWT signing keys: every 90 days, with a grace period where both the old and new keys are accepted.
  • SSH keys and certificates: on the schedule the PKI enforces, typically every 6—12 months.
  • Any secret after a suspected exposure: immediately. Not "as soon as we get to it." Immediately.

Manual rotation schedules do not work. They work for the first cycle, maybe the second, and by the third cycle someone forgets and the schedule becomes aspirational. Automate it.

AWS Secrets Manager handles automated rotation with a Lambda function that updates the credential in both the secrets store and the target service (RDS, for example). The rotation strategy matters: use alternating users for zero-downtime rotation, where both the old and new credentials are valid during the rotation window. AWS Secrets Manager best practices recommend this approach for production databases.

For teams that prefer a secrets-management platform, Doppler provides automated rotation with a broader set of integrations — GitHub, Stripe, DigitalOcean, and so on. If you use GitHub Actions for CI/CD, their secrets documentation covers storing encrypted secrets at the repository, environment, and organization level. The CLI supports uploading existing .env files during migration, which makes the transition from file-based management smoother than starting from scratch.

The application side of rotation is straightforward if you built your config layer correctly: the new secret value is available in the runtime environment after the next deployment or pod restart. For dynamic rotation — where the secret changes without a deployment — you need a watcher that polls the secrets manager and reloads the config at runtime. Most teams do not need this. The 90-day rotation cycle aligns cleanly with the deployment cadence.

Auditing Secrets Access and Changes

This is the section that enterprise security reviews care about most. We nearly lost a deal over a missing audit log — the prospect's security team asked who changed what and when, and our honest answer was "somewhere in the application logs, give us a day to find it." That is a losing answer.

Environment variables and secrets should be audited at two levels:

Who changed the config. If someone updates DATABASE_URL in staging or rotates STRIPE_SECRET_KEY in production, there should be a record of who did it, when, and what the previous value was (if the audit system captures before/after). AWS Secrets Manager logs every API call to CloudTrail. Doppler maintains a change history with user attribution. Kubernetes Secrets changes are tracked in the API server audit log.

Who accessed the secret and when. Every read of a secret should be logged. This is how you detect that a compromised CI token has been exfiltrating secrets for three weeks. CloudTrail for AWS Secrets Manager, the Doppler audit log, or a custom audit interceptor on your NestJS config endpoint — pick one, configure it, read the logs.

For the NestJS application side, we add a middleware that logs configuration access attempts in production — not the values, but the fact that a specific service requested a specific config key. This has helped us debug deployment issues more than once: "the payment service is requesting the Stripe key before the config module has loaded it" is a real error that is much easier to spot in an audit stream than in a crash log.

Environment Variables as Feature Flags

A feature flag is a boolean condition that controls whether a piece of code executes. Environment variables are the simplest way to implement feature flags for per-environment control — new checkout flow enabled in staging, disabled in production — without a third-party service.

TypeScript
1@Get('checkout')
2async checkout(@Body() body: CheckoutDto) {
3  if (this.configService.get<boolean>('FEATURE_NEW_CHECKOUT')) {
4    return this.newCheckoutService.process(body);
5  }
6  return this.legacyCheckoutService.process(body);
7}

This pattern works well for deployment-gated features. You ship the code behind FEATURE_NEW_CHECKOUT=false, enable it in staging for testing, and flip it to true in production when the feature is ready. The deployment and the feature activation are separate concerns.

Where env-var-based flags break down: per-user toggles, gradual rollouts, A/B testing, kill switches that need to activate without a deploy. For those, use LaunchDarkly, Unleash, or a custom feature flag service with a database-backed configuration store. Environment variables are environment-level, not user-level. Do not try to encode {"user_123": true, "user_456": false} in a JSON env var — that is a database concern wearing env-var clothes.

For the simple case though — "this feature goes live when NODE_ENV is production and we set the flag" — an env var is the right tool. It is auditable, it goes through the same validation as every other config value, and it requires zero infrastructure.

Close-up of HTML code on a computer monitor representing environment variables and configuration

Environment Variable Management SaaS Production Security: Summary

Environment variable management for SaaS production security comes down to a handful of practices that are easy to describe and harder to maintain without discipline:

  • Categorise your variables — secrets, configuration, environment identity — and store each according to its sensitivity.
  • Never commit a .env file. .gitignore it, use .env.example for documentation, run pre-commit hooks, and rotate immediately if something slips through.
  • Validate every variable at startup with NestJS ConfigService and Joi. A missing variable should be a hard crash, not a runtime surprise.
  • Inject secrets through the deployment platform, never through files in the artifact.
  • Rotate secrets on a schedule — 90 days for database credentials, 180 for API keys — and automate the rotation.
  • Audit config changes and secret access. Enterprise buyers will ask for this. Have an answer.

The same application code ships to every environment. The only thing that changes is the configuration, and that is the whole point. Get the config layer right, and deploying to a new environment becomes a small, predictable step rather than a prayer that the right secrets got injected somehow.

We use these practices across every SaaS we build — the NestJS project structure post covers how the config module fits into the broader module architecture, and the CI/CD post shows exactly how secrets flow from GitHub Actions into the deployment platform without touching a .env file in the pipeline.

The .env file that lives in your repo root right now? It is probably fine. But next time you clone a project and see .env sitting there in git status, ignore the excitement and add it to .gitignore first. Future-you, debugging a production issue at midnight, will not care that you saved thirty seconds on the first commit — but they will care a lot that the credentials are in a secrets manager where they belong. That is the end goal of environment variable management saas production security: secrets you can rotate, config you can validate, and an audit trail you can query — all without a .env file in sight.

Frequently Asked Questions

Use .env files with .gitignore for local development, a validation schema (Joi in NestJS ConfigService), and a secrets manager like AWS Secrets Manager or Doppler for staging and production. Never store production secrets in .env files committed to version control. Use environment-specific configs with clear naming conventions and typed access through a dedicated config module.

Add .env to .gitignore from day one. Use a .env.example file with placeholder values to document required variables. Run pre-commit hooks that scan for secrets in staged files. If a secret is accidentally committed, rotate it immediately and use git-filter-repo to remove it from history — but assume the secret is compromised regardless.

Yes. Validate all required environment variables when the application starts, not when a code path happens to need them. NestJS ConfigService with a Joi validation schema catches missing or malformed variables before the server starts serving traffic. This prevents runtime crashes caused by a missing DATABASE_URL at 3am during a deployment.

Rotate database credentials every 90 days and API keys every 180 days as a baseline. Rotate immediately after any suspected exposure, team member offboarding, or breach. Use automated rotation via AWS Secrets Manager or a secrets platform — manual rotation schedules are forgotten by the second cycle.

Yes, for simple boolean feature flags that are static per environment — like ENABLE_NEW_CHECKOUT=true in staging but not production. For dynamic flags that change without deploys, use a dedicated feature flag service. Environment-variable-based flags are best for deployment-gated features, not per-user toggles.

Portrait of Umar Farooq

About Umar Farooq

Umar Farooq is the founder and lead engineer of Codify SaaS. He builds B2B SaaS products and web applications on modern TypeScript stacks and enterprise Java, and writes code-first guides drawn from real production work — the schema decisions, the migrations that almost went wrong, and the performance fixes that actually moved the numbers. When he recommends an approach, he shows the code and explains the trade-offs.

Read full bio