Overview

{DRAFT} - Setting up Production logging in a NestJS backend

August 29, 2026 15 min read

In this post, I will cover how to set up production-ready logging for a NestJS backend application.

Before we dive into it, it helps to know what you are actually trying to achieve here.

A production logging setup has a few parts that matter:

  1. Structured JSON, so a log platform can search and filter
  2. A request ID, so concurrent requests do not smear into each other
  3. Redaction, so tokens and passwords never land in storage
  4. Automatic request and response lines, so you are not logging every HTTP call by hand
  5. Error logging that keeps the real stack trace

Don’t worry if you don’t understand all of these yet, we will cover them one by one in the sections below.

When you deploy an app there is usually a log collection service sitting in front of it, something like Elasticsearch or Datadog, and it reads your logs as key-value pairs. A search tool cannot query a sentence. One JSON object per line, with fields like level, msg, req.id and statusCode, is what lets you ask “every 500 on /auth/me in the last hour” and actually get an answer. Hence, we need JSON logs in production even though they are painful to read on your laptop while development, which is why dev and production do not share a format. Locally you want colour and a readable timestamp. In production you want raw JSON. We switch on NODE_ENV and leave it at that.

NestJS ships with a logger out of the box, and it is better than most blog posts I read online give it credit for. It can even do JSON, if you pass json: true to ConsoleLogger. So if structured output is all you are after, you can stop reading here and save yourself a dependency.

It has a few gaps though, and they are the reason this post exists.

There is no request context. Nothing ties a log line written deep inside a service back to the request that caused it. This is the one that decided it for me, and it gets its own section further down.

There is no automatic HTTP logging. Nothing records that a request arrived, what status it returned, or how long it took. You write that middleware yourself.

There is no redaction. No redact option, no censor, nothing. If somebody logs an object with a token in it, that token goes straight to your log storage.

There is also a performance difference. Pino checks the log level before doing any serialisation work, so suppressed debug calls cost close to nothing.

Worth noting that the Nest docs point at Pino for exactly this, saying that if you need integration with external logging systems or forwarding to a centralized service, you should reach for a dedicated Node logging library.

NestJS docs recommending Pino for external and centralized logging

Picking a logger

I read a lot of other blogs and articles and tried out a bunch of approaches myself before writing this post. Essentially the two main options for a logger package are Winston and Pino.

Honestly both work really well, but personally I enjoyed the experience of working with Pino more, so that is what I will cover here.

The thing that actually decided it for me is request context. Pino, through nestjs-pino, binds request data to logs from any service at any layer without you passing anything around. Winston does not do that on its own. You end up wiring up nestjs-cls and building the plumbing yourself.

That said, if your ops story is “email me when things catch fire” rather than “a collector scrapes stdout”, Winston’s transport model is genuinely nicer. Console, rotating files, and a Slack webhook for fatal errors, all swappable by editing a list.

Getting Started

Obviously the first step would be to install pino and its dependencies.

Terminal window
pnpm add nestjs-pino pino-http pino
pnpm add -D pino-pretty

The config

I keep the pino config in its own file, separate from the module, because the redact list and the serializers grow over time and I do not want a sixty line decorator.

src/common/logger/logger.config.ts:

import { randomUUID } from 'node:crypto';
import { createRequire } from 'node:module';
import type { ConfigService } from '@nestjs/config';
import type { Params } from 'nestjs-pino';
const require = createRequire(import.meta.url);
const REDACT_PATHS = [
'req.headers.authorization',
'req.headers.cookie',
'res.headers["set-cookie"]',
'*.password',
'*.currentPassword',
'*.newPassword',
'*.accessToken',
'*.refreshToken',
];
const REQUEST_ID_PATTERN = /^[\w-]{1,64}$/;
export function buildLoggerConfig(config: ConfigService): Params {
const isProd = config.get<string>('NODE_ENV') === 'production';
return {
assignResponse: true,
pinoHttp: {
level: config.get<string>('LOG_LEVEL') ?? (isProd ? 'info' : 'debug'),
// Pretty output locally, raw JSON to stdout in prod.
transport: isProd
? undefined
: {
target: require.resolve('pino-pretty'),
options: {
singleLine: true,
colorize: true,
translateTime: 'SYS:HH:MM:ss.l',
},
},
redact: { paths: REDACT_PATHS, censor: '[redacted]' },
// Reuse an inbound request id if it looks sane, otherwise mint one,
// and echo it back so clients can quote it in bug reports.
genReqId: (req, res) => {
const header = req.headers['x-request-id'];
const incoming = Array.isArray(header) ? header[0] : header;
const id =
incoming && REQUEST_ID_PATTERN.test(incoming)
? incoming
: randomUUID();
res.setHeader('x-request-id', id);
return id;
},
// Only log what you need. The default req serializer dumps every header.
serializers: {
req: (req) => ({ id: req.id, method: req.method, url: req.url }),
res: (res) => ({ statusCode: res.statusCode }),
},
// 4xx is a client mistake, 5xx is yours. Don't page yourself for 401s.
customLogLevel: (_req, res, err) => {
if (err || res.statusCode >= 500) return 'error';
if (res.statusCode >= 400) return 'warn';
return 'info';
},
// Your health module will otherwise flood the logs with k8s probes.
autoLogging: {
ignore: (req) => {
const path = req.url?.split('?')[0] ?? '';
return (
path === '/health' ||
path === '/healthz' ||
path.startsWith('/health/')
);
},
},
base: {
service: config.get<string>('SERVICE_NAME') ?? 'backend-service',
env: config.get<string>('NODE_ENV') ?? 'development',
},
},
};
}

Let me actually explain what is going on here.

The buildLoggerConfig function configures the logger for our app.

The first thing we make sure of is that in production we get JSON output, because that is what the log aggregation platform we are using can parse. However, JSON is very difficult to read while you are developing like I mentioned earlier, so we use pino-pretty to get a nicer output locally. That means the output is going to be colored, have a human-readable timestamp, and generally be easier to scan.

transport: isProd
? undefined
: {
target: require.resolve('pino-pretty'),
options: {
singleLine: true,
colorize: true,
translateTime: 'SYS:HH:MM:ss.l',
},
},

We know whether we are in production or development by checking the NODE_ENV environment variable. Technically you could infer the log level from that as well, but I like to read it from its own env var, because sometimes even in dev you want to switch between different log levels to cut noise.

The path redaction is fairly self explanatory. We want to keep sensitive data out of the logs, so we maintain a list of paths to redact, and the censor is the string that replaces the sensitive value.

redact: { paths: REDACT_PATHS, censor: '[redacted]' },

If somebody writes logger.log({ body }, 'Registering user') and body has a password field, it comes out censored. The header paths cover the credentials the framework handles for you: a bearer token in authorization, a session cookie on the way in, and a fresh one on the way out.

One honest caveat though. Pino wildcards only match one level, so { dto: { user: { password } } } slips straight through *.password. Rename the field to pwd and it slips through too. The real defence is logging the id instead of the whole object:

this.logger.log({ userId: user.id }, 'Profile updated'); // yes
this.logger.log({ user }, 'Profile updated'); // no

Now for the request ID, which I consider one of the most important parts of this setup.

genReqId: (req, res) => {
const header = req.headers['x-request-id'];
const incoming = Array.isArray(header) ? header[0] : header;
const id =
incoming && REQUEST_ID_PATTERN.test(incoming) ? incoming : randomUUID();
res.setHeader('x-request-id', id);
return id;
};

First let’s understand what a request ID even is and why we need it.

A request ID is a unique string attached to one incoming HTTP request, stamped onto every log line produced while handling it. That is the whole idea. The value does not matter. Nothing looks it up in a database. Its only job is to be the same on lines that belong together and different on lines that do not.

Your server handles requests concurrently. Three users hit /auth/me at the same moment and the log output interleaves:

INFO Login attempt
INFO Token validated
WARN Token expired
INFO Fetching user from DB
ERROR Database timeout
INFO Login succeeded

Which token expired? Was the database timeout on the same request as the expired token, or a different one? You cannot tell. The lines are in chronological order, but chronological order across concurrent requests is meaningless. Under real traffic it is worse. Hundreds of requests per second, and any given request’s lines are scattered across thousands of others.

With a request ID:

INFO Login attempt req.id=a1b2
INFO Token validated req.id=a1b2
WARN Token expired req.id=c3d4
INFO Fetching user req.id=a1b2
ERROR Database timeout req.id=e5f6
INFO Login succeeded req.id=a1b2

Now filter on a1b2 and you get one clean story. Attempt, validated, fetch, success. The expired token and the database timeout were other people’s requests. This makes it fairly easy to track down the root cause of an issue.

A request usually passes through a load balancer or gateway before it reaches your app, and those have already stamped it with an ID and logged it under that name. If you mint your own, you end up with two names for one request and nothing linking them. So we read x-request-id off the inbound request and only generate a UUID when it is not there. The same reasoning applies once you have a second service. Forward the header on outbound calls and one search returns the whole chain instead of four disconnected fragments.

Trusting the header is safe because the ID has no authority. Nothing looks it up or grants access based on it, it is just a label in a log line. Worst case a client sends garbage, which is why we check the shape and fall back to a UUID if it does not match.

The res.setHeader call puts the ID back on the response. When a user reports a bug, your frontend can show them that ID or attach it to something like a Sentry event, and you go straight to the exact request instead of hunting through a minute of traffic.

Lets take a look at the rest of the options.

serializers decides what actually lands in the log. The default req serializer dumps every header on every request, which is noisy and is also exactly how tokens end up in log storage. Mine emits three fields.

customLogLevel sorts the automatic request logs by who is at fault. A 401 from your auth guard is the client’s problem and gets warn. A 500 is yours and gets error. Without this, every completed request logs at info, which means you either alert on nothing or you alert on everything. This function can also return silent to drop a log entirely, which is another way to filter noisy routes.

autoLogging.ignore suppresses the request-completed line for health checks. Kubernetes probes hit /health every few seconds and there is nothing to learn from any of them.

base adds service and env to every line, which is what makes a shared index searchable once more than one service writes to it. One thing to know: setting base replaces pino’s defaults, so you lose pid and hostname. In a container that is fine, because your orchestrator already tags the pod. On a VM running several processes you probably want them back.

The module

src/common/logger/logger.module.ts:

import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { LoggerModule as PinoLoggerModule } from 'nestjs-pino';
import { buildLoggerConfig } from './logger.config.js';
@Module({
imports: [
PinoLoggerModule.forRootAsync({
inject: [ConfigService],
useFactory: buildLoggerConfig,
}),
],
exports: [PinoLoggerModule],
})
export class LoggerModule {}

Then register it in AppModule:

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { LoggerModule } from './common/logger/logger.module.js';
import { AuthModule } from './module/auth/auth.module.js';
import { HealthModule } from './module/health/health.module.js';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true, envFilePath: '.env' }),
LoggerModule,
HealthModule,
AuthModule,
],
})
export class AppModule {}

Put LoggerModule early in that list so it initialises before the feature modules that will log during startup.

Actually turning it on

Here is the step that is easiest to skip, and none of the above does anything without it. You have to swap out Nest’s logger in main.ts.

import { NestFactory } from '@nestjs/core';
import { Logger, LoggerErrorInterceptor } from 'nestjs-pino';
import { AppModule } from './app.module.js';
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter.js';
async function bootstrap() {
const app = await NestFactory.create(AppModule, { bufferLogs: true });
const logger = app.get(Logger);
app.useLogger(logger);
app.useGlobalInterceptors(new LoggerErrorInterceptor());
app.useGlobalFilters(new AllExceptionsFilter());
process.on('uncaughtException', (err) => {
logger.error({ err }, 'Uncaught exception');
process.exit(1);
});
process.on('unhandledRejection', (err) => {
logger.error({ err }, 'Unhandled rejection');
process.exit(1);
});
await app.listen(process.env.PORT ?? 3000);
}
void bootstrap();

bufferLogs: true deserves a sentence of its own. Without it, Nest uses its own logger for everything between NestFactory.create() and your useLogger() call. That window contains the startup messages and, more to the point, any error thrown while modules initialise. Those are exactly the failures you want structured. Setting bufferLogs holds them in a buffer and replays them through pino once it is ready.

This is also the only file where you import Logger from nestjs-pino. Everywhere else you import it from @nestjs/common.

Using it in your code

There is nothing exotic here. Your services use the standard Nest logger and pino runs underneath:

import { Injectable, Logger } from '@nestjs/common';
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
async findMe(userId: string) {
this.logger.log({ userId }, 'Loading profile');
}
}

new Logger(AuthService.name) sets the context field, so every line from this class is tagged and you can filter on it later. Do that per class. Do not inject anything, and do not import LoggerModule into AuthModule.

The habit that decides whether any of this setup pays off is putting the data in the first argument. Pino’s signature is (fields, message):

this.logger.log({ userId, plan: 'pro' }, 'Subscription upgraded'); // queryable
this.logger.log(`Subscription upgraded for ${userId}`); // a string again

Every value you interpolate into the message is a value you cannot filter on later. There is a %s style interpolation form if you want it, but I reach for the object form almost always.

Adding fields partway through a request

This is what assignResponse: true in the config was for, and it is the one case where you actually want PinoLogger instead of the Nest logger.

Say your auth guard resolves the user and you want userId on every log line for the rest of that request:

import { PinoLogger } from 'nestjs-pino';
@Injectable()
export class UserContextInterceptor implements NestInterceptor {
constructor(private readonly logger: PinoLogger) {}
intercept(ctx: ExecutionContext, next: CallHandler) {
const { user } = ctx.switchToHttp().getRequest();
if (user) this.logger.assign({ userId: user.id });
return next.handle();
}
}

Services further down keep using plain new Logger(X.name) and their lines pick up userId automatically. assign only exists on PinoLogger, which is why LoggerModule exports PinoLoggerModule. By default the assigned fields do not reach the automatic request-completed line, and assignResponse: true is what extends them to it.

Should the user ID even be in there

You might have paused at that assign({ userId }) call. You can absolutely add a user ID to your logs, some teams do it and some deliberately do not, so it is worth explaining where I land.

There is a real split on this online. The security and compliance side tends to lump user ID in with PII and say keep it out of logs entirely. Their reasoning is that logs usually have weaker access controls than your production database, and there is a genuine temptation to over-log identifying data just to have an audit trail. That is a fair warning. But it is mostly aimed at emails, names and phone numbers, not at a UUID that means nothing on its own.

The observability side treats user ID as normal, load-bearing context, in the same category as the request ID. That is also how debugging actually works in practice. Somebody files a ticket, you filter the logs by their ID, you read what happened.

One caveat worth checking on your end. If you are in a regulated space, so health, finance, or EU users, even a bare user ID counts as personal data for retention and access-logging purposes. It still has to go through whatever data classification process you already have.

What it looks like

In development:

[18:43:09.056] INFO: Nest application successfully started {"service":"backend-service","env":"development","context":"NestApplication"}
[18:43:13.990] INFO: request completed {"service":"backend-service","env":"development","req":{"id":"ae3b3216-0987-47c7-98a7-458c9f7934ed","method":"GET","url":"/auth/me"},"res":{"statusCode":200},"responseTime":162}

And in production, the same events as one JSON object per line:

{"level":30,"time":1788043465268,"service":"backend-service","env":"production","context":"NestApplication","msg":"Nest application successfully started"}
{"level":30,"time":1788043471451,"service":"backend-service","env":"production","req":{"id":"13be8864-4885-440d-89ab-92dc4d81c5ea","method":"GET","url":"/auth/me"},"res":{"statusCode":200},"responseTime":31,"msg":"request completed"}

Conclusion

I hope this post has helped you understand how to set up production-ready logging for a NestJS backend application. Trust me, this is something that looks trivial until something breaks in production and you are left scratching your head wondering how to debug it.