Structured Logging in Node.js with Winston on Ubuntu

Written by: Bagus Facsi Aginsa
Published at: 02 Jun 2026


Most Node.js projects start the same way: a few console.log statements scattered across route handlers, sprinkled with the occasional console.error when something breaks. That works fine while you are developing locally. In production, it is a liability.

When your application runs on a server and generates hundreds of log lines per minute, console.log gives you an unstructured wall of text with no timestamps, no severity levels, and no way to reliably search for a specific request or error. When your system catches fire at 2 AM and you are trying to figure out which request triggered the cascade, “look through raw text output” is a painful place to be.

This tutorial shows you how to replace that pattern with a proper structured logging setup using Winston. You will configure multiple transports so development logs are readable in the terminal and production logs are machine-parseable JSON, add daily log rotation so your disk does not fill up, and wire in request correlation IDs using Express middleware so you can trace any individual request through your log history.

This tutorial is for developers who already write Node.js backend code and want their logging to work correctly in production, not just during development.


What Structured Logging Actually Means

Unstructured logging looks like this:

User 42 logged in from 10.0.0.5
POST /checkout failed: timeout after 5000ms

These lines are readable to a human who knows the context, but they are nearly useless to a log aggregation tool. To find all checkout failures in the last hour, you would have to grep for a specific string and hope the message format never changed.

Structured logging means every log entry is a JSON object with consistent, named fields:

{"level":"error","message":"POST /checkout failed","reason":"timeout","durationMs":5032,"requestId":"a3f8c1","timestamp":"2026-06-02T14:23:01.442Z"}

Now you can filter level = error, aggregate by reason, measure durationMs distributions, and join entries across services using the same requestId. Tools like Loki, Elasticsearch, Datadog, and CloudWatch Logs all work on this principle. If you want to understand log aggregation at the infrastructure level, the Loki and Promtail setup guide covers how to ship these structured logs to a central store.

The key idea is that you should be able to query your logs with the same logic you use to query a database. Structured logging is what makes that possible.


Prerequisites

  • Ubuntu 20.04, 22.04, or 24.04
  • Node.js 18 or newer, check with node --version
  • npm, bundled with Node.js
  • Basic familiarity with Express

If you need Node.js:

curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs

Project Setup

Create a working directory and install the dependencies:

mkdir ~/winston-demo && cd ~/winston-demo
npm init -y
npm install express winston winston-daily-rotate-file uuid
  • winston is the logging library itself. It handles log levels, formats, and routing log output to different destinations (transports).
  • winston-daily-rotate-file adds a transport that writes to a new log file each day and deletes old files automatically.
  • uuid generates unique request IDs, used later for correlation.

Creating the Logger Module

Keep all logging configuration in one file. This makes it easy to change the format or add a transport later without touching every file in your project.

Create logger.js:

const { createLogger, format, transports } = require("winston");
const { combine, timestamp, json, colorize, printf } = format;

const isDev = process.env.NODE_ENV !== "production";

const devFormat = combine(
  colorize({ all: true }),
  timestamp({ format: "HH:mm:ss" }),
  printf(({ level, message, timestamp, ...meta }) => {
    const metaStr = Object.keys(meta).length ? " " + JSON.stringify(meta) : "";
    return `${timestamp} [${level}] ${message}${metaStr}`;
  })
);

const prodFormat = combine(
  timestamp(),
  json()
);

const logger = createLogger({
  level: process.env.LOG_LEVEL || (isDev ? "debug" : "info"),
  format: isDev ? devFormat : prodFormat,
  transports: [
    new transports.Console(),
  ],
});

module.exports = logger;

Run a quick sanity check before going further:

node -e "const l = require('./logger'); l.info('hello world'); l.debug('debug message');"

You should see two colored lines in the terminal. The debug line appears because NODE_ENV is not set, so the level defaults to debug.

Now run it with production mode:

NODE_ENV=production node -e "const l = require('./logger'); l.info('hello world', { userId: 42 });"

The output is now a single JSON object with level, message, userId, and timestamp.


Log Levels and When to Use Each

Winston follows the npm log level convention. From most severe to least severe:

Level When to use it
error Something failed and requires attention. An unhandled exception, a database connection drop, a failed payment.
warn Something unexpected happened but the application recovered. A retry that succeeded, a deprecated API being called, a slow query.
info Normal application events worth recording. A server started, a user authenticated, a job completed.
http HTTP request/response summaries. Useful when you want request logs separate from application logs.
verbose Detailed internal flow, one level below info. Use when debug would be too noisy but you want more than info.
debug Development-only detail. Variable values, code paths taken, external call parameters.

In production, running at info level is the right default for most applications. You get meaningful events without overwhelming your log storage. Drop to debug only when you are actively diagnosing a problem. The LOG_LEVEL environment variable in the logger module makes that easy to change without touching code.


Adding File Transport with Daily Rotation

Logging only to the console is fine for containers where stdout is captured by Docker or your platform’s log driver. But many deployments still write log files to disk. Without rotation, those files grow indefinitely until the disk fills up.

Update logger.js to add file transports:

const { createLogger, format, transports } = require("winston");
const DailyRotateFile = require("winston-daily-rotate-file");
const { combine, timestamp, json, colorize, printf } = format;

const isDev = process.env.NODE_ENV !== "production";

const devFormat = combine(
  colorize({ all: true }),
  timestamp({ format: "HH:mm:ss" }),
  printf(({ level, message, timestamp, ...meta }) => {
    const metaStr = Object.keys(meta).length ? " " + JSON.stringify(meta) : "";
    return `${timestamp} [${level}] ${message}${metaStr}`;
  })
);

const prodFormat = combine(
  timestamp(),
  json()
);

const fileTransportOptions = {
  format: combine(timestamp(), json()),
  datePattern: "YYYY-MM-DD",
  zippedArchive: true,
  maxSize: "20m",
  maxFiles: "14d",
};

const logger = createLogger({
  level: process.env.LOG_LEVEL || (isDev ? "debug" : "info"),
  format: isDev ? devFormat : prodFormat,
  transports: [
    new transports.Console(),
    new DailyRotateFile({
      ...fileTransportOptions,
      filename: "logs/app-%DATE%.log",
    }),
    new DailyRotateFile({
      ...fileTransportOptions,
      filename: "logs/error-%DATE%.log",
      level: "error",
    }),
  ],
});

module.exports = logger;

Create the logs directory and test it:

mkdir -p logs
NODE_ENV=production node -e "
  const l = require('./logger');
  l.info('app started', { port: 3000 });
  l.error('database unreachable', { host: '10.0.0.5', port: 5432 });
"

Check what was written:

ls logs/
cat logs/app-$(date +%Y-%m-%d).log

The maxFiles: "14d" setting tells winston-daily-rotate-file to delete files older than 14 days automatically. The maxSize: "20m" setting caps each file at 20 MB before rotating to a new one mid-day. Both options prevent the logs directory from growing out of control.

The error transport is a second DailyRotateFile instance with level: "error", which means only error level entries go to the error log file. All levels go to app-*.log. Having a dedicated error file makes it much faster to check whether your application is currently erroring without parsing the full log stream.


Request Correlation IDs with Express

One of the most useful things you can add to application logging is a unique ID attached to every log line from a given HTTP request. When an error happens and you know the request ID, you can filter the logs to see exactly what that request did from start to finish.

Create app.js:

const express = require("express");
const { v4: uuidv4 } = require("uuid");
const logger = require("./logger");

const app = express();
app.use(express.json());

app.use((req, res, next) => {
  req.requestId = req.headers["x-request-id"] || uuidv4();
  req.log = logger.child({ requestId: req.requestId });

  const start = Date.now();
  res.on("finish", () => {
    req.log.info("request completed", {
      method: req.method,
      url: req.url,
      status: res.statusCode,
      durationMs: Date.now() - start,
    });
  });

  next();
});

app.get("/", (req, res) => {
  req.log.debug("handling root route");
  res.json({ ok: true });
});

app.get("/error", (req, res, next) => {
  const err = new Error("something went wrong");
  next(err);
});

app.use((err, req, res, next) => {
  req.log.error("unhandled error", {
    error: err.message,
    stack: err.stack,
  });
  res.status(500).json({ error: "internal server error" });
});

app.listen(3000, () => {
  logger.info("server started", { port: 3000 });
});

Start the server and send a few requests:

NODE_ENV=production node app.js &
curl http://localhost:3000/
curl http://localhost:3000/error

Every log line from a given request now carries the same requestId. When you see an error in the log and want to know what led to it, filter by that ID and you get the full story.

The logger.child({ requestId: req.requestId }) call creates a child logger that inherits all settings from the parent and automatically includes the requestId field on every log call made through it. This is cleaner than passing the ID manually on every log line.

If your client sends an X-Request-ID header (common in service-to-service calls), the middleware reuses that ID rather than generating a new one. This lets a correlation ID span multiple services in a microservices setup.


Logging Errors Correctly

A common mistake is logging an Error object directly:

logger.error("something failed", { error: err });

Winston will serialize the error object, but Error objects in JavaScript do not serialize their stack property to JSON by default. The stack is the most useful part of an error. Always log it explicitly:

logger.error("something failed", {
  error: err.message,
  stack: err.stack,
  code: err.code,
});

For unhandled exceptions and unhandled promise rejections, add two safety nets to logger.js:

process.on("uncaughtException", (err) => {
  logger.error("uncaught exception", { error: err.message, stack: err.stack });
  process.exit(1);
});

process.on("unhandledRejection", (reason) => {
  logger.error("unhandled rejection", {
    error: reason instanceof Error ? reason.message : String(reason),
    stack: reason instanceof Error ? reason.stack : undefined,
  });
});

These handlers ensure that no exception silently disappears without leaving a trace in your logs. The process.exit(1) after an uncaught exception is intentional. An uncaught exception leaves the process in an unknown state. It is better to restart cleanly and let your process manager bring it back up.


Common Mistakes

Logging inside tight loops Every logger.info call has overhead. Logging inside a loop that runs thousands of times per request will hurt throughput noticeably. Log before and after the loop, not on every iteration. The logging performance comparison shows exactly how much throughput drops when you log more frequently.

Using console.log alongside Winston If you install Winston but still have console.log calls throughout the codebase, your logs will be split between two streams with inconsistent formatting. Do a search and replace to convert remaining console.log and console.error calls to logger.info and logger.error. You can also override console.log to pipe through Winston if you need a transition period:

console.log = (...args) => logger.info(args.join(" "));

Logging sensitive data Log entries are often written to disk, shipped to third-party aggregators, and retained for weeks. Do not log passwords, session tokens, API keys, or full credit card numbers. Scrub or redact sensitive fields before logging:

const safeBody = { ...req.body };
delete safeBody.password;
delete safeBody.creditCard;
logger.info("form submitted", { body: safeBody });

Not creating the logs directory If logs/ does not exist, DailyRotateFile will throw an error at startup. Add a startup check or create the directory as part of your deploy process:

mkdir -p logs

Best Practices

One logger module, imported everywhere. Never call createLogger in multiple places. Keep one logger.js and import it wherever you need logging. This ensures every log entry in your application uses the same format and transports.

Use child loggers for context. logger.child({ service: "payments", userId: req.userId }) creates a logger that includes those fields on every call. Attach context at the boundary (middleware, job entry point) rather than passing extra metadata on every individual log call.

Set LOG_LEVEL via environment variable, not code. This means you can enable debug logging on a production instance without a deploy. Your process manager or deployment config controls the verbosity.

Keep log messages static. The message field should describe the event class, not the specific instance. Put variable data in metadata fields. "user logged in" with { userId: 42 } is easier to aggregate than "user 42 logged in", which creates a unique message for each user.

Test your log output in CI. Broken logger config (wrong file path, bad format options) tends to surface only at runtime. Add a smoke test that imports your logger and calls each log level to catch configuration errors before they reach production.


Conclusion

A production Node.js application should never rely on console.log. Winston gives you structured JSON output, multiple transports, automatic log rotation, and child loggers for attaching request context, all configured in one place.

The setup covered here handles the most common production requirements: human-readable logs during development, JSON logs for production aggregation, separate error log files, daily rotation with automatic cleanup, and correlation IDs for tracing individual requests.

From here, the natural next step is shipping these logs to a central aggregation system. The Loki and Promtail guide shows how to collect and query structured logs across multiple servers from a single Grafana dashboard.