NestJS – Configuration

August 18, 20268 min readUpdated 9/5/2026

Every application reads values from its environment, and the naive version of that — process.env.WHATEVER scattered through the code — has three problems worth naming before looking at any library.

A typo in a variable name is undefined rather than a compile error. There is no single place to see what the application needs in order to run. And every value is a string until somebody remembers to parse it, which they will do inconsistently.

@nestjs/config solves the first two on its own. The third takes a small amount of deliberate work, and it is the part worth copying.

Registering it

ConfigModule.forRoot({
  isGlobal: true,
  load: [loadConfig],
  envFilePath: ['.env'],
}),

isGlobal: true makes ConfigService injectable everywhere without each feature module importing ConfigModule. This is the one place a global module is uncontroversial: configuration is genuinely ambient, and the alternative is an import in every module that exists purely to satisfy the container.

envFilePath names the dotenv files to read. It is a list, so ['.env.local', '.env'] gives you an override file that takes precedence.

load is the interesting one, and skipping it is the difference between using this library and using it well.

Parse once, into a typed object

Without load, ConfigService hands out raw strings from process.env. With it, you supply a factory that returns a shape:

export interface AppConfig {
  port: number
  database: {
    host: string
    port: number
    username: string
    password: string
    database: string
  }
  jwt: {
    secret: string
    expiresIn: string
  }
  uploads: {
    directory: string
    maxBytes: number
  }
  corsOrigins: string[]
}

That interface is the complete answer to "what does this application need in order to run". It is one file, it is checked by the compiler, and it groups related values so a consumer asks for jwt rather than for two loose strings.

The factory that builds it:

export function loadConfig(): AppConfig {
  return {
    port: envInt('CONTRACTOR_PORT', 3001),

    database: {
      host: env('CONTRACTOR_DB_HOST', 'localhost'),
      port: envInt('CONTRACTOR_DB_PORT', 5434),
      username: env('CONTRACTOR_DB_USER', 'contractor'),
      password: env('CONTRACTOR_DB_PASSWORD', 'contractor'),
      database: env('CONTRACTOR_DB_NAME', 'contractor'),
    },
    // ...
  }
}

Every value is read, parsed and typed in one place. Nothing else in the application touches process.env.

Blank is not the same as set

function env(name: string, fallback: string): string {
  const value = process.env[name]
  return value === undefined || value.trim() === '' ? fallback : value
}

An empty line in a .env file — CONTRACTOR_DB_HOST= — should behave like not setting the variable, not like setting it to the empty string. Without that check the application tries to connect to a database host of "", and the error names neither the variable nor the file.

Fail loudly at startup

function envInt(name: string, fallback: number): number {
  const raw = process.env[name]
  if (raw === undefined || raw.trim() === '') return fallback
  const parsed = Number(raw)
  if (!Number.isInteger(parsed)) {
    throw new Error(`${name} must be an integer, got "${raw}"`)
  }
  return parsed
}

A typo'd port that quietly becomes the default is a confusing half hour: the application starts, listens on the wrong port, and nothing says why. A startup error naming the variable is thirty seconds.

That is the general principle for configuration — fail at startup, not at first use. A missing database password should stop the deploy, not surface as a 500 on whichever endpoint happens to be hit first. Nest supports this with a validationSchema option taking a Joi schema, and a hand-written factory like this one gets you the same guarantee with no extra dependency and better error messages.

Reading it back

const uploads = this.config.getOrThrow<AppConfig['uploads']>('uploads')

Prefer getOrThrow over get. get returns undefined for a missing key and the failure surfaces somewhere unrelated; getOrThrow fails where the mistake is.

The type parameter is worth using consistently. AppConfig['uploads'] derives the type from the interface rather than restating it, so a field added there is available immediately and a field renamed is a compile error at every call site.

When a module needs configuration

Some modules cannot be configured with a literal, because the values come from something that is itself injected. That is what the async factory forms are for:

JwtModule.registerAsync({
  inject: [ConfigService],
  useFactory: (config: ConfigService<AppConfig>) => {
    const jwt = config.getOrThrow<AppConfig['jwt']>('jwt')
    return {
      secret: jwt.secret,
      signOptions: {
        algorithm: 'HS256',
        expiresIn: jwt.expiresIn as `${number}${'s' | 'm' | 'h' | 'd'}`,
      },
      verifyOptions: { algorithms: ['HS256'] },
    }
  },
}),

The synchronous register() would have to read process.env directly, putting a second untyped copy of the configuration beside the parsed one — and two copies of the same fact eventually disagree.

The cast on expiresIn is honest rather than lazy, and worth understanding because the pattern recurs. jsonwebtoken types that option as a template-literal union of every valid duration, and a value read from the environment is a plain string that TypeScript cannot narrow to it. The alternative is hand-writing that union in the config interface, where it would drift from whatever the library accepts. A bad duration is caught at startup by the library itself, loudly — so the cast trades a compile-time check the environment cannot provide for a runtime one that actually fires.

The database is the exception

TypeOrmModule.forRoot(buildDataSourceOptions()),

A plain synchronous call, not forRootAsync. It works because buildDataSourceOptions() calls loadConfig() itself rather than injecting ConfigService, and it is that way for a specific reason: the TypeORM CLI needs the same options to run migrations, and the CLI has no Nest container to inject from.

Two configurations — one for the running application and one for migrations — is how an application ends up running against a schema its migrations never produced. Sharing one function is worth losing the injected form for.

Defaults in code, and what that costs

Every value in this application has a default, so a fresh clone runs with no .env to create first:

CONTRACTOR_PORT=3001
CONTRACTOR_DB_HOST=localhost
CONTRACTOR_DB_PORT=5434
CONTRACTOR_DB_USER=contractor
CONTRACTOR_DB_NAME=contractor
CONTRACTOR_JWT_EXPIRES_IN=7d
CONTRACTOR_UPLOAD_MAX_BYTES=5242880
CONTRACTOR_CORS_ORIGINS=http://localhost:5177

That is a real benefit for a demo, and it has a real cost worth being clear-eyed about: a production deployment that forgets to set a variable gets the development default rather than a loud failure. Most importantly, it means there is a working JWT secret checked into the repository.

For anything handling real data, invert it for the values that matter: default the harmless ones and throw for the rest.

function required(name: string): string {
  const value = process.env[name]
  if (value === undefined || value.trim() === '') {
    throw new Error(`${name} is required`)
  }
  return value
}

A secret is the clearest case. A development default for a database host is a convenience; a development default for a signing key is a key an attacker can read on GitHub.

Configuration in tests

A test that boots the application picks up whatever is in the environment, which is how a suite ends up writing to a real database because .env was present. Two ways to control it.

Override the provider, which is the cleanest for a unit test:

{ provide: ConfigService, useValue: { getOrThrow: () => ({ maxBytes: 1024 }) } }

Or give ConfigModule.forRoot a different envFilePath in the testing module, so the whole application runs against a .env.test. That is the right shape for end-to-end tests, where the point is to exercise the real wiring.

What to avoid is a test that mutates process.env partway through. The factory runs once at startup, so a variable changed after that has no effect — and the test that appears to work is usually one that got the default it wanted for an unrelated reason.

Secrets do not belong in environment variables either

Worth saying, because "put it in the environment" is often where the advice stops. An environment variable is visible to every process in the container, appears in a crash dump, and is frequently printed by a well-meaning debug endpoint.

For anything genuinely sensitive the usual step up is a secrets manager, fetched at startup — which is exactly what an async factory is for:

// A second `load` entry, alongside the one above — and it may be async.
load: [loadConfig, async () => ({ jwt: await fetchSigningKey() })],

A load entry may be async, so the module waits for it before anything that depends on ConfigService is constructed. That ordering is the useful part: a secret fetched lazily on first use turns one slow network call into a failure mode on an arbitrary request.

The same values, in Docker

One entry in this application's configuration carries a comment longer than the code, and it is worth repeating because the shape of the mistake is universal:

host: env('CONTRACTOR_DB_HOST', 'localhost'),
port: envInt('CONTRACTOR_DB_PORT', 5434),

5434 is the port on the host. Inside the compose network Postgres is on 5432, and the difference is a published-port mapping only. Run the application on your laptop and 5434 is right; run it in a container on the same network and 5434 refuses the connection, because that mapping exists only outside.

The general form: a default that is correct in one environment is wrong in another, and connection failures name neither the variable nor the reason. Which is the argument for keeping every such value in one file with a comment beside it, rather than scattered through the code where the pattern is invisible.

One more thing to type

ConfigService takes a type parameter, and using it consistently is what turns the service from a string bag into something the compiler checks:

constructor(private readonly config: ConfigService<AppConfig>) {}

With it, a call to getOrThrow('uplaods') is a compile error rather than a runtime one. Without it every key is a bare string and every return value is effectively any, which spreads outward through whatever consumes it.

It costs six characters at each injection site and turns the most common configuration mistake — a mistyped key — into something you find while typing rather than on the first request that needed that value.

What belongs in configuration

Values that differ between environments: hosts, ports, credentials, limits, feature flags, allowed origins. Not business rules. The list of valid project statuses is not configuration, and putting it there means the application's behaviour is no longer described by its code.

The useful test is whether staging and production could sensibly hold different values. A database URL, yes. Whether accepting a quote declines the others, no.

Next: databases with TypeORM — where those connection settings are actually used.