Fastify #

Fastify is a Node.js web framework born with one promise: high performance without sacrificing developer experience. Internal benchmarks show Fastify can serve 30K+ requests per second on standard hardware — 2-3x faster than Express. But performance isn’t the only reason developers switch to Fastify. What makes Fastify special is its schema-first philosophy: by defining a JSON Schema for each route, you get automatic validation, fast serialization (5x faster than JSON.stringify), and free OpenAPI documentation.

For local development, Fastify is lighter than NestJS and more structured than Express — the sweet spot in between. Hot reload can use tsx watch (TypeScript) or nodemon (JavaScript), and schema validation is built in. Docker Compose unifies the toolchain and supporting services, so every developer has an identical environment.

This article covers a thorough Docker Compose setup for Fastify: multi-stage Dockerfiles, hot reload, JSON Schema and Zod validation, the plugin system, hooks, TypeScript, Prisma integration, and performance best practices.

Prerequisites #

Make sure the host has installed:

  • Docker and Docker Compose (latest versions, v2.20+)
  • Node.js 20+ — minimum runtime
  • TypeScript 5+ — optional, but highly recommended for type safety
  • curl or HTTPie — testing endpoints

The assumed Fastify project structure:

fastify-app/
├── src/
│   ├── server.ts           # factory function
│   ├── app.ts              # build app with routes
│   ├── routes/
│   │   ├── users.ts
│   │   ├── auth.ts
│   │   └── health.ts
│   ├── plugins/
│   │   ├── prisma.ts
│   │   ├── redis.ts
│   │   └── auth.ts
│   ├── schemas/
│   │   ├── user.ts
│   │   └── common.ts
│   ├── lib/
│   │   ├── errors.ts
│   │   └── logger.ts
│   └── types/
│       └── fastify.d.ts
├── test/
├── docker-compose.yml
├── Dockerfile
├── .dockerignore
├── .env
├── tsconfig.json
├── package.json
└── package-lock.json
The server.ts + app.ts pattern is a Fastify best practice. server.ts is a factory creating a Fastify instance with full configuration. app.ts is the entry point that listens. This pattern makes testing easy: tests can build the app without listen, then inject() requests directly in memory.

Multi-Stage Dockerfile #

Fastify is written in modern TypeScript with ECMAScript modules. For development, we run via tsx watch — a TypeScript execution tool with hot reload. For production, we compile first with tsc or tsup, then run Node in a slim image.

# syntax=docker/dockerfile:1.6
FROM node:20-alpine AS deps

WORKDIR /app

# Copy the manifest first for layer caching
COPY package.json package-lock.json ./

# Install all dependencies (including devDependencies)
RUN npm install --no-audit --no-fund

# Copy the TypeScript configuration
COPY tsconfig.json ./

# Copy source code
COPY src ./src

# ----- Production builder -----
FROM deps AS builder

RUN npm run build

# ----- Production runner -----
FROM node:20-alpine AS runner

WORKDIR /app

ENV NODE_ENV=production \
    PORT=3000

RUN addgroup -S app && adduser -S app -G app

# Copy only production dependencies
COPY package.json package-lock.json ./
RUN npm install --omit=dev --no-audit --no-fund

# Copy the build output
COPY --from=builder --chown=app:app /app/dist ./dist

USER app

EXPOSE 3000

CMD ["node", "dist/server.js"]

Stage Explanations #

The deps stage — the development base. Installs tsx, typescript, prisma, and other devDependencies. tsx watch reads TypeScript files directly without manual compilation.

The builder stage — runs npm run build (which runs tsc or tsup) to produce the dist/ output.

The runner stage — a slim image. Only production dependencies and dist/. A non-root user for security.

The node:20-alpine image already includes glibc compatible with binary modules like bcrypt, sharp, and prisma. For very platform-specific modules, you sometimes need node:20-bookworm-slim (Debian-based). Check your module’s documentation.

docker-compose.yml for Development #

# docker-compose.yml
services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
      target: deps
    image: fastify-app:dev
    container_name: fastify-dev
    command: npm run dev
    working_dir: /app
    ports:
      - "3000:3000"
      - "5555:5555"           # Prisma Studio (optional)
    volumes:
      - ./src:/app/src
      - ./test:/app/test
      - /app/node_modules
      - /app/dist
    environment:
      NODE_ENV: development
      PORT: 3000
      DATABASE_URL: postgres://app:pass@db:5432/fastify_dev
      REDIS_URL: redis://cache:6379
      LOG_LEVEL: info
      JWT_SECRET: dev-secret-change-me
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    container_name: fastify-db
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: dev
      POSTGRES_DB: fastify_dev
    volumes:
      - db-data:/var/lib/postgresql/data
      - ./db/init:/docker-entrypoint-initdb.d:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d fastify_dev"]
      interval: 10s
      timeout: 5s
      retries: 5
    ports:
      - "5432:5432"

  cache:
    image: redis:7-alpine
    container_name: fastify-cache
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 3
    ports:
      - "6379:6379"

volumes:
  db-data:

Service Explanations #

api — the Fastify service. target: deps selects the development stage. command: npm run dev runs tsx watch src/server.ts — every TypeScript file change mounted from the host triggers an automatic restart.

Anonymous volumes for node_modules and dist — both mandatory. The container’s node_modules holds binary modules compiled for the container platform, not the host. dist is mounted as an anonymous volume to prevent overlap with tsx watch, which manages hot reload itself.

Port 5555 — for Prisma Studio (a database GUI) runnable via npx prisma studio. Optional; remove it if unused.

db and cache — Postgres and Redis with healthchecks. The app starts with condition: service_healthy.

Lifecycle Diagram #

sequenceDiagram
    participant Dev as Developer
    participant VSC as VS Code
    participant Bind as Bind Mount
    participant App as Fastify Container
    participant DB as Postgres
    Dev->>VSC: Edit src/routes/users.ts
    VSC->>Bind: Write to host FS
    Bind->>App: Sync to container
    App->>App: tsx watch detects the change
    App->>App: Restart the changed module
    App->>DB: Pool connections stay alive
    Note over Dev,DB: No image rebuild,<br/>no container restart needed

Hot Reload with tsx watch #

For TypeScript projects, tsx is the lightest choice. It runs TypeScript directly (transpile only) without caching, with built-in watch mode. Alternatives: ts-node-dev (older, more features), or nodemon + tsx (most flexible).

package.json:

{
  "name": "fastify-app",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/server.ts",
    "build": "tsc -p tsconfig.build.json",
    "start": "node dist/server.js",
    "test": "vitest run",
    "test:watch": "vitest",
    "db:studio": "prisma studio",
    "db:migrate": "prisma migrate dev"
  },
  "dependencies": {
    "fastify": "^4.28.1",
    "@fastify/cors": "^9.0.1",
    "@fastify/helmet": "^11.1.1",
    "@fastify/rate-limit": "^9.1.0",
    "@fastify/jwt": "^8.0.1",
    "@fastify/swagger": "^8.15.0",
    "@fastify/swagger-ui": "^4.1.0",
    "fastify-type-provider-zod": "^2.0.0",
    "zod": "^3.23.8",
    "@prisma/client": "^5.18.0",
    "ioredis": "^5.4.1",
    "pino-pretty": "^11.2.2"
  },
  "devDependencies": {
    "tsx": "^4.16.5",
    "typescript": "^5.5.4",
    "prisma": "^5.18.0",
    "vitest": "^2.0.5",
    "@types/node": "^20.14.12"
  }
}

tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true,
    "resolveJsonModule": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "declaration": false,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "test"]
}

tsconfig.build.json (for production builds, excluding tests):

{
  "extends": "./tsconfig.json",
  "exclude": ["node_modules", "dist", "test", "**/*.spec.ts"]
}

.dockerignore:

node_modules
dist
coverage
.git
.gitignore
.env
.env.local
Dockerfile
docker-compose.yml
.dockerignore
README.md
test
*.log
prisma/migrations/dev.db*

Server and App Entry Points #

Separating server.ts (factory) and app.ts (build) makes testing easy. Tests can build without listen, then inject() requests directly.

src/server.ts — the factory:

import Fastify, { FastifyInstance } from 'fastify';
import { app } from './app.js';

export async function build(): Promise<FastifyInstance> {
  return app();
}

async function start() {
  const server = await build();

  const port = Number(process.env.PORT) || 3000;
  const host = '0.0.0.0';

  try {
    await server.listen({ port, host });
    server.log.info(`Fastify listening on http://${host}:${port}`);
  } catch (err) {
    server.log.error(err);
    process.exit(1);
  }
}

start();

src/app.ts — the app with plugins and routes:

import Fastify, { FastifyInstance } from 'fastify';
import cors from '@fastify/cors';
import helmet from '@fastify/helmet';
import rateLimit from '@fastify/rate-limit';
import jwt from '@fastify/jwt';
import swagger from '@fastify/swagger';
import swaggerUi from '@fastify/swagger-ui';

import { userRoutes } from './routes/users.js';
import { authRoutes } from './routes/auth.js';
import { healthRoutes } from './routes/health.js';
import prismaPlugin from './plugins/prisma.js';
import redisPlugin from './plugins/redis.js';

export async function app(): Promise<FastifyInstance> {
  const fastify = Fastify({
    logger: {
      level: process.env.LOG_LEVEL || 'info',
      transport: process.env.NODE_ENV === 'development'
        ? { target: 'pino-pretty' }
        : undefined,
    },
    requestIdHeader: 'x-request-id',
    requestIdLogLabel: 'reqId',
    disableRequestLogging: false,
    trustProxy: true,
  });

  // Security and global middleware
  await fastify.register(helmet, { contentSecurityPolicy: false });
  await fastify.register(cors, {
    origin: process.env.CORS_ORIGIN || '*',
    credentials: true,
  });
  await fastify.register(rateLimit, {
    max: 100,
    timeWindow: '1 minute',
  });

  // Database and cache
  await fastify.register(prismaPlugin);
  await fastify.register(redisPlugin);

  // JWT auth
  await fastify.register(jwt, {
    secret: process.env.JWT_SECRET || 'dev-secret',
  });

  // OpenAPI docs
  await fastify.register(swagger, {
    openapi: {
      info: {
        title: 'Fastify API',
        description: 'API documentation',
        version: '1.0.0',
      },
      servers: [{ url: 'http://localhost:3000' }],
    },
  });
  await fastify.register(swaggerUi, {
    routePrefix: '/docs',
  });

  // Routes
  await fastify.register(healthRoutes, { prefix: '/health' });
  await fastify.register(authRoutes, { prefix: '/api/v1/auth' });
  await fastify.register(userRoutes, { prefix: '/api/v1/users' });

  return fastify;
}
fastify.register() differs from app.use() in Express. Register creates an encapsulated context — plugins or routes registered here only apply in that scope, not leaking to others. This is one of Fastify’s most powerful features: each route can have its own plugins without global conflicts.

Schema Validation — Fastify’s Heart #

Fastify natively uses JSON Schema to validate body, params, querystring, and headers — 2-3x faster than other validators. As a bonus, these schemas double as automatic OpenAPI documentation via @fastify/swagger.

Approach 1: Native JSON Schema #

src/schemas/user.ts:

export const userSchema = {
  type: 'object',
  properties: {
    id: { type: 'integer' },
    email: { type: 'string', format: 'email' },
    name: { type: 'string' },
    role: { type: 'string', enum: ['user', 'admin'] },
    createdAt: { type: 'string', format: 'date-time' },
  },
} as const;

export const createUserSchema = {
  body: {
    type: 'object',
    required: ['email', 'name', 'password'],
    properties: {
      email: { type: 'string', format: 'email' },
      name: { type: 'string', minLength: 2, maxLength: 100 },
      password: { type: 'string', minLength: 8, maxLength: 128 },
      role: { type: 'string', enum: ['user', 'admin'], default: 'user' },
    },
    additionalProperties: false,    // reject extra fields
  },
  response: {
    201: userSchema,
  },
} as const;

src/routes/users.ts:

import { FastifyPluginAsync } from 'fastify';
import { createUserSchema, userSchema } from '../schemas/user.js';

export const userRoutes: FastifyPluginAsync = async (fastify) => {
  // List users
  fastify.get('/', {
    schema: {
      querystring: {
        type: 'object',
        properties: {
          limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
          offset: { type: 'integer', minimum: 0, default: 0 },
        },
      },
      response: {
        200: {
          type: 'object',
          properties: {
            data: { type: 'array', items: userSchema },
            total: { type: 'integer' },
          },
        },
      },
    },
  }, async (request) => {
    const { limit = 20, offset = 0 } = request.query;
    const [data, total] = await fastify.prisma.user.findAndCount({
      take: limit,
      skip: offset,
    });
    return { data, total };
  });

  // Create a user
  fastify.post('/', { schema: createUserSchema }, async (request, reply) => {
    const { email, name, password, role } = request.body;
    const passwordHash = await fastify.bcrypt.hash(password);

    const user = await fastify.prisma.user.create({
      data: { email, name, passwordHash, role },
    });

    reply.code(201);
    return user;
  });
};

Approach 2: Zod (Type-Safe) #

For developers who prefer Zod, fastify-type-provider-zod converts Zod schemas to JSON Schema automatically. TypeScript inference gives request.body the correct type.

src/routes/users-zod.ts:

import { FastifyPluginAsync } from 'fastify';
import { z } from 'zod';
import { ZodTypeProvider } from '../types/fastify.js';

const createUserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(2).max(100),
  password: z.string().min(8).max(128),
  role: z.enum(['user', 'admin']).default('user'),
});

const userResponse = z.object({
  id: z.number().int(),
  email: z.string().email(),
  name: z.string(),
  role: z.enum(['user', 'admin']),
  createdAt: z.string().datetime(),
});

export const userRoutesZod: FastifyPluginAsync = async (fastify) => {
  // Cast to the type provider
  const app = fastify.withTypeProvider<ZodTypeProvider>();

  app.post('/', {
    schema: {
      body: createUserSchema,
      response: { 201: userResponse },
    },
  }, async (request) => {
    // request.body is automatically typed as z.infer<typeof createUserSchema>
    const { email, name, password, role } = request.body;
    const passwordHash = await fastify.bcrypt.hash(password);

    const user = await app.prisma.user.create({
      data: { email, name, passwordHash, role },
    });

    return user;
  });
};

src/types/fastify.d.ts — augment types for Prisma, Redis, etc. decorators:

import 'fastify';
import { PrismaClient } from '@prisma/client';
import type { Redis } from 'ioredis';
import type { ZodTypeProvider } from 'fastify-type-provider-zod';

declare module 'fastify' {
  interface FastifyInstance {
    prisma: PrismaClient;
    redis: Redis;
    bcrypt: {
      hash: (data: string) => Promise<string>;
      compare: (data: string, hash: string) => Promise<boolean>;
    };
  }

  interface FastifyRequest {
    user?: { id: number; role: string };
  }
}

Validation Approach Comparison #

AspectNative JSON SchemaZod (via type provider)
PerformanceFastestSlightly slower (conversion)
Type safetyManual typesAutomatic from schemas
Learning curveNeed to know JSON SchemaFamiliar to TS devs
OpenAPI generationBuilt-inBuilt-in
ReusabilityImportableImportable
Best forHigh performance, complex schemasTS teams, complex validation
Use additionalProperties: false in JSON Schema or .strict() in Zod. Without it, extra fields in the request body pass validation. For public APIs, this is a security hole — clients can inject fields you didn’t anticipate.

The Plugin System #

Plugins are Fastify’s way of packaging reusable code. Plugins are registered with fastify.register(), and each registration creates an encapsulated context. This makes testing and modularity far cleaner than Express.

src/plugins/prisma.ts — the Prisma plugin:

import fp from 'fastify-plugin';
import { PrismaClient } from '@prisma/client';

declare module 'fastify' {
  interface FastifyInstance {
    prisma: PrismaClient;
  }
}

async function prismaPlugin(fastify: any) {
  const prisma = new PrismaClient({
    log: process.env.NODE_ENV === 'development'
      ? ['query', 'error', 'warn']
      : ['error'],
  });

  await prisma.$connect();

  fastify.decorate('prisma', prisma);

  fastify.addHook('onClose', async () => {
    await prisma.$disconnect();
  });
}

export default fp(prismaPlugin, { name: 'prisma' });

src/plugins/redis.ts — the Redis plugin:

import fp from 'fastify-plugin';
import Redis from 'ioredis';

declare module 'fastify' {
  interface FastifyInstance {
    redis: Redis;
  }
}

async function redisPlugin(fastify: any) {
  const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379', {
    maxRetriesPerRequest: 3,
    enableReadyCheck: true,
  });

  redis.on('error', (err) => {
    fastify.log.error({ err }, 'Redis connection error');
  });

  fastify.decorate('redis', redis);

  fastify.addHook('onClose', async () => {
    await redis.quit();
  });
}

export default fp(redisPlugin, { name: 'redis' });

fastify-plugin (fp) lets plugins “escape” encapsulation. Without fp, the prisma and redis decorators are only available in the plugin’s scope. With fp, the decorators are available in the parent scope (where the plugin is registered). For infrastructure plugins like databases and caches, always use fp.

Plugin Lifecycle Diagram #

stateDiagram-v2
    [*] --> Created: fastify = Fastify()
    Created --> Registering: register(prismaPlugin)
    Registering --> Registered: decorate('prisma', client)
    Registered --> Listening: app.listen(3000)
    Listening --> Running: request in
    Running --> Listening: response out
    Listening --> Closing: SIGTERM/SIGINT
    Closing --> Cleanup: onClose hook
    Cleanup --> Disconnected: prisma.$disconnect()
    Disconnected --> [*]

Hooks — Preprocessing and Postprocessing #

Hooks are functions running at specific points in the request lifecycle. Fastify has many hooks; the most common:

HookTimingBest For
onRequestBefore routingAuth, rate limiting
preParsingBefore body parsingContent-type validation
preValidationBefore schema validationInput transformation
preHandlerAfter validationSetting context, permission checks
preSerializationBefore serializationOutput transformation
onSendBefore sending the responseLogging, headers
onResponseAfter the response is sentAudit logs
onErrorOn errorsCustom error responses
onCloseOn app shutdownCleanup

src/plugins/auth.ts — the auth hook:

import fp from 'fastify-plugin';
import jwt from 'jsonwebtoken';

export default fp(async (fastify) => {
  fastify.decorate('authenticate', async (request: any, reply: any) => {
    try {
      await request.jwtVerify();
      request.user = request.user as { id: number; role: string };
    } catch (err) {
      reply.code(401).send({ error: 'Unauthorized', message: 'Invalid token' });
    }
  });
});

declare module 'fastify' {
  interface FastifyInstance {
    authenticate: (request: any, reply: any) => Promise<void>;
  }
}

Use it in a route:

fastify.get('/me', { preHandler: [fastify.authenticate] }, async (request) => {
  return fastify.prisma.user.findUnique({ where: { id: request.user.id } });
});

Error Handling #

Fastify has a built-in error handler. For custom errors, override via setErrorHandler. Throw custom errors with consistent errorCodes.

src/lib/errors.ts:

export class AppError extends Error {
  constructor(
    public statusCode: number,
    public code: string,
    message: string,
    public details?: unknown,
  ) {
    super(message);
    this.name = 'AppError';
  }
}

export class NotFoundError extends AppError {
  constructor(message = 'Resource not found') {
    super(404, 'NotFound', message);
  }
}

export class ValidationError extends AppError {
  constructor(message: string, details?: unknown) {
    super(400, 'ValidationError', message, details);
  }
}

export class ConflictError extends AppError {
  constructor(message = 'Resource conflict') {
    super(409, 'Conflict', message);
  }
}

export class UnauthorizedError extends AppError {
  constructor(message = 'Unauthenticated') {
    super(401, 'Unauthorized', message);
  }
}

Custom error handler in app.ts:

fastify.setErrorHandler((error, request, reply) => {
  // Validation error from a schema
  if (error.validation) {
    return reply.code(400).send({
      error: 'ValidationError',
      message: error.message,
      details: error.validation,
    });
  }

  // Custom AppError
  if (error instanceof AppError) {
    request.log.warn({ err: error }, 'application error');
    return reply.code(error.statusCode).send({
      error: error.code,
      message: error.message,
      ...(error.details ? { details: error.details } : {}),
    });
  }

  // Default — full log on the server, generic response to the client
  request.log.error({ err: error }, 'unhandled error');
  return reply.code(500).send({
    error: 'InternalServerError',
    message: 'Something went wrong',
  });
});

Prisma Integration #

Prisma is a TypeScript-first ORM that pairs perfectly with Fastify. Automatic type generation, SQL-injection-safe query builder, and a built-in migration tool.

prisma/schema.prisma:

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id           Int      @id @default(autoincrement())
  email        String   @unique
  name         String
  passwordHash String   @map("password_hash")
  role         String   @default("user")
  createdAt    DateTime @default(now()) @map("created_at")
  updatedAt    DateTime @updatedAt @map("updated_at")

  @@map("users")
  @@index([email])
}

Generate the Prisma client on install:

# Add to the postinstall script
# "postinstall": "prisma generate"

# Or run manually
docker compose exec api npx prisma generate

db/init/01-init.sql — initial SQL (optional, runs when Postgres first starts):

-- Create the extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

-- Can also pre-create the schema
-- CREATE SCHEMA IF NOT EXISTS app;
For synchronize: false in production Prisma, run manual migrations: docker compose exec api npx prisma migrate deploy. In development, Prisma can sync the schema automatically during prisma generate. But it’s better to use migration files from the start, so the schema history is recorded.

Testing with Fastify.inject() #

One of Fastify’s most powerful features is app.inject() — tests can send HTTP requests directly to the app without starting a server on a port. Faster and more reliable than supertest + listening on an ephemeral port.

test/users.test.ts:

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { build } from '../src/server.js';

let app: any;

beforeAll(async () => {
  app = await build();
  await app.ready();
});

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

describe('GET /api/v1/users', () => {
  it('returns a list of users', async () => {
    const response = await app.inject({
      method: 'GET',
      url: '/api/v1/users',
    });

    expect(response.statusCode).toBe(200);
    const body = response.json();
    expect(body).toHaveProperty('data');
    expect(Array.isArray(body.data)).toBe(true);
  });

  it('rejects requests without auth', async () => {
    const response = await app.inject({
      method: 'GET',
      url: '/api/v1/users/me',
    });
    expect(response.statusCode).toBe(401);
  });
});

describe('POST /api/v1/users', () => {
  it('rejects a body without a valid email', async () => {
    const response = await app.inject({
      method: 'POST',
      url: '/api/v1/users',
      payload: { name: 'Test', password: 'short' },
    });
    expect(response.statusCode).toBe(400);
  });
});

app.inject() is far faster than supertest because there’s no HTTP, socket, or port-binding overhead. Tests can run in parallel without port conflicts.


Build and Run #

# Build the image the first time
docker compose build

# Run all services
docker compose up -d

# View logs
docker compose logs -f api

# Prisma generate (after changing the schema)
docker compose exec api npx prisma generate

# Prisma migrate
docker compose exec api npx prisma migrate dev

# Open Prisma Studio
open http://localhost:5555

# Access the Swagger UI
open http://localhost:3000/docs

# Stop
docker compose down

Access:

ServiceURL
APIhttp://localhost:3000
Swagger UIhttp://localhost:3000/docs
Health checkhttp://localhost:3000/health
Prisma Studiohttp://localhost:5555
Postgreslocalhost:5432 (user app, password dev)
Redislocalhost:6379
The Swagger UI at /docs is a bonus of Fastify + @fastify/swagger + @fastify/swagger-ui. Just add a schema to each route and OpenAPI 3.0 documentation appears automatically. Very useful for frontend developers or QA who need to test without asking the backend developer.

Best Practices #

1. Always Define Schemas #

// ANTI-PATTERN: without a schema
fastify.post('/users', async (request) => {
  const { email } = request.body;  // request.body is any!
  // ...
});
// CORRECT: with a schema
fastify.post('/users', {
  schema: {
    body: { type: 'object', properties: { email: { type: 'string', format: 'email' } } },
    response: { 201: userSchema },
  },
}, async (request) => {
  const { email } = request.body;  // typed and validated
});

Schemas aren’t optional — they’re what make Fastify “fast” and “secure”.

2. Use fastify-plugin for Infrastructure Plugins #

Plugins that decorate (Prisma, Redis, JWT) must use fp() so the decorators are available in the parent scope. Routing plugins don’t need fp() — leave them encapsulated.

3. Separate Build and Listen #

The app() + server.ts pattern:

  • app() — returns a ready-to-use Fastify instance (for tests)
  • server.ts — calls app() then listen()

Tests can inject() without starting a server. Production can listen() on the right host.

4. Centralized Error Handling #

setErrorHandler in app.ts is the single source of truth for error responses. Custom error classes (NotFoundError, ValidationError) make throw + handle explicit and consistent.

5. Logging with Pino #

Fastify defaults to Pino — one of the fastest loggers for Node.js. In development, pino-pretty for colored output. In production, JSON logs to stdout (for aggregation into ELK, Datadog, etc.).

6. Schema-First with Zod for TypeScript Teams #

For TypeScript teams, fastify-type-provider-zod gives full type safety from Zod schemas to handlers. The editor auto-completes request.body from the schema.

7. Health Checks for Containers #

fastify.get('/health', async () => {
  try {
    await fastify.prisma.$queryRaw`SELECT 1`;
    await fastify.redis.ping();
    return { status: 'ok', uptime: process.uptime() };
  } catch (err) {
    throw new Error('Database or cache unavailable');
  }
});

8. JSON Schema Reuse #

// schemas/common.ts
export const idParam = {
  type: 'object',
  properties: { id: { type: 'integer', minimum: 1 } },
  required: ['id'],
} as const;

export const paginationQuery = {
  type: 'object',
  properties: {
    limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
    offset: { type: 'integer', minimum: 0, default: 0 },
  },
} as const;

Reusing across many routes reduces duplication.


When Fastify, When Something Else? #

FrameworkPerformanceStructureValidation TypeBest For
FastifyVery highModular, pluginsJSON Schema / ZodHigh-performance APIs
ExpressStandardFlexibleManual / librariesSimple APIs, prototypes
NestJSHighOpinionatedclass-validatorEnterprise, monorepos
KoaHighMinimalManualCustom middleware
HapiStandardPlugin-centricJoi (built-in)Large conventional apps
Fastify is ideal for public APIs receiving many requests and needing automatic validation. For low-traffic internal apps, Express or Hapi is more than enough. For monorepos with 10+ services, NestJS provides consistency Fastify doesn’t offer by default.

Troubleshooting #

Hot Reload Not Restarting #

Check command: npm run dev in docker-compose.yml. Make sure the dev script in package.json is tsx watch src/server.ts. Also check the logs: docker compose logs -f apitsx prints “restarting” on changes.

Schema Validation Rejecting All Requests #

Make sure additionalProperties: false in the body schema isn’t invalidating previously-valid fields. Also check the required array — if you mark a field as required, requests without it are rejected.

Prisma Can’t Connect #

Error: P1001: Can't reach database server

The hostname in DATABASE_URL must be db (the Compose service name), not localhost. Also check: docker compose exec api env | grep DATABASE.

Decorators Not Available #

request.user, fastify.prisma, etc. — make sure the plugin is decorated with fastify.decorate() and registered in the right scope. For global decorators, use fastify-plugin (fp()).

Port Already in Use #

# Find the process using port 3000
lsof -i :3000

# Stop the process
kill -9 <PID>

# Or change the port in docker-compose.yml
ports:
  - "3001:3000"

Summary #

  • Fastify is ideal for local API development needing high performance and automatic validation. Hot reload via tsx watch keeps TypeScript iteration fast without leaving the container.
  • Multi-stage Dockerfiles: a deps stage for development (tsx, typescript, prisma), a builder stage for compiling, a slim runner stage for production with a non-root user.
  • The anonymous volumes /app/node_modules and /app/dist are mandatory — they protect binary modules and build output from host overrides.
  • Separating server.ts (factory) + app.ts (build) makes testing easy. Tests can inject() requests directly without listening on a port.
  • The schema-first philosophy — JSON Schema or Zod to validate body, params, querystring. Bonus: automatic OpenAPI documentation via @fastify/swagger.
  • The plugin system with fastify.register() creates encapsulated contexts. Use fastify-plugin (fp()) for infrastructure plugins (Prisma, Redis) so decorators are globally available.
  • Hooks like onRequest, preHandler, onClose — Fastify’s middleware. More structured and faster than Express middleware.
  • Centralized error handling with setErrorHandler. Custom error classes (NotFoundError, ValidationError) make throw + handle explicit.
  • The Prisma ORM + the plugin pattern. Automatic type generation, safe SQL queries, a built-in migration tool.
  • Free Swagger UI at /docs — OpenAPI documentation auto-generated from schemas.
  • Testing with app.inject() — HTTP requests straight into the app without listening on a port, faster and more reliable than supertest.
  • The Pino logger is built in, one of the fastest in Node.js. pino-pretty for development, JSON logs for production.
  • When to choose Fastify: high-performance APIs, schema validation, need OpenAPI. When to choose Express: prototypes, lightweight API gateways. When to choose NestJS: enterprise monorepos.
  • Troubleshooting: hot reload (check scripts + logs), schema rejection (check additionalProperties), Prisma (service hostname), decorators (fp + scope), port conflicts (lsof).
  • Best practices: always define schemas, reuse JSON Schemas, separate build/listen, centralized error handlers, schema-first with Zod for TS, container health checks, non-root users in production.

← Previous: NestJS   Next: Next.js →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact