Blog

Stop Committing .env Files: A Saner Way to Handle Secrets

Leaking a database password or API key in a commit is avoidable, and this is how to manage configuration and secrets across every environment cleanly.

February 20, 20266 min readMuhammad Shehzaib
DEVOPSSECURITYSECRETSCONFIG

Almost every backend developer has, at some point, accidentally committed a .env file or pasted a production key into a chat. Secrets sprawl is one of the most common and most damaging mistakes in backend work, because a leaked credential can mean a compromised database or a runaway cloud bill. The root cause is usually not carelessness but a missing system: secrets are handled ad hoc, copied around, and stored wherever is convenient in the moment. The fix is to draw a clear line between configuration, which is non-sensitive and can vary by environment, and secrets, which are sensitive and must be protected, then to manage each with the right tool. This article lays out practical patterns for a Node application running on a VPS, from local development through CI to production, so secrets stay out of your repository and out of your logs.

Config Versus Secrets

The first useful distinction is between configuration and secrets. Configuration is information that varies between environments but is not sensitive: which port to listen on, the log level, a feature flag, the public URL of your API. Secrets are values that grant access or must stay confidential: database passwords, signing keys, third-party API tokens, encryption keys. Both belong outside your code, but they call for different handling. Configuration can often live in plain files committed to the repository as sane defaults, while secrets must never touch version control and need encryption at rest and restricted access. The twelve-factor approach popularized storing both in the environment, which works, but conflating the two leads people to treat harmless config with paranoid friction or, worse, to treat real secrets as casually as config. Name the categories explicitly so every value has an obvious home and level of protection.

The Twelve-Factor Approach

The twelve-factor methodology recommends storing configuration in the environment rather than in code, and this principle underpins most modern deployment. The reasoning is that the same built artifact, your Docker image, should run unchanged in development, staging, and production, with only the environment variables differing. This is why you never bake environment-specific values or secrets into the image: doing so couples the artifact to one environment and forces a rebuild to change a setting. In Node you read these values through process.env, ideally in one configuration module that validates and parses them at startup rather than scattering process.env access throughout the codebase. That central module can fail fast if a required variable is missing, turning a confusing runtime crash deep in a request into a clear boot-time error. Treating config as environment input keeps your image portable and your deployments a matter of supplying the right values.

Local .env Files Done Right

For local development, a .env file loaded by a library like dotenv is the standard and is perfectly fine, provided you follow a few rules. First, add .env to your .gitignore immediately, before you ever put a real value in it, so it can never be committed. Second, commit a .env.example file that lists every variable your app needs with placeholder or safe default values, so a new developer knows exactly what to set without you sharing real secrets. Third, keep local secrets genuinely local; use throwaway development credentials, not production ones, so a leaked laptop or a screen share does not expose anything important. Load the file only outside production, since in production the real environment supplies the variables and loading a file invites mistakes. This simple convention, ignore the real file and commit the example, prevents the single most common secret leak in backend repositories.

Validating Config At Startup

A subtle but costly failure mode is an app that starts with a missing or malformed configuration value and only crashes later, often in production under load, with a cryptic error far from the real cause. The cure is to validate all configuration at boot. Define a schema for your environment variables using a library like zod or envalid, parse process.env through it once when the app starts, and refuse to start if anything required is missing or the wrong type. This converts a class of mysterious runtime bugs into a single clear message that names the offending variable before the server ever accepts a request. The same module can coerce types, so a port arrives as a number and a flag as a boolean rather than every value being a string. Centralizing and validating configuration also documents what your service needs when deploying to a new environment.

Secrets In Containers

Getting secrets into a container safely takes some care, because the obvious methods leak. Passing secrets as build arguments bakes them into image layers where anyone who pulls the image can extract them, so never do that. Setting them as environment variables at runtime is acceptable for many setups and is how Compose and most platforms inject them, but remember that environment variables are visible to anything that can inspect the container, can appear in crash dumps, and may be logged if you print the environment. For a single VPS with Compose, keeping secrets in a gitignored env file referenced by the Compose file is a reasonable baseline. For stronger isolation, Docker supports secrets mounted as files readable only by the service, which keeps them out of the environment entirely. Whatever the mechanism, the secret should arrive at runtime from outside the image, never be part of the image itself.

Secrets In CI And CD

Your pipeline needs credentials too, and they deserve the same discipline as application secrets. Store them as encrypted secrets in your CI system, GitHub Actions in this case, rather than in the workflow file, and reference them through the secrets context so they are masked in logs. Scope each secret to the narrowest level that works, preferring a specific environment or repository over an organization-wide secret that every workflow can read. Use GitHub Environments to gate production credentials behind required reviewers, so a deploy to production needs an approval before the production secrets are even exposed to the run. Be vigilant about logging: a single careless echo of an environment variable can print a secret into a build log that is visible to everyone with repository access. Where your provider supports it, prefer short-lived tokens minted per run over static keys, since a leaked ephemeral token expires on its own.

Dedicated Secret Managers

As a system grows beyond one server, env files stop scaling and a dedicated secret manager earns its keep. Tools like HashiCorp Vault, AWS Secrets Manager, or a cloud provider equivalent store secrets encrypted, control access with fine-grained policies, log every access for auditing, and crucially support rotation so a credential can be changed centrally without redeploying. Your application authenticates to the manager at startup, often using a short-lived identity token, and fetches the secrets it needs rather than reading them from the environment. This adds a dependency and some complexity, so it is overkill for a hobby project on a single VPS, but it becomes essential when you have many services, multiple environments, compliance requirements, or a team where you must control and audit who can see what. Adopt one when the operational burden of managing env files manually starts to outweigh the setup cost.

Rotation And Damage Control

Plan for the day a secret leaks, because eventually one will. The most important capability is rotation: the ability to replace a credential quickly without taking the system down. Build your app to read secrets from a single place so swapping a value means changing one source and restarting, not hunting through code. Keep credentials scoped to the minimum privilege they need, so a leaked key for one service cannot touch unrelated systems and the blast radius stays small. If a secret is exposed, revoke it first and investigate second; a revoked credential is harmless even if you do not yet know how it leaked. Use automated scanners that flag secrets accidentally committed to the repository, and enable push protection so commits containing recognizable credentials are blocked before they ever reach the remote. Treating leaks as a when, not an if, turns a potential disaster into a routine, recoverable event.