# 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.

Backend with Node.js roadmap · Production · 18 min read · https://levelcode.org/roadmaps/node-backend/deploying-node

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
```

> [!TIP]
> Run one Node process per container and scale by running more containers. Node's `cluster` module and process managers like PM2 were the answer when you deployed to bare servers; on a container platform, the platform already does that job, and one process per container keeps signals, logs and health checks simple.

## 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`.

**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.

## Quiz

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

   A. Queries are too slow for health checks
   B. During a database outage, every instance would fail liveness and be restarted in a loop, without fixing anything
   C. Liveness checks can't make network calls
   D. It would leak database credentials

   **Answer: B.** Liveness failure triggers restarts. A dependency outage isn't fixed by restarting your process; reflect it in readiness instead, so traffic is held back.

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

   A. npm doesn't support Node 22
   B. SIGTERM can't be handled in Node
   C. npm is process 1 and may not forward SIGTERM to Node, so the shutdown handler never runs before SIGKILL
   D. The image is too large

   **Answer: C.** Run node directly in exec form so your process receives the platform's signals and can shut down gracefully.

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

   A. They're shorter
   B. They can be searched and aggregated by field, such as every error for one orderId or requestId
   C. They're encrypted
   D. They don't need a log level

   **Answer: B.** Log systems index JSON fields, so you can filter, count and correlate events instead of grepping free text.

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

   A. The final image contains only production dependencies, making it smaller and reducing what an attacker could use
   B. Dev dependencies don't work in Docker
   C. It's required for TypeScript
   D. It makes npm ci faster

   **Answer: A.** TypeScript, test runners and build tools aren't needed at runtime. Leaving them out shrinks the image and its attack surface.
