NestJS course Β· Module 12: Containers and CI/CD
Secrets Management - the Empire's sealed dispatches
In this lesson6
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.
The order of .env files
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, .forRoot({, the options with isGlobal: true,, and finally }).
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 .env.local - not from .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.
The chancery - HashiCorp Vault
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.
Priority of sources
In a mature setup secrets may come from several places at once. From highest priority down:
- HashiCorp Vault - production; it has auditing and rotation.
- AWS Secrets Manager - cloud; the same, but built into the provider's platform.
- Environment variables - the fallback; they always work, locally and in tests too.
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.
Reading in the application
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>, ('DATABASE'). 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.
Secrets in Kubernetes
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.
Summary
The chancery issues dispatches against a receipt:
.envin 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,- in
envFilePaththe file listed first wins:.env.localshadows.env.development, which shadows.env, - registration in order:
ConfigModule,.forRoot({,isGlobal: true,,}), - HashiCorp Vault is a tool for centrally managing secrets with auditing and rotation,
- reaching Vault needs a secret too - but one instead of twenty,
- priority of sources from the top: Vault (production) β AWS Secrets Manager (cloud) β environment variables (fallback),
- reading is always the same:
this+.configService+.get<string>+('DATABASE')- the logic does not know where the value came from, - Sealed Secrets allow encrypted secrets to be stored safely in a Git repository - only the controller in the cluster can decrypt them.
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.
Code for this lesson: src/secrets-management.ts
1// Secrets Management - Imperium Secret Dispatches
2console.log("=== SECRETS MANAGEMENT ===\n");
3
4interface SecretProvider {
5 name: string;
6 type: string;
7 features: string[];
8 bestFor: string;
9}
10
11const providers: SecretProvider[] = [
12 {
13 name: 'HashiCorp Vault',
14 type: 'Self-hosted / Cloud',
15 features: ['Dynamic secrets', 'Rotation', 'Audit log', 'Access policies'],
16 bestFor: 'Large organizations, multi-cloud',
17 },
18 {
19 name: 'AWS Secrets Manager',
20 type: 'Cloud (AWS)',
21 features: ['Native AWS integration', 'Auto-rotation', 'Lambda triggers'],
22 bestFor: 'Applications on AWS',
23 },
24 {
25 name: 'Kubernetes Secrets',
26 type: 'Kubernetes-native',
27 features: ['Base64 encoding', 'RBAC', 'Mounted as env/files'],
28 bestFor: 'Applications in Kubernetes',
29 },
30 {
31 name: 'Sealed Secrets',
32 type: 'Kubernetes + Git',
33 features: ['Encryption with the cluster key', 'Safe in Git', 'GitOps'],
34 bestFor: 'GitOps workflow',
35 },
36];
37
38providers.forEach((p, i) => {
39 console.log(`${i + 1}. ${p.name} (${p.type})`);
40 console.log(` Best for: ${p.bestFor}`);
41 console.log(` Functions:`);
42 p.features.forEach(f => console.log(` - ${f}`));
43 console.log();
44});
45
46// Why NOT .env in production
47console.log("=== WHY NOT .env IN PRODUCTION ===\n");
48const risks = [
49 'Accidental commit to the repository',
50 'No rotation - passwords never change',
51 'No audit - who had access?',
52 'Plain text on disk - no encryption',
53 'Manual copying to each server',
54];
55
56risks.forEach((r, i) => console.log(` ${i + 1}. ${r}`));
57console.log("\nUse: Vault, AWS Secrets Manager or K8s Secrets");
58Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. Why is storing secrets in .env files in production dangerous?
2. What is HashiCorp Vault?
These are 2 of 3 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Code editor
Define an async config loader: connect to Vault, read secrets (DATABASE, JWT_SECRET, REDIS_PASSWORD), and return them as a config object
- Horizontal ordering
Arrange the elements of reading an environment variable via ConfigService:
- Click in order
Arrange the elements of ConfigModule registration in the correct order: