Building Production-Ready APIs with Fastify and TypeScript

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

Why Fastify?

Fastify is one of the fastest Node.js web frameworks available, consistently benchmarking at over 70,000 requests per second. Unlike Express, Fastify was built from the ground up with performance, developer experience, and TypeScript in mind. Its plugin architecture, built-in schema validation with JSON Schema, and automatic serialization make it an excellent choice for production APIs.

In this tutorial, you will build a complete REST API for a task management system — from project scaffolding to deployment-ready configuration.

Prerequisites

Before starting, make sure you have:

  • Node.js 20+ installed
  • TypeScript fundamentals (interfaces, generics, decorators)
  • Basic understanding of REST API concepts
  • A code editor (VS Code recommended)
  • Docker installed (for the database)

What You'll Build

A production-ready task management API with:

  • CRUD operations for tasks and projects
  • JWT authentication with refresh tokens
  • PostgreSQL database with Drizzle ORM
  • Request validation using JSON Schema / TypeBox
  • Structured logging with Pino
  • Rate limiting and CORS
  • Comprehensive error handling
  • Unit and integration tests

Step 1: Project Setup

Start by creating a new project and installing dependencies:

mkdir fastify-tasks-api && cd fastify-tasks-api
npm init -y

Install the core dependencies:

npm install fastify @fastify/cors @fastify/rate-limit @fastify/jwt @fastify/swagger @fastify/swagger-ui @sinclair/typebox drizzle-orm postgres dotenv

Install dev dependencies:

npm install -D typescript @types/node tsx vitest drizzle-kit

Initialize TypeScript:

npx tsc --init

Update your tsconfig.json with these settings:

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

Add scripts to your package.json:

{
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/server.ts",
    "build": "tsc",
    "start": "node dist/server.js",
    "test": "vitest",
    "db:generate": "drizzle-kit generate",
    "db:migrate": "drizzle-kit migrate",
    "db:studio": "drizzle-kit studio"
  }
}

Step 2: Project Structure

Organize your project with a clean, modular structure:

src/
├── config/
│   └── env.ts            # Environment configuration
├── db/
│   ├── index.ts          # Database connection
│   ├── schema.ts         # Drizzle schema definitions
│   └── migrate.ts        # Migration runner
├── modules/
│   ├── auth/
│   │   ├── auth.routes.ts
│   │   ├── auth.service.ts
│   │   └── auth.schema.ts
│   └── tasks/
│       ├── tasks.routes.ts
│       ├── tasks.service.ts
│       └── tasks.schema.ts
├── plugins/
│   ├── auth.ts           # JWT auth plugin
│   ├── cors.ts           # CORS plugin
│   └── rate-limit.ts     # Rate limiting plugin
├── utils/
│   └── errors.ts         # Custom error classes
├── app.ts                # Fastify app factory
└── server.ts             # Server entry point

Step 3: Environment Configuration

Create a .env file at the project root:

DATABASE_URL=postgres://postgres:postgres@localhost:5432/fastify_tasks
JWT_SECRET=your-super-secret-jwt-key-change-in-production
JWT_REFRESH_SECRET=your-refresh-secret-key-change-in-production
PORT=3000
HOST=0.0.0.0
NODE_ENV=development

Now create the environment configuration module with TypeBox validation:

// src/config/env.ts
import { Type, type Static } from "@sinclair/typebox";
import { Value } from "@sinclair/typebox/value";
import "dotenv/config";
 
const EnvSchema = Type.Object({
  DATABASE_URL: Type.String(),
  JWT_SECRET: Type.String({ minLength: 32 }),
  JWT_REFRESH_SECRET: Type.String({ minLength: 32 }),
  PORT: Type.Number({ default: 3000 }),
  HOST: Type.String({ default: "0.0.0.0" }),
  NODE_ENV: Type.Union([
    Type.Literal("development"),
    Type.Literal("production"),
    Type.Literal("test"),
  ]),
});
 
type Env = Static<typeof EnvSchema>;
 
const rawEnv = {
  ...process.env,
  PORT: Number(process.env.PORT) || 3000,
};
 
if (!Value.Check(EnvSchema, rawEnv)) {
  const errors = [...Value.Errors(EnvSchema, rawEnv)];
  console.error("❌ Invalid environment variables:");
  errors.forEach((err) => console.error(`  ${err.path}: ${err.message}`));
  process.exit(1);
}
 
export const env: Env = rawEnv as Env;

This gives you validated, type-safe environment variables. If any required variable is missing or invalid, the app exits immediately with a clear error message.

Step 4: Database Schema with Drizzle

Start a PostgreSQL container:

docker run -d --name fastify-pg \
  -e POSTGRES_PASSWORD=postgres \
  -e POSTGRES_DB=fastify_tasks \
  -p 5432:5432 \
  postgres:16-alpine

Define the database schema:

// src/db/schema.ts
import {
  pgTable,
  uuid,
  varchar,
  text,
  timestamp,
  boolean,
  pgEnum,
} from "drizzle-orm/pg-core";
 
export const taskStatusEnum = pgEnum("task_status", [
  "todo",
  "in_progress",
  "done",
  "cancelled",
]);
 
export const taskPriorityEnum = pgEnum("task_priority", [
  "low",
  "medium",
  "high",
  "urgent",
]);
 
export const users = pgTable("users", {
  id: uuid("id").defaultRandom().primaryKey(),
  email: varchar("email", { length: 255 }).notNull().unique(),
  passwordHash: varchar("password_hash", { length: 255 }).notNull(),
  name: varchar("name", { length: 100 }).notNull(),
  createdAt: timestamp("created_at").defaultNow().notNull(),
  updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
 
export const projects = pgTable("projects", {
  id: uuid("id").defaultRandom().primaryKey(),
  name: varchar("name", { length: 200 }).notNull(),
  description: text("description"),
  ownerId: uuid("owner_id")
    .references(() => users.id)
    .notNull(),
  createdAt: timestamp("created_at").defaultNow().notNull(),
  updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
 
export const tasks = pgTable("tasks", {
  id: uuid("id").defaultRandom().primaryKey(),
  title: varchar("title", { length: 300 }).notNull(),
  description: text("description"),
  status: taskStatusEnum("status").default("todo").notNull(),
  priority: taskPriorityEnum("priority").default("medium").notNull(),
  dueDate: timestamp("due_date"),
  completed: boolean("completed").default(false).notNull(),
  projectId: uuid("project_id")
    .references(() => projects.id)
    .notNull(),
  assigneeId: uuid("assignee_id").references(() => users.id),
  createdAt: timestamp("created_at").defaultNow().notNull(),
  updatedAt: timestamp("updated_at").defaultNow().notNull(),
});

Set up the database connection:

// src/db/index.ts
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import { env } from "../config/env.js";
import * as schema from "./schema.js";
 
const client = postgres(env.DATABASE_URL);
export const db = drizzle(client, { schema });
export type Database = typeof db;

Create the Drizzle config file at the project root:

// drizzle.config.ts
import { defineConfig } from "drizzle-kit";
 
export default defineConfig({
  schema: "./src/db/schema.ts",
  out: "./drizzle",
  dialect: "postgresql",
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
});

Generate and run migrations:

npm run db:generate
npm run db:migrate

Step 5: Custom Error Handling

Create structured error classes that Fastify can serialize:

// src/utils/errors.ts
export class AppError extends Error {
  constructor(
    public statusCode: number,
    message: string,
    public code: string
  ) {
    super(message);
    this.name = "AppError";
  }
}
 
export class NotFoundError extends AppError {
  constructor(resource: string, id: string) {
    super(404, `${resource} with id '${id}' not found`, "NOT_FOUND");
  }
}
 
export class UnauthorizedError extends AppError {
  constructor(message = "Authentication required") {
    super(401, message, "UNAUTHORIZED");
  }
}
 
export class ForbiddenError extends AppError {
  constructor(message = "Insufficient permissions") {
    super(403, message, "FORBIDDEN");
  }
}
 
export class ConflictError extends AppError {
  constructor(message: string) {
    super(409, message, "CONFLICT");
  }
}
 
export class ValidationError extends AppError {
  constructor(message: string) {
    super(400, message, "VALIDATION_ERROR");
  }
}

Step 6: Authentication Plugin

Build a reusable JWT authentication plugin:

// src/plugins/auth.ts
import fp from "fastify-plugin";
import fjwt from "@fastify/jwt";
import { type FastifyInstance, type FastifyRequest } from "fastify";
import { env } from "../config/env.js";
import { UnauthorizedError } from "../utils/errors.js";
 
declare module "fastify" {
  interface FastifyInstance {
    authenticate: (
      request: FastifyRequest
    ) => Promise<void>;
  }
}
 
declare module "@fastify/jwt" {
  interface FastifyJWT {
    payload: { userId: string; email: string };
    user: { userId: string; email: string };
  }
}
 
export default fp(async (fastify: FastifyInstance) => {
  await fastify.register(fjwt, {
    secret: env.JWT_SECRET,
    sign: { expiresIn: "15m" },
  });
 
  fastify.decorate(
    "authenticate",
    async (request: FastifyRequest) => {
      try {
        await request.jwtVerify();
      } catch {
        throw new UnauthorizedError("Invalid or expired token");
      }
    }
  );
});

Step 7: Request Validation Schemas with TypeBox

TypeBox generates JSON Schema at runtime while giving you full TypeScript types at compile time. This is one of Fastify's superpowers — your validation and types stay perfectly in sync:

// src/modules/auth/auth.schema.ts
import { Type, type Static } from "@sinclair/typebox";
 
export const RegisterBody = Type.Object({
  email: Type.String({ format: "email" }),
  password: Type.String({ minLength: 8, maxLength: 128 }),
  name: Type.String({ minLength: 1, maxLength: 100 }),
});
 
export const LoginBody = Type.Object({
  email: Type.String({ format: "email" }),
  password: Type.String(),
});
 
export const AuthResponse = Type.Object({
  accessToken: Type.String(),
  refreshToken: Type.String(),
  user: Type.Object({
    id: Type.String(),
    email: Type.String(),
    name: Type.String(),
  }),
});
 
export type RegisterBodyType = Static<typeof RegisterBody>;
export type LoginBodyType = Static<typeof LoginBody>;
export type AuthResponseType = Static<typeof AuthResponse>;
// src/modules/tasks/tasks.schema.ts
import { Type, type Static } from "@sinclair/typebox";
 
export const TaskParams = Type.Object({
  id: Type.String({ format: "uuid" }),
});
 
export const TaskQuerystring = Type.Object({
  status: Type.Optional(
    Type.Union([
      Type.Literal("todo"),
      Type.Literal("in_progress"),
      Type.Literal("done"),
      Type.Literal("cancelled"),
    ])
  ),
  priority: Type.Optional(
    Type.Union([
      Type.Literal("low"),
      Type.Literal("medium"),
      Type.Literal("high"),
      Type.Literal("urgent"),
    ])
  ),
  projectId: Type.Optional(Type.String({ format: "uuid" })),
  page: Type.Optional(Type.Number({ minimum: 1, default: 1 })),
  limit: Type.Optional(Type.Number({ minimum: 1, maximum: 100, default: 20 })),
});
 
export const CreateTaskBody = Type.Object({
  title: Type.String({ minLength: 1, maxLength: 300 }),
  description: Type.Optional(Type.String()),
  status: Type.Optional(
    Type.Union([
      Type.Literal("todo"),
      Type.Literal("in_progress"),
      Type.Literal("done"),
      Type.Literal("cancelled"),
    ])
  ),
  priority: Type.Optional(
    Type.Union([
      Type.Literal("low"),
      Type.Literal("medium"),
      Type.Literal("high"),
      Type.Literal("urgent"),
    ])
  ),
  dueDate: Type.Optional(Type.String({ format: "date-time" })),
  projectId: Type.String({ format: "uuid" }),
  assigneeId: Type.Optional(Type.String({ format: "uuid" })),
});
 
export const UpdateTaskBody = Type.Partial(CreateTaskBody);
 
export const TaskResponse = Type.Object({
  id: Type.String(),
  title: Type.String(),
  description: Type.Union([Type.String(), Type.Null()]),
  status: Type.String(),
  priority: Type.String(),
  dueDate: Type.Union([Type.String(), Type.Null()]),
  completed: Type.Boolean(),
  projectId: Type.String(),
  assigneeId: Type.Union([Type.String(), Type.Null()]),
  createdAt: Type.String(),
  updatedAt: Type.String(),
});
 
export const TaskListResponse = Type.Object({
  data: Type.Array(TaskResponse),
  total: Type.Number(),
  page: Type.Number(),
  limit: Type.Number(),
});
 
export type TaskParamsType = Static<typeof TaskParams>;
export type TaskQuerystringType = Static<typeof TaskQuerystring>;
export type CreateTaskBodyType = Static<typeof CreateTaskBody>;
export type UpdateTaskBodyType = Static<typeof UpdateTaskBody>;

Step 8: Service Layer

Keep your business logic in service modules, separate from route handlers:

// src/modules/tasks/tasks.service.ts
import { eq, and, sql, type SQL } from "drizzle-orm";
import { type Database } from "../../db/index.js";
import { tasks } from "../../db/schema.js";
import {
  type CreateTaskBodyType,
  type UpdateTaskBodyType,
  type TaskQuerystringType,
} from "./tasks.schema.js";
import { NotFoundError } from "../../utils/errors.js";
 
export class TasksService {
  constructor(private db: Database) {}
 
  async list(query: TaskQuerystringType) {
    const page = query.page ?? 1;
    const limit = query.limit ?? 20;
    const offset = (page - 1) * limit;
 
    const conditions: SQL[] = [];
    if (query.status) conditions.push(eq(tasks.status, query.status));
    if (query.priority) conditions.push(eq(tasks.priority, query.priority));
    if (query.projectId) conditions.push(eq(tasks.projectId, query.projectId));
 
    const where = conditions.length > 0 ? and(...conditions) : undefined;
 
    const [data, countResult] = await Promise.all([
      this.db
        .select()
        .from(tasks)
        .where(where)
        .limit(limit)
        .offset(offset)
        .orderBy(tasks.createdAt),
      this.db
        .select({ count: sql<number>`count(*)` })
        .from(tasks)
        .where(where),
    ]);
 
    return {
      data,
      total: Number(countResult[0].count),
      page,
      limit,
    };
  }
 
  async getById(id: string) {
    const result = await this.db
      .select()
      .from(tasks)
      .where(eq(tasks.id, id))
      .limit(1);
 
    if (result.length === 0) {
      throw new NotFoundError("Task", id);
    }
    return result[0];
  }
 
  async create(data: CreateTaskBodyType) {
    const result = await this.db
      .insert(tasks)
      .values({
        title: data.title,
        description: data.description,
        status: data.status ?? "todo",
        priority: data.priority ?? "medium",
        dueDate: data.dueDate ? new Date(data.dueDate) : null,
        projectId: data.projectId,
        assigneeId: data.assigneeId,
      })
      .returning();
 
    return result[0];
  }
 
  async update(id: string, data: UpdateTaskBodyType) {
    // Verify the task exists first
    await this.getById(id);
 
    const result = await this.db
      .update(tasks)
      .set({
        ...data,
        dueDate: data.dueDate ? new Date(data.dueDate) : undefined,
        updatedAt: new Date(),
      })
      .where(eq(tasks.id, id))
      .returning();
 
    return result[0];
  }
 
  async delete(id: string) {
    await this.getById(id);
    await this.db.delete(tasks).where(eq(tasks.id, id));
  }
}

Step 9: Route Handlers

Now connect everything in route handlers. Notice how Fastify's schema option gives you automatic validation and type inference:

// src/modules/tasks/tasks.routes.ts
import { type FastifyInstance } from "fastify";
import { TasksService } from "./tasks.service.js";
import {
  TaskParams,
  TaskQuerystring,
  CreateTaskBody,
  UpdateTaskBody,
  TaskResponse,
  TaskListResponse,
  type TaskParamsType,
  type TaskQuerystringType,
  type CreateTaskBodyType,
  type UpdateTaskBodyType,
} from "./tasks.schema.js";
import { db } from "../../db/index.js";
 
export default async function tasksRoutes(fastify: FastifyInstance) {
  const service = new TasksService(db);
 
  // All routes in this plugin require authentication
  fastify.addHook("onRequest", fastify.authenticate);
 
  // GET /tasks
  fastify.get<{ Querystring: TaskQuerystringType }>(
    "/",
    {
      schema: {
        querystring: TaskQuerystring,
        response: { 200: TaskListResponse },
        tags: ["Tasks"],
        summary: "List all tasks with optional filters",
      },
    },
    async (request) => {
      return service.list(request.query);
    }
  );
 
  // GET /tasks/:id
  fastify.get<{ Params: TaskParamsType }>(
    "/:id",
    {
      schema: {
        params: TaskParams,
        response: { 200: TaskResponse },
        tags: ["Tasks"],
        summary: "Get a task by ID",
      },
    },
    async (request) => {
      return service.getById(request.params.id);
    }
  );
 
  // POST /tasks
  fastify.post<{ Body: CreateTaskBodyType }>(
    "/",
    {
      schema: {
        body: CreateTaskBody,
        response: { 201: TaskResponse },
        tags: ["Tasks"],
        summary: "Create a new task",
      },
    },
    async (request, reply) => {
      const task = await service.create(request.body);
      return reply.status(201).send(task);
    }
  );
 
  // PATCH /tasks/:id
  fastify.patch<{ Params: TaskParamsType; Body: UpdateTaskBodyType }>(
    "/:id",
    {
      schema: {
        params: TaskParams,
        body: UpdateTaskBody,
        response: { 200: TaskResponse },
        tags: ["Tasks"],
        summary: "Update a task",
      },
    },
    async (request) => {
      return service.update(request.params.id, request.body);
    }
  );
 
  // DELETE /tasks/:id
  fastify.delete<{ Params: TaskParamsType }>(
    "/:id",
    {
      schema: {
        params: TaskParams,
        tags: ["Tasks"],
        summary: "Delete a task",
      },
    },
    async (request, reply) => {
      await service.delete(request.params.id);
      return reply.status(204).send();
    }
  );
}

Step 10: App Factory and Server

Create the application factory. This pattern makes testing much easier since you can create isolated app instances:

// src/app.ts
import Fastify, { type FastifyInstance } from "fastify";
import cors from "@fastify/cors";
import rateLimit from "@fastify/rate-limit";
import swagger from "@fastify/swagger";
import swaggerUi from "@fastify/swagger-ui";
import authPlugin from "./plugins/auth.js";
import tasksRoutes from "./modules/tasks/tasks.routes.js";
import { AppError } from "./utils/errors.js";
import { env } from "./config/env.js";
 
export async function buildApp(): Promise<FastifyInstance> {
  const app = Fastify({
    logger: {
      level: env.NODE_ENV === "production" ? "info" : "debug",
      transport:
        env.NODE_ENV === "development"
          ? { target: "pino-pretty", options: { colorize: true } }
          : undefined,
    },
  });
 
  // --- Plugins ---
  await app.register(cors, {
    origin: env.NODE_ENV === "production"
      ? ["https://yourdomain.com"]
      : true,
    credentials: true,
  });
 
  await app.register(rateLimit, {
    max: 100,
    timeWindow: "1 minute",
  });
 
  await app.register(authPlugin);
 
  // --- Swagger Docs ---
  await app.register(swagger, {
    openapi: {
      info: {
        title: "Fastify Tasks API",
        version: "1.0.0",
        description: "A production-ready task management API",
      },
      components: {
        securitySchemes: {
          bearerAuth: {
            type: "http",
            scheme: "bearer",
            bearerFormat: "JWT",
          },
        },
      },
      security: [{ bearerAuth: [] }],
    },
  });
 
  await app.register(swaggerUi, {
    routePrefix: "/docs",
  });
 
  // --- Routes ---
  await app.register(tasksRoutes, { prefix: "/api/tasks" });
 
  // --- Health Check ---
  app.get("/health", async () => ({
    status: "ok",
    timestamp: new Date().toISOString(),
    uptime: process.uptime(),
  }));
 
  // --- Global Error Handler ---
  app.setErrorHandler((error, request, reply) => {
    if (error instanceof AppError) {
      return reply.status(error.statusCode).send({
        error: error.code,
        message: error.message,
        statusCode: error.statusCode,
      });
    }
 
    // Fastify validation errors
    if (error.validation) {
      return reply.status(400).send({
        error: "VALIDATION_ERROR",
        message: error.message,
        statusCode: 400,
      });
    }
 
    // Unexpected errors
    request.log.error(error);
    return reply.status(500).send({
      error: "INTERNAL_SERVER_ERROR",
      message:
        env.NODE_ENV === "production"
          ? "An unexpected error occurred"
          : error.message,
      statusCode: 500,
    });
  });
 
  return app;
}
// src/server.ts
import { buildApp } from "./app.js";
import { env } from "./config/env.js";
 
async function start() {
  const app = await buildApp();
 
  try {
    await app.listen({ port: env.PORT, host: env.HOST });
    app.log.info(`Server running at http://${env.HOST}:${env.PORT}`);
    app.log.info(`API docs at http://${env.HOST}:${env.PORT}/docs`);
  } catch (err) {
    app.log.error(err);
    process.exit(1);
  }
}
 
start();

Start the dev server:

npm run dev

Visit http://localhost:3000/docs to see the auto-generated Swagger documentation.

Step 11: Adding Graceful Shutdown

Production servers need to handle shutdown signals properly — finishing in-flight requests before exiting:

// Add to src/server.ts after app.listen()
 
const signals: NodeJS.Signals[] = ["SIGINT", "SIGTERM"];
 
for (const signal of signals) {
  process.on(signal, async () => {
    app.log.info(`Received ${signal}, shutting down gracefully...`);
    await app.close();
    process.exit(0);
  });
}
 
process.on("unhandledRejection", (err) => {
  app.log.error(err, "Unhandled rejection");
  process.exit(1);
});

Step 12: Testing with Vitest

Fastify's inject method lets you test routes without starting a real HTTP server:

// src/modules/tasks/__tests__/tasks.routes.test.ts
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { buildApp } from "../../../app.js";
import { type FastifyInstance } from "fastify";
 
describe("Tasks Routes", () => {
  let app: FastifyInstance;
  let authToken: string;
 
  beforeAll(async () => {
    app = await buildApp();
 
    // Create a test user and get a token
    // In a real app, you'd use a test database
    const registerRes = await app.inject({
      method: "POST",
      url: "/api/auth/register",
      payload: {
        email: "test@example.com",
        password: "securepassword123",
        name: "Test User",
      },
    });
 
    const body = JSON.parse(registerRes.body);
    authToken = body.accessToken;
  });
 
  afterAll(async () => {
    await app.close();
  });
 
  it("should return 401 without auth token", async () => {
    const response = await app.inject({
      method: "GET",
      url: "/api/tasks",
    });
 
    expect(response.statusCode).toBe(401);
  });
 
  it("should create a task", async () => {
    const response = await app.inject({
      method: "POST",
      url: "/api/tasks",
      headers: {
        authorization: `Bearer ${authToken}`,
      },
      payload: {
        title: "Write documentation",
        description: "Write API docs for the tasks module",
        priority: "high",
        projectId: "some-project-id",
      },
    });
 
    expect(response.statusCode).toBe(201);
    const task = JSON.parse(response.body);
    expect(task.title).toBe("Write documentation");
    expect(task.priority).toBe("high");
  });
 
  it("should validate request body", async () => {
    const response = await app.inject({
      method: "POST",
      url: "/api/tasks",
      headers: {
        authorization: `Bearer ${authToken}`,
      },
      payload: {
        // Missing required 'title' field
        description: "No title provided",
      },
    });
 
    expect(response.statusCode).toBe(400);
  });
 
  it("should return health check", async () => {
    const response = await app.inject({
      method: "GET",
      url: "/health",
    });
 
    expect(response.statusCode).toBe(200);
    const body = JSON.parse(response.body);
    expect(body.status).toBe("ok");
  });
});

Configure Vitest:

// vitest.config.ts
import { defineConfig } from "vitest/config";
 
export default defineConfig({
  test: {
    globals: true,
    environment: "node",
    include: ["src/**/*.test.ts"],
    coverage: {
      provider: "v8",
      reporter: ["text", "html"],
    },
  },
});

Run tests:

npm test

Step 13: Docker Configuration

Create a multi-stage Dockerfile for production:

# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
 
# Production stage
FROM node:20-alpine AS runner
WORKDIR /app
 
RUN addgroup --system --gid 1001 nodejs && \
    adduser --system --uid 1001 fastify
 
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/drizzle ./drizzle
 
RUN npm ci --omit=dev
 
USER fastify
EXPOSE 3000
 
HEALTHCHECK --interval=30s --timeout=3s \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
 
CMD ["node", "dist/server.js"]

And a docker-compose.yml for local development:

version: "3.8"
services:
  api:
    build: .
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgres://postgres:postgres@db:5432/fastify_tasks
      - JWT_SECRET=dev-secret-that-is-at-least-32-chars-long
      - JWT_REFRESH_SECRET=dev-refresh-secret-32-chars-long-too
      - NODE_ENV=production
    depends_on:
      db:
        condition: service_healthy
 
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: fastify_tasks
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5
 
volumes:
  pgdata:

Performance Tips

Fastify is already fast out of the box, but here are ways to squeeze out even more performance:

1. Use Schema for Response Serialization

When you define response schemas, Fastify uses fast-json-stringify instead of JSON.stringify, which can be up to 2x faster:

// The response schema in your route options enables fast serialization
schema: {
  response: {
    200: TaskResponse  // This enables fast-json-stringify
  }
}

2. Connection Pooling

Configure your PostgreSQL connection pool based on your workload:

const client = postgres(env.DATABASE_URL, {
  max: 20,            // Maximum pool size
  idle_timeout: 30,   // Close idle connections after 30s
  connect_timeout: 10 // Timeout for new connections
});

3. Enable HTTP/2 for Production

import { readFileSync } from "fs";
 
const app = Fastify({
  http2: true,
  https: {
    key: readFileSync("./certs/key.pem"),
    cert: readFileSync("./certs/cert.pem"),
  },
});

Troubleshooting

Common Issues

"Cannot find module" errors with ESM: Make sure your package.json has "type": "module" and all local imports use the .js extension (even for .ts files). TypeScript resolves .js imports to .ts files during compilation.

Fastify plugin registration order matters: Plugins must be registered before routes that depend on them. Always register authPlugin before routes that use fastify.authenticate.

Type errors with request.user: Ensure you have the declare module "@fastify/jwt" augmentation in your auth plugin. Without it, TypeScript will not recognize the user property on requests.

Schema validation not working: Check that you are passing TypeBox schemas (not plain TypeScript types) to the schema option. TypeBox produces JSON Schema objects at runtime, which is what Fastify needs.

Next Steps

Now that you have a production-ready Fastify API, consider adding:

  • WebSocket support with @fastify/websocket for real-time updates
  • File uploads with @fastify/multipart
  • Caching with @fastify/caching or Redis
  • OpenTelemetry instrumentation for distributed tracing
  • CI/CD pipeline with GitHub Actions for automated testing and deployment

Conclusion

You have built a complete, production-ready API with Fastify and TypeScript. The combination of Fastify's built-in schema validation, TypeBox's type-safe schemas, Drizzle ORM's type-safe queries, and structured error handling gives you a robust foundation that catches bugs at compile time and validates data at runtime.

Fastify's plugin architecture makes it easy to add features incrementally. The app factory pattern keeps your code testable, and Docker makes deployment consistent across environments. Whether you are building a microservice or a full-featured backend, this architecture scales well as your project grows.


Want to read more tutorials? Check out our latest tutorial on Build a Production Monorepo with Turborepo, Next.js, and Shared Packages.

Discuss Your Project with Us

We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.

Let's find the best solutions for your needs.

Related Articles