Skip to main content

Cloudsoft Zone Build, deploy and scale with practical guides to PHP, Node.js, React, DevOps and AI automation. Explore the guides

Cloudsoft Zone
Node.jsDevelopmentDevOpsJavaScriptTutorial

NestJS with Fastify: build a production-ready REST API

Build a REST API on NestJS 12 and Fastify 5 with schema validation, helmet, CORS, rate limits, pino logs, health checks, graceful shutdown, inject() tests and Docker.

Cloudsoft Zone

Updated 12 min read

Diagram of a request passing through Fastify settings, helmet and CORS plugins, ThrottlerGuard, a Standard Schema pipe and the Nest controller and service.
Image: Cloudsoft Zone
In this article

NestJS gives you modules, dependency injection and decorators; Fastify gives you a fast HTTP core with a plugin system built for production. This tutorial builds a small but complete REST API on NestJS 12 with the Fastify adapter: schema validation, security headers, CORS, rate limiting, structured logs, liveness and readiness checks, graceful shutdown, in-process tests and a Docker image. Every snippet below was compiled and run, and the outputs shown are real.

Tested with: NestJS 12.1.0 (@nestjs/platform-fastify 12.1.0, which pins Fastify 5.12.5), @nestjs/throttler 6.7.1, @nestjs/terminus 12.1.0, @fastify/helmet 13.1.1, nestjs-pino 5.2.1, zod 4.6.5, TypeScript 5.9, Node.js 24.19.0, 26 September 2026.

Why Fastify, and what changes from Express

If you are still choosing between the two HTTP layers, our Express 5 vs Fastify 5 comparison covers performance and ecosystem. Inside Nest, the practical differences are few but they bite:

  • Express middleware packages (helmet, express-rate-limit, compression) do not work. Use the @fastify/* plugins and register them with app.register().
  • Fastify listens on localhost only by default. In a container you must pass host: '0.0.0.0', or nothing outside the container can reach the app.
  • Nest middleware receives the raw Node.js req and res, not Fastify's wrappers. That affects anything reading req.url (see the logging section).
  • Redirects are written res.status(302).redirect('/login').

NestJS 12, released on 27 August 2026, ships its packages as ESM and requires Node.js 20.19+ or 22.12+. Existing CommonJS apps still work through Node's require(esm); the example below compiles to CommonJS and runs unchanged on Node.js 24.

Project setup

Shell
npm i @nestjs/core@12.1.0 @nestjs/common@12.1.0 @nestjs/platform-fastify@12.1.0 \
  @nestjs/throttler@6.7.1 @nestjs/terminus@12.1.0 @fastify/helmet@13.1.1 \
  nestjs-pino@5.2.1 pino-http@11.0.0 pino@10.3.1 zod@4.6.5 reflect-metadata rxjs
npm i -D typescript @types/node @nestjs/testing@12.1.0
JSON
{
  "compilerOptions": {
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "target": "es2023",
    "outDir": "dist",
    "rootDir": ".",
    "strict": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "skipLibCheck": true,
    "sourceMap": true
  },
  "include": ["src", "test"]
}

The two decorator options are required: Nest reads constructor types through emitted metadata to inject dependencies. If injection fails at start-up, our guide to fixing "Nest can't resolve dependencies" walks through every cause.

Bootstrap: adapter, security headers, CORS, shutdown

createApp() is exported separately so tests can build the exact same app without listening on a port:

TypeScript
// src/main.ts
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import {
  FastifyAdapter,
  type NestFastifyApplication,
} from '@nestjs/platform-fastify';
import helmet from '@fastify/helmet';
import { Logger } from 'nestjs-pino';
import { AppModule } from './app.module.js';

export async function createApp() {
  const adapter = new FastifyAdapter({
    trustProxy: process.env.TRUST_PROXY ?? false, // e.g. '10.0.0.0/8' behind a load balancer
    bodyLimit: 1_048_576, // 1 MiB, Fastify's default, stated explicitly
    requestIdHeader: 'x-request-id',
  });

  const app = await NestFactory.create<NestFastifyApplication>(AppModule, adapter, {
    bufferLogs: true,
  });

  app.useLogger(app.get(Logger));
  await app.register(helmet);
  app.enableCors({
    origin: (process.env.CORS_ORIGINS ?? '').split(',').filter(Boolean),
    methods: ['GET', 'POST', 'PATCH', 'DELETE'],
    credentials: true,
  });
  app.setGlobalPrefix('api/v1', { exclude: ['health/live', 'health/ready'] });
  app.enableShutdownHooks();
  return app;
}

async function bootstrap() {
  const app = await createApp();
  await app.listen({ port: Number(process.env.PORT ?? 3000), host: '0.0.0.0' });
}

if (process.env.NODE_ENV !== 'test') {
  void bootstrap();
}

Points worth copying:

  • trustProxy decides whether request.ip comes from X-Forwarded-For. Leave it off unless a proxy you control sits in front, then set it to that proxy's address range. Trusting every hop lets clients fake their IP and dodge rate limits.
  • bodyLimit is 1 MiB by default. Set it explicitly and lower it if your API only takes small JSON bodies.
  • @fastify/helmet sends a strict Content-Security-Policy, HSTS, X-Content-Type-Options: nosniff and related headers. If you serve Swagger UI over plain HTTP outside development, its upgrade-insecure-requests directive will break it; serve docs over HTTPS. The HTTP security headers checklist explains each header.
  • CORS takes an explicit list of origins from configuration. Never combine credentials: true with a wildcard.
  • enableShutdownHooks() makes Nest listen for SIGTERM and run its lifecycle hooks; without it, a container stop kills requests mid-flight.

Validation with Standard Schema

NestJS 12 added a schema option to @Body(), @Query() and @Param() and a built-in StandardSchemaValidationPipe, so Zod, Valibot or ArkType schemas validate requests without class-validator DTOs. Class-based DTOs with ValidationPipe remain fully supported; pick one style per project.

TypeScript
// src/notes/notes.schemas.ts
import { z } from 'zod';

export const createNoteSchema = z.strictObject({
  title: z.string().trim().min(1).max(120),
  body: z.string().max(10_000).default(''),
});
export type CreateNoteInput = z.infer<typeof createNoteSchema>;

export const listNotesQuerySchema = z.strictObject({
  page: z.coerce.number().int().min(1).default(1),
  pageSize: z.coerce.number().int().min(1).max(50).default(20),
});
export type ListNotesQuery = z.infer<typeof listNotesQuerySchema>;

export const noteIdSchema = z.uuid();

z.strictObject rejects unknown keys, which is the Standard Schema equivalent of forbidNonWhitelisted: a client cannot sneak isAdmin: true into your service. The max(50) on pageSize keeps every list endpoint bounded.

TypeScript
// src/notes/notes.controller.ts
import { Body, Controller, Get, HttpCode, Param, Post, Query } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { NotesService } from './notes.service.js';
import {
  createNoteSchema, listNotesQuerySchema, noteIdSchema,
  type CreateNoteInput, type ListNotesQuery,
} from './notes.schemas.js';

@Controller('notes')
export class NotesController {
  constructor(private readonly notes: NotesService) {}

  @Get()
  list(@Query({ schema: listNotesQuerySchema }) query: ListNotesQuery) {
    return this.notes.list(query);
  }

  @Get(':id')
  get(@Param('id', { schema: noteIdSchema }) id: string) {
    return { data: this.notes.get(id) };
  }

  @Post()
  @HttpCode(201)
  @Throttle({ default: { limit: 10, ttl: 60_000 } })
  create(@Body({ schema: createNoteSchema }) body: CreateNoteInput) {
    return { data: this.notes.create(body) };
  }
}
TypeScript
// src/notes/notes.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import type { CreateNoteInput, ListNotesQuery } from './notes.schemas.js';

export interface Note { id: string; title: string; body: string; createdAt: string }

@Injectable()
export class NotesService {
  // In-memory store keeps the example self-contained; swap for your repository.
  private readonly notes = new Map<string, Note>();

  create(input: CreateNoteInput): Note {
    const note = { id: randomUUID(), ...input, createdAt: new Date().toISOString() };
    this.notes.set(note.id, note);
    return note;
  }

  list({ page, pageSize }: ListNotesQuery) {
    const all = [...this.notes.values()];
    const start = (page - 1) * pageSize;
    return { data: all.slice(start, start + pageSize), meta: { page, pageSize, total: all.length } };
  }

  get(id: string): Note {
    const note = this.notes.get(id);
    if (!note) throw new NotFoundException('Note not found', { errorCode: 'NOTE_NOT_FOUND' });
    return note;
  }
}

The new errorCode option on Nest's HTTP exceptions gives clients a stable identifier to branch on. Real responses from the running app:

Shell
$ curl -s -X POST localhost:3000/api/v1/notes \
    -H 'content-type: application/json' -d '{"title":"","x":1}'
{"message":["title: Too small: expected string to have >=1 characters",
 "Unrecognized key: \"x\""],"error":"Bad Request","statusCode":400}

$ curl -s localhost:3000/api/v1/notes/9b2f6d1e-3c1a-4b8e-9a4f-1f2e3d4c5b6a
{"message":"Note not found","error":"Not Found","statusCode":404,"errorCode":"NOTE_NOT_FOUND"}

Rate limiting, logging and health checks in the root module

TypeScript
// src/app.module.ts
import { Module } from '@nestjs/common';
import { APP_GUARD, APP_PIPE } from '@nestjs/core';
import { StandardSchemaValidationPipe } from '@nestjs/common';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { TerminusModule } from '@nestjs/terminus';
import { LoggerModule } from 'nestjs-pino';
import { NotesModule } from './notes/notes.module.js';
import { HealthController } from './health/health.controller.js';

@Module({
  imports: [
    LoggerModule.forRoot({
      pinoHttp: {
        level: process.env.LOG_LEVEL ?? 'info',
        redact: ['req.headers.authorization', 'req.headers.cookie'],
        autoLogging: {
          // Behind Fastify's middleware layer req.url is mount-relative; use originalUrl.
          ignore: (req) =>
            ((req as { originalUrl?: string }).originalUrl ?? req.url ?? '').startsWith('/health'),
        },
      },
    }),
    ThrottlerModule.forRoot({
      throttlers: [{ name: 'default', ttl: 60_000, limit: 100 }],
    }),
    TerminusModule.forRoot({ gracefulShutdownTimeoutMs: 5_000 }),
    NotesModule,
  ],
  controllers: [HealthController],
  providers: [
    { provide: APP_PIPE, useValue: new StandardSchemaValidationPipe() },
    { provide: APP_GUARD, useClass: ThrottlerGuard },
  ],
})
export class AppModule {}

Rate limiting

ThrottlerGuard, registered as a global guard, works the same on Fastify as on Express. ttl is in milliseconds. The controller tightens POST /notes to 10 requests a minute; in our test the 11th request returned 429 Too Many Requests with Retry-After: 60. The default storage is in memory, so each instance counts separately: behind a load balancer with three instances, a client gets three times the limit. Use a shared store (the docs point to a community Redis storage package) or enforce limits at the proxy.

For a coarse, cheap global limit you can also register Fastify's own plugin, which rejects requests before Nest does any routing work:

TypeScript
import rateLimit from '@fastify/rate-limit'

// Runs inside Fastify before Nest routing: cheapest way to shed abusive traffic
await app.register(rateLimit, { max: 300, timeWindow: '1 minute' })

Logging

nestjs-pino writes one JSON line per request with a request ID, method, URL, status and timing, and replaces Nest's logger so your Logger calls end up in the same stream. redact keeps tokens and cookies out of logs. One Fastify-specific catch we hit: in the autoLogging.ignore callback, req.url is / because the middleware layer strips the mount path. Use req.originalUrl, as above, or health checks flood your logs.

Health checks

TypeScript
// src/health/health.controller.ts
import { Controller, Get } from '@nestjs/common';
import { SkipThrottle } from '@nestjs/throttler';
import { HealthCheck, HealthCheckService, MemoryHealthIndicator } from '@nestjs/terminus';

@SkipThrottle()
@Controller('health')
export class HealthController {
  constructor(
    private readonly health: HealthCheckService,
    private readonly memory: MemoryHealthIndicator,
  ) {}

  // Liveness: is the process able to serve at all? No dependency checks here.
  @Get('live')
  live() {
    return { status: 'ok' };
  }

  // Readiness: can this instance take traffic right now?
  @Get('ready')
  @HealthCheck()
  ready() {
    return this.health.check([
      () => this.memory.checkHeap('memory_heap', 300 * 1024 * 1024),
      // add: () => this.db.pingCheck('database'), redis, downstream APIs
    ]);
  }
}

Keep liveness dumb: if /health/live checks the database, a database blip makes the orchestrator restart every healthy instance at once. Put dependency checks in readiness. Both routes are excluded from the api/v1 prefix and from throttling, so probes never get a 429.

Five-step sequence of graceful shutdown: SIGTERM, onModuleDestroy, a five-second grace delay with readiness at 503, Fastify closing, then exit.
What happens between docker stop and process exit with enableShutdownHooks and a Terminus grace delay.

Graceful shutdown

With enableShutdownHooks() and TerminusModule.forRoot({ gracefulShutdownTimeoutMs: 5_000 }), a SIGTERM runs onModuleDestroy, then Terminus's beforeApplicationShutdown hook, which marks the app as shutting down and waits five seconds before Fastify closes. We checked it: after SIGTERM, /health/ready returned 503 with "status":"shutting_down" while /health/live still answered 200, and the process exited cleanly after the delay. That window is what lets a load balancer stop sending traffic before connections close.

Close database pools and queue connections in onApplicationShutdown, and make sure the platform's stop timeout (10 seconds for docker stop, 30 for a Kubernetes pod by default) is longer than the grace delay plus your slowest request.

Testing without opening a port

Fastify's inject() sends a fake request through the whole stack: plugins, guards, pipes, controller and serialisation. It is fast and needs no free port. This uses Node's built-in test runner; the same code works in Vitest, which NestJS 12 now uses by default for new ESM projects.

TypeScript
// test/notes.e2e.test.ts
import { after, before, test } from 'node:test';
import assert from 'node:assert/strict';
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
import { createApp } from '../src/main.js';

let app: NestFastifyApplication;

before(async () => {
  app = await createApp();
  await app.init();
  await app.getHttpAdapter().getInstance().ready();
});
after(() => app.close());

test('creates a note and reads it back', async () => {
  const created = await app.inject({
    method: 'POST', url: '/api/v1/notes',
    payload: { title: 'Ship it', body: 'Fastify adapter' },
  });
  assert.equal(created.statusCode, 201);
  const { data } = created.json();
  const read = await app.inject({ method: 'GET', url: `/api/v1/notes/${data.id}` });
  assert.equal(read.json().data.title, 'Ship it');
});

test('rejects unknown fields with 400', async () => {
  const res = await app.inject({
    method: 'POST', url: '/api/v1/notes', payload: { title: 'x', isAdmin: true },
  });
  assert.equal(res.statusCode, 400);
});

test('caps pageSize at 50', async () => {
  const res = await app.inject({ method: 'GET', url: '/api/v1/notes?pageSize=500' });
  assert.equal(res.statusCode, 400);
});

test('sends helmet headers and a readiness report', async () => {
  const res = await app.inject({ method: 'GET', url: '/health/ready' });
  assert.equal(res.statusCode, 200);
  assert.equal(res.headers['x-content-type-options'], 'nosniff');
  assert.equal(res.json().status, 'ok');
});
Shell
$ NODE_ENV=test LOG_LEVEL=silent node --test "dist/test/*.test.js"
✔ creates a note and reads it back (36ms)
✔ rejects unknown fields with 400 (1ms)
✔ caps pageSize at 50 (0.8ms)
✔ sends helmet headers and a readiness report (0.7ms)
ℹ tests 4  ℹ pass 4  ℹ fail 0

Note the getInstance().ready() call: without it, Fastify plugins registered in createApp() may not have loaded when the first inject() runs. For more on the built-in runner, see testing a Node.js API with node:test.

Docker image

Dockerfile
# syntax=docker/dockerfile:1
FROM node:24-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npx tsc -p . && npm prune --omit=dev

FROM node:24-bookworm-slim
ENV NODE_ENV=production PORT=3000
WORKDIR /app
COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist/src ./dist/src
COPY --chown=node:node package.json ./
USER node
EXPOSE 3000
HEALTHCHECK --interval=15s --timeout=3s --retries=3 \
  CMD node -e "fetch('http://127.0.0.1:3000/health/live').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
# Node is PID 1 and receives SIGTERM directly (exec form, no shell)
CMD ["node", "dist/src/main.js"]

The image runs as the unprivileged node user, contains only production dependencies and the compiled output, and starts Node directly so SIGTERM reaches it. For multi-stage details, caching and image scanning, see our production Dockerfile guide for Node.js 24.

From here, the hard part of a real API is rarely the framework: it is versioning, idempotent writes, consistent errors across many endpoints and integrations with payment or CRM systems that fail in their own ways. If you are designing an API that other teams or partners will depend on, talk to us about API development and integration before the contract is frozen.

Production checklist

  • Configuration validated at start-up (@nestjs/config 12 accepts a Zod schema as validationSchema); the app refuses to boot with a missing secret.
  • trustProxy limited to your proxy's addresses; rate limits backed by a shared store when you run more than one instance.
  • Every list endpoint bounded (pageSize maximum) and every sort key allowlisted.
  • Errors return a consistent body with a stable code; our RFC 9457 guide shows a standard format.
  • Logs are JSON, carry the request ID, and redact credentials.
  • Readiness checks real dependencies; liveness does not.
  • Upgrades to Nest and Fastify patch releases happen monthly, with npm audit --omit=dev in CI.

Common mistakes

  • Registering Express middleware such as helmet() with app.use() on the Fastify adapter.
  • Calling app.listen(3000) in Docker and wondering why the port mapping returns nothing (Fastify bound to localhost).
  • Throttling health probes, so the orchestrator sees 429s under load and kills healthy pods.
  • Using z.object instead of z.strictObject and silently accepting extra fields (Zod strips them, but you lose the 400 that tells the client it sent something wrong).
  • Forgetting await app.getHttpAdapter().getInstance().ready() in tests and getting flaky failures.
  • Running Node behind a shell in the Dockerfile (CMD npm start), so SIGTERM never reaches the app.

When not to use NestJS with Fastify

If your team depends on Express-only middleware (some authentication or file-upload libraries, or Nest recipes that assume Express), staying on @nestjs/platform-express is the cheaper choice: Fastify's speed advantage rarely matters next to a database round trip. If the service is a handful of endpoints with no shared business logic, plain Fastify without Nest is less code. And if you run on a serverless platform with frequent cold starts, measure Nest's start-up time first; a dependency-injection container is not free.

Cloudsoft Zone designs and builds production APIs on NestJS and Fastify: validated contracts, rate limits, observability and tested integrations with the systems your business already runs. See our API development and integration service.

Frequently asked questions

  • Which NestJS version should I use with Fastify in 2026?

    NestJS 12 (12.1.0 at the time of writing) with @nestjs/platform-fastify 12.1.0, which depends on Fastify 5.12.5. NestJS 12 needs Node.js 20.19+ or 22.12+; Node.js 24 LTS is a good default.

  • Does @nestjs/throttler work with the Fastify adapter?

    Yes. Register ThrottlerGuard as an APP_GUARD. If you are behind a proxy, set Fastify's trustProxy to that proxy's addresses so the client IP is correct, and use a shared store when you run several instances.

  • Do I have to use Zod with NestJS 12?

    No. NestJS 12 adds Standard Schema support (Zod, Valibot, ArkType), but class-validator DTOs with ValidationPipe remain fully supported. Choose one approach per project.

  • Why does my NestJS Fastify app not respond inside Docker?

    Fastify listens on localhost by default. Call app.listen({ port, host: '0.0.0.0' }) so the container's published port can reach it.

Advertise on CloudSoftZone

Your next customer could start right here.

Introduce your tool, product or business to readers exploring technology.

Advertise with us

Explore placements and request a private quote

Sources

  1. NestJS v12.0.0 release notes — GitHub, 2026-08-27
  2. Performance (Fastify) — NestJS documentation
  3. Helmet — NestJS documentation
  4. Rate limiting — NestJS documentation
  5. Health checks (Terminus) — NestJS documentation
  6. Lifecycle events — NestJS documentation
  7. Fastify server options — Fastify
  8. nestjs-pino — GitHub

Written with substantial AI assistance. Facts checked against cited sources. Editorial policy.

Comments

No comments yet. Be the first to join the conversation.

Leave a comment

Every comment is read by a moderator before it appears. Your email address is never published; it is used only to contact you about this comment.

Never published. Used only to contact you about this comment.

Loading the security check…

Comments are moderated before publication.