So far, the service has reached into process.env wherever it pleased and hardcoded http://localhost:3000 in three routes. That works fine in development. But in production, a teammate might deploy with a typo in DATABASE_URL or forget to set PORT. If the server boots anyway, it will die halfway through the first request with a cryptic stack trace. This chapter is the first of Section 6: Production Readiness. We will replace those scattered reads with a single validated config module loaded at startup.

This chapter relies on four core ideas. Here is what they mean in plain English.
process.env. DATABASE_URL, PORT, and NODE_ENV are environment variables. They let you configure a deploy without changing code. However, is always a string (, never the number ) and might be missing entirely.process.env.PORT"3000"3000DATABASE_URL is a real URI, PORT is a number in range, and NODE_ENV is an allowed value. Catching a typo here means you catch it once, loudly, instead of dealing with a mysterious failure later.DATABASE_URL should kill the process at boot with exit(1). Orchestrators read this signal as "do not route traffic here." This is much safer than booting in a broken state and surfacing a confusing connection error halfway through a user request.process.env and re-parsing strings, the app reads one shared object with real types (like config.PORT being a number). This object is frozen so nothing can mutate it. Reading process.env directly leaves every value as string | undefined, duplicates default values, and makes it hard to know what the service actually requires.We need a schema, a validator, and a URI-format check. The common instinct is to install zod or env-schema. However, both are new dependencies for a job the project can already do.
Fastify uses TypeBox to express JSON Schemas and Ajv to compile and run them. We have written TypeBox schemas for route bodies and responses since chapter 5, and Ajv has been validating every request all along. They are already in the dependency tree as transitive dependencies of Fastify. Instead of adding a new framework, we will promote the three packages we need to explicit dependencies.
bashnpm install @sinclair/typebox ajv ajv-formats
Nothing new is downloaded. The @sinclair/typebox, ajv, and ajv-formats packages were already resolved transitively. This install just records them as direct dependencies so config.ts does not rely on hidden packages. The payoff is a single mental model: the same tools you use for routes now validate the environment.
Three Ajv options do the heavy lifting here:
coerceTypes: true turns the always-string PORT="4000" into the number 4000 the schema declares.useDefaults: true fills in the schema's default values for any optional variable that is absent.allErrors: true collects every problem in one pass. A broken deploy reports all missing variables at once instead of requiring a restart for each fix.The ajv-formats package adds format: "uri", which we use to reject a malformed DATABASE_URL or BASE_URL.
| Variable | Required? | Type / format | Default | Notes |
|---|---|---|---|---|
DATABASE_URL | required | string, uri | — | must be present and a valid URI |
PORT | optional | integer, 1..65535 | 3000 | coerced from string; non-numeric → error |
BASE_URL | optional | string, uri | http://localhost:3000 | public base for shortUrl |
NODE_ENV | optional | enum | development | one of development | | |
The schema sets additionalProperties: true on purpose. A real server environment is full of variables we do not care about, like PATH, HOME, and TEST_DATABASE_URL. We validate the four we actually read and ignore the rest. We do not want to reject an environment just because it contains extra variables.
Check out the start branch:
bashgit checkout 20-config-startnpm install
The start branch includes __tests__/config.test.ts but no src/config.ts to satisfy it. The suite imports a module that does not exist yet, so it cannot load. The first test pins down the happy path. A valid environment should parse into the typed object, with PORT coerced from a string to a number:
typescript// __tests__/config.test.tsit("parses a valid environment into a typed config object", () => {const config = loadConfig(validEnv);expect(config).toEqual({DATABASE_URL: "postgres://user:pass@localhost:5433/urlshortener",PORT: 4000,BASE_URL: "https://sho.rt",NODE_ENV: "production",});});
Notice that loadConfig takes its environment as an argument instead of reading the global object. That single design choice makes the whole module testable. Every test passes a plain object and asserts on the result without any process-wide environment juggling. One test makes the rule explicit: loading config must never touch the real process.env:
typescript// __tests__/config.test.tsit("does not read or mutate the global process.env", () => {const before = { ...process.env };loadConfig(validEnv);expect(process.env).toEqual(before);});
The rest of the suite covers the failure modes that matter at boot. These include a missing DATABASE_URL, a malformed URI, a non-numeric PORT, an unknown NODE_ENV, and a multi-problem environment that must report every error in a single message.
Run the unit suite:
bashnpm test

The config suite fails to run with Cannot find module '../src/config'. It shows as 1 suite failed of 14, while the other 13 suites and all 68 prior tests stay green. Running npm run typecheck fails for the same reason: the imported module does not exist yet. This is an honest failing test state. Nothing is broken; the implementation simply is not written.
Switch to the finish branch:
bashgit checkout 20-config-finishnpm install
The schema is plain TypeBox. The defaults live on the schema itself, so Ajv applies them automatically. This means there is exactly one place in the code that says PORT defaults to 3000:
typescript// src/config.tsexport const DEFAULT_PORT = 3000;export const DEFAULT_BASE_URL = "http://localhost:3000";const ConfigSchema = Type.Object({DATABASE_URL: Type.String({ format: "uri", minLength: 1 }),PORT: Type.Integer({ minimum: 1, maximum: 65535, default: DEFAULT_PORT }),BASE_URL: Type.String({format: "uri",minLength: 1,default: DEFAULT_BASE_URL,}),NODE_ENV: Type.Unsafe<"development" | "test" | "production">({type: "string",enum: ["development", "test", "production"],default: "development",}),},{ additionalProperties: true });export type Config = Static<typeof ConfigSchema>;
The Static<typeof ConfigSchema> utility derives the TypeScript type straight from the schema. This is the same trick we use for route types. You define the shape once. The validator and the static type both come from it, so they can never drift apart.
Compiling the validator wires up the three Ajv options and the URI format:
typescript// src/config.tsconst ajv = new Ajv({allErrors: true,useDefaults: true,coerceTypes: true,});addFormats(ajv);const validate = ajv.compile(ConfigSchema);
Next is the loader. The detail that makes it safe is the copy step. Ajv's coerceTypes and useDefaults options mutate the object they validate in place, so we never want to hand it the caller's original object. We pull the four variables we care about into a fresh candidate object. We drop the keys that are absent so useDefaults can fill them, validate that copy, and freeze the result:
typescript// src/config.tsexport const loadConfig = (env: NodeJS.ProcessEnv = process.env): Config => {const candidate: Record<string, unknown> = {DATABASE_URL: env.DATABASE_URL,PORT: env.PORT,BASE_URL: env.BASE_URL,NODE_ENV: env.NODE_ENV,};for (const key of Object.keys(candidate)) {if (candidate[key] === undefined) {delete candidate[key];}}if (!validate(candidate)) {const details = formatErrors(validate.errors ?? []);throw new Error(`Invalid environment configuration:\n${details}`);}return Object.freeze(candidate as Config);};
Three important decisions are packed in here:
env defaults to process.env but can be injected. Production calls loadConfig() and reads the real environment. Tests call loadConfig(fixture) and read a plain object. It is the same function, with no globals to stub.process.env is never mutated. Coercion and defaults rewrite candidate while leaving the source untouched. This is exactly what the test asserts.Object.freeze turns an accidental config.PORT = … into a thrown error instead of a silent, app-wide surprise.The error message is the part operators actually see, so it names each offending variable on its own line:
typescript// src/config.tsconst formatErrors = (errors: ErrorObject[]): string =>errors.map((err) => {const key =(err.params as { missingProperty?: string }).missingProperty ??err.instancePath.replace(/^\//, "") ??"(root)";return ` - ${key}: ${err.message}`;}).join("\n");
Because Ajv runs with allErrors: true, a deploy with three mistakes prints three lines. You can fix the whole environment in a single pass instead of restarting the server after each fix.
The app needs one config, not a fresh load per import. But an eager const config = loadConfig() at the module top level would call loadConfig() the instant anything imports config.ts. This would immediately read process.env. In the Docker-free unit tests, there is no DATABASE_URL in the plain shell. That eager load would throw an error and crash config.test.ts on import.
The fix is a lazy, memoized accessor. It loads on the first call, caches the result, and returns the cache forever after:
typescript// src/config.tslet cached: Config | undefined;export const getConfig = (): Config => {if (!cached) {cached = loadConfig();}return cached;};
Now process.env is only read when something actually calls getConfig(). Only the server does this at startup. Unit tests import loadConfig, pass their own fixtures, and never trigger the singleton. The module is testable without a real environment, and the running app still gets a single shared config.
The server.ts file is the one place that turns a bad environment into a clean exit. It calls getConfig() before doing anything else. On failure, it prints the message and exits with a non-zero status code:
typescript// src/server.tsconst start = async (): Promise<void> => {let config;try {config = getConfig();} catch (err) {console.error(err instanceof Error ? err.message : err);process.exit(1);}const app = await buildApp({ baseUrl: config.BASE_URL });try {await app.listen({ port: config.PORT });app.log.info(`Documentation at ${config.BASE_URL}/documentation (NODE_ENV=${config.NODE_ENV})`);} catch (err) {app.log.error(err);process.exit(1);}};start();
This is the main point of the chapter. A misconfigured deploy dies at the boot boundary with exit(1). This is the signal every orchestrator understands as "this container did not start, do not route traffic to it." A failed loadConfig() against a clean environment prints exactly what is wrong:
Invalid environment configuration:
- DATABASE_URL: must have required property 'DATABASE_URL'
- PORT: must be integer
- NODE_ENV: must be equal to one of the allowed values
The hardcoded port: 3000 and the literal documentation URL are gone. The server.ts file now reads config.PORT and config.BASE_URL, which are the validated values.
Three routes built shortUrl from a hardcoded http://localhost:3000/${shortCode}. That string was correct in development and wrong in every other environment. This was the note we left for "later." Later is now. Each route takes a baseUrl and builds the URL from it:
typescript// src/routes/shorten.tsreturn {shortCode,url,shortUrl: `${baseUrl}/${shortCode}`,};
The list.ts and stats.ts routes get the same treatment. The buildApp function accepts an optional baseUrl, falling back to the DEFAULT_BASE_URL constant, and passes it down to each route:
typescript// src/app.tsconst baseUrl = opts.baseUrl ?? DEFAULT_BASE_URL;
That fallback is what keeps the existing test suite green. The route tests assert the literal http://localhost:3000/..., and DEFAULT_BASE_URL is http://localhost:3000. This is the same constant the schema uses as its default. When a test calls buildApp() with no baseUrl, every shortUrl comes out identical to before, so not a single assertion changes. Production passes the validated config.BASE_URL from server.ts, while tests get the default. The fallback is a plain constant, not getConfig(). This ensures the unit tests stay Docker-free because they never load config or read process.env.
Why not route
DATABASE_URLthrough config too? Prisma's datasource reads it from the environment directly, and the integration setup swaps inTEST_DATABASE_URLthe same way. Theconfig.tsfile still validates thatDATABASE_URLis present and well-formed at boot to keep the fail-fast guarantee. However, it deliberately does not change how Prisma reads it. Validating the contract and owning the plumbing are two different jobs.
A config module is only as useful as its documentation. The .env.example file is a committed, secret-free template. It tells a new developer exactly which variables exist, what they default to, and what each one means:
bash# .env.example (excerpt)PORT=3000BASE_URL=http://localhost:3000NODE_ENV=development
The real .env file stays gitignored. The .env.example file carries only safe local defaults and never a real secret. A teammate copies it to .env, adjusts what they need, and the validated config does the rest. Anyone who misspells a value learns at boot from the fail-fast message, not from a 2 a.m. page.
Run both test suites:
bashnpm testnpm run test:integration

Unit tests are at 14 suites and 78 tests. The new config.test.ts adds its 10 cases, and every prior test still passes because BASE_URL defaults to the old hardcoded value. Integration tests are unchanged at 10 suites and 28 tests. The routes serialize the same URLs they always did. Running npm run typecheck passes with no output. The service now reads its environment in one validated place, refuses to start when that environment is wrong, and builds public URLs from a configurable base instead of a hardcoded host.
The service boots safely but stays mostly silent, and a SIGTERM signal kills it mid-request. Next, we will add structured logging and graceful shutdown. This includes JSON logs with request IDs, plus a clean drain-and-close routine when the server stops.
testproduction