NodeJSHTTP Logging with Morgan

HTTP Logging with Morgan

Morgan is Express middleware dedicated to one job: logging incoming HTTP requests. For every request it writes a one-line access log — method, URL, status code, response size, response time — in formats modelled on the classic Apache/nginx access logs. It's the simplest way to get "who hit what and what happened" visibility. This page covers the predefined formats, custom tokens, writing to files, conditional skipping, and the important distinction between access logging (Morgan) and application logging (Winston/Pino).

Setup and predefined formats

Bash
npm install morgan

JS
import express from 'express'
import morgan from 'morgan'

const app = express()

// 'dev' — concise, colour-coded by status, great for development:
app.use(morgan('dev'))
GET /api/orders/9981 200 12.480 ms - 256
POST /api/users 201 45.110 ms - 89
GET /api/missing 404 1.230 ms - 142

Format

Output style

Use

dev

Concise, colour-coded status

Development terminal

combined

Apache combined (incl. referrer, user-agent)

Production access logs

common

Apache common log format

Standard access logs

short

Shorter than default

Less verbose output

tiny

Minimal — method, url, status, size, time

Low-noise logging

Use `dev` locally and `combined` in production
Morgan ships with named formats so you don't have to assemble tokens by hand. `dev` is built for humans at a terminal — short and colour-coded so a red `500` jumps out. `combined` is the production standard: it's the Apache "combined" format including the referrer and user-agent, which log-analysis tools understand out of the box. Pick the format per environment just like you pick log levels — concise and colourful in dev, complete and parseable in production.
The combined format and custom tokens

JS
app.use(morgan('combined'))
10.0.0.5 - - [21/Jun/2026:09:30:02 +0000] "GET /api/orders/9981 HTTP/1.1" 200 256
 "https://app.example.com" "Mozilla/5.0 (Macintosh; ...)"

Define custom tokens and a custom format string

JS
// A token to log an authenticated user id set by your auth middleware:
morgan.token('user', (req) => req.user?.id ?? 'anon')
// A token for a correlation/request id:
morgan.token('reqId', (req) => req.id)

app.use(
  morgan(':reqId :user :method :url :status :res[content-length] - :response-time ms'),
)
abc123 42 GET /api/orders/9981 200 256 - 12.480 ms
Custom tokens let you add request context like user id or correlation id
Beyond the built-in tokens (`:method`, `:url`, `:status`, `:response-time`, etc.), `morgan.token(name, fn)` registers your own. The function receives `req` and `res`, so you can surface anything attached during the request — the authenticated user from your [auth middleware](/nodejs/protecting-routes), a correlation id, the tenant, an API version. Composing a custom format string with these tokens turns Morgan's access log into a focused, queryable record of who did what.
Writing access logs to a file or stream

JS
import fs from 'node:fs'
import path from 'node:path'

// Append access logs to a file via a write stream:
const accessLogStream = fs.createWriteStream(path.join('logs', 'access.log'), {
  flags: 'a',   // append, don't truncate
})
app.use(morgan('combined', { stream: accessLogStream }))

Pipe Morgan INTO your structured logger instead of a file

JS
import { logger } from './logger.js'

// Send each access line through Winston/Pino so it lands in one place:
app.use(
  morgan('combined', {
    stream: { write: (message) => logger.info(message.trim()) },
  }),
)
In containers, pipe Morgan into your app logger or stdout — not a managed file
Morgan writes to `process.stdout` by default, which is exactly right for containerized deployments where the platform captures stdout. The `stream` option lets you redirect elsewhere — a file via `createWriteStream`, or, more usefully, an object with a `write` method that forwards each line into your structured [Winston](/nodejs/winston)/[Pino](/nodejs/pino) logger. The latter unifies access and app logs in one pipeline and one format. Avoid managing rotating log files yourself in modern deployments; let the platform or a logrotate sidecar handle it.
Skipping noise

JS
// Only log errors in production (skip the 2xx/3xx noise):
app.use(
  morgan('combined', {
    skip: (req, res) => res.statusCode < 400,
  }),
)

// Or skip health-check and static-asset spam:
app.use(
  morgan('dev', {
    skip: (req) => req.url === '/health' || req.url.startsWith('/static'),
  }),
)
Health-check and asset requests can drown your logs — skip them deliberately
A load balancer hitting `/health` every few seconds, plus static-asset requests, can generate thousands of meaningless access-log lines that bury the requests you actually care about and inflate log-storage costs. The `skip` option takes a predicate `(req, res) => boolean`; returning `true` suppresses that line. Common patterns: skip successful responses in production (log only `status >= 400`), or skip specific noisy paths like [health checks](/nodejs/health-checks) and static files. Be careful not to skip so much that you lose the audit trail you need.
Morgan vs application logging
Morgan = access logs (the request envelope); Winston/Pino = application logs (your logic)
Keep the two concerns distinct. **Morgan** logs the *request envelope* — one line per HTTP request describing method, path, status, and timing — answering "what traffic is hitting my server?". **Application logging** with [Winston](/nodejs/winston) or [Pino](/nodejs/pino) records what your *code* did — business events, decisions, errors with stack traces, debug detail. You typically run both: Morgan (or `pino-http`) for access logging at the edge, and a structured logger for everything inside your handlers. In an all-Pino stack, `pino-http` often replaces Morgan entirely since it produces structured access logs in the same format as the rest of your logs.
Next
Make all your logs queryable and correlatable: [Structured Logging](/nodejs/structured-logging).