Configuration, environments and secrets
Every application needs values that differ between your laptop and production: a database URL, a JWT secret, a payment key. Getting this wrong is how credentials end up in Git and how an application starts successfully and fails two hours later because something was missing.
The rules
Never hard-code a secret. Not in source, not in a committed file, not "just for now".
Read configuration from the environment. It is the one mechanism every hosting platform supports.
Validate at startup. A missing variable should stop the application
immediately with a clear message — not surface as undefined in a signing
function at 2am.
Never log a secret. Logging the whole config object at startup is a common and quiet way to put a database password into a log aggregator.
ConfigModule
npm install @nestjs/config --workspace=apps/api
// apps/api/src/app.module.ts
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: ".env",
cache: true,
}),
],
})
export class AppModule {}
isGlobal so every module can inject ConfigService without importing.
cache because reading process.env repeatedly is slower than it needs to be.
@Injectable()
export class AuthService {
constructor(private readonly config: ConfigService) {}
sign(payload: object) {
const secret = this.config.getOrThrow<string>("JWT_SECRET");
return jwt.sign(payload, secret);
}
}
getOrThrow rather than get. get returns undefined for a missing
value, and jwt.sign(payload, undefined) fails somewhere far from the cause.
getOrThrow fails where the problem is — the rule from module 6 of the Python
course.
Validate everything at startup
Better still, check the whole environment before the application boots:
// apps/api/src/config/env.validation.ts
import { z } from "zod";
const schema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: z.coerce.number().int().positive().default(3001),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32, "JWT_SECRET must be at least 32 characters"),
JWT_EXPIRES_IN: z.string().default("1d"),
CORS_ORIGIN: z.string().url().default("http://localhost:3000"),
});
export type Env = z.infer<typeof schema>;
export function validateEnv(raw: Record<string, unknown>): Env {
const result = schema.safeParse(raw);
if (!result.success) {
const problems = result.error.issues
.map((issue) => ` ${issue.path.join(".")}: ${issue.message}`)
.join("\n");
throw new Error(`Invalid environment configuration:\n${problems}`);
}
return result.data;
}
ConfigModule.forRoot({
isGlobal: true,
validate: validateEnv,
})
Start it with JWT_SECRET missing:
Error: Invalid environment configuration:
JWT_SECRET: Required
DATABASE_URL: Invalid url
The application refuses to start, and the message names every problem at once. That is worth the twenty lines. The alternative is deploying successfully and discovering the gap when the first user tries to log in.
z.coerce.number() matters because every environment variable is a string —
the same conversion problem as query parameters and input().
The minimum length on JWT_SECRET is a real check: a short secret is
brute-forceable, and this is the place to enforce it.
Typed configuration
Strings scattered through the codebase are typo-prone:
// apps/api/src/config/configuration.ts
export default () => ({
port: parseInt(process.env.PORT ?? "3001", 10),
database: { url: process.env.DATABASE_URL! },
jwt: {
secret: process.env.JWT_SECRET!,
expiresIn: process.env.JWT_EXPIRES_IN ?? "1d",
},
cors: { origin: process.env.CORS_ORIGIN ?? "http://localhost:3000" },
});
ConfigModule.forRoot({ isGlobal: true, load: [configuration], validate: validateEnv })
const expiresIn = this.config.get<string>("jwt.expiresIn");
Grouped and dotted. The ! assertions are acceptable only because
validateEnv already guaranteed those values exist — without it they would be
lying to the compiler.
Environments
| File | Purpose | Committed |
|---|---|---|
.env |
your local values | no |
.env.example |
the list of variable names, no values | yes |
.env.test |
test values | usually yes, if no secrets |
# apps/api/.env.example
NODE_ENV=development
PORT=3001
DATABASE_URL=postgresql://user:password@localhost:5432/kirana
JWT_SECRET=generate-with-openssl-rand-base64-32
CORS_ORIGIN=http://localhost:3000
The example file is documentation. A new developer copies it, fills it in, and knows exactly what is needed. Keeping it current is the difference between a project someone can start in five minutes and one that needs a conversation.
Generating a real secret:
openssl rand -base64 32
Not a password you thought of. A JWT secret needs to be random.
In production
Nobody uses a .env file in production. Platforms provide environment variables
through their own configuration — Railway, Render, Vercel and AWS all have a
settings screen or a secrets manager. The code does not change, because it reads
process.env either way.
That is the practical reason for this approach: the same code works everywhere, and only the values differ.
Different values per environment
@Injectable()
export class AppService {
constructor(private readonly config: ConfigService) {}
get isProduction() {
return this.config.get("NODE_ENV") === "production";
}
}
Useful for: verbose logging in development, real payment keys only in production, seeding only outside production.
Be careful with conditionals on NODE_ENV. A code path that only runs in
production is a code path you never tested. Keep the differences to
configuration values where you can, rather than branching logic.
What not to put in configuration
Not everything variable belongs in the environment.
Business rules — a free-delivery threshold, a maximum cart size — belong in code or the database. They are decisions, not deployment details, and they should be visible in the codebase and changeable without a redeploy.
The test: would this differ between your laptop and production? Yes means configuration. "It might change one day" means it is a constant, or a database row.
Check your work
Why getOrThrow over get: a missing value fails immediately with a clear
message rather than becoming undefined somewhere unrelated.
Why validate the whole environment at startup: the application refuses to start and names every problem at once, instead of failing at the first request that needed the missing value.
Why z.coerce.number(): every environment variable is a string, so PORT
arrives as "3001".
Which file is committed: .env.example, never .env.
How to generate a JWT secret: openssl rand -base64 32, not a chosen
password.
Why the ! assertions in configuration.ts are acceptable: validation
already guaranteed those values exist before this runs.
What does not belong in configuration: business rules. They are decisions that belong in code or data, not deployment details.
Practice
- Install
@nestjs/configand register it globally. ReadPORTin a service. - Use
getfor a missing variable and watchundefinedtravel. Switch togetOrThrow. - Write the Zod validation schema. Start with
JWT_SECRETmissing and read the error. - Set
JWT_SECRETto"short"and confirm the length rule rejects it. - Set
PORT=abcand confirm coercion fails clearly. - Add the typed configuration file and read a nested value with dot notation.
- Write
.env.examplewith every variable and no real values. Confirm.envis gitignored and the example is not. - Generate a real secret with
openssl rand -base64 32. - Log the entire config object at startup, find the secret in your terminal, and remove it.
That is module five. You can structure a NestJS application, map routes, inject dependencies, validate input, handle errors consistently, and configure it safely.
Next module: the database.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship