Step 11 of 12

Logging, config & deployment

Get a Node API production-ready: structured logs with pino, validated config, health checks, graceful shutdown, and a small, non-root Docker image.

18 min 7 sections

An API that works on your laptop is about halfway to one that works in production. In production, nobody watches the terminal, the platform restarts and moves your process whenever it likes, configuration comes from the environment, and when something breaks at 3 a.m., the logs are all you have. This lesson covers the pieces that make a Node service operable: structured logging, validated configuration, health checks, graceful shutdown and a production Docker image.

Structured logging with pino

console.log('order created', id) produces a line of text that's easy to read and hard to search. In production, logs go to a system (CloudWatch, Datadog, Loki, Elastic) that indexes them, and you'll want to ask questions like "show every error for order 812 in the last hour". That needs structured logs: one JSON object per line, with consistent fields.

pino is the standard fast structured logger for Node:

bash
npm install pino
npm install --save-dev pino-pretty
ts
// src/logger.ts
import pino from 'pino'
import { config } from './config.ts'

export const logger = pino({
  level: config.LOG_LEVEL,                       // 'debug' locally, 'info' in production
  redact: ['req.headers.cookie', 'req.headers.authorization', '*.password', '*.token'],
  base: { service: 'orders-api' },
})
ts
logger.info({ orderId: 812, totalCents: 3860 }, 'order created')
logger.error({ err, orderId: 812 }, 'payment failed')
json
{"level":30,"time":1793520000000,"service":"orders-api","orderId":812,"totalCents":3860,"msg":"order created"}
{"level":50,"time":1793520000123,"service":"orders-api","err":{"type":"Error","message":"card declined","stack":"Error: card declined\n    at ..."},"orderId":812,"msg":"payment failed"}

Put data in fields, not in the message: orderId becomes searchable, and the message stays constant so you can count occurrences. Pass errors as err, and pino serialises the type, message and stack. redact replaces sensitive paths with [Redacted], so a log line can't leak a password or session cookie.

Locally, pipe the JSON through pino-pretty for readable output: node src/server.ts | npx pino-pretty.

Request logging and correlation

Log one line per request, and attach a request ID to every log line written while handling it, so you can follow a single request through the logs:

ts
// src/http/request-logger.ts
import { createMiddleware } from 'hono/factory'
import type { Logger } from 'pino'
import { logger } from '../logger.ts'

export type LogEnv = { Variables: { log: Logger; requestId: string } }

export const requestLogger = createMiddleware<LogEnv>(async (c, next) => {
  const requestId = c.req.header('x-request-id') ?? crypto.randomUUID()
  const log = logger.child({ requestId })
  c.set('log', log)
  c.set('requestId', requestId)
  c.header('x-request-id', requestId)

  const start = performance.now()
  await next()
  log.info(
    { method: c.req.method, path: c.req.path, status: c.res.status, ms: Math.round(performance.now() - start) },
    'request completed',
  )
})

Handlers then log with c.get('log').info(...), and every line carries the same requestId. Return that ID to clients in error responses, so a support ticket can be matched to the exact log lines. Reusing an incoming x-request-id header lets a request be traced across several services.

What to log: requests (method, path, status, duration), significant business events (order created, payment failed), and every unexpected error with its stack. What never to log: passwords, tokens, full card numbers, and more personal data than you need.

Configuration from the environment

Configuration that differs between environments, such as database URLs, API keys, ports and log levels, comes from environment variables, never from code. Validate it all at startup, as in the TypeScript lesson, so a missing variable fails the deploy instead of the first request that needs it:

ts
// src/config.ts
import { z } from 'zod'

const Env = z.object({
  NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
  PORT: z.coerce.number().int().positive().default(3000),
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
  DATABASE_URL: z.url(),
  SHUTDOWN_TIMEOUT_MS: z.coerce.number().int().positive().default(10_000),
})

const parsed = Env.safeParse(process.env)
if (!parsed.success) {
  console.error(`Invalid configuration:\n${z.prettifyError(parsed.error)}`)
  process.exit(1)
}
export const config = parsed.data

Commit a .env.example listing every variable with a harmless sample value, so new developers know what to set. Real secrets live in your platform's secret manager and are injected as environment variables, never committed.

Health checks

Your platform (Kubernetes, ECS, Fly.io, Render, a load balancer) repeatedly asks the service whether it's alive and ready. Two endpoints answer two different questions:

ts
// in src/app.ts
app.get('/healthz', (c) => c.json({ status: 'ok' })) // liveness: is the process responsive?

app.get('/readyz', async (c) => {                    // readiness: can it serve traffic right now?
  if (shuttingDown) return c.json({ status: 'shutting down' }, 503)
  try {
    await pool.query('SELECT 1')
    return c.json({ status: 'ready' })
  } catch {
    return c.json({ status: 'database unavailable' }, 503)
  }
})
  • Liveness failing means "this process is stuck; restart it". Keep it trivial: if it checked the database, a database outage would make the platform restart every healthy instance in a loop, making things worse.
  • Readiness failing means "don't send me traffic right now". It's the right place to check dependencies, and to report "shutting down" during a deploy.

Graceful shutdown

Deploys, scaling and node maintenance all stop your process. The platform sends SIGTERM, waits a grace period (often 30 seconds), then kills it with SIGKILL. If the process exits immediately on SIGTERM, requests in flight are cut off mid-response. A graceful shutdown:

  1. Stops being ready, so the load balancer stops sending new requests.
  2. Stops accepting new connections and finishes requests in progress.
  3. Closes the database pool and other resources.
  4. Exits, with a deadline in case something hangs.
ts
// src/server.ts
import { serve } from '@hono/node-server'
import { app, markShuttingDown } from './app.ts'
import { config } from './config.ts'
import { pool } from './db.ts'
import { logger } from './logger.ts'
import { migrate } from './migrate.ts'

await migrate(pool)

const server = serve({ fetch: app.fetch, port: config.PORT }, (info) => {
  logger.info({ port: info.port }, 'listening')
})

let stopping = false
async function shutdown(signal: string) {
  if (stopping) return
  stopping = true
  logger.info({ signal }, 'shutting down')
  markShuttingDown()                                      // /readyz now returns 503

  const forceExit = setTimeout(() => {
    logger.error('shutdown timed out, exiting')
    process.exit(1)
  }, config.SHUTDOWN_TIMEOUT_MS)
  forceExit.unref()

  server.close(async () => {                              // waits for in-flight requests
    await pool.end()
    logger.info('shutdown complete')
    process.exit(0)
  })
  if ('closeIdleConnections' in server) server.closeIdleConnections() // don't wait for idle keep-alive sockets
}

process.on('SIGTERM', () => void shutdown('SIGTERM'))
process.on('SIGINT', () => void shutdown('SIGINT'))
process.on('unhandledRejection', (reason) => {
  logger.fatal({ err: reason }, 'unhandled rejection')
  process.exit(1)
})

server.close stops accepting connections and calls back once existing ones finish. Keep-alive connections that are open but idle would otherwise hold it open until they time out, which is what closeIdleConnections handles. The forced exit guarantees the process ends before the platform's SIGKILL, with a log line saying why. Set SHUTDOWN_TIMEOUT_MS below the platform's grace period.

A production Docker image

A multi-stage Dockerfile builds with all the tools, then copies only what production needs into a small final image:

dockerfile
# syntax=docker/dockerfile:1

FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build

FROM node:22-slim AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

FROM node:22-slim
ENV NODE_ENV=production
WORKDIR /app
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY migrations ./migrations
COPY package.json ./
USER node
EXPOSE 3000
CMD ["node", "--enable-source-maps", "dist/server.js"]

The choices that matter:

  • npm ci --omit=dev in a separate stage: the final image has no TypeScript, test tools or build cache, so it's smaller and has less attack surface.
  • USER node: the official images include an unprivileged node user. Never run your app as root; a vulnerability then has far less to work with.
  • CMD ["node", ...] in exec form, not npm start. With npm start, npm becomes process 1 and may not forward SIGTERM to Node, so your graceful shutdown never runs and the container is killed after the grace period.
  • Copy package*.json and install before copying src, so Docker reuses the cached npm ci layer when only your code changed.
  • Add a .dockerignore with node_modules, dist, .env and .git, so local files and secrets never end up in the image.
bash
docker build -t orders-api .
docker run --rm -p 3000:3000 -e DATABASE_URL=postgres://... orders-api

Practice

Make the orders API production-ready:

  1. Add the pino logger with redaction, the request-logger middleware, and log every unexpected error in app.onError with the request ID, returning the ID in the 500 response body.
  2. Add /healthz and /readyz, and a markShuttingDown() function exported from app.ts.
  3. Implement graceful shutdown. Test it: add a route that waits 3 seconds before answering, call it, send SIGTERM to the server while it's waiting, and check the request still completes before the process exits.
  4. Write the Dockerfile and .dockerignore, build the image, run it against your PostgreSQL, and confirm with docker exec <container> whoami that it runs as node.
Show solution
ts
// src/app.ts (excerpt)
import { Hono } from 'hono'
import { pool } from './db.ts'
import { requestLogger, type LogEnv } from './http/request-logger.ts'

let shuttingDown = false
export const markShuttingDown = () => {
  shuttingDown = true
}

export const app = new Hono<LogEnv>()
app.use(requestLogger)

app.get('/healthz', (c) => c.json({ status: 'ok' }))
app.get('/readyz', async (c) => {
  if (shuttingDown) return c.json({ status: 'shutting down' }, 503)
  try {
    await pool.query('SELECT 1')
    return c.json({ status: 'ready' })
  } catch {
    return c.json({ status: 'database unavailable' }, 503)
  }
})
app.get('/slow', async (c) => {
  await new Promise((resolve) => setTimeout(resolve, 3000))
  return c.json({ done: true })
})

app.onError((err, c) => {
  c.get('log').error({ err, method: c.req.method, path: c.req.path }, 'unhandled error')
  return c.json({ error: { code: 'internal_error', message: 'Something went wrong.', requestId: c.get('requestId') } }, 500)
})
bash
node src/server.ts &
SERVER=$!
sleep 1
curl -s localhost:3000/slow &        # starts a 3-second request
sleep 0.5
kill -TERM $SERVER                   # shut down while it's in flight
wait
text
{"level":30,"service":"orders-api","port":3000,"msg":"listening"}
{"level":30,"service":"orders-api","signal":"SIGTERM","msg":"shutting down"}
{"level":30,"service":"orders-api","requestId":"0b6c...","method":"GET","path":"/slow","status":200,"ms":3002,"msg":"request completed"}
{"done":true}
{"level":30,"service":"orders-api","msg":"shutdown complete"}

The request that was in flight when SIGTERM arrived still completes with a 200, and only then does the pool close and the process exit. Without graceful shutdown, curl would report an empty reply from the server.

text
# .dockerignore
node_modules
dist
.env
.git
coverage

docker exec <container> whoami prints node. If it printed root, the USER node line is missing or comes before a step that needs root.

Key takeaways

  • Log structured JSON with pino: data in fields, errors as err, secrets redacted, and a request ID on every line of a request.
  • Read configuration from environment variables, validate it at startup with Zod, and keep secrets in a secret manager.
  • Keep liveness trivial and put dependency checks in readiness, which also reports "shutting down" during deploys.
  • On SIGTERM: stop being ready, stop accepting connections, finish in-flight requests, close the pool, exit before the deadline.
  • Build a multi-stage image with production dependencies only, run as the node user, and start Node directly so it receives signals.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.Why should a liveness check not query the database?

  2. 2.The container runs CMD ["npm", "start"], and deploys cut off in-flight requests even though the app handles SIGTERM. Why?

  3. 3.What's the main benefit of structured (JSON) logs over plain text lines?

  4. 4.Why use a separate stage with npm ci --omit=dev for the final image?

Finished reading?

Mark it complete to fill in your roadmap.