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
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);
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;
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,
});
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);
}
};
}
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
});
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.' },
});
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' });
}
}
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);
}
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 });
}
Create a custom error class for business logic errors:
export class AppError extends Error {
constructor(message, status = 400) {
super(message);
this.status = status;
}
}
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;
}
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'));
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"]
# 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:
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-limitwith 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');
});
});
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)