RizTech Academy logo
RizTech Academy
NestJS FundamentalsLesson 6 of 625 min

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

  1. Install @nestjs/config and register it globally. Read PORT in a service.
  2. Use get for a missing variable and watch undefined travel. Switch to getOrThrow.
  3. Write the Zod validation schema. Start with JWT_SECRET missing and read the error.
  4. Set JWT_SECRET to "short" and confirm the length rule rejects it.
  5. Set PORT=abc and confirm coercion fails clearly.
  6. Add the typed configuration file and read a nested value with dot notation.
  7. Write .env.example with every variable and no real values. Confirm .env is gitignored and the example is not.
  8. Generate a real secret with openssl rand -base64 32.
  9. 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