Building a Scalable REST API with Node.js, Express, and PostgreSQL — by Moataseem Shaaban
Moataseem Shaaban shares his approach to building production-grade REST APIs using Node.js, Express, and PostgreSQL with authentication, validation, error handling, and deployment on a VPS.
Moataseem Shaaban
Full Stack Developer & Software Engineer
I'm Moataseem Shaaban, a full stack developer and software engineer from Cairo, Egypt. Building backend APIs that handle real production traffic is a core part of my work. In this guide, I'll share the exact patterns and architecture I use when building scalable REST APIs with Node.js, Express, and PostgreSQL — from project structure to deployment on a hardened VPS.
Why This Stack?
Node.js gives you JavaScript on the server, meaning you can share types, validation logic, and utilities between frontend and backend. Express is the most battle-tested Node.js web framework with a massive ecosystem. PostgreSQL is the most advanced open-source relational database, offering JSON support, full-text search, and excellent performance.
Together, they form a stack that's both developer-friendly and production-capable.
Project Structure
A well-organized project structure is the foundation of maintainable code:
src/
├── config/
│ ├── database.js # PostgreSQL connection
│ └── env.js # Environment variables
├── middleware/
│ ├── auth.js # JWT authentication
│ ├── validate.js # Request validation
│ ├── errorHandler.js # Global error handling
│ └── rateLimit.js # Rate limiting
├── routes/
│ ├── auth.routes.js # Authentication routes
│ ├── user.routes.js # User CRUD routes
│ └── index.js # Route aggregator
├── controllers/
│ ├── auth.controller.js
│ └── user.controller.js
├── services/
│ ├── auth.service.js # Business logic
│ └── user.service.js
├── models/
│ └── user.model.js # Database queries
├── utils/
│ ├── ApiError.js # Custom error class
│ └── asyncHandler.js # Async error wrapper
└── app.js # Express app setupThis follows a layered architecture: Routes → Controllers → Services → Models. Each layer has a single responsibility.
Database Setup
Connection Pool
Never create a new database connection per request. Use a connection pool:
import pg from 'pg';
const { Pool } = pg;
const pool = new Pool({
host: process.env.DB_HOST,
port: process.env.DB_PORT,
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
max: 20, // Maximum pool size
idleTimeoutMillis: 30000, // Close idle connections after 30s
connectionTimeoutMillis: 2000,
});
export default pool;Migrations
Don't create tables manually. Use migrations to version your schema:
-- migrations/001_create_users.sql
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
name VARCHAR(100) NOT NULL,
role VARCHAR(20) DEFAULT 'user',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_users_email ON users(email);Authentication with JWT
Registration Flow
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
export async function register(email, password, name) {
// Hash password with cost factor of 12
const passwordHash = await bcrypt.hash(password, 12);
const result = await pool.query(
'INSERT INTO users (email, password_hash, name) VALUES ($1, $2, $3) RETURNING id, email, name',
[email, passwordHash, name]
);
const user = result.rows[0];
const token = jwt.sign(
{ id: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '7d' }
);
return { user, token };
}Authentication Middleware
export function authenticate(req, res, next) {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
throw new ApiError(401, 'Authentication required');
}
const token = header.split(' ')[1];
const payload = jwt.verify(token, process.env.JWT_SECRET);
req.user = payload;
next();
}Request Validation
Never trust client input. Use a validation library like Zod:
import { z } from 'zod';
const registerSchema = z.object({
email: z.string().email('Invalid email format'),
password: z.string().min(8, 'Password must be at least 8 characters'),
name: z.string().min(2).max(100),
});
export function validate(schema) {
return (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
throw new ApiError(400, 'Validation failed', result.error.issues);
}
req.body = result.data;
next();
};
}Error Handling
A global error handler keeps your controllers clean:
export class ApiError extends Error {
constructor(statusCode, message, details = null) {
super(message);
this.statusCode = statusCode;
this.details = details;
}
}
export function errorHandler(err, req, res, next) {
const statusCode = err.statusCode || 500;
const message = statusCode === 500 ? 'Internal server error' : err.message;
// Log the full error in development
if (process.env.NODE_ENV !== 'production') {
console.error(err);
}
res.status(statusCode).json({
success: false,
message,
...(err.details && { details: err.details }),
});
}Rate Limiting
Protect your API from abuse:
import rateLimit from 'express-rate-limit';
export const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
message: { error: 'Too many requests, please try again later' },
});
export const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5, // 5 login attempts per window
message: { error: 'Too many login attempts' },
});Security Best Practices
Essential Middleware
import helmet from 'helmet';
import cors from 'cors';
import express from 'express';
const app = express();
app.use(helmet()); // Security headers
app.use(cors({ origin: process.env.FRONTEND_URL }));
app.use(express.json({ limit: '10kb' })); // Limit body size
app.disable('x-powered-by'); // Hide Express fingerprintEnvironment Variables
Never hardcode secrets. Use environment variables and validate them at startup:
const requiredEnvVars = ['DB_HOST', 'DB_PASSWORD', 'JWT_SECRET'];
for (const envVar of requiredEnvVars) {
if (!process.env[envVar]) {
console.error('Missing required environment variable: ' + envVar);
process.exit(1);
}
}Testing
Write integration tests that hit real endpoints with a test database:
import request from 'supertest';
import app from '../src/app.js';
describe('POST /api/auth/register', () => {
it('should create a new user', async () => {
const res = await request(app)
.post('/api/auth/register')
.send({
email: 'test@example.com',
password: 'securePassword123',
name: 'Test User',
});
expect(res.status).toBe(201);
expect(res.body.user.email).toBe('test@example.com');
expect(res.body.token).toBeDefined();
});
});Deployment Considerations
Process Management with PM2
pm2 start src/app.js --name api -i max # Cluster mode
pm2 save # Persist across rebootsNginx Reverse Proxy
location /api/ {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}Database Security
- ●Bind PostgreSQL to
127.0.0.1only - ●Use strong passwords and dedicated database users
- ●Never expose port 5432 publicly
- ●Set up automated backups with
pg_dump
Conclusion
A production REST API needs more than just routes and controllers. It needs proper authentication, validation, error handling, rate limiting, and security hardening. The Node.js + Express + PostgreSQL stack gives you the flexibility and performance to handle all of this while keeping your codebase clean and maintainable.
Start with the layered architecture, add security from day one, and test everything. Your future self will thank you.
— Moataseem Shaaban, Full Stack Developer. See more of my work at moataseem.com or explore my projects on GitHub.