Production-Ready Express: Structured Logs, Graceful Shutdown, Compression and Indexes

MERN Stack Full Course: MongoDB, Express, React and Node.js · Advanced Topics · 8 min read · Updated

Ready for real visitors

  1. In production each log line is a JSON object with the path, the status and the time. Passwords and tokens never appear.
  2. The health check checks the database. 503 means do not send traffic here.
  3. When you deploy, the process manager sends SIGTERM to the old process.
  4. The server stops taking new connections, lets current requests finish, closes the database and exits.
  5. Indexes are built on purpose after a deploy, and text responses are compressed.

Code that works on your laptop is not yet code that is ready for real visitors. Production needs things that are easy to forget because nothing looks broken without them: logs you can search when something goes wrong, a server that stops cleanly instead of cutting people off mid-request, a health check that tells the truth, and a database that has the right indexes. This lesson adds all of them with small changes.

1. Logs you can read at 3 a.m.

// Lesson 37: logs a person reads in development, and one JSON object per line in production
// (easy for tools such as pm2, journald, Loki or any log search to read and filter).
const production = process.env.NODE_ENV === 'production';

const write = (level, message, extra = {}) => {
  if (production) {
    console[level === 'error' ? 'error' : 'log'](JSON.stringify({ time: new Date().toISOString(), level, message, ...extra }));
  } else {
    console[level === 'error' ? 'error' : 'log'](`[${level}] ${message}`, Object.keys(extra).length ? extra : '');
  }
};

export const log = {
  info: (m, x) => write('info', m, x),
  warn: (m, x) => write('warn', m, x),
  error: (m, x) => write('error', m, x),
};

In development a person reads the terminal, so a readable line is best. In production the logs are collected and searched by tools, so one JSON object per line (with time, level, message and fields) is best: you can filter by status code or find slow requests with a search. The tiny logger does both, chosen by NODE_ENV.

import { log } from '../utils/logger.js';

// One line per request, written when the answer has been sent. We log the path WITHOUT the query string
// and never the body or headers: they can contain passwords and tokens.
export function logger(req, res, next) {
  const started = Date.now();
  res.on('finish', () => {
    log.info(`${req.method} ${req.path} ${res.statusCode}`, { ms: Date.now() - started, status: res.statusCode, ip: req.ip });
  });
  next();
}
  • It logs the path only (req.path), not the full URL. A query string can contain private information, such as a search for a person's name.
  • It never logs the body or the headers. They contain passwords and the Authorization token, and logs are copied around far more freely than databases are.
  • It records the status and the time in milliseconds, which is enough to find slow and failing routes.

2. Shutting down politely

import 'dotenv/config';
import mongoose from 'mongoose';
import { checkEnv } from './config/env.js';
import { connectDB } from './config/db.js';
import { log } from './utils/logger.js';
import { app } from './app.js';

const port = Number(process.env.PORT) || 5000;
let server;

// Graceful shutdown: when pm2 or the host says "stop", finish the requests in progress, close the database, then exit.
async function shutdown(signal) {
  log.info(`${signal} received, shutting down`);
  const force = setTimeout(() => { log.error('Forced exit after 10 seconds'); process.exit(1); }, 10_000);
  force.unref();
  try {
    await new Promise((resolve) => (server ? server.close(resolve) : resolve()));
    await mongoose.disconnect();
    process.exit(0);
  } catch (err) {
    log.error('Error while shutting down', { error: err.message });
    process.exit(1);
  }
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
// A promise nobody caught is a bug. Log it; pm2 restarts the process if we exit.
process.on('unhandledRejection', (reason) => { log.error('Unhandled rejection', { reason: String(reason) }); process.exit(1); });

try {
  checkEnv();
  await connectDB();
  server = app.listen(port, () => log.info(`API listening on port ${port}`, { env: process.env.NODE_ENV || 'development' }));
} catch (err) {
  log.error(`Could not start: ${err.message}`);
  process.exit(1);
}

When you deploy a new version, the process manager sends the old process a SIGTERM signal. If the program simply dies, any requests being answered at that moment fail. The handler here does it properly: it stops accepting new connections and waits for the current ones to finish (server.close), closes the database connection, and exits. A timer forces exit after 10 seconds so a stuck connection cannot block a deploy. unhandledRejection logs promises that nobody caught and exits, since the process may be in a bad state and the process manager will start a fresh one.

3. A health check that tells the truth

import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import compression from 'compression';
import mongoose from 'mongoose';
import { logger } from './middleware/logger.js';
import { authLimiter, rejectOperators } from './middleware/security.js';
import { notFound, errorHandler } from './middleware/error.js';
import authRouter from './routes/auth.js';
import jobsRouter from './routes/jobs.js';

export const app = express();

if (process.env.TRUST_PROXY) app.set('trust proxy', Number(process.env.TRUST_PROXY) || process.env.TRUST_PROXY);

app.use(helmet());
app.use(compression()); // gzip JSON and text answers: much smaller on slow mobile connections
if (process.env.NODE_ENV !== 'test') app.use(logger);
app.use(cors({ origin: (process.env.CLIENT_ORIGIN || 'http://localhost:5173').split(',') }));
app.use(express.json({ limit: '100kb' }));
app.use(rejectOperators);

// Used by load balancers, uptime monitors and pm2. 503 tells them "do not send traffic here yet".
app.get('/api/health', (req, res) => {
  const db = mongoose.connection.readyState === 1;
  res.status(db ? 200 : 503).json({ ok: db, db: db ? 'connected' : 'not connected', uptimeSeconds: Math.round(process.uptime()) });
});
app.use('/api/auth', authLimiter, authRouter);
app.use('/api/jobs', jobsRouter);

app.use(notFound);
app.use(errorHandler);

/api/health now answers 503 when the database is not connected, and 200 when it is, together with the uptime. Load balancers, uptime monitors and your own deploy script use it to decide whether to send traffic. A health check that always says “OK” is worse than none.

compression() gzips text responses (JSON, HTML, JavaScript), which are often a quarter of their original size. That matters on mobile connections. In many setups nginx does it instead; use one or the other.

4. Build indexes on purpose

import mongoose from 'mongoose';
import { log } from '../utils/logger.js';

export async function connectDB(uri = process.env.MONGODB_URI) {
  if (!uri) throw new Error('MONGODB_URI is missing. Copy .env.example to .env and set it.');
  mongoose.connection.on('disconnected', () => log.warn('MongoDB disconnected'));
  mongoose.connection.on('reconnected', () => log.info('MongoDB reconnected'));
  await mongoose.connect(uri, {
    serverSelectionTimeoutMS: 8000,
    // In production, build indexes on purpose with `npm run indexes`, not on every start of a busy server.
    autoIndex: process.env.NODE_ENV !== 'production',
  });
  log.info(`MongoDB connected: ${mongoose.connection.name}`);
}

Mongoose creates the indexes described in your schemas when the app starts (autoIndex). On a small database that is fine. On a large one, building an index can be slow and heavy, and you do not want it to happen every time a server restarts. So in production autoIndex is off, and you run the sync deliberately after a deploy that changed an index.

// Creates (and removes outdated) indexes to match the models.   Run after a deploy that changed an index:  npm run indexes
import 'dotenv/config';
import mongoose from 'mongoose';
import { connectDB } from '../config/db.js';
import Job from '../models/Job.js';
import User from '../models/User.js';

await connectDB();
for (const model of [User, Job]) {
  const dropped = await model.syncIndexes();
  console.log(`${model.modelName}: indexes in sync${dropped.length ? ` (removed ${dropped.join(', ')})` : ''}`);
}
await mongoose.disconnect();

syncIndexes() creates missing indexes and removes ones that are no longer in the schema. It is the same function that fixed the test flakiness in lesson 24. Our deploy.sh in lesson 39 runs it for you.

Your task

  1. Run npm install in server (adds compression). Start with NODE_ENV=production and a long JWT_SECRET and look at the JSON log lines.
  2. Press Ctrl + C while a request is running (add a one second delay to a route to test). Watch the shutdown messages.
  3. Stop MongoDB and call /api/health. You should get 503.
  4. Run npm run indexes and read what it prints.

How to check it worked

  • In production mode each log line is one JSON object with no passwords or tokens.
  • Ctrl + C prints the shutdown messages and exits with code 0.
  • The health check answers 503 without a database.

Summary

  • Log JSON in production, only path, status and timing, never secrets.
  • Handle SIGTERM: finish requests, close the database, then exit.
  • Make health checks honest, compress text, and build indexes deliberately.

Download the solution (mern-lesson-37.zip)

Frequently asked questions

Why log JSON in production?

Log tools can search and filter structured fields such as status and duration, which is much harder with free text lines. Keep readable lines for development.

What is a graceful shutdown?

Stopping a server in a way that lets requests that are already being answered finish, closing database connections cleanly, and only then exiting. It avoids errors for people during a deploy.

Why turn autoIndex off in production?

Building indexes on a large collection can be slow and heavy, and it should not happen on every restart. Run syncIndexes yourself after a deploy that changes indexes.