DEV Community

Paradane
Paradane

Posted on

Building a Production-Ready API in Node.js: A Complete Walkthrough

Most Node.js API tutorials stop at "and then you res.json() the data." But a production API needs rate limiting, proper error handling, structured logging, input validation, authentication, and a deployment strategy that won't fall over at 2 AM. At Paradane, we've built dozens of production APIs for clients — from e-commerce backends to SaaS platforms — and we've settled on a stack and patterns that work reliably.

This walkthrough covers building a production-ready REST API from scratch using Express, PostgreSQL, and a handful of battle-tested libraries. No magic frameworks — just the pieces you actually need.

The Stack

  • Runtime: Node.js 20 LTS
  • Framework: Express 4 (still the most stable and well-understood)
  • Database: PostgreSQL with pg (native driver, no ORM overhead)
  • Validation: Zod (parse, don't validate)
  • Auth: JWT with jsonwebtoken + bcrypt
  • Rate limiting: express-rate-limit
  • Logging: pino (fastest Node.js logger)
  • Testing: Vitest + Supertest
  • Deployment: Docker + docker-compose

Project Structure

api/
├── src/
│   ├── index.js          # Entry point, server startup
│   ├── app.js            # Express app setup, middleware
│   ├── config/
│   │   └── env.js        # Environment variable validation
│   ├── middleware/
│   │   ├── auth.js       # JWT verification
│   │   ├── errorHandler.js
│   │   ├── rateLimiter.js
│   │   └── validate.js   # Zod validation middleware
│   ├── routes/
│   │   ├── auth.js
│   │   └── users.js
│   ├── services/
│   │   └── userService.js
│   ├── db/
│   │   ├── pool.js       # pg Pool singleton
│   │   └── migrations/
│   └── utils/
│       └── logger.js
├── tests/
├── docker-compose.yml
├── Dockerfile
└── package.json
Enter fullscreen mode Exit fullscreen mode

Step 1: Environment Config That Won't Bite You

Never trust process.env directly. Validate it at startup so you catch missing variables before they cause cryptic runtime errors:

// src/config/env.js
import { z } from 'zod';

const envSchema = z.object({
  PORT: z.coerce.number().default(3000),
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
  NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
  RATE_LIMIT_WINDOW_MS: z.coerce.number().default(900000),
  RATE_LIMIT_MAX: z.coerce.number().default(100),
});

export const env = envSchema.parse(process.env);
Enter fullscreen mode Exit fullscreen mode

If DATABASE_URL is missing or JWT_SECRET is too short, the app crashes immediately with a clear Zod error — not three hours later when someone tries to log in.

Step 2: Database Pool (Keep It Simple)

A single pg.Pool instance, exported once and imported everywhere:

// src/db/pool.js
import pg from 'pg';
import { env } from '../config/env.js';

const pool = new pg.Pool({
  connectionString: env.DATABASE_URL,
  max: 20,
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 2000,
});

pool.on('error', (err) => {
  console.error('Unexpected pool error:', err);
});

export default pool;
Enter fullscreen mode Exit fullscreen mode

No ORM. Raw SQL with parameterized queries. You get full control over query performance, and you never fight an ORM's opinion about how joins should work.

Step 3: Structured Logging with Pino

console.log is fine for debugging. It's not fine for production. Pino gives you structured JSON logs that are fast and machine-parseable:

// src/utils/logger.js
import pino from 'pino';
import { env } from '../config/env.js';

export const logger = pino({
  level: env.NODE_ENV === 'production' ? 'info' : 'debug',
  transport: env.NODE_ENV === 'development'
    ? { target: 'pino-pretty', options: { colorize: true } }
    : undefined,
});
Enter fullscreen mode Exit fullscreen mode

In production, logs go to stdout as JSON. Your log aggregator (CloudWatch, Datadog, Loki) picks them up. In development, pino-pretty makes them readable.

Step 4: Validation Middleware

Zod schemas at the route level. Every incoming request body, query, and param gets validated before it touches business logic:

// src/middleware/validate.js
import { ZodError } from 'zod';

export function validate(schema) {
  return (req, res, next) => {
    try {
      req.validated = schema.parse({
        body: req.body,
        query: req.query,
        params: req.params,
      });
      next();
    } catch (err) {
      if (err instanceof ZodError) {
        return res.status(400).json({
          error: 'Validation failed',
          details: err.errors.map(e => ({
            path: e.path.join('.'),
            message: e.message,
          })),
        });
      }
      next(err);
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

Usage in a route:

import { z } from 'zod';
import { validate } from '../middleware/validate.js';

const createUserSchema = z.object({
  body: z.object({
    email: z.string().email(),
    password: z.string().min(8),
    name: z.string().min(1).max(100),
  }),
});

router.post('/users', validate(createUserSchema), async (req, res) => {
  const { email, password, name } = req.validated.body;
  // ... business logic
});
Enter fullscreen mode Exit fullscreen mode

Step 5: Rate Limiting

express-rate-limit is simple and effective. Configure it globally, then tighten it for auth routes:

// src/middleware/rateLimiter.js
import rateLimit from 'express-rate-limit';
import { env } from '../config/env.js';

export const globalLimiter = rateLimit({
  windowMs: env.RATE_LIMIT_WINDOW_MS,
  max: env.RATE_LIMIT_MAX,
  standardHeaders: true,
  legacyHeaders: false,
  message: { error: 'Too many requests, please try again later.' },
});

export const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 10,                  // 10 attempts per window
  message: { error: 'Too many login attempts, please try again later.' },
});
Enter fullscreen mode Exit fullscreen mode

Step 6: Authentication

JWT with access tokens (short-lived) and refresh tokens (long-lived, stored in DB):

// src/middleware/auth.js
import jwt from 'jsonwebtoken';
import { env } from '../config/env.js';

export function authenticate(req, res, next) {
  const header = req.headers.authorization;
  if (!header?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing or malformed token' });
  }

  try {
    const token = header.slice(7);
    const payload = jwt.verify(token, env.JWT_SECRET);
    req.user = { id: payload.sub, role: payload.role };
    next();
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'Token expired' });
    }
    return res.status(401).json({ error: 'Invalid token' });
  }
}
Enter fullscreen mode Exit fullscreen mode

Password hashing with bcrypt (cost factor 12 — balances security and speed):

import bcrypt from 'bcrypt';

const SALT_ROUNDS = 12;

export async function hashPassword(plain) {
  return bcrypt.hash(plain, SALT_ROUNDS);
}

export async function verifyPassword(plain, hash) {
  return bcrypt.compare(plain, hash);
}
Enter fullscreen mode Exit fullscreen mode

Step 7: Centralized Error Handling

Express's default error handling is barebones. A custom error handler gives you consistent error shapes and proper logging:

// src/middleware/errorHandler.js
import { logger } from '../utils/logger.js';

export function errorHandler(err, req, res, _next) {
  logger.error({ err, reqId: req.id, path: req.path }, 'Unhandled error');

  if (err.status) {
    return res.status(err.status).json({ error: err.message });
  }

  // Don't leak stack traces in production
  const message = process.env.NODE_ENV === 'production'
    ? 'Internal server error'
    : err.message;

  res.status(500).json({ error: message });
}
Enter fullscreen mode Exit fullscreen mode

Create a custom error class for business logic errors:

export class AppError extends Error {
  constructor(message, status = 400) {
    super(message);
    this.status = status;
  }
}
Enter fullscreen mode Exit fullscreen mode

Now your services can throw new AppError('User not found', 404) and the error handler formats it correctly.

Step 8: Putting It All Together — app.js

// src/app.js
import express from 'express';
import { globalLimiter } from './middleware/rateLimiter.js';
import { errorHandler } from './middleware/errorHandler.js';
import { logger } from './utils/logger.js';
import authRoutes from './routes/auth.js';
import userRoutes from './routes/users.js';

export function createApp() {
  const app = express();

  // Trust proxy if behind nginx/load balancer (needed for rate limiter IP detection)
  app.set('trust proxy', 1);

  // Body parsing
  app.use(express.json({ limit: '1mb' }));

  // Request ID for log correlation
  app.use((req, res, next) => {
    req.id = crypto.randomUUID();
    res.setHeader('X-Request-Id', req.id);
    next();
  });

  // Request logging
  app.use((req, res, next) => {
    const start = Date.now();
    res.on('finish', () => {
      logger.info({
        method: req.method,
        path: req.path,
        status: res.statusCode,
        duration: Date.now() - start,
        reqId: req.id,
      }, 'request completed');
    });
    next();
  });

  // Global rate limiter
  app.use(globalLimiter);

  // Health check (no auth required)
  app.get('/health', (_req, res) => {
    res.json({ status: 'ok', timestamp: new Date().toISOString() });
  });

  // Routes
  app.use('/auth', authRoutes);
  app.use('/users', userRoutes);

  // 404 handler
  app.use((_req, res) => {
    res.status(404).json({ error: 'Not found' });
  });

  // Error handler (must be last)
  app.use(errorHandler);

  return app;
}
Enter fullscreen mode Exit fullscreen mode

Step 9: Graceful Shutdown

A production server needs to handle SIGTERM gracefully — finish in-flight requests, close the database pool, then exit:

// src/index.js
import { createApp } from './app.js';
import { env } from './config/env.js';
import pool from './db/pool.js';
import { logger } from './utils/logger.js';

const app = createApp();

const server = app.listen(env.PORT, () => {
  logger.info({ port: env.PORT }, 'server started');
});

async function shutdown(signal) {
  logger.info({ signal }, 'shutdown initiated');

  // Stop accepting new connections
  server.close();

  // Close database pool
  try {
    await pool.end();
    logger.info('database pool closed');
  } catch (err) {
    logger.error({ err }, 'error closing database pool');
  }

  process.exit(0);
}

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
Enter fullscreen mode Exit fullscreen mode

Step 10: Docker Deployment

# Dockerfile
FROM node:20-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci --only=production

COPY src/ ./src/

USER node

EXPOSE 3000

CMD ["node", "src/index.js"]
Enter fullscreen mode Exit fullscreen mode
# docker-compose.yml
services:
  api:
    build: .
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgresql://user:pass@db:5432/mydb
      - JWT_SECRET=${JWT_SECRET}
      - NODE_ENV=production
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

  db:
    image: postgres:16-alpine
    environment:
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=pass
      - POSTGRES_DB=mydb
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user -d mydb"]
      interval: 5s
      retries: 5

volumes:
  pgdata:
Enter fullscreen mode Exit fullscreen mode

What We Skipped (And Why)

  • ORM (Prisma, Sequelize, TypeORM): They're great for rapid prototyping, but for production APIs with complex queries, raw SQL gives you more control and better performance. We use migration tools (like node-pg-migrate) instead of ORM-managed schemas.
  • GraphQL: Powerful, but adds complexity. REST with good documentation (OpenAPI/Swagger) serves most use cases. We reach for GraphQL when clients need flexible querying across multiple related resources.
  • TypeScript: We use it on most projects, but kept this walkthrough in plain JS for clarity. The patterns translate directly — add types to your Zod schemas and service functions.
  • Redis for rate limiting: express-rate-limit with in-memory storage works for single-instance deployments. Add Redis when you scale horizontally.

Testing (Don't Skip This)

// tests/users.test.js
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { createApp } from '../src/app.js';

const app = createApp();

describe('GET /health', () => {
  it('returns 200 with status ok', async () => {
    const res = await request(app).get('/health');
    expect(res.status).toBe(200);
    expect(res.body.status).toBe('ok');
  });
});
Enter fullscreen mode Exit fullscreen mode

The Checklist Before You Ship

  • [ ] Environment variables validated at startup (Zod schema)
  • [ ] Rate limiting on all routes, tighter on auth
  • [ ] All inputs validated (Zod middleware)
  • [ ] Structured logging (Pino, JSON in production)
  • [ ] Centralized error handler (no stack traces in production)
  • [ ] Authentication with short-lived access tokens
  • [ ] Passwords hashed with bcrypt (cost ≥ 12)
  • [ ] Health check endpoint (no auth)
  • [ ] Graceful shutdown (SIGTERM handling)
  • [ ] Database pool with connection limits and error handling
  • [ ] Request IDs for log correlation
  • [ ] Docker deployment with health checks

The Bottom Line

A production API isn't about the framework — it's about the layers around your business logic. Validation, rate limiting, logging, error handling, and graceful shutdown are what keep your API running at 2 AM when you're asleep. The patterns above have served us well across dozens of client projects, from small e-commerce backends to multi-tenant SaaS platforms.


At Paradane, we build production APIs for businesses — REST, GraphQL, and real-time. If you're planning an API project or need help hardening an existing one, we'd be happy to talk.

Top comments (0)