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:5177That 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.