You met the
.env file in the introductory module and locally it works perfectly. In production it is different - and it is worth knowing why, before somebody convinces you that adding it to .gitignore is enough.Four reasons at once. The risk of an accidental commit - one
git add -f, or a new developer without a configured .gitignore, and the password is in the repository's history forever. No rotation - changing the database password means editing the file by hand on every server. No audit - nobody knows who read a secret, or when. And plain text - passwords sit in the clear on disk, readable by any process with permission.Note what is not among those reasons:
.env files are not slow, do not consume memory and work in Node.js perfectly well. The problem is not technical but organisational.Rome did not send sealed dispatches by an ordinary messenger. They went to a chancery, which issued them against a receipt and knew who had collected what.
Before we get to the chancery, one thing about the files themselves.
ConfigModule accepts a list of them:1ConfigModule.forRoot({
2 isGlobal: true,
3 envFilePath: ['.env.local', '.env.development', '.env'],
4});The registration order is fixed:
, ConfigModule
, the options with .forRoot({
, and finally isGlobal: true,
.})
And now the thing that misleads most often: the file listed first wins. When the same variable appears in several files, the value comes from
- not from .env.local
.env, despite it looking like the "main" one.The logic runs from most specific to most general.
.env.local holds your personal overrides that nobody else has; .env.development holds the settings of a whole environment; .env holds the defaults. The more specific shadows the more general.HashiCorp Vault is a tool for centrally managing secrets with auditing and rotation. It is not a frontend framework, not a testing system and not an image compressor - it is a vault that issues values on request and records every issue.
It solves exactly the gaps we listed: the secret does not sit in a file (nothing to commit), it can be changed in one place (rotation), and every read is noted (audit).
The application fetches secrets at startup, before anything needs them:
1export async function loadSecrets(): Promise<Record<string, string>> {
2 const vault = new VaultClient({
3 endpoint: process.env.VAULT_ADDR,
4 token: process.env.VAULT_TOKEN,
5 });
6
7 const { data } = await vault.read('secret/data/legion-api');
8
9 return {
10 DATABASE: data.data.DATABASE,
11 JWT_SECRET: data.data.JWT_SECRET,
12 REDIS_PASSWORD: data.data.REDIS_PASSWORD,
13 };
14}Note the paradox visible here: to reach the vault you also need a key - an address and a token, which still arrive from environment variables. Vault does not remove that problem, it reduces it to one secret instead of twenty. That one is often additionally issued short-lived by the infrastructure itself.
In a mature setup secrets may come from several places at once. From highest priority down:
The rule is simple: the higher, the more control. The environment-variable fallback sits at the bottom not because it is bad but because it offers neither audit nor rotation - while working without any infrastructure at all, so a local run needs no Vault.
Whatever the source, the code reaches for a secret in the same way:
1const dbUrl = this.configService.get<string>('DATABASE');The order of the parts is fixed:
, this
, .configService
, .get<string>
. It is the same method you met with configuration - and that is the point. The service does not know whether the value came from Vault, from the cloud or from an environment variable; changing the source touches not one line of logic.('DATABASE')
A cluster brings an extra difficulty: we describe configuration in YAML files kept in a repository - and a secret in such a file is a password in Git again.
Sealed Secrets allow encrypted secrets to be stored safely in a Git repository. You encrypt a value with the public key of a controller running in the cluster; the resulting file can be committed without worry, because only that controller can decrypt it, with a private key that never leaves the cluster.
This resolves the contradiction between "everything in the repository" and "no secrets in the repository". Sealed Secrets do not scale pods, do not compress images and do not monitor usage - they encrypt secrets so they can travel with the code.
The chancery issues dispatches against a receipt:
.env in production is dangerous for four reasons at once: risk of an accidental commit, no rotation, no audit, plain text - not because of speed or memory,envFilePath the file listed first wins: .env.local shadows .env.development, which shadows .env,ConfigModule, .forRoot({, isGlobal: true,, }),this + .configService + .get<string> + ('DATABASE') - the logic does not know where the value came from,In the next lesson we return to the pipeline - this time with build matrices, artifacts and caching. For now remember: a
.env file tells you what the password is; a chancery also tells you who collected it and when it was changed.