Logo
Readers on this post right now:0
A Fundamental Guide to NestJS - How To Get Started
30 Oct 2026 45 min read

A Fundamental Guide to NestJS - How To Get Started

An overview of NestJS fundamentals and essential development concepts, covering everything from application architecture and database integration to authentication, security, testing, and deployment through practical examples.

We are going to build a real REST API together, one small concept at a time. By the final step, our system will have a clean module structure, a database, validation, configuration, middleware, guards, interceptors, exception handling, JWT authentication, role-based access, security hardening, tests, and a Docker image ready for production.

Every step follows the same rhythm. First we look at the idea in an animated diagram. Then we write the code. Then we recap what we just learned, so the next step always starts from solid ground.

Our running project is a Cats API. It is small enough to hold in our heads and rich enough to show every important NestJS feature.

Versions used in this guide: NestJS 11 (Express 5 under the hood), TypeScript, and Node.js 20 or newer. Node 22 LTS is a comfortable choice.

Step 1: What is NestJS?

01 what is nestjs

NestJS is a framework for building server-side applications with Node.js and TypeScript. It does not replace Node.js; it runs on Node.js and provides an application architecture on top of the underlying HTTP platform.

Look at the stack in the animation from the bottom up:

  1. Node.js runs our JavaScript code. We usually write that code in TypeScript, which is compiled to JavaScript before it runs.
  2. Express (or Fastify) is the HTTP server doing the low-level work. By default, Nest uses Express.
  3. NestJS adds structure: modules, dependency injection, decorators, and a clear request pipeline.
  4. Our application is then mostly controllers and services, which is exactly where we want to spend our time.

Why are we choosing it? Four reasons:

  • TypeScript first. We get autocompletion and compile-time safety everywhere.
  • Opinionated structure. Every Nest project looks familiar, so teams move quickly and new people find their way around.
  • Dependency injection built in. We stop creating objects by hand and let the framework wire them up.
  • Easy to test. Because dependencies are injected, replacing them with mocks is simple.

Quick recap

  • NestJS is a layer on top of Node.js and Express/Fastify.
  • It gives our code structure, not just routes.

Step 2: Setting up with the Nest CLI

02 setup cli

We are going to scaffold our project with the official CLI. First we make sure Node.js is recent enough:

node --version   # v20 or newer

Then we install the CLI and create the project:

npm install -g @nestjs/cli
nest new cats-app
cd cats-app

The CLI asks which package manager we prefer. We pick one and let it install dependencies. Now we start the development server:

npm run start:dev

Opening http://localhost:3000 shows Hello World!. Our first API is alive, and because we used start:dev, it reloads automatically whenever we save a file.

Next, we generate a whole feature in one command. This is the CLI at its best:

nest g resource cats

We choose REST API as the transport layer and answer Yes to generating CRUD entry points. The CLI creates:

CREATE src/cats/cats.controller.ts
CREATE src/cats/cats.module.ts
CREATE src/cats/cats.service.ts
CREATE src/cats/dto/create-cat.dto.ts
CREATE src/cats/dto/update-cat.dto.ts
CREATE src/cats/entities/cat.entity.ts
UPDATE src/app.module.ts

It also registered the new module in app.module.ts for us.

The scripts we will use most often live in package.json:

ScriptWhat it does
npm run start:devRuns the app in watch mode
npm run start:prodRuns the compiled app from dist/
npm run buildCompiles TypeScript to JavaScript
npm run testRuns unit tests
npm run test:e2eRuns end-to-end tests
npm run lintChecks code style

Quick recap

  • nest new creates a project, nest g resource creates a feature.
  • start:dev gives us live reload.

Step 3: Understanding the project structure

03 project structure

Before we write more code, we are going to learn what each file is for. After the previous step, our project looks like this:

cats-app/
├── src/
│   ├── main.ts
│   ├── app.module.ts
│   ├── app.controller.ts
│   ├── app.service.ts
│   └── cats/
│       ├── cats.module.ts
│       ├── cats.controller.ts
│       ├── cats.service.ts
│       ├── dto/
│       │   ├── create-cat.dto.ts
│       │   └── update-cat.dto.ts
│       └── entities/
│           └── cat.entity.ts
├── test/
│   └── app.e2e-spec.ts
├── nest-cli.json
├── tsconfig.json
└── package.json

Here is what each important file does:

FileRole
main.tsThe entry point. Creates the app and starts listening on a port.
app.module.tsThe root module. It imports feature modules so Nest can find everything.
cats.module.tsA feature module that groups the cat controller and service.
cats.controller.tsMaps incoming HTTP requests to handler methods.
cats.service.tsHolds business logic. A provider that Nest can inject.
create-cat.dto.tsDescribes the shape of incoming data.
cat.entity.tsMaps a class to a database table.

The entry point is short enough to read in one breath:

// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(process.env.PORT ?? 3000);
}
bootstrap();

NestFactory.create(AppModule) builds the whole application starting from the root module. Everything else hangs off that one line.

Quick recap

  • main.ts boots the app, app.module.ts is the root of the module graph.
  • Each feature gets its own folder with a module, controller and service.

Step 4: The building blocks at a glance

04 building blocks

NestJS has a lot of vocabulary, but eight ideas cover almost everything we are going to build. We meet them here once, and then we add each one to our system in its own step.

BlockIts jobYou will recognize it by
ModuleOrganizes related code into one cohesive unit@Module()
ControllerReceives requests and returns responses@Controller()
ProviderInjectable logic such as services and repositories@Injectable()
MiddlewareRuns before the route handler: logging, CORSNestMiddleware
GuardDecides if a request may continue: auth and roles@UseGuards()
InterceptorWraps handlers to map, time or cache responses@UseInterceptors()
PipeValidates and transforms incoming data@UsePipes()
FilterCatches exceptions and shapes error responses@Catch()

A helpful way to remember them: modules organize, controllers route, providers work, and the remaining four (middleware, guards, interceptors, pipes) plus filters form the pipeline that every request travels through.

Quick recap

  • Eight blocks, one job each.
  • We are going to add them one at a time, in the order a request meets them.

Part 2: Core concepts

Step 5: Modules, organizing our code

05 modules

A module is a class decorated with @Module(). It tells Nest which controllers and providers belong together. Every app has at least one module, the root.

The generator already created our feature module:

// src/cats/cats.module.ts
import { Module } from '@nestjs/common';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';

@Module({
  controllers: [CatsController],
  providers: [CatsService],
  exports: [CatsService],
})
export class CatsModule {}

The four properties of @Module() are worth memorizing:

PropertyMeaning
controllersControllers that belong to this module
providersServices and other injectables created by this module
importsOther modules whose exported providers we want to use
exportsThe subset of our providers that other modules may use

The root module then imports our feature module:

// src/app.module.ts
@Module({
  imports: [CatsModule],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

Here is the part that surprises many newcomers. Providers are private to their module by default. If another module needs CatsService, two things must happen: CatsModule must export it, and the other module must import CatsModule.

We are going to use exactly this pattern later when authentication needs the users service:

@Module({
  providers: [UsersService],
  exports: [UsersService],      // 1. UsersModule shares it
})
export class UsersModule {}

@Module({
  imports: [UsersModule],       // 2. AuthModule asks for it
  providers: [AuthService],
})
export class AuthModule {}

A small tip: when a module such as configuration is needed everywhere, we can mark it @Global() or pass isGlobal: true, and we will do that in Step 13.

Quick recap

  • Modules group controllers and providers.
  • To share a provider: exports on one side, imports on the other.

Step 6: Controllers and routing

06 controllers routing

Controllers receive requests and send back responses. We are going to declare routes with decorators, and Nest builds the routing table for us.

// src/cats/cats.controller.ts
import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common';

@Controller('cats')
export class CatsController {
  @Get()
  findAll() {
    return 'This action returns all cats';
  }

  @Get(':id')
  findOne(@Param('id') id: string) {
    return `This action returns cat #${id}`;
  }

  @Post()
  create(@Body() body: unknown) {
    return 'This action adds a new cat';
  }

  @Delete(':id')
  remove(@Param('id') id: string) {
    return `This action removes cat #${id}`;
  }
}

The prefix in @Controller('cats') combines with each method decorator:

RequestHandler
GET /catsfindAll()
GET /cats/7findOne('7')
POST /catscreate(body)
DELETE /cats/7remove('7')

A few facts that save us debugging time:

  • Whatever the method returns becomes the response body. Objects are sent as JSON automatically.
  • The default status is 200, except POST, which defaults to 201. We can change it with @HttpCode(204).
  • Routes are matched in declaration order. A fixed path such as @Get('featured') must be declared before @Get(':id'), otherwise :id swallows it.

Quick recap

  • @Controller('cats') sets a prefix, @Get()/@Post() set the method and path.
  • The return value is the response.

Step 7: Request decorators

07 request decorators

A request carries data in several places. Instead of digging through a raw request object, we are going to ask Nest for exactly what we need with parameter decorators.

import { Body, Headers, Param, Post, Query } from '@nestjs/common';

// A demo endpoint to show every decorator in one place.
@Post(':id')
update(
  @Param('id') id: string,
  @Query('sort') sort: string,
  @Headers('authorization') auth: string,
  @Body() dto: UpdateCatDto,
) {
  return { id, sort, hasToken: !!auth, dto };
}

For a request like this:

POST /cats/7?sort=asc
Authorization: Bearer eyJhbGci...
Content-Type: application/json

{ "name": "Tom", "age": 3 }

Nest hands our method the following:

DecoratorReadsValue in our example
@Param('id')A route parameter'7'
@Query('sort')The query string'asc'
@Headers('authorization')A request header'Bearer eyJhbGci...'
@Body()The JSON body{ name: 'Tom', age: 3 }

Notice that id arrives as a string, even though it looks like a number. That is not a bug, it is how HTTP works, and in Step 10 we are going to solve it with a pipe.

We can also grab the whole request with @Req(), but we avoid it when possible. The specific decorators keep our handlers short, readable and independent of Express.

Quick recap

  • @Param, @Query, @Headers and @Body extract exactly what a handler needs.
  • Route parameters and query-string values arrive as strings. JSON request bodies can contain strings, numbers, booleans, arrays, and objects.

Step 8: Providers and services

08 providers services

Controllers should stay thin. They receive a request, call something, and return the result. The real work belongs in providers, and the most common provider is a service.

A provider is any class marked with @Injectable(). That decorator tells Nest: "this class can be created and handed to others". We start with an in-memory version of our service so we can focus on the idea:

// src/cats/cats.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';

export interface Cat {
  id: number;
  name: string;
  age: number;
}

@Injectable()
export class CatsService {
  private cats: Cat[] = [];
  private nextId = 1;

  findAll(): Cat[] {
    return this.cats;
  }

  findOne(id: number): Cat {
    const cat = this.cats.find((c) => c.id === id);
    if (!cat) throw new NotFoundException(`Cat #${id} not found`);
    return cat;
  }

  create(data: Omit<Cat, 'id'>): Cat {
    const cat = { id: this.nextId++, ...data };
    this.cats.push(cat);
    return cat;
  }

  remove(id: number): void {
    this.cats = this.cats.filter((c) => c.id !== id);
  }
}

Writing the class is only half the job. We also register it in the module's providers array, so Nest knows it exists:

@Module({
  controllers: [CatsController],
  providers: [CatsService],   // registered here
})
export class CatsModule {}

After registration, Nest can create the service once and share it wherever it is requested.

Services are not the only providers. Repositories, factories, helpers and even plain configuration values are providers too. The rule is simple: if Nest should create it and inject it, it is a provider.

Quick recap

  • @Injectable() plus an entry in providers makes a class injectable.
  • Business logic lives in services, not controllers.

Step 9: Dependency injection

09 dependency injection

Now we connect the controller to the service, and this is where NestJS shows its character. We never write new CatsService(). We ask for what we need in the constructor, and Nest delivers it.

@Controller('cats')
export class CatsController {
  constructor(private readonly catsService: CatsService) {}

  @Get()
  findAll() {
    return this.catsService.findAll();
  }

  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.catsService.findOne(Number(id)); // Prefer ParseIntPipe in a real route.
  }
}

Here is what happens behind the scenes, step by step, exactly as the animation shows:

  1. At startup, Nest builds an IoC container (Inversion of Control) holding every registered provider.
  2. It reads the constructor of CatsController and sees that a CatsService is required.
  3. It looks the provider up in the container, creates it if needed, and passes it in.
  4. If another class asks for CatsService, it receives the same instance.

That last point matters. By default, providers are singletons within their module/application context: one shared instance is reused rather than created for every request. That is efficient, but it also means mutable state in a singleton service (like our in-memory array) is shared across requests. Do not use an in-memory array as production persistence.

Why is this better than creating objects by hand?

  • We change a dependency in one place, not in every file that uses it.
  • Tests can swap a real service for a mock without touching the controller.
  • Nest knows the dependency graph, so it can create things in the right order.

Custom providers

The shorthand providers: [CatsService] is equivalent to this class-provider declaration:

{ provide: CatsService, useClass: CatsService }

Writing it out lets us do more. We can provide plain values, or build a provider with a factory:

providers: [
  CatsService,
  { provide: 'APP_NAME', useValue: 'Cats API' },
  {
    provide: 'PAGE_SIZE',
    useFactory: (config: ConfigService) => config.get<number>('pageSize'),
    inject: [ConfigService],
  },
],

Values registered under a string token are requested with @Inject():

constructor(@Inject('APP_NAME') private readonly appName: string) {}

A note on scopes

The default scope is the singleton. Nest can also create a provider per request (Scope.REQUEST) or per consumer (Scope.TRANSIENT). We reach for those only when a provider truly needs per-request state, because they cost performance.

Quick recap

  • We declare dependencies in the constructor, and Nest injects them.
  • Providers are singletons by default.
  • useClass, useValue and useFactory give us full control when needed.

Part 3: Data and input

Step 10: Pipes

10 pipes

Remember the problem from Step 7? Route parameters arrive as strings, but our service expects a number. We are going to fix it with a pipe.

A pipe runs just before the handler and does one of two jobs:

  • Transformation: convert input into the form we want.
  • Validation: check the input and throw if it is wrong.
import { ParseIntPipe } from '@nestjs/common';

@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
  return this.catsService.findOne(id);
}

Now GET /cats/42 gives our handler the number 42. And GET /cats/abc never reaches our handler. Nest answers with a clear error:

{
  "message": "Validation failed (numeric string is expected)",
  "error": "Bad Request",
  "statusCode": 400
}

Nest ships with several ready-made pipes:

PipeWhat it does
ParseIntPipeString to integer
ParseFloatPipeString to float
ParseBoolPipe'true' / 'false' to boolean
ParseUUIDPipeChecks that a value is a UUID
ParseEnumPipeChecks that a value belongs to an enum
ParseArrayPipeParses and validates arrays
DefaultValuePipeSupplies a default when a value is missing
ValidationPipeValidates whole objects (our next step)

When none of them fits, writing our own is easy:

import { ArgumentMetadata, Injectable, PipeTransform } from '@nestjs/common';

@Injectable()
export class TrimPipe implements PipeTransform<string, string> {
  transform(value: string, metadata: ArgumentMetadata) {
    return typeof value === 'string' ? value.trim() : value;
  }
}

Pipes can be applied to a single parameter (as above), to a whole route, to a controller, or globally.

Quick recap

  • Pipes transform and validate input right before the handler.
  • A failed pipe returns 400 Bad Request automatically.

Step 11: DTOs and validation

11 dto validation

Validating one parameter is useful. Validating a whole request body is essential. We are going to describe our data once, in a class called a DTO (Data Transfer Object), and let Nest enforce it everywhere.

First we install the libraries behind ValidationPipe:

npm install class-validator class-transformer @nestjs/mapped-types

Now we write the DTO. Each decorator is a rule:

// src/cats/dto/create-cat.dto.ts
import { IsInt, IsOptional, IsString, Min, MaxLength } from 'class-validator';

export class CreateCatDto {
  @IsString()
  @MaxLength(50)
  name: string;

  @IsInt()
  @Min(0)
  age: number;

  @IsOptional()
  @IsString()
  breed?: string;
}

For updates, every field becomes optional. We do not want to copy the rules, so we derive the class:

// src/cats/dto/update-cat.dto.ts
import { PartialType } from '@nestjs/mapped-types';
import { CreateCatDto } from './create-cat.dto';

export class UpdateCatDto extends PartialType(CreateCatDto) {}

Then we switch validation on for the whole application in main.ts:

import { ValidationPipe } from '@nestjs/common';

app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,            // remove properties that are not in the DTO
    forbidNonWhitelisted: true, // reject the request if unknown properties are present
    transform: true,            // turn plain JSON into DTO instances
  }),
);

Finally, the controller uses the DTO as the type of the body:

@Post()
create(@Body() createCatDto: CreateCatDto) {
  return this.catsService.create(createCatDto);
}

A valid body such as { "name": "Tom", "age": 2 } passes straight through. An invalid one, such as { "age": -1 }, never reaches the service. Nest replies with a precise list of what went wrong:

{
  "message": [
    "name must be a string",
    "age must not be less than 0"
  ],
  "error": "Bad Request",
  "statusCode": 400
}

The whitelist option deserves a moment. Without it, extra fields such as "role": "admin" may reach application code if they are not otherwise handled. With whitelist: true, properties without validation decorators are removed; with forbidNonWhitelisted: true, the request is rejected instead. This helps reduce mass-assignment risks, but authorization must still be enforced in the service layer.

Quick recap

  • DTO classes plus class-validator decorators define the rules once.
  • ValidationPipe enforces them globally, and whitelist blocks unexpected fields.

Step 12: Database with TypeORM

12 database typeorm

Our in-memory array forgets everything when the server restarts. We are going to add a real database. We use PostgreSQL with TypeORM here, but Nest works just as well with Prisma, Mongoose, MikroORM or Drizzle.

We start a local PostgreSQL with Docker and install the packages:

docker run --name cats-db \
  -e POSTGRES_PASSWORD=postgres \
  -e POSTGRES_DB=cats \
  -p 5432:5432 -d postgres:17

npm install @nestjs/typeorm typeorm pg

The entity

An entity is a class that maps to a table:

// src/cats/entities/cat.entity.ts
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';

@Entity()
export class Cat {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;

  @Column()
  age: number;

  @Column({ nullable: true })
  breed?: string;
}

Connecting the database

We configure the connection once in the root module. For now, the values are written directly. In the next step we are going to move them to environment variables.

// src/app.module.ts
import { TypeOrmModule } from '@nestjs/typeorm';

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'postgres',
      host: 'localhost',
      port: 5432,
      username: 'postgres',
      password: 'postgres',
      database: 'cats',
      autoLoadEntities: true,
      synchronize: true, // development only!
    }),
    CatsModule,
  ],
})
export class AppModule {}

Careful: synchronize: true rebuilds tables to match our entities, which is handy while learning and dangerous in production. For real deployments we use migrations.

Registering the entity in its feature module

// src/cats/cats.module.ts
@Module({
  imports: [TypeOrmModule.forFeature([Cat])],
  controllers: [CatsController],
  providers: [CatsService],
})
export class CatsModule {}

The service uses a repository

Look how little changes. The array becomes a repository, and the repository is injected like any other provider:

// src/cats/cats.service.ts
@Injectable()
export class CatsService {
  constructor(
    @InjectRepository(Cat)
    private readonly catsRepository: Repository<Cat>,
  ) {}

  findAll() {
    return this.catsRepository.find();
  }

  async findOne(id: number) {
    const cat = await this.catsRepository.findOneBy({ id });
    if (!cat) throw new NotFoundException(`Cat #${id} not found`);
    return cat;
  }

  create(dto: CreateCatDto) {
    const cat = this.catsRepository.create(dto);
    return this.catsRepository.save(cat);
  }

  async update(id: number, dto: UpdateCatDto) {
    const cat = await this.findOne(id);
    Object.assign(cat, dto);
    return this.catsRepository.save(cat);
  }

  async remove(id: number) {
    const cat = await this.findOne(id);
    await this.catsRepository.remove(cat);
  }
}

The controller does not change at all, apart from awaiting promises, which Nest does for us. That is the payoff of keeping controllers thin: we swapped the whole storage layer without touching the HTTP layer.

Quick recap

  • Entities map classes to tables, repositories run queries.
  • forRoot connects, forFeature registers entities for a module.
  • synchronize is for development, migrations are for production.

Step 13: Configuration and environment variables

13 configuration

Right now our database password sits in the source code. We are going to move every setting and secret into environment variables, and read them through Nest's configuration module.

npm install @nestjs/config

We create a .env file in the project root:

PORT=3000
NODE_ENV=development

DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=postgres
DB_NAME=cats

JWT_SECRET=change-me-to-a-long-random-string

Two housekeeping rules come immediately. We add .env to .gitignore, so secrets never reach the repository, and we commit a .env.example with fake values so teammates know which variables exist.

A typed configuration file

Environment variables are always strings. Nest does not convert them for us, so we do the parsing once, in one place:

// src/config/configuration.ts
export default () => ({
  port: parseInt(process.env.PORT ?? '3000', 10),
  nodeEnv: process.env.NODE_ENV ?? 'development',
  database: {
    host: process.env.DB_HOST,
    port: parseInt(process.env.DB_PORT ?? '5432', 10),
    user: process.env.DB_USER,
    password: process.env.DB_PASSWORD,
    name: process.env.DB_NAME,
  },
  jwt: {
    secret: process.env.JWT_SECRET,
  },
});

Loading it globally

// src/app.module.ts
@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true, load: [configuration] }),
    TypeOrmModule.forRootAsync({
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        type: 'postgres',
        host: config.get<string>('database.host'),
        port: config.get<number>('database.port'),
        username: config.get<string>('database.user'),
        password: config.get<string>('database.password'),
        database: config.get<string>('database.name'),
        autoLoadEntities: true,
        synchronize: config.get<string>('nodeEnv') === 'development',
      }),
    }),
    CatsModule,
  ],
})
export class AppModule {}

forRootAsync is the pattern to remember: it waits for ConfigService to be ready, then builds the database options from it.

Any service can now ask for configuration through dependency injection:

constructor(private readonly config: ConfigService) {}

const port = this.config.get<number>('port');

And main.ts can listen on the configured port:

const config = app.get(ConfigService);
await app.listen(config.get<number>('port') ?? 3000);

Note: get<number>() only tells TypeScript what type to expect. The actual conversion is the parseInt we wrote in the configuration file. For stricter checks, configure startup validation with @nestjs/config (for example, a Joi schema or a custom validation function) so the app refuses to boot when required values are missing or invalid. Also keep synchronize disabled outside local development and use migrations for schema changes.

Quick recap

  • Settings and secrets live in the environment, never in git.
  • ConfigModule loads them, ConfigService reads them, forRootAsync connects them to other modules.

Part 4: The request pipeline

Step 14: Middleware

14 middleware

We have arrived at the request pipeline, the series of stations every request passes through. The first station is middleware.

Middleware is a function that runs before the route handler. It can read or change the request and response, and it must either end the response or call next() to continue. That makes it a good fit for work that is not specific to one route, such as logging.

We are going to log every request to our cats endpoints:

// src/common/logger.middleware.ts
import { Injectable, Logger, NestMiddleware } from '@nestjs/common';
import { NextFunction, Request, Response } from 'express';

@Injectable()
export class LoggerMiddleware implements NestMiddleware {
  private readonly logger = new Logger('HTTP');

  use(req: Request, res: Response, next: NextFunction) {
    const { method, originalUrl } = req;
    const startedAt = Date.now();

    res.on('finish', () => {
      this.logger.log(
        `${method} ${originalUrl} ${res.statusCode} +${Date.now() - startedAt}ms`,
      );
    });

    next(); // hand the request to the next station
  }
}

Middleware is attached from inside a module, by implementing NestModule:

// src/app.module.ts
import { MiddlewareConsumer, NestModule } from '@nestjs/common';

export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(LoggerMiddleware).forRoutes('cats');
  }
}

Now every call to /cats prints a line such as GET /cats/7 200 +4ms. In a complete file, remember to import LoggerMiddleware and make AppModule implement NestModule; the snippet above shows the relevant registration method.

Nest middleware is equivalent to Express middleware, so we can also use well-known packages here. Simple cases can use a plain function (export function logger(req, res, next) { ... }) and be registered with app.use(logger) in main.ts for every route.

Express 5 note: Nest 11 runs on Express 5, which requires wildcards in route strings to be named. When we need a catch-all pattern, we write something like '{*splat}' rather than a bare '*'. Targeting a path ('cats') or a controller class (forRoutes(CatsController)) avoids the issue.

Quick recap

  • Middleware runs before guards. It must call next() or end the response; otherwise the request hangs.
  • It suits cross-cutting work like logging, CORS and body parsing.

Step 15: Guards

15 guards

We are going to add guards to our system. A guard answers exactly one question: is this request allowed to continue? It returns true to let the request through, or false to stop it.

Why not do this in middleware? Because a guard has access to the execution context. It knows which controller and which method will run next, and that knowledge is what makes role checks and metadata-based rules possible later.

We start with the simplest version: a request is allowed only if it carries an Authorization header.

// src/auth/auth.guard.ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Request } from 'express';

@Injectable()
export class AuthGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest<Request>();
    return !!request.headers.authorization;
  }
}

Then we attach it to the route we want to protect:

import { UseGuards } from '@nestjs/common';

@UseGuards(AuthGuard)
@Delete(':id')
remove(@Param('id', ParseIntPipe) id: number) {
  return this.catsService.remove(id);
}

Now a DELETE /cats/7 with a token reaches the handler, and one without it is stopped. When a guard returns false, Nest answers with 403 Forbidden.

A guard can be bound at three levels:

LevelHow
One route@UseGuards(AuthGuard) on a method
One controller@UseGuards(AuthGuard) on the class
The whole app{ provide: APP_GUARD, useClass: AuthGuard } in a module

For global guards, the APP_GUARD provider is the better choice over app.useGlobalGuards(), because it lets the guard use dependency injection. We are going to need that in Step 19.

This first guard only checks that a header exists, which anybody can fake. Do not worry. We are going to upgrade this very same guard to verify a signed token in Step 19.

Quick recap

  • A guard decides allow or deny, using canActivate().
  • Guards run after middleware and know which handler is about to run.

Step 16: Interceptors

16 interceptors

Guards decide whether a request enters. Interceptors wrap the handler itself. They get a chance to act before the handler runs and again after it returns, which is perfect for shaping responses, measuring time, caching and timeouts.

An interceptor receives a next handler. Calling next.handle() runs the rest of the pipeline and gives us back an RxJS stream of the result, which we can transform.

We are going to make every successful response share one shape, { data: ... }, and log how long each request took:

// src/common/transform.interceptor.ts
import {
  CallHandler,
  ExecutionContext,
  Injectable,
  Logger,
  NestInterceptor,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map, tap } from 'rxjs/operators';

@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, { data: T }> {
  private readonly logger = new Logger('Timing');

  intercept(context: ExecutionContext, next: CallHandler<T>): Observable<{ data: T }> {
    const startedAt = Date.now();

    return next.handle().pipe(
      map((data) => ({ data })),
      tap(() => this.logger.log(`Handled in ${Date.now() - startedAt}ms`)),
    );
  }
}

A handler that returns an array of cats now produces:

{ "data": [ { "id": 1, "name": "Tom", "age": 2 } ] }

A consistent shape makes life easier for whoever consumes our API, since every success response is parsed the same way. Apply this interceptor only if that response envelope is part of your API contract; wrapping responses can affect clients that expect the original payload.

We bind interceptors just like guards:

@UseInterceptors(TransformInterceptor)       // one controller or route
app.useGlobalInterceptors(new TransformInterceptor()); // the whole app

Other jobs that fit this pattern well:

  • Timeouts: pipe the stream through RxJS timeout() to fail slow requests.
  • Caching: return a stored value without calling the handler at all.
  • Mapping errors: use catchError() to translate low-level errors.

Quick recap

  • Interceptors wrap the handler, running logic before and after it.
  • RxJS operators like map and tap transform the outgoing result.

Step 17: Exception filters

17 exception filters

Things go wrong: a cat does not exist, a token expired, a database is down. We are going to throw errors freely in our code and handle them in one place with an exception filter.

Nest has built-in exceptions that already carry the right HTTP status:

ExceptionStatus
BadRequestException400
UnauthorizedException401
ForbiddenException403
NotFoundException404
ConflictException409
InternalServerErrorException500

We already used one in our service: throw new NotFoundException(...). Out of the box, Nest turns it into a neat JSON response. A custom filter lets us decide the exact shape and add extra information:

// src/common/http-exception.filter.ts
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
import { Request, Response } from 'express';

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();

    const status = exception.getStatus();
    const body = exception.getResponse();
    const message =
      typeof body === 'string' ? body : (body as { message?: unknown }).message;

    response.status(status).json({
      statusCode: status,
      message,
      path: request.url,
      timestamp: new Date().toISOString(),
    });
  }
}

Using exception.getResponse() is deliberate. It keeps the detailed message array that ValidationPipe produces, instead of flattening it into one sentence.

Now when a cat is missing, the client receives a predictable shape:

{
  "statusCode": 404,
  "message": "Cat #99 not found",
  "path": "/cats/99",
  "timestamp": "2026-10-31T10:15:00.000Z"
}

We bind filters in the same familiar ways:

@UseFilters(HttpExceptionFilter)               // one route or controller
app.useGlobalFilters(new HttpExceptionFilter()); // the whole app

A filter with an empty @Catch() can catch all thrown exceptions, including unexpected errors that are not HttpExceptions. If you add one, log the full error on the server and return a generic 500 response to the client. Do not expose stack traces, SQL details, or secrets in production responses. The example above intentionally handles only HttpException instances.

Quick recap

  • We throw built-in exceptions, and filters turn them into consistent responses.
  • @Catch(HttpException) handles HTTP errors, a bare @Catch() handles everything.

Step 18: The request lifecycle

18 request lifecycle

We now know every station. Let's put them in order, because the order explains many surprises.

Incoming request
   |
   v
1. Middleware
2. Guards
3. Interceptors (inbound)
4. Pipes
5. Controller handler
6. Service
7. Interceptors (after the handler)
   |
   v
Response sent to the client

(Exception filters catch errors thrown at any stage above.)

Each stage has one clear question to answer:

StageThe question it answers
MiddlewareIs there generic work to do first? (logging, parsing)
GuardsIs this request allowed?
Interceptors (before)Do we need to start a timer or add context?
PipesIs the input valid, and in the right type?
Controller and serviceWhat should actually happen?
Interceptors (after)How should the response look?
Exception filtersHow should a failure be reported?

Two details are worth keeping in mind:

  • Global, then controller, then route. For guards, interceptors and pipes, the global ones run first, then those on the controller, then those on the route. Interceptors unwind in the opposite order on the way out.
  • Filters work from the other end. An exception is first offered to the filter closest to the failing route, then the controller's, then the global ones.

When something behaves strangely, we ask where in this list it lives. A request rejected before the handler is usually a guard or pipe. A response with the wrong shape is usually an interceptor. A confusing error body is a filter.

Quick recap

  • Middleware, guards, interceptors, pipes, handler, interceptors, with filters watching for errors.
  • Global first, then controller, then route.

Part 5: Security and shipping

Step 19: JWT authentication

19 jwt authentication

Time to give our API real identity. We are going to add login and protect our routes with JSON Web Tokens (JWT).

A JWT is a token with three parts: a header, a payload (claims such as the user id), and a signature. In this example, the server signs it with a secret and later verifies that signature and the token's expiry. A signed JWT is not encrypted by default, so do not put passwords or other secrets in its payload. The server does not need to store a session for this basic access-token flow, which is why it is commonly described as stateless.

Here is the flow we are going to build:

  1. The client sends credentials to POST /auth/login.
  2. AuthService checks the user and the password hash.
  3. JwtService signs a token and returns it.
  4. The client stores the token and sends Authorization: Bearer <token> on later requests.
  5. Our guard verifies the signature and attaches the user to the request.

We install what we need:

npm install @nestjs/jwt bcrypt
npm install -D @types/bcrypt

Users and password hashing

We never store plain passwords. We store a hash. To keep the focus on authentication, we keep users in memory here. This is demonstration-only: users disappear when the process restarts, and multiple server instances will not share the same user list. A real application should persist users in a database, enforce a unique email constraint, and use the repository pattern from Step 12.

// src/users/role.enum.ts
export enum Role {
  User = 'user',
  Admin = 'admin',
}
// src/users/users.service.ts
import { Injectable } from '@nestjs/common';
import * as bcrypt from 'bcrypt';
import { Role } from './role.enum';

export interface User {
  id: number;
  email: string;
  passwordHash: string;
  role: Role;
}

@Injectable()
export class UsersService {
  private users: User[] = [];
  private nextId = 1;

  async create(email: string, password: string, role: Role = Role.User) {
    const passwordHash = await bcrypt.hash(password, 10);
    const user: User = { id: this.nextId++, email, passwordHash, role };
    this.users.push(user);
    return user;
  }

  findByEmail(email: string) {
    return this.users.find((u) => u.email === email);
  }
}
// src/users/users.module.ts
@Module({
  providers: [UsersService],
  exports: [UsersService], // shared, exactly like we learned in Step 5
})
export class UsersModule {}

The auth service

// src/auth/auth.service.ts
import { ConflictException, Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { UsersService } from '../users/users.service';

@Injectable()
export class AuthService {
  constructor(
    private readonly usersService: UsersService,
    private readonly jwtService: JwtService,
  ) {}

  async register(email: string, password: string) {
    if (this.usersService.findByEmail(email)) {
      throw new ConflictException('Email is already registered');
    }
    const user = await this.usersService.create(email, password);
    return { id: user.id, email: user.email };
  }

  async signIn(email: string, password: string) {
    const user = this.usersService.findByEmail(email);
    if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
      throw new UnauthorizedException('Invalid credentials');
    }

    const payload = { sub: user.id, email: user.email, role: user.role };
    return { access_token: await this.jwtService.signAsync(payload) };
  }
}

Notice that both "unknown email" and "wrong password" produce the same error. That is intentional, so attackers cannot discover which emails are registered.

Credentials DTO and controller

// src/auth/dto/auth-credentials.dto.ts
import { IsEmail, IsString, MinLength } from 'class-validator';

export class AuthCredentialsDto {
  @IsEmail()
  email: string;

  @IsString()
  @MinLength(8)
  password: string;
}
// src/auth/auth.controller.ts
@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @Public()
  @Post('register')
  register(@Body() dto: AuthCredentialsDto) {
    return this.authService.register(dto.email, dto.password);
  }

  @Public()
  @HttpCode(HttpStatus.OK)
  @Post('login')
  signIn(@Body() dto: AuthCredentialsDto) {
    return this.authService.signIn(dto.email, dto.password);
  }

  @Get('profile')
  getProfile(@Req() req: Request) {
    return req['user'];
  }
}

Upgrading our guard

Here is the promised moment. We take the guard from Step 15 and make it verify a real token:

// src/auth/public.decorator.ts
import { SetMetadata } from '@nestjs/common';

export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
// src/auth/auth.guard.ts
import {
  CanActivate,
  ExecutionContext,
  Injectable,
  UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import { Request } from 'express';
import { IS_PUBLIC_KEY } from './public.decorator';

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(
    private readonly jwtService: JwtService,
    private readonly reflector: Reflector,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (isPublic) return true;

    const request = context.switchToHttp().getRequest<Request>();
    const token = this.extractTokenFromHeader(request);
    if (!token) throw new UnauthorizedException();

    try {
      request['user'] = await this.jwtService.verifyAsync(token);
    } catch {
      throw new UnauthorizedException();
    }
    return true;
  }

  private extractTokenFromHeader(request: Request): string | undefined {
    const [type, token] = request.headers.authorization?.split(' ') ?? [];
    return type === 'Bearer' ? token : undefined;
  }
}

Three upgrades happened at once. The guard now throws UnauthorizedException, giving us the correct 401 instead of 403. It verifies the signature and expiry through JwtService. And it respects a @Public() marker, read with Reflector, so we can make every route private by default and open only the ones that should be open.

Wiring the module

// src/auth/auth.module.ts
import { APP_GUARD } from '@nestjs/core';

@Module({
  imports: [
    UsersModule,
    JwtModule.registerAsync({
      global: true,
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        secret: config.get<string>('jwt.secret'),
        signOptions: { expiresIn: '15m' },
      }),
    }),
  ],
  controllers: [AuthController],
  providers: [AuthService, { provide: APP_GUARD, useClass: AuthGuard }],
})
export class AuthModule {}

The secret comes from configuration (Step 13), and tokens expire after fifteen minutes. Ensure the secret is a long, randomly generated value supplied by the deployment environment—not the example value from .env. Registering the guard with APP_GUARD protects the whole application. We then mark the read-only list as open:

@Public()
@Get()
findAll() {
  return this.catsService.findAll();
}

Trying it out

# 1. Register
curl -X POST http://localhost:3000/auth/register \
  -H 'Content-Type: application/json' \
  -d '{"email":"tom@cats.dev","password":"meow-meow-1"}'

# 2. Log in and copy the access_token
curl -X POST http://localhost:3000/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"tom@cats.dev","password":"meow-meow-1"}'

# 3. Call a protected route
curl http://localhost:3000/auth/profile \
  -H 'Authorization: Bearer <access_token>'

Without the header, step 3 returns 401 Unauthorized. With it, we get the token's payload back.

Where we can go from here: short-lived access tokens are commonly paired with refresh tokens, and some teams keep tokens in httpOnly cookies instead of headers. Nest also offers the @nestjs/passport package for strategy-based authentication. The guard we wrote is the same building block they all rely on.

Quick recap

  • Passwords are hashed with bcrypt, tokens are signed with JwtService.
  • One global guard protects everything, and @Public() opens the exceptions.

Step 20: Role-based access control

20 roles rbac

Knowing who a user is, is only half of security. We also need to decide what they may do. We are going to add role-based access control, so only admins can delete cats.

The trick is to combine two Nest features we already know: custom decorators that attach metadata to a route, and a guard that reads it.

First, the decorator. It stores the allowed roles as metadata:

// src/auth/roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
import { Role } from '../users/role.enum';

export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);

Then the guard. It reads that metadata and compares it with the role stored in the token's payload, which our AuthGuard already attached to request.user:

// src/auth/roles.guard.ts
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Role } from '../users/role.enum';
import { ROLES_KEY } from './roles.decorator';

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private readonly reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (!requiredRoles) return true; // no restriction on this route

    const { user } = context.switchToHttp().getRequest();
    return requiredRoles.includes(user?.role);
  }
}

Using it takes two decorators on the route. Make sure RolesGuard is registered as a provider in the module that uses it (or in a shared module):

@Roles(Role.Admin)
@UseGuards(RolesGuard)
@Delete(':id')
remove(@Param('id', ParseIntPipe) id: number) {
  return this.catsService.remove(id);
}

Here is the order of events for DELETE /cats/7:

  1. Our global AuthGuard runs first, verifies the token and sets request.user.
  2. RolesGuard reads @Roles(Role.Admin) from the route.
  3. A user whose role is admin passes. A user whose role is user gets 403 Forbidden.

Note the difference between the two failures. 401 means "we do not know who you are", and 403 means "we know who you are, and the answer is no". Using both correctly makes an API much easier to debug.

Because the rule lives in the decorator, the route itself reads like documentation: @Roles(Role.Admin) says exactly who may call it.

Quick recap

  • A decorator stores metadata, a guard reads it with Reflector.
  • 401 means unauthenticated, 403 means not allowed.

Step 21: Security layers

21 security layers

Good security is never one wall, it is layers. Each layer stops a different kind of attack, and a request must pass all of them before it touches our data. We already added two layers (guards and validation). Now we are going to add the rest.

Layer 1: Helmet

Helmet sets secure HTTP headers that protect against common attacks such as cross-site scripting and clickjacking.

npm install helmet
import helmet from 'helmet';

app.use(helmet());

Layer 2: CORS

CORS tells browsers which origins are allowed to read responses from our API. It is not authentication and does not stop non-browser clients. Allow only the origins you trust; when credentials: true is enabled, do not use origin: '*'.

app.enableCors({
  origin: ['https://cats.example.com'],
  credentials: true,
});

Layer 3: Rate limiting

Rate limiting slows down brute-force and abusive clients. The throttler package counts requests per client and rejects extras with 429 Too Many Requests.

npm install @nestjs/throttler
// src/app.module.ts
import { APP_GUARD } from '@nestjs/core';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';

@Module({
  imports: [
    ThrottlerModule.forRoot([
      {
        ttl: 60000, // time window in milliseconds
        limit: 10,  // max requests per window
      },
    ]),
    // ...other imports
  ],
  providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],
})
export class AppModule {}

Some routes need different rules. A login endpoint deserves a much tighter limit:

@Throttle({ default: { limit: 3, ttl: 60000 } })
@Post('login')
signIn(@Body() dto: AuthCredentialsDto) { /* ... */ }

And a health check can opt out entirely with @SkipThrottle().

Behind a proxy? When the app runs behind a trusted load balancer, the client's address may be forwarded in X-Forwarded-For. Configure Express's trust proxy setting for your actual deployment topology only. Do not blindly trust forwarded headers from arbitrary clients, because that can let attackers spoof their IP and bypass rate limits.

Layers 4 and 5: Authentication, authorization and validation

These are the layers we built earlier. AuthGuard rejects requests without a valid token, RolesGuard enforces permissions, and ValidationPipe validates request data. Use both whitelist and forbidNonWhitelisted when you want unknown fields rejected rather than silently removed.

Our final main.ts

Everything we have configured comes together in one readable file:

// src/main.ts
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import helmet from 'helmet';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/http-exception.filter';
import { TransformInterceptor } from './common/transform.interceptor';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.use(helmet());
  app.enableCors({ origin: ['https://cats.example.com'], credentials: true });

  app.useGlobalPipes(
    new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
  );
  app.useGlobalFilters(new HttpExceptionFilter());
  app.useGlobalInterceptors(new TransformInterceptor());

  const config = app.get(ConfigService);
  await app.listen(config.get<number>('port') ?? 3000);
}
bootstrap();

A security checklist

ConcernWhat we did
Secrets in codeMoved to environment variables (Step 13)
PasswordsHashed with bcrypt, never stored in plain text (Step 19)
Unauthenticated accessGlobal AuthGuard, routes private by default (Step 19)
Excess privilegesRole checks with RolesGuard (Step 20)
Malicious inputValidationPipe with whitelist (Step 11)
Brute forceThrottler, with stricter limits on login (Step 21)
Browser attacksHelmet and a strict CORS list (Step 21)
Leaking internalsException filter hides stack traces (Step 17)

We also remember what lives outside the code: serve everything over HTTPS, keep dependencies updated, and rotate secrets when anyone who knew them leaves.

Quick recap

  • Layer defenses: Helmet, CORS, rate limiting, guards, validation.
  • Each layer blocks a different class of attack.

Step 22: Testing

22 testing

Nest's dependency injection pays off most when we test. Because dependencies are injected, we can replace a real service with a lightweight mock in a few lines. Jest is already set up by the CLI.

A unit test with a mocked service

We build a tiny testing module that contains only what the test needs:

// src/cats/cats.controller.spec.ts
import { Test } from '@nestjs/testing';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';

describe('CatsController', () => {
  let controller: CatsController;

  const catsServiceMock = {
    findAll: jest.fn().mockResolvedValue([]),
  };

  beforeEach(async () => {
    const moduleRef = await Test.createTestingModule({
      controllers: [CatsController],
      providers: [{ provide: CatsService, useValue: catsServiceMock }],
    }).compile();

    controller = moduleRef.get(CatsController);
  });

  it('returns cats', async () => {
    await expect(controller.findAll()).resolves.toEqual([]);
    expect(catsServiceMock.findAll).toHaveBeenCalled();
  });
});

Look at the providers line. It is the same useValue custom provider we met in Step 9, now used to swap in a mock. The controller never knows the difference. No database is needed, and the test runs in milliseconds.

An end-to-end test

Unit tests check pieces. End-to-end tests call the real HTTP layer with supertest, so guards, pipes and routing are all exercised together:

// test/cats.e2e-spec.ts
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { AppModule } from '../src/app.module';

describe('Cats (e2e)', () => {
  let app: INestApplication;

  beforeAll(async () => {
    const moduleRef = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleRef.createNestApplication();
    // main.ts is not executed in tests, so we repeat our global setup here
    app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
    await app.init();
  });

  afterAll(async () => {
    await app.close();
  });

  // This test assumes GET /cats is marked with @Public().
  it('GET /cats returns 200', () => {
    return request(app.getHttpServer()).get('/cats').expect(200);
  });

  it('DELETE /cats/1 without a token returns 401', () => {
    return request(app.getHttpServer()).delete('/cats/1').expect(401);
  });
});

Two lessons hide in this file. First, main.ts is not run in tests, so global pipes, filters and interceptors must be added again. Second, with a real database in the loop, point tests at a dedicated test database, or replace the repository with .overrideProvider(getRepositoryToken(Cat)).useValue(mockRepository). The sample e2e test also assumes that the route /cats is public; otherwise the global authentication guard will return 401.

We run everything with:

npm run test        # unit tests
npm run test:e2e    # end-to-end tests
npm run test:cov    # coverage report

Quick recap

  • Test.createTestingModule builds a small app with only what a test needs.
  • Mocks are just custom providers, and e2e tests use supertest.

Step 23: Build and deploy

23 build deploy

Our API works, it is secure, and it is tested. The final step is to ship it. The journey is short: compile, package, run.

Build

npm run build

nest build compiles the TypeScript in src/ into plain JavaScript in dist/. In production, we run that output with Node directly:

npm run start:prod   # node dist/main

Package with Docker

A multi-stage Dockerfile keeps the final image small. The first stage builds, and the second stage carries only the compiled code and production dependencies.

# ---- build stage ----
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# ---- runtime stage ----
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/main"]

We also add a .dockerignore, so that local files never end up in the image:

node_modules
dist
.env
.env.*
!.env.example
.git

Then we build and run it, passing configuration at runtime:

docker build -t cats-app .
docker run -p 3000:3000 --env-file .env.production cats-app

The production checklist

Before the first real deployment, we run through this list:

ItemWhy it matters
NODE_ENV=productionEnables production behavior in Nest and libraries
Secrets as environment variablesNothing sensitive is baked into the image
synchronize: false plus migrationsSchema changes become explicit and reviewable
app.enableShutdownHooks()Lets the app close connections cleanly on stop signals
A health endpointLoad balancers need something to ask. @nestjs/terminus provides this
HTTPS in front of the appUsually terminated by a proxy or platform
CI running lint, tests and buildBroken code never reaches production

The same image can run on a container platform, Kubernetes, or a virtual machine, provided the runtime environment supplies the required variables and networking. We now have a repeatable path from a source commit to a deployable image.

Quick recap

  • nest build produces dist/, and node dist/main runs it.
  • A multi-stage Docker image packages it, configured entirely through the environment.

What we built

Let's look back. Step by step, we turned an empty folder into a well-structured API with many of the building blocks used in production. The examples are a learning foundation, not a complete production implementation: persistence, migrations, secret management, authorization checks, observability and deployment settings still need to be adapted to the application.

PartWhat we addedSteps
FoundationsThe CLI, the file layout and the vocabulary1 to 4
CoreModules, controllers, providers and dependency injection5 to 9
Data and inputPipes, DTOs, a database and configuration10 to 13
Request pipelineMiddleware, guards, interceptors and filters, and how they fit14 to 18
Security and shippingJWT, roles, security layers, tests and deployment19 to 23

And here is the final shape of our project:

src/
├── main.ts
├── app.module.ts
├── config/
│   └── configuration.ts
├── common/
│   ├── logger.middleware.ts
│   ├── transform.interceptor.ts
│   └── http-exception.filter.ts
├── auth/
│   ├── auth.module.ts
│   ├── auth.controller.ts
│   ├── auth.service.ts
│   ├── auth.guard.ts
│   ├── roles.guard.ts
│   ├── roles.decorator.ts
│   ├── public.decorator.ts
│   └── dto/
│       └── auth-credentials.dto.ts
├── users/
│   ├── users.module.ts
│   ├── users.service.ts
│   └── role.enum.ts
└── cats/
    ├── cats.module.ts
    ├── cats.controller.ts
    ├── cats.service.ts
    ├── dto/
    └── entities/

Where we go next

NestJS has much more to explore, and every topic below builds on the same ideas we just learned:

  • API documentation with @nestjs/swagger, generating interactive docs from our decorators.
  • GraphQL as an alternative or addition to REST.
  • WebSockets for real-time features.
  • Microservices with message brokers such as NATS, RabbitMQ or Kafka.
  • Queues and background jobs with BullMQ.
  • Caching with the built-in cache manager.
  • Health checks with @nestjs/terminus.
  • Other data layers such as Prisma, Mongoose or Drizzle.

The important thing is that none of them requires a new way of thinking. Modules organize, controllers route, providers work, and the request pipeline keeps everything safe and consistent. We have the mental model, and the rest is practice.

Thanks for building along. Happy coding!

#Architecture#Backend#Security#nestjs#nodejs#api#database

Discussion (0)

Comments are loaded live from this story.

Join the discussion — sign in to like stories and share your thoughts.