Change Theme Color
Backend· 14 min read

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.

MS

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 setup

This 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:

javascript
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:

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

javascript
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

javascript
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:

javascript
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:

javascript
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:

javascript
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

javascript
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 fingerprint

Environment Variables

Never hardcode secrets. Use environment variables and validate them at startup:

javascript
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:

javascript
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

bash
pm2 start src/app.js --name api -i max  # Cluster mode
pm2 save                                 # Persist across reboots

Nginx Reverse Proxy

nginx
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.1 only
  • 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.

MS
Loading0%
Initializing portfolio...