Building a RESTful API that scales requires more than just routing and controllers. Production systems demand robust logging, security, performance optimization, and observability. NestJS, with its opinionated architecture and TypeScript-first approach, provides the perfect foundation for these requirements.
This guide walks you through building a comprehensive RESTful API with enterprise-grade features: structured logging with Winston, JWT-based authentication with role-based access control (RBAC), rate limiting and throttling, Redis caching, and PostgreSQL with Prisma ORM. By the end, you'll have a production-ready template you can adapt for your projects.
Why these specific features matter:
Logging : Debugging production issues requires structured, parseable logs compatible with observability stacks like Loki and ELK.
Authentication & Authorization : RBAC with JWT tokens ensures secure, scalable access control.
Rate Limiting : Protects your API from abuse and ensures fair resource distribution.
Caching : Dramatically improves performance and reduces database load.
PostgreSQL + Prisma : Modern, type-safe database access with migrations and seeding.
Start by creating a new NestJS project:
npm i -g @nestjs/cli
nest new nestjs-api
cd nestjs-api
Now install all required dependencies:
npm install @nestjs/common @nestjs/core @nestjs/platform-express @nestjs/jwt @nestjs/passport passport passport-jwt bcryptjs @prisma/client prisma redis winston winston-daily-rotate-file express-rate-limit @nestjs/throttler dotenv class-validator class-transformer
npm install -D @types/express @types/node @types/bcryptjs typescript ts-loader @prisma/cli
Create a .env.example file for reference:
# Database
DATABASE_URL = "postgresql://user:password@localhost:5432/nestjs_api"
# JWT
JWT_SECRET = "your-super-secret-jwt-key-change-in-production"
JWT_EXPIRATION = "15m"
JWT_REFRESH_SECRET = "your-super-secret-refresh-key"
JWT_REFRESH_EXPIRATION = "7d"
# Redis
REDIS_HOST = "localhost"
REDIS_PORT = 6379
REDIS_PASSWORD = ""
# Logging
LOG_LEVEL = "debug"
LOG_DIR = "./logs"
# Environment
NODE_ENV = "development"
PORT = 3000
Copy to .env.local:
cp .env.example .env.local
Initialize Prisma in your project:
Update prisma/schema.prisma with your database models:
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env ( "DATABASE_URL" )
}
model User {
id String @id @default ( cuid ())
email String @unique
username String @unique
password String
firstName String ?
lastName String ?
role Role @default ( USER )
isActive Boolean @default ( true )
createdAt DateTime @default ( now ())
updatedAt DateTime @updatedAt
posts Post []
refreshTokens RefreshToken []
@@map ( "users" )
}
model Post {
id String @id @default ( cuid ())
title String
content String
published Boolean @default ( false )
authorId String
author User @relation ( fields : [ authorId ], references : [ id ], onDelete : Cascade )
createdAt DateTime @default ( now ())
updatedAt DateTime @updatedAt
@@map ( "posts" )
}
model RefreshToken {
id String @id @default ( cuid ())
token String @unique
userId String
user User @relation ( fields : [ userId ], references : [ id ], onDelete : Cascade )
expiresAt DateTime
createdAt DateTime @default ( now ())
@@map ( "refresh_tokens" )
}
enum Role {
ADMIN
USER
MODERATOR
}
Create and run migrations:
npx prisma migrate dev --name init
Create prisma/seed.ts:
import { PrismaClient } from '@prisma/client' ;
import * as bcrypt from 'bcryptjs' ;
const prisma = new PrismaClient ();
async function main () {
// Clear existing data
await prisma . post . deleteMany ();
await prisma . refreshToken . deleteMany ();
await prisma . user . deleteMany ();
// Create admin user
const adminPassword = await bcrypt . hash ( 'admin123' , 10 );
const admin = await prisma . user . create ({
data : {
email : 'admin@example.com' ,
username : 'admin' ,
password : adminPassword ,
firstName : 'Admin' ,
lastName : 'User' ,
role : 'ADMIN' ,
isActive : true ,
},
});
// Create regular users
const userPassword = await bcrypt . hash ( 'user123' , 10 );
const user1 = await prisma . user . create ({
data : {
email : 'user1@example.com' ,
username : 'user1' ,
password : userPassword ,
firstName : 'John' ,
lastName : 'Doe' ,
role : 'USER' ,
isActive : true ,
},
});
const user2 = await prisma . user . create ({
data : {
email : 'user2@example.com' ,
username : 'user2' ,
password : userPassword ,
firstName : 'Jane' ,
lastName : 'Smith' ,
role : 'USER' ,
isActive : true ,
},
});
// Create sample posts
await prisma . post . create ({
data : {
title : 'Getting Started with NestJS' ,
content : 'NestJS is a progressive Node.js framework...' ,
published : true ,
authorId : admin . id ,
},
});
await prisma . post . create ({
data : {
title : 'Advanced TypeScript Patterns' ,
content : 'Learn advanced TypeScript patterns for scalable applications...' ,
published : true ,
authorId : user1 . id ,
},
});
console . log ( 'Seeding completed successfully' );
}
main ()
. catch (( e ) => {
console . error ( e );
process . exit ( 1 );
})
. finally ( async () => {
await prisma . $disconnect ();
});
Update package.json to include seed script:
package.json (prisma section) "prisma" : {
"seed" : "ts-node prisma/seed.ts"
}
Run the seed:
Winston is the most popular logging library for NestJS because it's flexible, supports multiple transports, and integrates seamlessly with observability platforms like Loki and ELK Stack. It provides structured logging with rotation, compression, and expiration policies.
Create src/common/logger/logger.service.ts:
src/common/logger/logger.service.ts import { Injectable , LoggerService } from '@nestjs/common' ;
import * as winston from 'winston' ;
import * as DailyRotateFile from 'winston-daily-rotate-file' ;
import * as fs from 'fs' ;
import * as path from 'path' ;
@ Injectable ()
export class CustomLoggerService implements LoggerService {
private logger : winston . Logger ;
constructor () {
const logsDir = process . env . LOG_DIR || './logs' ;
// Create logs directory if it doesn't exist
if ( ! fs . existsSync ( logsDir )) {
fs . mkdirSync ( logsDir , { recursive : true });
}
// Define log levels
const levels = {
fatal : 0 ,
error : 1 ,
warn : 2 ,
info : 3 ,
debug : 4 ,
trace : 5 ,
};
// Define colors for console output
const colors = {
fatal : 'red' ,
error : 'red' ,
warn : 'yellow' ,
info : 'green' ,
debug : 'blue' ,
trace : 'gray' ,
};
winston . addColors ( colors );
// Create logger instance
this . logger = winston . createLogger ({
levels ,
format : winston . format . combine (
winston . format . timestamp ({ format : 'YYYY-MM-DD HH:mm:ss' }),
winston . format . errors ({ stack : true }),
winston . format . splat (),
winston . format . json (),
),
defaultMeta : { service : 'nestjs-api' },
transports : [
// Console transport for development
new winston . transports . Console ({
format : winston . format . combine (
winston . format . colorize (),
winston . format . printf (({ level , message , timestamp , ... meta }) => {
const metaStr = Object . keys ( meta ). length ? JSON . stringify ( meta ) : '' ;
return ` ${ timestamp } [ ${ level } ]: ${ message } ${ metaStr } ` ;
}),
),
}),
// Daily rotate file for all logs
new DailyRotateFile ({
filename : path . join ( logsDir , 'application-%DATE%.log' ),
datePattern : 'YYYY-MM-DD' ,
maxSize : '20m' ,
maxDays : '14d' ,
compress : true ,
format : winston . format . json (),
}),
// Daily rotate file for errors only
new DailyRotateFile ({
filename : path . join ( logsDir , 'error-%DATE%.log' ),
datePattern : 'YYYY-MM-DD' ,
maxSize : '20m' ,
maxDays : '30d' ,
compress : true ,
level : 'error' ,
format : winston . format . json (),
}),
],
});
// Set log level from environment
const logLevel = process . env . LOG_LEVEL || 'info' ;
this . logger . level = logLevel ;
}
log ( message : string , context ? : string ) {
this . logger . info ( message , { context });
}
error ( message : string , trace ? : string , context ? : string ) {
this . logger . error ( message , { trace , context });
}
warn ( message : string , context ? : string ) {
this . logger . warn ( message , { context });
}
debug ( message : string , context ? : string ) {
this . logger . debug ( message , { context });
}
verbose ( message : string , context ? : string ) {
this . logger . info ( message , { context , level : 'trace' });
}
fatal ( message : string , context ? : string ) {
this . logger . log ( 'fatal' , message , { context });
}
trace ( message : string , context ? : string ) {
this . logger . log ( 'trace' , message , { context });
}
}
Create src/common/logger/logger.module.ts:
src/common/logger/logger.module.ts import { Module } from '@nestjs/common' ;
import { CustomLoggerService } from './logger.service' ;
@ Module ({
providers : [ CustomLoggerService ],
exports : [ CustomLoggerService ],
})
export class LoggerModule {}
Create src/common/middleware/logging.middleware.ts:
src/common/middleware/logging.middleware.ts import { Injectable , NestMiddleware } from '@nestjs/common' ;
import { Request , Response , NextFunction } from 'express' ;
import { CustomLoggerService } from '../logger/logger.service' ;
@ Injectable ()
export class LoggingMiddleware implements NestMiddleware {
constructor ( private logger : CustomLoggerService ) {}
use ( req : Request , res : Response , next : NextFunction ) {
const { method , originalUrl , ip } = req ;
const startTime = Date . now ();
res . on ( 'finish' , () => {
const duration = Date . now () - startTime ;
const { statusCode } = res ;
const logData = {
method ,
url : originalUrl ,
statusCode ,
duration : ` ${ duration } ms` ,
ip ,
userAgent : req . get ( 'user-agent' ),
};
if ( statusCode >= 500 ) {
this . logger . error ( `HTTP Request` , JSON . stringify ( logData ), 'HTTP' );
} else if ( statusCode >= 400 ) {
this . logger . warn ( `HTTP Request` , JSON . stringify ( logData ), 'HTTP' );
} else {
this . logger . log ( `HTTP Request` , JSON . stringify ( logData ), 'HTTP' );
}
});
next ();
}
}
JWT (JSON Web Tokens) provide stateless authentication. The pattern we'll implement uses:
Access Token : Short-lived (15 minutes), used for API requests
Refresh Token : Long-lived (7 days), stored in httpOnly cookies, used to get new access tokens
This approach balances security and user experience.
Create src/auth/auth.service.ts:
import { Injectable , UnauthorizedException , BadRequestException } from '@nestjs/common' ;
import { JwtService } from '@nestjs/jwt' ;
import { PrismaService } from '../prisma/prisma.service' ;
import * as bcrypt from 'bcryptjs' ;
import { CustomLoggerService } from '../common/logger/logger.service' ;
@ Injectable ()
export class AuthService {
constructor (
private prisma : PrismaService ,
private jwtService : JwtService ,
private logger : CustomLoggerService ,
) {}
async validateUser ( email : string , password : string ) {
const user = await this . prisma . user . findUnique ({
where : { email },
});
if ( ! user ) {
this . logger . warn ( `Login attempt with non-existent email: ${ email } ` , 'Auth' );
throw new UnauthorizedException ( 'Invalid credentials' );
}
if ( ! user . isActive ) {
this . logger . warn ( `Login attempt with inactive user: ${ email } ` , 'Auth' );
throw new UnauthorizedException ( 'User account is inactive' );
}
const isPasswordValid = await bcrypt . compare ( password , user . password );
if ( ! isPasswordValid ) {
this . logger . warn ( `Failed login attempt for user: ${ email } ` , 'Auth' );
throw new UnauthorizedException ( 'Invalid credentials' );
}
this . logger . log ( `User logged in: ${ email } ` , 'Auth' );
return user ;
}
async login ( user : any ) {
const payload = {
sub : user . id ,
email : user . email ,
username : user . username ,
role : user . role ,
};
const accessToken = this . jwtService . sign ( payload , {
secret : process . env . JWT_SECRET ,
expiresIn : process . env . JWT_EXPIRATION || '15m' ,
});
const refreshToken = this . jwtService . sign ( payload , {
secret : process . env . JWT_REFRESH_SECRET ,
expiresIn : process . env . JWT_REFRESH_EXPIRATION || '7d' ,
});
// Store refresh token in database
const expiresAt = new Date ();
expiresAt . setDate ( expiresAt . getDate () + 7 );
await this . prisma . refreshToken . create ({
data : {
token : refreshToken ,
userId : user . id ,
expiresAt ,
},
});
return {
accessToken ,
refreshToken ,
user : {
id : user . id ,
email : user . email ,
username : user . username ,
role : user . role ,
},
};
}
async refreshAccessToken ( refreshToken : string ) {
try {
const payload = this . jwtService . verify ( refreshToken , {
secret : process . env . JWT_REFRESH_SECRET ,
});
// Verify token exists in database
const storedToken = await this . prisma . refreshToken . findUnique ({
where : { token : refreshToken },
});
if ( ! storedToken || storedToken . expiresAt < new Date ()) {
throw new UnauthorizedException ( 'Refresh token expired or invalid' );
}
const user = await this . prisma . user . findUnique ({
where : { id : payload . sub },
});
if ( ! user || ! user . isActive ) {
throw new UnauthorizedException ( 'User not found or inactive' );
}
const newPayload = {
sub : user . id ,
email : user . email ,
username : user . username ,
role : user . role ,
};
const newAccessToken = this . jwtService . sign ( newPayload , {
secret : process . env . JWT_SECRET ,
expiresIn : process . env . JWT_EXPIRATION || '15m' ,
});
return { accessToken : newAccessToken };
} catch ( error ) {
this . logger . error ( 'Invalid refresh token' , error . message , 'Auth' );
throw new UnauthorizedException ( 'Invalid refresh token' );
}
}
async logout ( userId : string ) {
await this . prisma . refreshToken . deleteMany ({
where : { userId },
});
this . logger . log ( `User logged out: ${ userId } ` , 'Auth' );
}
}
Create src/auth/strategies/jwt.strategy.ts:
src/auth/strategies/jwt.strategy.ts import { Injectable } from '@nestjs/common' ;
import { PassportStrategy } from '@nestjs/passport' ;
import { ExtractJwt , Strategy } from 'passport-jwt' ;
@ Injectable ()
export class JwtStrategy extends PassportStrategy ( Strategy ) {
constructor () {
super ({
jwtFromRequest : ExtractJwt . fromAuthHeaderAsBearerToken (),
ignoreExpiration : false ,
secretOrKey : process . env . JWT_SECRET ,
});
}
async validate ( payload : any ) {
return {
id : payload . sub ,
email : payload . email ,
username : payload . username ,
role : payload . role ,
};
}
}
Create src/auth/guards/roles.guard.ts:
src/auth/guards/roles.guard.ts import { Injectable , CanActivate , ExecutionContext , ForbiddenException } from '@nestjs/common' ;
import { Reflector } from '@nestjs/core' ;
@ Injectable ()
export class RolesGuard implements CanActivate {
constructor ( private reflector : Reflector ) {}
canActivate ( context : ExecutionContext ) : boolean {
const requiredRoles = this . reflector . get < string []>( 'roles' , context . getHandler ());
if ( ! requiredRoles ) {
return true ;
}
const request = context . switchToHttp (). getRequest ();
const user = request . user ;
if ( ! user ) {
throw new ForbiddenException ( 'User not found' );
}
if ( ! requiredRoles . includes ( user . role )) {
throw new ForbiddenException (
`User role ${ user . role } is not authorized to access this resource` ,
);
}
return true ;
}
}
Create src/auth/decorators/roles.decorator.ts:
src/auth/decorators/roles.decorator.ts import { SetMetadata } from '@nestjs/common' ;
export const Roles = ( ... roles : string []) => SetMetadata ( 'roles' , roles );
Create src/auth/auth.controller.ts:
src/auth/auth.controller.ts import {
Controller ,
Post ,
Body ,
UseGuards ,
Req ,
Res ,
HttpCode ,
} from '@nestjs/common' ;
import { AuthService } from './auth.service' ;
import { JwtAuthGuard } from './guards/jwt-auth.guard' ;
import { Response , Request } from 'express' ;
@ Controller ( 'auth' )
export class AuthController {
constructor ( private authService : AuthService ) {}
@ Post ( 'login' )
@ HttpCode ( 200 )
async login (
@ Body () body : { email : string ; password : string },
@ Res ({ passthrough : true }) res : Response ,
) {
const user = await this . authService . validateUser ( body . email , body . password );
const { accessToken , refreshToken , user : userData } = await this . authService . login ( user );
// Set refresh token in httpOnly cookie
res . cookie ( 'refreshToken' , refreshToken , {
httpOnly : true ,
secure : process . env . NODE_ENV === 'production' ,
sameSite : 'strict' ,
maxAge : 7 * 24 * 60 * 60 * 1000 , // 7 days
});
return {
accessToken ,
user : userData ,
};
}
@ Post ( 'refresh' )
@ HttpCode ( 200 )
async refresh (@ Req () req : Request ) {
const refreshToken = req . cookies . refreshToken ;
if ( ! refreshToken ) {
throw new Error ( 'Refresh token not found' );
}
return this . authService . refreshAccessToken ( refreshToken );
}
@ Post ( 'logout' )
@ UseGuards ( JwtAuthGuard )
@ HttpCode ( 200 )
async logout (@ Req () req : Request ) {
await this . authService . logout ( req . user . id );
return { message : 'Logged out successfully' };
}
}
Create src/auth/guards/jwt-auth.guard.ts:
src/auth/guards/jwt-auth.guard.ts import { Injectable } from '@nestjs/common' ;
import { AuthGuard } from '@nestjs/passport' ;
@ Injectable ()
export class JwtAuthGuard extends AuthGuard ( 'jwt' ) {}
Create src/auth/auth.module.ts:
import { Module } from '@nestjs/common' ;
import { JwtModule } from '@nestjs/jwt' ;
import { PassportModule } from '@nestjs/passport' ;
import { AuthService } from './auth.service' ;
import { AuthController } from './auth.controller' ;
import { JwtStrategy } from './strategies/jwt.strategy' ;
import { PrismaModule } from '../prisma/prisma.module' ;
import { LoggerModule } from '../common/logger/logger.module' ;
@ Module ({
imports : [
PrismaModule ,
LoggerModule ,
PassportModule ,
JwtModule . register ({
secret : process . env . JWT_SECRET ,
signOptions : { expiresIn : '15m' },
}),
],
providers : [ AuthService , JwtStrategy ],
controllers : [ AuthController ],
exports : [ AuthService ],
})
export class AuthModule {}
Rate Limiting : Restricts requests per IP address globally
Throttling : Restricts requests per user/endpoint, more granular control
We'll implement both for comprehensive protection.
Create src/common/middleware/rate-limit.middleware.ts:
src/common/middleware/rate-limit.middleware.ts import { Injectable , NestMiddleware } from '@nestjs/common' ;
import { Request , Response , NextFunction } from 'express' ;
import * as rateLimit from 'express-rate-limit' ;
@ Injectable ()
export class RateLimitMiddleware implements NestMiddleware {
private limiter = rateLimit ({
windowMs : 15 * 60 * 1000 , // 15 minutes
max : 100 , // limit each IP to 100 requests per windowMs
message : 'Too many requests from this IP, please try again later.' ,
standardHeaders : true , // Return rate limit info in the `RateLimit-*` headers
legacyHeaders : false , // Disable the `X-RateLimit-*` headers
skip : ( req : Request ) => {
// Skip rate limiting for health checks
return req . path === '/health' ;
},
});
use ( req : Request , res : Response , next : NextFunction ) {
this . limiter ( req , res , next );
}
}
Create src/common/guards/throttle.guard.ts:
src/common/guards/throttle.guard.ts import { Injectable , CanActivate , ExecutionContext , TooManyRequestsException } from '@nestjs/common' ;
import { Request } from 'express' ;
interface ThrottleRecord {
count : number ;
resetTime : number ;
}
@ Injectable ()
export class ThrottleGuard implements CanActivate {
private throttleMap = new Map < string , ThrottleRecord >();
private readonly windowMs = 60 * 1000 ; // 1 minute
private readonly maxRequests = 30 ; // 30 requests per minute
canActivate ( context : ExecutionContext ) : boolean {
const request = context . switchToHttp (). getRequest < Request >();
const key = ` ${ request . ip } - ${ request . path } ` ;
const now = Date . now ();
let record = this . throttleMap . get ( key );
if ( ! record || now > record . resetTime ) {
record = {
count : 1 ,
resetTime : now + this . windowMs ,
};
} else {
record . count ++ ;
}
this . throttleMap . set ( key , record );
if ( record . count > this . maxRequests ) {
throw new TooManyRequestsException (
`Too many requests. Max ${ this . maxRequests } requests per minute.` ,
);
}
return true ;
}
}
Create src/common/decorators/throttle.decorator.ts:
src/common/decorators/throttle.decorator.ts import { SetMetadata } from '@nestjs/common' ;
export interface ThrottleOptions {
limit : number ;
windowMs : number ;
}
export const Throttle = ( options : ThrottleOptions ) =>
SetMetadata ( 'throttle' , options );
For production, use Redis-backed throttling. Create src/common/guards/redis-throttle.guard.ts:
src/common/guards/redis-throttle.guard.ts import { Injectable , CanActivate , ExecutionContext , TooManyRequestsException } from '@nestjs/common' ;
import { Request } from 'express' ;
import * as redis from 'redis' ;
import { CustomLoggerService } from '../logger/logger.service' ;
@ Injectable ()
export class RedisThrottleGuard implements CanActivate {
private redisClient : redis . RedisClient ;
constructor ( private logger : CustomLoggerService ) {
this . redisClient = redis . createClient ({
host : process . env . REDIS_HOST || 'localhost' ,
port : parseInt ( process . env . REDIS_PORT || '6379' ),
password : process . env . REDIS_PASSWORD ,
});
this . redisClient . on ( 'error' , ( err ) => {
this . logger . error ( 'Redis connection error' , err . message , 'RedisThrottle' );
});
}
async canActivate ( context : ExecutionContext ) : Promise < boolean > {
const request = context . switchToHttp (). getRequest < Request >();
const key = `throttle: ${ request . ip } : ${ request . path } ` ;
const limit = 30 ;
const windowMs = 60 ;
return new Promise (( resolve , reject ) => {
this . redisClient . incr ( key , ( err , count ) => {
if ( err ) {
this . logger . error ( 'Redis error' , err . message , 'RedisThrottle' );
resolve ( true ); // Allow request if Redis fails
return ;
}
if ( count === 1 ) {
this . redisClient . expire ( key , windowMs );
}
if ( count > limit ) {
reject (
new TooManyRequestsException (
`Too many requests. Max ${ limit } requests per ${ windowMs } seconds.` ,
),
);
} else {
resolve ( true );
}
});
});
}
}
Redis provides in-memory caching with automatic expiration, perfect for reducing database load and improving response times. We'll implement both simple caching and cache invalidation strategies.
Create src/cache/cache.service.ts:
src/cache/cache.service.ts import { Injectable } from '@nestjs/common' ;
import * as redis from 'redis' ;
import { promisify } from 'util' ;
import { CustomLoggerService } from '../common/logger/logger.service' ;
@ Injectable ()
export class CacheService {
private redisClient : redis . RedisClient ;
private getAsync : ( key : string ) => Promise < string >;
private setAsync : ( key : string , value : string , mode : string , duration : number ) => Promise < string >;
private delAsync : ( key : string ) => Promise < number >;
private keysAsync : ( pattern : string ) => Promise < string []>;
constructor ( private logger : CustomLoggerService ) {
this . redisClient = redis . createClient ({
host : process . env . REDIS_HOST || 'localhost' ,
port : parseInt ( process . env . REDIS_PORT || '6379' ),
password : process . env . REDIS_PASSWORD ,
});
this . getAsync = promisify ( this . redisClient . get ). bind ( this . redisClient );
this . setAsync = promisify ( this . redisClient . set ). bind ( this . redisClient );
this . delAsync = promisify ( this . redisClient . del ). bind ( this . redisClient );
this . keysAsync = promisify ( this . redisClient . keys ). bind ( this . redisClient );
this . redisClient . on ( 'error' , ( err ) => {
this . logger . error ( 'Redis connection error' , err . message , 'Cache' );
});
this . redisClient . on ( 'connect' , () => {
this . logger . log ( 'Redis connected' , 'Cache' );
});
}
async get < T >( key : string ) : Promise < T | null > {
try {
const value = await this . getAsync ( key );
if ( value ) {
this . logger . debug ( `Cache hit: ${ key } ` , 'Cache' );
return JSON . parse ( value );
}
this . logger . debug ( `Cache miss: ${ key } ` , 'Cache' );
return null ;
} catch ( error ) {
this . logger . error ( `Cache get error for key ${ key } ` , error . message , 'Cache' );
return null ;
}
}
async set < T >( key : string , value : T , ttlSeconds : number = 3600 ) : Promise < void > {
try {
await this . setAsync ( key , JSON . stringify ( value ), 'EX' , ttlSeconds );
this . logger . debug ( `Cache set: ${ key } (TTL: ${ ttlSeconds } s)` , 'Cache' );
} catch ( error ) {
this . logger . error ( `Cache set error for key ${ key } ` , error . message , 'Cache' );
}
}
async delete ( key : string ) : Promise < void > {
try {
await this . delAsync ( key );
this . logger . debug ( `Cache deleted: ${ key } ` , 'Cache' );
} catch ( error ) {
this . logger . error ( `Cache delete error for key ${ key } ` , error . message , 'Cache' );
}
}
async deletePattern ( pattern : string ) : Promise < void > {
try {
const keys = await this . keysAsync ( pattern );
if ( keys . length > 0 ) {
await Promise . all ( keys . map (( key ) => this . delete ( key )));
this . logger . debug ( `Cache pattern deleted: ${ pattern } ( ${ keys . length } keys)` , 'Cache' );
}
} catch ( error ) {
this . logger . error ( `Cache pattern delete error for ${ pattern } ` , error . message , 'Cache' );
}
}
async flush () : Promise < void > {
try {
this . redisClient . flushdb ();
this . logger . log ( 'Cache flushed' , 'Cache' );
} catch ( error ) {
this . logger . error ( 'Cache flush error' , error . message , 'Cache' );
}
}
}
Create src/cache/decorators/cache.decorator.ts:
src/cache/decorators/cache.decorator.ts import { SetMetadata } from '@nestjs/common' ;
export interface CacheOptions {
key : string ;
ttl ? : number ; // Time to live in seconds
}
export const CacheKey = ( options : CacheOptions ) => SetMetadata ( 'cache' , options );
Create src/cache/interceptors/cache.interceptor.ts:
src/cache/interceptors/cache.interceptor.ts import {
Injectable ,
NestInterceptor ,
ExecutionContext ,
CallHandler ,
} from '@nestjs/common' ;
import { Observable , of } from 'rxjs' ;
import { tap } from 'rxjs/operators' ;
import { Request } from 'express' ;
import { Reflector } from '@nestjs/core' ;
import { CacheService } from '../cache.service' ;
import { CacheOptions } from '../decorators/cache.decorator' ;
@ Injectable ()
export class CacheInterceptor implements NestInterceptor {
constructor (
private reflector : Reflector ,
private cacheService : CacheService ,
) {}
async intercept (
context : ExecutionContext ,
next : CallHandler ,
) : Promise < Observable < any >> {
const cacheOptions = this . reflector . get < CacheOptions >( 'cache' , context . getHandler ());
if ( ! cacheOptions ) {
return next . handle ();
}
const request = context . switchToHttp (). getRequest < Request >();
const cacheKey = ` ${ cacheOptions . key } : ${ request . user ?. id || ' anonymous ' } ` ;
// Try to get from cache
const cachedData = await this . cacheService . get ( cacheKey );
if ( cachedData ) {
return of ( cachedData );
}
// If not in cache, execute handler and cache result
return next . handle (). pipe (
tap ( async ( data ) => {
const ttl = cacheOptions . ttl || 3600 ;
await this . cacheService . set ( cacheKey , data , ttl );
}),
);
}
}
Create src/cache/cache.module.ts:
src/cache/cache.module.ts import { Module } from '@nestjs/common' ;
import { CacheService } from './cache.service' ;
import { LoggerModule } from '../common/logger/logger.module' ;
@ Module ({
imports : [ LoggerModule ],
providers : [ CacheService ],
exports : [ CacheService ],
})
export class CacheModule {}
Create src/prisma/prisma.service.ts:
src/prisma/prisma.service.ts import { Injectable , OnModuleInit , OnModuleDestroy } from '@nestjs/common' ;
import { PrismaClient } from '@prisma/client' ;
@ Injectable ()
export class PrismaService extends PrismaClient implements OnModuleInit , OnModuleDestroy {
async onModuleInit () {
await this . $connect ();
}
async onModuleDestroy () {
await this . $disconnect ();
}
}
Create src/prisma/prisma.module.ts:
src/prisma/prisma.module.ts import { Module } from '@nestjs/common' ;
import { PrismaService } from './prisma.service' ;
@ Module ({
providers : [ PrismaService ],
exports : [ PrismaService ],
})
export class PrismaModule {}
Create src/posts/posts.service.ts:
src/posts/posts.service.ts import { Injectable , NotFoundException , ForbiddenException } from '@nestjs/common' ;
import { PrismaService } from '../prisma/prisma.service' ;
import { CacheService } from '../cache/cache.service' ;
import { CustomLoggerService } from '../common/logger/logger.service' ;
@ Injectable ()
export class PostsService {
constructor (
private prisma : PrismaService ,
private cache : CacheService ,
private logger : CustomLoggerService ,
) {}
async findAll ( page : number = 1 , limit : number = 10 ) {
const cacheKey = `posts:all: ${ page } : ${ limit } ` ;
const cached = await this . cache . get ( cacheKey );
if ( cached ) {
return cached ;
}
const skip = ( page - 1 ) * limit ;
const [ posts , total ] = await Promise . all ([
this . prisma . post . findMany ({
skip ,
take : limit ,
include : { author : { select : { id : true , username : true , email : true } } },
orderBy : { createdAt : 'desc' },
}),
this . prisma . post . count (),
]);
const result = {
data : posts ,
pagination : {
total ,
page ,
limit ,
pages : Math . ceil ( total / limit ),
},
};
await this . cache . set ( cacheKey , result , 300 ); // Cache for 5 minutes
this . logger . log ( `Fetched posts page ${ page } ` , 'Posts' );
return result ;
}
async findOne ( id : string ) {
const cacheKey = `post: ${ id } ` ;
const cached = await this . cache . get ( cacheKey );
if ( cached ) {
return cached ;
}
const post = await this . prisma . post . findUnique ({
where : { id },
include : { author : { select : { id : true , username : true , email : true } } },
});
if ( ! post ) {
this . logger . warn ( `Post not found: ${ id } ` , 'Posts' );
throw new NotFoundException ( 'Post not found' );
}
await this . cache . set ( cacheKey , post , 600 ); // Cache for 10 minutes
return post ;
}
async create ( data : { title : string ; content : string }, userId : string ) {
const post = await this . prisma . post . create ({
data : {
title : data . title ,
content : data . content ,
authorId : userId ,
},
include : { author : { select : { id : true , username : true , email : true } } },
});
// Invalidate list cache
await this . cache . deletePattern ( 'posts:all:*' );
this . logger . log ( `Post created: ${ post . id } ` , 'Posts' );
return post ;
}
async update ( id : string , data : any , userId : string ) {
const post = await this . prisma . post . findUnique ({ where : { id } });
if ( ! post ) {
throw new NotFoundException ( 'Post not found' );
}
if ( post . authorId !== userId ) {
this . logger . warn ( `Unauthorized update attempt for post ${ id } by user ${ userId } ` , 'Posts' );
throw new ForbiddenException ( 'You can only update your own posts' );
}
const updated = await this . prisma . post . update ({
where : { id },
data ,
include : { author : { select : { id : true , username : true , email : true } } },
});
// Invalidate caches
await this . cache . delete ( `post: ${ id } ` );
await this . cache . deletePattern ( 'posts:all:*' );
this . logger . log ( `Post updated: ${ id } ` , 'Posts' );
return updated ;
}
async delete ( id : string , userId : string ) {
const post = await this . prisma . post . findUnique ({ where : { id } });
if ( ! post ) {
throw new NotFoundException ( 'Post not found' );
}
if ( post . authorId !== userId ) {
this . logger . warn ( `Unauthorized delete attempt for post ${ id } by user ${ userId } ` , 'Posts' );
throw new ForbiddenException ( 'You can only delete your own posts' );
}
await this . prisma . post . delete ({ where : { id } });
// Invalidate caches
await this . cache . delete ( `post: ${ id } ` );
await this . cache . deletePattern ( 'posts:all:*' );
this . logger . log ( `Post deleted: ${ id } ` , 'Posts' );
}
}
Create src/posts/posts.controller.ts:
src/posts/posts.controller.ts import {
Controller ,
Get ,
Post ,
Put ,
Delete ,
Param ,
Body ,
UseGuards ,
UseInterceptors ,
Req ,
Query ,
} from '@nestjs/common' ;
import { PostsService } from './posts.service' ;
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard' ;
import { RolesGuard } from '../auth/guards/roles.guard' ;
import { Roles } from '../auth/decorators/roles.decorator' ;
import { CacheInterceptor } from '../cache/interceptors/cache.interceptor' ;
import { CacheKey } from '../cache/decorators/cache.decorator' ;
import { ThrottleGuard } from '../common/guards/throttle.guard' ;
import { Request } from 'express' ;
@ Controller ( 'posts' )
@ UseInterceptors ( CacheInterceptor )
export class PostsController {
constructor ( private postsService : PostsService ) {}
@ Get ()
@ UseGuards ( ThrottleGuard )
@ CacheKey ({ key : 'posts:list' , ttl : 300 })
async findAll (@ Query ( 'page' ) page : string = '1' , @ Query ( 'limit' ) limit : string = '10' ) {
return this . postsService . findAll ( parseInt ( page ), parseInt ( limit ));
}
@ Get ( ':id' )
@ UseGuards ( ThrottleGuard )
@ CacheKey ({ key : 'post:detail' , ttl : 600 })
async findOne (@ Param ( 'id' ) id : string ) {
return this . postsService . findOne ( id );
}
@ Post ()
@ UseGuards ( JwtAuthGuard , ThrottleGuard )
async create (@ Body () body : { title : string ; content : string }, @ Req () req : Request ) {
return this . postsService . create ( body , req . user . id );
}
@ Put ( ':id' )
@ UseGuards ( JwtAuthGuard , ThrottleGuard )
async update (
@ Param ( 'id' ) id : string ,
@ Body () body : any ,
@ Req () req : Request ,
) {
return this . postsService . update ( id , body , req . user . id );
}
@ Delete ( ':id' )
@ UseGuards ( JwtAuthGuard , ThrottleGuard )
async delete (@ Param ( 'id' ) id : string , @ Req () req : Request ) {
return this . postsService . delete ( id , req . user . id );
}
}
Create src/posts/posts.module.ts:
src/posts/posts.module.ts import { Module } from '@nestjs/common' ;
import { PostsService } from './posts.service' ;
import { PostsController } from './posts.controller' ;
import { PrismaModule } from '../prisma/prisma.module' ;
import { CacheModule } from '../cache/cache.module' ;
import { LoggerModule } from '../common/logger/logger.module' ;
@ Module ({
imports : [ PrismaModule , CacheModule , LoggerModule ],
providers : [ PostsService ],
controllers : [ PostsController ],
})
export class PostsModule {}
Create src/app.module.ts:
import { Module , NestModule , MiddlewareConsumer } from '@nestjs/common' ;
import { ConfigModule } from '@nestjs/config' ;
import { AppController } from './app.controller' ;
import { AppService } from './app.service' ;
import { AuthModule } from './auth/auth.module' ;
import { PostsModule } from './posts/posts.module' ;
import { PrismaModule } from './prisma/prisma.module' ;
import { CacheModule } from './cache/cache.module' ;
import { LoggerModule } from './common/logger/logger.module' ;
import { LoggingMiddleware } from './common/middleware/logging.middleware' ;
import { RateLimitMiddleware } from './common/middleware/rate-limit.middleware' ;
import { CustomLoggerService } from './common/logger/logger.service' ;
@ Module ({
imports : [
ConfigModule . forRoot ({
isGlobal : true ,
envFilePath : '.env.local' ,
}),
LoggerModule ,
PrismaModule ,
CacheModule ,
AuthModule ,
PostsModule ,
],
controllers : [ AppController ],
providers : [ AppService , CustomLoggerService ],
})
export class AppModule implements NestModule {
configure ( consumer : MiddlewareConsumer ) {
consumer
. apply ( RateLimitMiddleware )
. forRoutes ( '*' )
. apply ( LoggingMiddleware )
. forRoutes ( '*' );
}
}
Update src/main.ts:
import { NestFactory } from '@nestjs/core' ;
import { ValidationPipe } from '@nestjs/common' ;
import * as cookieParser from 'cookie-parser' ;
import { AppModule } from './app.module' ;
import { CustomLoggerService } from './common/logger/logger.service' ;
async function bootstrap () {
const app = await NestFactory . create ( AppModule );
const logger = app . get ( CustomLoggerService );
// Use custom logger
app . useLogger ( logger );
// Global validation pipe
app . useGlobalPipes (
new ValidationPipe ({
whitelist : true ,
forbidNonWhitelisted : true ,
transform : true ,
}),
);
// Cookie parser
app . use ( cookieParser ());
// CORS
app . enableCors ({
origin : process . env . CORS_ORIGIN || 'http://localhost:3000' ,
credentials : true ,
});
const port = process . env . PORT || 3000 ;
await app . listen ( port );
logger . log ( `Application running on port ${ port } ` , 'Bootstrap' );
}
bootstrap ();
Problem : Logging passwords, tokens, or API keys exposes sensitive information.
Why it happens : Developers log entire request/response objects without filtering.
Solution : Always sanitize logs before writing:
Sanitized logging example const sanitizeData = ( data : any ) => {
const sensitive = [ 'password' , 'token' , 'secret' , 'apiKey' ];
const sanitized = { ... data };
sensitive . forEach ( key => {
if ( sanitized [ key ]) {
sanitized [ key ] = '***REDACTED***' ;
}
});
return sanitized ;
};
this . logger . log ( `User login: ${ JSON . stringify ( sanitizeData ( user )) } ` , 'Auth' );
Problem : Users see stale data after updates because cache isn't invalidated.
Why it happens : Developers forget to clear related cache keys when data changes.
Solution : Always invalidate cache in update/delete operations:
Cache invalidation pattern async update ( id : string , data : any ) {
const result = await this . prisma . post . update ({
where : { id },
data ,
});
// Invalidate specific and related caches
await this . cache . delete ( `post: ${ id } ` );
await this . cache . deletePattern ( 'posts:all:*' );
return result ;
}
Problem : Short or predictable JWT secrets can be brute-forced.
Why it happens : Using default or simple secrets in production.
Solution : Use strong, random secrets:
Generate strong JWT secret node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
Store in .env.local and never commit to version control.
Problem : Application crashes when Redis is unavailable.
Why it happens : No fallback mechanism when cache service fails.
Solution : Implement graceful degradation:
async get < T >( key : string ): Promise < T | null > {
try {
const value = await this . getAsync ( key );
return value ? JSON . parse ( value ) : null ;
} catch (error) {
this.logger. error ( `Cache error: ${ error . message } ` , 'Cache' );
// Return null to trigger database query instead of crashing
return null;
}
}
Problem : Too many logs cause performance degradation and storage bloat.
Why it happens : Logging at DEBUG level in production.
Solution : Use environment-based log levels:
NODE_ENV = "production"
LOG_LEVEL = "info" # Only log info, warn, error, fatal
Problem : Log files grow indefinitely, consuming disk space.
Why it happens : Forgetting to configure log rotation.
Solution : Winston daily rotate file already handles this, but verify configuration:
new DailyRotateFile ({
filename : path . join ( logsDir , 'application-%DATE%.log' ),
datePattern : 'YYYY-MM-DD' ,
maxSize : '20m' , // Rotate when file reaches 20MB
maxDays : '14d' , // Delete logs older than 14 days
compress : true , // Compress rotated files
})
Problem : Multiple requests update the same resource, causing cache inconsistency.
Why it happens : Not using atomic operations for cache updates.
Solution : Use Redis transactions or implement versioning:
const cacheKey = `post: ${ id } :v ${ version } ` ;
await this . cache . set ( cacheKey , data , ttl );
// When updating, increment version
const newVersion = version + 1 ;
await this . cache . delete ( `post: ${ id } :v ${ version } ` );
Problem : Database grows with expired refresh tokens.
Why it happens : No cleanup mechanism for expired tokens.
Solution : Implement a cleanup job:
@ Cron ( '0 0 * * *' ) // Run daily at midnight
async cleanupExpiredTokens () {
const deleted = await this . prisma . refreshToken . deleteMany ({
where : {
expiresAt : {
lt : new Date (),
},
},
});
this . logger . log ( `Cleaned up ${ deleted . count } expired tokens` , 'Auth' );
}
Use structured logging that's compatible with Loki and ELK Stack:
Structured logging for Loki this . logger . log (
'User authentication successful' ,
JSON . stringify ({
userId : user . id ,
email : user . email ,
role : user . role ,
timestamp : new Date (). toISOString (),
service : 'auth' ,
}),
);
This JSON format is easily parsed by Loki and ELK for aggregation and analysis.
Create src/health/health.controller.ts:
src/health/health.controller.ts import { Controller , Get } from '@nestjs/common' ;
import { PrismaService } from '../prisma/prisma.service' ;
import { CacheService } from '../cache/cache.service' ;
@ Controller ( 'health' )
export class HealthController {
constructor (
private prisma : PrismaService ,
private cache : CacheService ,
) {}
@ Get ()
async check () {
try {
// Check database
await this . prisma . $queryRaw `SELECT 1` ;
// Check cache
await this . cache . set ( 'health-check' , { ok : true }, 10 );
return {
status : 'healthy' ,
timestamp : new Date (). toISOString (),
services : {
database : 'ok' ,
cache : 'ok' ,
},
};
} catch ( error ) {
return {
status : 'unhealthy' ,
timestamp : new Date (). toISOString (),
error : error . message ,
};
}
}
}
Create src/posts/dto/create-post.dto.ts:
src/posts/dto/create-post.dto.ts import { IsString , IsNotEmpty , MinLength , MaxLength } from 'class-validator' ;
export class CreatePostDto {
@ IsString ()
@ IsNotEmpty ()
@ MinLength ( 5 )
@ MaxLength ( 200 )
title : string ;
@ IsString ()
@ IsNotEmpty ()
@ MinLength ( 10 )
@ MaxLength ( 5000 )
content : string ;
}
Create src/common/interceptors/logging.interceptor.ts:
src/common/interceptors/logging.interceptor.ts import {
Injectable ,
NestInterceptor ,
ExecutionContext ,
CallHandler ,
} from '@nestjs/common' ;
import { Observable } from 'rxjs' ;
import { tap , catchError } from 'rxjs/operators' ;
import { Request , Response } from 'express' ;
import { CustomLoggerService } from '../logger/logger.service' ;
@ Injectable ()
export class LoggingInterceptor implements NestInterceptor {
constructor ( private logger : CustomLoggerService ) {}
intercept ( context : ExecutionContext , next : CallHandler ) : Observable < any > {
const request = context . switchToHttp (). getRequest < Request >();
const response = context . switchToHttp (). getResponse < Response >();
const startTime = Date . now ();
return next . handle (). pipe (
tap (( data ) => {
const duration = Date . now () - startTime ;
this . logger . log (
` ${ request . method } ${ request . path } - ${ response . statusCode } ( ${ duration } ms)` ,
'HTTP' ,
);
}),
catchError (( error ) => {
const duration = Date . now () - startTime ;
this . logger . error (
` ${ request . method } ${ request . path } - ${ error . status || 500 } ( ${ duration } ms)` ,
error . message ,
'HTTP' ,
);
throw error ;
}),
);
}
}
Different data types need different cache durations:
const CACHE_TTL = {
USER_PROFILE : 600 , // 10 minutes
POST_LIST : 300 , // 5 minutes
POST_DETAIL : 900 , // 15 minutes
SYSTEM_CONFIG : 3600 , // 1 hour
TEMPORARY_DATA : 60 , // 1 minute
};
// Usage
await this . cache . set ( cacheKey , data , CACHE_TTL . POST_LIST );
Implement tiered rate limiting:
const RATE_LIMITS = {
GLOBAL : { windowMs : 15 * 60 * 1000 , max : 100 }, // 100 req/15min
AUTH : { windowMs : 15 * 60 * 1000 , max : 5 }, // 5 login attempts
API : { windowMs : 60 * 1000 , max : 30 }, // 30 req/minute
UPLOAD : { windowMs : 60 * 60 * 1000 , max : 10 }, // 10 uploads/hour
};
Configure Prisma for optimal connection pooling:
prisma/schema.prisma (connection pool) datasource db {
provider = "postgresql"
url = env ( "DATABASE_URL" )
}
// In .env.local
DATABASE_URL="postgresql: //user:password@localhost:5432/nestjs_api?schema=public&connection_limit=20"
Use different configurations per environment:
Environment configuration const config = {
development : {
logLevel : 'debug' ,
cacheTtl : 60 ,
rateLimitMax : 1000 ,
},
production : {
logLevel : 'info' ,
cacheTtl : 3600 ,
rateLimitMax : 100 ,
},
};
const env = process . env . NODE_ENV || 'development' ;
const appConfig = config [ env ];
Add request IDs for distributed tracing:
import { v4 as uuidv4 } from 'uuid' ;
@ Injectable ()
export class RequestIdMiddleware implements NestMiddleware {
use ( req : Request , res : Response , next : NextFunction ) {
const requestId = req . headers [ 'x-request-id' ] || uuidv4 ();
req . id = requestId ;
res . setHeader ( 'x-request-id' , requestId );
next ();
}
}
Then include in all logs:
Include request ID in logs this . logger . log ( message , JSON . stringify ({
requestId : req . id ,
userId : req . user ?. id ,
... otherData ,
}));
If you're building a microservices system, consider:
Using message queues (RabbitMQ, Kafka) instead of direct HTTP calls
Implementing service mesh (Istio, Linkerd) for cross-service communication
Distributed tracing (Jaeger, Zipkin) instead of request IDs
For WebSocket-heavy applications:
Use Socket.io or WebSocket libraries directly
Implement pub/sub patterns with Redis
Consider GraphQL subscriptions instead of REST
If you're building a simple CRUD API with minimal traffic:
Skip Redis caching initially
Use simpler logging (console.log is fine)
Implement rate limiting only if needed
Add complexity as you scale
NestJS is heavyweight for serverless:
Use lightweight frameworks like Express or Fastify
Avoid persistent connections (Redis, database pools)
Use managed services for caching and logging
For ultra-low latency requirements:
Consider compiled languages (Go, Rust)
Use in-memory databases (Redis, Memcached)
Implement custom optimization strategies
You now have a production-grade RESTful API template with enterprise features. Let's recap what you've built:
Logging : Winston with daily rotation, compression, and expiration policies. Structured JSON logs compatible with Loki and ELK Stack for observability.
Authentication & Authorization : JWT-based auth with refresh tokens stored in httpOnly cookies. RBAC guards ensure users can only access resources they're authorized for.
Rate Limiting & Throttling : Global rate limiting protects against abuse. Per-endpoint throttling provides granular control. Redis-backed throttling scales across multiple instances.
Caching : Redis caching dramatically reduces database load. Cache invalidation patterns ensure data consistency. TTL-based strategies optimize memory usage.
Database : Prisma ORM provides type-safe database access. Migrations ensure schema consistency. Seeders populate development data quickly.
Deploy to production : Use Docker and Kubernetes for orchestration
Monitor and observe : Set up Loki/ELK for log aggregation, Prometheus for metrics
Load test : Use tools like k6 or Apache JMeter to verify rate limiting and caching
Implement API versioning : Plan for backward compatibility as your API evolves
Add API documentation : Use Swagger/OpenAPI for interactive documentation
Implement webhooks : Allow clients to subscribe to events
Add GraphQL : Consider GraphQL alongside REST for flexible queries
The foundation is solid. Scale it with confidence.
Tip
Keep your .env.local file out of version control. Use .env.example as a template for team members. Rotate JWT secrets regularly in production.