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
Theserver.ts+app.tspattern is a Fastify best practice.server.tsis a factory creating a Fastify instance with full configuration.app.tsis the entry point thatlistens. This pattern makes testing easy: tests canbuildthe app withoutlisten, theninject()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.
Thenode:20-alpineimage already includes glibc compatible with binary modules likebcrypt,sharp, andprisma. For very platform-specific modules, you sometimes neednode: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 neededHot 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 fromapp.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 #
| Aspect | Native JSON Schema | Zod (via type provider) |
|---|---|---|
| Performance | Fastest | Slightly slower (conversion) |
| Type safety | Manual types | Automatic from schemas |
| Learning curve | Need to know JSON Schema | Familiar to TS devs |
| OpenAPI generation | Built-in | Built-in |
| Reusability | Importable | Importable |
| Best for | High performance, complex schemas | TS teams, complex validation |
UseadditionalProperties: falsein 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. Withoutfp, theprismaandredisdecorators are only available in the plugin’s scope. Withfp, the decorators are available in the parent scope (where the plugin is registered). For infrastructure plugins like databases and caches, always usefp.
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:
| Hook | Timing | Best For |
|---|---|---|
onRequest | Before routing | Auth, rate limiting |
preParsing | Before body parsing | Content-type validation |
preValidation | Before schema validation | Input transformation |
preHandler | After validation | Setting context, permission checks |
preSerialization | Before serialization | Output transformation |
onSend | Before sending the response | Logging, headers |
onResponse | After the response is sent | Audit logs |
onError | On errors | Custom error responses |
onClose | On app shutdown | Cleanup |
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;
Forsynchronize: falsein production Prisma, run manual migrations:docker compose exec api npx prisma migrate deploy. In development, Prisma can sync the schema automatically duringprisma 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:
| Service | URL |
|---|---|
| API | http://localhost:3000 |
| Swagger UI | http://localhost:3000/docs |
| Health check | http://localhost:3000/health |
| Prisma Studio | http://localhost:5555 |
| Postgres | localhost:5432 (user app, password dev) |
| Redis | localhost:6379 |
The Swagger UI at/docsis 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— callsapp()thenlisten()
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? #
| Framework | Performance | Structure | Validation Type | Best For |
|---|---|---|---|---|
| Fastify | Very high | Modular, plugins | JSON Schema / Zod | High-performance APIs |
| Express | Standard | Flexible | Manual / libraries | Simple APIs, prototypes |
| NestJS | High | Opinionated | class-validator | Enterprise, monorepos |
| Koa | High | Minimal | Manual | Custom middleware |
| Hapi | Standard | Plugin-centric | Joi (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 api — tsx 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 watchkeeps TypeScript iteration fast without leaving the container.- Multi-stage Dockerfiles: a
depsstage for development (tsx, typescript, prisma), abuilderstage for compiling, a slimrunnerstage for production with a non-root user.- The anonymous volumes
/app/node_modulesand/app/distare mandatory — they protect binary modules and build output from host overrides.- Separating
server.ts(factory) +app.ts(build) makes testing easy. Tests caninject()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. Usefastify-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-prettyfor 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.