Building a Production-Ready Express.js REST API: My Journey from Zero to Hero
Hey there! š So I've been working on this Express.js backend lately, and honestly, it turned out pretty solid. Thought I'd share the whole journey with you guys. I'm not usually one to write long posts, but this one's worth it - trust me.
What We're Building
Alright, so the goal was simple: build a bulletproof REST API that doesn't fall apart when things get real. We're talking about proper authentication, role-based access, security that actually works, and a database setup that won't give you nightmares.
Tech Stack (because everyone asks):
- Express.js - The classic choice, reliable as always
- Azure PostgreSQL - Because cloud databases just make sense
- JWT - For authentication that scales
- Redis - Session management (optional in dev)
- bcrypt - Password hashing done right
- Winston - Logging that actually helps debug
Project Structure (The Foundation)
server/
āāā src/
ā āāā config/ # Database, Redis config
ā āāā middleware/ # Auth, error handling, validation
ā āāā models/ # User, RefreshToken models
ā āāā routes/ # API endpoints
ā āāā services/ # Business logic
ā āāā utils/ # Logger, helpers
āāā scripts/ # Migration, seeding scripts
āāā tests/ # Test files
āāā server.js # Main application entry
āāā .env # Environment variables
I know, I know - folder structure discussions can get heated. But this one's clean and scales well, so let's go with it.
Database Setup: Azure PostgreSQL (The Real Deal)
Connection Configuration
First things first - connecting to Azure PostgreSQL isn't as straightforward as local development. Here's what actually works:
// src/config/database.js
const { Pool } = require("pg");
class Database {
constructor() {
this.pool = null;
}
async connect() {
const config = {
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT) || 5432,
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: String(process.env.DB_PASSWORD),
ssl:
process.env.DB_SSL === "true" ? { rejectUnauthorized: false } : false,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
keepAlive: true,
keepAliveInitialDelayMillis: 10000,
};
this.pool = new Pool(config);
// Always test the connection
const client = await this.pool.connect();
await client.query("SELECT NOW()");
client.release();
return this.pool;
}
}
Pro tip: That SSL configuration is crucial for Azure. Don't skip it.
Environment Variables (.env)
# Database
DB_HOST=rudrserver.postgres.database.azure.com
DB_PORT=5432
DB_NAME=bettererp
DB_USER=rudr
DB_PASSWORD=Meghna_23
DB_SSL=true
# JWT
JWT_SECRET=your-super-secret-jwt-key-make-it-long-and-random
JWT_REFRESH_SECRET=another-secret-for-refresh-tokens
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
# Server
PORT=3000
NODE_ENV=development
# Redis (optional in dev)
REDIS_URL=redis://localhost:6379
Authentication: JWT Done Right
User Model with Proper Password Hashing
// src/models/User.js
const bcrypt = require("bcryptjs");
class User {
static async create(userData) {
const { email, password, firstName, lastName, role = "user" } = userData;
// Hash password before storing
const saltRounds = 12;
const hashedPassword = await bcrypt.hash(password, saltRounds);
const query = `
INSERT INTO users (email, password, first_name, last_name, role)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, email, first_name, last_name, role, created_at
`;
const result = await database.query(query, [
email,
hashedPassword,
firstName,
lastName,
role,
]);
return result.rows[0];
}
static async validatePassword(plainPassword, hashedPassword) {
return await bcrypt.compare(plainPassword, hashedPassword);
}
}
JWT Service (The Heart of Auth)
// src/services/authService.js
const jwt = require("jsonwebtoken");
const { v4: uuidv4 } = require("uuid");
class AuthService {
generateAccessToken(user) {
return jwt.sign(
{
userId: user.id,
email: user.email,
role: user.role,
},
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRES_IN }
);
}
generateRefreshToken(userId) {
const tokenId = uuidv4();
return {
token: jwt.sign({ userId, tokenId }, process.env.JWT_REFRESH_SECRET, {
expiresIn: process.env.JWT_REFRESH_EXPIRES_IN,
}),
tokenId,
};
}
async login(email, password) {
const user = await User.findByEmail(email);
if (!user) {
throw new Error("Invalid credentials");
}
const isValid = await User.validatePassword(password, user.password);
if (!isValid) {
throw new Error("Invalid credentials");
}
const accessToken = this.generateAccessToken(user);
const { token: refreshToken, tokenId } = this.generateRefreshToken(user.id);
// Store refresh token in database
await RefreshToken.create({
userId: user.id,
tokenId,
token: refreshToken,
});
return {
user: { ...user, password: undefined }, // Never return password
tokens: { accessToken, refreshToken },
};
}
}
Middleware: Security & Authorization
Authentication Middleware
// src/middleware/auth.js
const jwt = require("jsonwebtoken");
const authenticateToken = async (req, res, next) => {
const authHeader = req.headers.authorization;
const token = authHeader && authHeader.split(" ")[1];
if (!token) {
return res.status(401).json({
success: false,
message: "Access token required",
});
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (error) {
return res.status(401).json({
success: false,
message: "Invalid or expired token",
});
}
};
const requireRole = (roles) => {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({
success: false,
message: "Authentication required",
});
}
if (!roles.includes(req.user.role)) {
return res.status(403).json({
success: false,
message: "Insufficient permissions",
});
}
next();
};
};
Error Handling (Because Things Break)
// src/middleware/errorHandler.js
const logger = require("../utils/logger");
const errorHandler = (error, req, res, next) => {
logger.error("Error:", {
message: error.message,
stack: error.stack,
url: req.url,
method: req.method,
});
// Validation errors
if (error.name === "ValidationError") {
return res.status(400).json({
success: false,
message: "Validation failed",
errors: error.details,
});
}
// JWT errors
if (error.name === "JsonWebTokenError") {
return res.status(401).json({
success: false,
message: "Invalid token",
});
}
// Default server error
res.status(500).json({
success: false,
message:
process.env.NODE_ENV === "production"
? "Internal server error"
: error.message,
});
};
API Routes: Clean and Organized
Authentication Routes
// src/routes/auth.js
const express = require("express");
const { body, validationResult } = require("express-validator");
const AuthService = require("../services/authService");
const router = express.Router();
const authService = new AuthService();
// Register endpoint
router.post(
"/register",
[
body("email").isEmail().normalizeEmail(),
body("password")
.isLength({ min: 8 })
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])/),
body("firstName").trim().isLength({ min: 1 }),
body("lastName").trim().isLength({ min: 1 }),
],
async (req, res, next) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: "Validation failed",
errors: errors.array(),
});
}
const result = await authService.register(req.body);
res.status(201).json({
success: true,
message: "User registered successfully",
data: result,
});
} catch (error) {
next(error);
}
}
);
// Login endpoint
router.post(
"/login",
[body("email").isEmail().normalizeEmail(), body("password").exists()],
async (req, res, next) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: "Validation failed",
errors: errors.array(),
});
}
const result = await authService.login(req.body.email, req.body.password);
res.json({
success: true,
message: "Login successful",
data: result,
});
} catch (error) {
next(error);
}
}
);
Protected Routes (Role-Based Access)
// src/routes/protected.js
const express = require("express");
const { authenticateToken, requireRole } = require("../middleware/auth");
const router = express.Router();
// Apply authentication to all routes
router.use(authenticateToken);
// User-accessible route
router.get("/profile", (req, res) => {
res.json({
success: true,
message: "Profile data",
data: { userId: req.user.userId, role: req.user.role },
});
});
// Admin-only routes
router.get("/admin", requireRole(["admin"]), (req, res) => {
res.json({
success: true,
message: "Admin area accessed",
data: { adminId: req.user.userId },
});
});
router.get("/dashboard", requireRole(["admin"]), (req, res) => {
res.json({
success: true,
message: "Dashboard data",
data: { stats: "Admin dashboard stats here" },
});
});
Database Migrations & Seeding
Migration Script
// scripts/migrate.js
const path = require("path");
require("dotenv").config({ path: path.join(__dirname, "../.env") });
const { connectDB, query, closeDB } = require("../src/config/database");
async function runMigrations() {
try {
await connectDB();
// Users table
await query(`
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
first_name VARCHAR(255) NOT NULL,
last_name VARCHAR(255) NOT NULL,
role VARCHAR(50) DEFAULT 'user',
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
// Refresh tokens table
await query(`
CREATE TABLE IF NOT EXISTS refresh_tokens (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
token_id VARCHAR(255) UNIQUE NOT NULL,
token TEXT NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
console.log("ā
Migrations completed successfully!");
} catch (error) {
console.error("ā Migration failed:", error);
} finally {
await closeDB();
}
}
Admin User Seeding
// scripts/seed-admin.js
async function seedAdmin() {
try {
await connectDB();
const existingAdmin = await User.findByEmail("admin@example.com");
if (existingAdmin) {
console.log("Admin user already exists");
return;
}
const adminUser = await User.create({
email: "admin@example.com",
password: "Admin123!",
firstName: "Admin",
lastName: "User",
role: "admin",
});
console.log("š Admin user created successfully!");
console.log("š§ Email: admin@example.com");
console.log("š Password: Admin123!");
} catch (error) {
console.error("ā Failed to create admin user:", error.message);
} finally {
await closeDB();
}
}
Security: Because It Matters
Helmet.js and Security Headers
// server.js
const helmet = require("helmet");
const rateLimit = require("express-rate-limit");
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
scriptSrc: ["'self'"],
imgSrc: ["'self'", "data:", "https:"],
},
},
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true,
},
})
);
// Rate limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per windowMs
message: {
success: false,
message: "Too many requests, please try again later.",
},
});
app.use("/api/", limiter);
Input Validation
const { body } = require("express-validator");
// Password validation
const passwordValidation = body("password")
.isLength({ min: 8 })
.withMessage("Password must be at least 8 characters")
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])/)
.withMessage(
"Password must contain uppercase, lowercase, number and special character"
);
// Email validation
const emailValidation = body("email")
.isEmail()
.normalizeEmail()
.withMessage("Please provide a valid email");
Logging: Winston for the Win
// src/utils/logger.js
const winston = require("winston");
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || "info",
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
transports: [
new winston.transports.File({ filename: "logs/error.log", level: "error" }),
new winston.transports.File({ filename: "logs/combined.log" }),
],
});
if (process.env.NODE_ENV !== "production") {
logger.add(
new winston.transports.Console({
format: winston.format.simple(),
})
);
}
Testing the API
Package.json Scripts
{
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"migrate": "node scripts/migrate.js",
"seed:admin": "node scripts/seed-admin.js",
"cleanup": "node scripts/cleanup.js"
}
}
Testing with PowerShell (Because Windows)
# Register a new user
$user = @{email='test@example.com'; password='TestPass123!'; firstName='Test'; lastName='User'} | ConvertTo-Json
Invoke-RestMethod -Uri 'http://localhost:3000/api/v1/auth/register' -Method POST -Body $user -ContentType 'application/json'
# Login and get token
$login = @{email='test@example.com'; password='TestPass123!'} | ConvertTo-Json
$response = Invoke-RestMethod -Uri 'http://localhost:3000/api/v1/auth/login' -Method POST -Body $login -ContentType 'application/json'
$token = $response.data.tokens.accessToken
# Access protected route
$headers = @{Authorization="Bearer $token"}
Invoke-RestMethod -Uri 'http://localhost:3000/api/v1/protected/profile' -Method GET -Headers $headers
Redis Integration (Optional but Recommended)
// src/config/redis.js
const redis = require("redis");
class RedisClient {
constructor() {
this.client = null;
this.isConnected = false;
}
async connect() {
if (process.env.NODE_ENV === "development" && !process.env.REDIS_URL) {
console.log("Redis disabled in development mode");
return null;
}
try {
this.client = redis.createClient({
url: process.env.REDIS_URL,
});
await this.client.connect();
this.isConnected = true;
console.log("Redis connected successfully");
return this.client;
} catch (error) {
console.warn(
"Redis connection failed, continuing without cache:",
error.message
);
return null;
}
}
}
Production Considerations
Environment-Based Configuration
// Different configs for different environments
const config = {
development: {
logLevel: "debug",
corsOrigin: "*",
rateLimit: false,
},
production: {
logLevel: "error",
corsOrigin: process.env.ALLOWED_ORIGINS?.split(",") || [],
rateLimit: true,
},
};
const currentConfig = config[process.env.NODE_ENV] || config.development;
Graceful Shutdown
// server.js
let server;
async function startServer() {
try {
await connectDB();
await connectRedis();
server = app.listen(PORT, () => {
logger.info(`Server running on port ${PORT}`);
});
} catch (error) {
logger.error("Failed to start server:", error);
process.exit(1);
}
}
// Graceful shutdown
process.on("SIGTERM", async () => {
logger.info("SIGTERM received, shutting down gracefully");
if (server) {
server.close(async () => {
await closeDB();
await closeRedis();
process.exit(0);
});
}
});
What We've Accomplished
ā
Solid Authentication: JWT with refresh tokens, bcrypt password hashing
ā
Role-Based Authorization: Admin and user roles with proper middleware
ā
Security: Helmet.js, rate limiting, input validation, CORS
ā
Database: Azure PostgreSQL with connection pooling
ā
Error Handling: Centralized error middleware with proper logging
ā
Validation: Express-validator for input sanitization
ā
Logging: Winston for production-ready logging
ā
Migration Scripts: Database setup and admin seeding
ā
Testing: PowerShell scripts for API testing
What's Next?
- Redis Integration: Enable for production session management
- API Documentation: Swagger/OpenAPI integration
- Unit Tests: Jest test suite for comprehensive coverage
- Docker: Containerization for easy deployment
- CI/CD: GitHub Actions for automated testing and deployment
Final Thoughts
Look, building a REST API isn't rocket science, but doing it right? That takes some thought. This setup gives you a solid foundation that won't embarrass you in production. The authentication is bulletproof, the database connection is stable, and the code is organized in a way that won't make future-you want to rewrite everything.
The best part? It's actually pretty straightforward once you get the patterns down. No over-engineering, no unnecessary complexity - just clean, working code that scales.
Hope this helps someone out there who's trying to build something similar. If you have questions or want to improve something, you know where to find me. Now excuse me while I go grab some coffee and pretend I'm not secretly proud of how this turned out. ā
Tech Stack Summary:
- Express.js + Node.js
- Azure PostgreSQL
- JWT Authentication
- bcrypt Password Hashing
- Redis (Optional)
- Winston Logging
- Express Validator
- Helmet.js Security
Repository: bettererp