Construire des APIs prêtes pour la production avec Fastify et TypeScript

Pourquoi Fastify ?
Fastify est l'un des frameworks Node.js les plus rapides disponibles, atteignant régulièrement plus de 70 000 requêtes par seconde dans les benchmarks. Contrairement à Express, Fastify a été construit dès le départ avec un accent sur la performance, l'expérience développeur et le support natif de TypeScript. Son architecture en plugins, la validation intégrée par JSON Schema et la sérialisation automatique en font un excellent choix pour les APIs en production.
Dans ce tutoriel, vous allez construire une API REST complète pour un système de gestion de tâches — de la mise en place du projet à la configuration prête pour le déploiement.
Prérequis
Avant de commencer, assurez-vous d'avoir :
- Node.js 20+ installé
- Les fondamentaux de TypeScript (interfaces, génériques)
- Une compréhension basique des concepts REST API
- Un éditeur de code (VS Code recommandé)
- Docker installé (pour la base de données)
Ce que vous allez construire
Une API de gestion de tâches prête pour la production avec :
- Des opérations CRUD pour les tâches et les projets
- Une authentification JWT avec jetons de rafraîchissement
- Une base de données PostgreSQL avec Drizzle ORM
- La validation des requêtes avec JSON Schema / TypeBox
- Un logging structuré avec Pino
- La limitation de débit et CORS
- Une gestion complète des erreurs
- Des tests unitaires et d'intégration
Étape 1 : Configuration du projet
Commencez par créer un nouveau projet et installer les dépendances :
mkdir fastify-tasks-api && cd fastify-tasks-api
npm init -yInstallez les dépendances principales :
npm install fastify @fastify/cors @fastify/rate-limit @fastify/jwt @fastify/swagger @fastify/swagger-ui @sinclair/typebox drizzle-orm postgres dotenvInstallez les dépendances de développement :
npm install -D typescript @types/node tsx vitest drizzle-kitInitialisez TypeScript :
npx tsc --initMettez à jour votre tsconfig.json avec ces paramètres :
{
"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"]
}Ajoutez les scripts à votre 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"
}
}Étape 2 : Structure du projet
Organisez votre projet avec une structure propre et modulaire :
src/
├── config/
│ └── env.ts # Configuration de l'environnement
├── db/
│ ├── index.ts # Connexion à la base de données
│ ├── schema.ts # Définitions de schéma Drizzle
│ └── migrate.ts # Exécuteur de migrations
├── modules/
│ ├── auth/
│ │ ├── auth.routes.ts
│ │ ├── auth.service.ts
│ │ └── auth.schema.ts
│ └── tasks/
│ ├── tasks.routes.ts
│ ├── tasks.service.ts
│ └── tasks.schema.ts
├── plugins/
│ ├── auth.ts # Plugin JWT
│ ├── cors.ts # Plugin CORS
│ └── rate-limit.ts # Limitation de débit
├── utils/
│ └── errors.ts # Classes d'erreurs personnalisées
├── app.ts # Fabrique d'application Fastify
└── server.ts # Point d'entrée du serveur
Étape 3 : Configuration de l'environnement
Créez un fichier .env à la racine du projet :
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=developmentMaintenant, créez le module de configuration avec validation TypeBox :
// 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("❌ Variables d'environnement invalides :");
errors.forEach((err) => console.error(` ${err.path}: ${err.message}`));
process.exit(1);
}
export const env: Env = rawEnv as Env;Cela vous donne des variables d'environnement validées et typées. Si une variable requise est manquante ou invalide, l'application s'arrête immédiatement avec un message d'erreur clair.
Étape 4 : Schéma de base de données avec Drizzle
Démarrez un conteneur PostgreSQL :
docker run -d --name fastify-pg \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=fastify_tasks \
-p 5432:5432 \
postgres:16-alpineDéfinissez le schéma de la base de données :
// 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(),
});Configurez la connexion à la base de données :
// 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;Créez le fichier de configuration Drizzle à la racine du projet :
// 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!,
},
});Générez et exécutez les migrations :
npm run db:generate
npm run db:migrateÉtape 5 : Gestion des erreurs personnalisée
Créez des classes d'erreurs structurées que Fastify peut sérialiser :
// 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");
}
}Étape 6 : Plugin d'authentification
Construisez un plugin d'authentification JWT réutilisable :
// 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("Jeton invalide ou expiré");
}
}
);
});Étape 7 : Schémas de validation avec TypeBox
TypeBox génère du JSON Schema au runtime tout en vous donnant des types TypeScript complets à la compilation. C'est l'un des super-pouvoirs de Fastify — votre validation et vos types restent parfaitement synchronisés :
// 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>;Étape 8 : Couche de services
Gardez votre logique métier dans des modules de service, séparée des gestionnaires de routes :
// 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) {
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));
}
}Étape 9 : Gestionnaires de routes
Maintenant, connectez tout dans les gestionnaires de routes. Remarquez comment l'option schema de Fastify vous donne la validation automatique et l'inférence de types :
// 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);
fastify.addHook("onRequest", fastify.authenticate);
// GET /tasks
fastify.get<{ Querystring: TaskQuerystringType }>(
"/",
{
schema: {
querystring: TaskQuerystring,
response: { 200: TaskListResponse },
tags: ["Tasks"],
summary: "Lister toutes les tâches avec filtres optionnels",
},
},
async (request) => {
return service.list(request.query);
}
);
// GET /tasks/:id
fastify.get<{ Params: TaskParamsType }>(
"/:id",
{
schema: {
params: TaskParams,
response: { 200: TaskResponse },
tags: ["Tasks"],
summary: "Obtenir une tâche par ID",
},
},
async (request) => {
return service.getById(request.params.id);
}
);
// POST /tasks
fastify.post<{ Body: CreateTaskBodyType }>(
"/",
{
schema: {
body: CreateTaskBody,
response: { 201: TaskResponse },
tags: ["Tasks"],
summary: "Créer une nouvelle tâche",
},
},
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: "Mettre à jour une tâche",
},
},
async (request) => {
return service.update(request.params.id, request.body);
}
);
// DELETE /tasks/:id
fastify.delete<{ Params: TaskParamsType }>(
"/:id",
{
schema: {
params: TaskParams,
tags: ["Tasks"],
summary: "Supprimer une tâche",
},
},
async (request, reply) => {
await service.delete(request.params.id);
return reply.status(204).send();
}
);
}Étape 10 : Fabrique d'application et serveur
Créez la fabrique d'application. Ce pattern rend les tests beaucoup plus faciles car vous pouvez créer des instances isolées :
// 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);
// --- Documentation Swagger ---
await app.register(swagger, {
openapi: {
info: {
title: "Fastify Tasks API",
version: "1.0.0",
description: "Une API de gestion de tâches prête pour la production",
},
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" });
// --- Vérification de santé ---
app.get("/health", async () => ({
status: "ok",
timestamp: new Date().toISOString(),
uptime: process.uptime(),
}));
// --- Gestionnaire d'erreurs global ---
app.setErrorHandler((error, request, reply) => {
if (error instanceof AppError) {
return reply.status(error.statusCode).send({
error: error.code,
message: error.message,
statusCode: error.statusCode,
});
}
if (error.validation) {
return reply.status(400).send({
error: "VALIDATION_ERROR",
message: error.message,
statusCode: 400,
});
}
request.log.error(error);
return reply.status(500).send({
error: "INTERNAL_SERVER_ERROR",
message:
env.NODE_ENV === "production"
? "Une erreur inattendue s'est produite"
: 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(`Serveur en cours d'exécution sur http://${env.HOST}:${env.PORT}`);
app.log.info(`Documentation API sur http://${env.HOST}:${env.PORT}/docs`);
} catch (err) {
app.log.error(err);
process.exit(1);
}
}
start();Démarrez le serveur de développement :
npm run devVisitez http://localhost:3000/docs pour voir la documentation Swagger générée automatiquement.
Étape 11 : Arrêt gracieux
Les serveurs de production doivent gérer correctement les signaux d'arrêt — terminer les requêtes en cours avant de quitter :
// Ajoutez à src/server.ts après app.listen()
const signals: NodeJS.Signals[] = ["SIGINT", "SIGTERM"];
for (const signal of signals) {
process.on(signal, async () => {
app.log.info(`Signal ${signal} reçu, arrêt gracieux en cours...`);
await app.close();
process.exit(0);
});
}
process.on("unhandledRejection", (err) => {
app.log.error(err, "Rejet non géré");
process.exit(1);
});Étape 12 : Tests avec Vitest
La méthode inject de Fastify vous permet de tester les routes sans démarrer un vrai serveur HTTP :
// 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();
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("devrait retourner 401 sans jeton d'authentification", async () => {
const response = await app.inject({
method: "GET",
url: "/api/tasks",
});
expect(response.statusCode).toBe(401);
});
it("devrait créer une tâche", async () => {
const response = await app.inject({
method: "POST",
url: "/api/tasks",
headers: {
authorization: `Bearer ${authToken}`,
},
payload: {
title: "Écrire la documentation",
description: "Écrire la documentation API pour le module de tâches",
priority: "high",
projectId: "some-project-id",
},
});
expect(response.statusCode).toBe(201);
const task = JSON.parse(response.body);
expect(task.title).toBe("Écrire la documentation");
expect(task.priority).toBe("high");
});
it("devrait valider le corps de la requête", async () => {
const response = await app.inject({
method: "POST",
url: "/api/tasks",
headers: {
authorization: `Bearer ${authToken}`,
},
payload: {
description: "Pas de titre fourni",
},
});
expect(response.statusCode).toBe(400);
});
it("devrait retourner la vérification de santé", 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");
});
});Configurez 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"],
},
},
});Lancez les tests :
npm testÉtape 13 : Configuration Docker
Créez un Dockerfile multi-étapes pour la production :
# Étape de construction
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Étape de production
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"]Et un docker-compose.yml pour le développement local :
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:Conseils de performance
Fastify est déjà rapide par défaut, mais voici comment en tirer encore plus de performance :
1. Utilisez le schéma pour la sérialisation des réponses
Quand vous définissez des schémas de réponse, Fastify utilise fast-json-stringify au lieu de JSON.stringify, ce qui peut être jusqu'à 2 fois plus rapide :
schema: {
response: {
200: TaskResponse // Ceci active fast-json-stringify
}
}2. Pool de connexions
Configurez votre pool de connexions PostgreSQL selon votre charge de travail :
const client = postgres(env.DATABASE_URL, {
max: 20, // Taille maximale du pool
idle_timeout: 30, // Fermer les connexions inactives après 30s
connect_timeout: 10 // Timeout pour les nouvelles connexions
});3. Activez HTTP/2 pour la production
import { readFileSync } from "fs";
const app = Fastify({
http2: true,
https: {
key: readFileSync("./certs/key.pem"),
cert: readFileSync("./certs/cert.pem"),
},
});Dépannage
Problèmes courants
Erreurs "Cannot find module" avec ESM :
Assurez-vous que votre package.json contient "type": "module" et que toutes les importations locales utilisent l'extension .js (même pour les fichiers .ts). TypeScript résout les importations .js vers les fichiers .ts lors de la compilation.
L'ordre d'enregistrement des plugins Fastify est important :
Les plugins doivent être enregistrés avant les routes qui en dépendent. Enregistrez toujours authPlugin avant les routes qui utilisent fastify.authenticate.
Erreurs de types avec request.user :
Assurez-vous d'avoir l'augmentation declare module "@fastify/jwt" dans votre plugin d'authentification. Sans cela, TypeScript ne reconnaîtra pas la propriété user sur les requêtes.
La validation de schéma ne fonctionne pas :
Vérifiez que vous passez des schémas TypeBox (pas des types TypeScript simples) à l'option schema. TypeBox produit des objets JSON Schema au runtime, ce dont Fastify a besoin.
Prochaines étapes
Maintenant que vous avez une API Fastify prête pour la production, envisagez d'ajouter :
- Le support WebSocket avec
@fastify/websocketpour les mises à jour en temps réel - Le téléchargement de fichiers avec
@fastify/multipart - Le cache avec
@fastify/cachingou Redis - L'instrumentation OpenTelemetry pour le traçage distribué
- Un pipeline CI/CD avec GitHub Actions pour les tests et le déploiement automatisés
Conclusion
Vous avez construit une API complète et prête pour la production avec Fastify et TypeScript. La combinaison de la validation intégrée de Fastify, des schémas TypeBox typés, des requêtes Drizzle ORM typées et de la gestion structurée des erreurs vous donne une base solide qui détecte les bugs à la compilation et valide les données au runtime.
L'architecture en plugins de Fastify facilite l'ajout incrémental de fonctionnalités. Le pattern de fabrique d'application garde votre code testable, et Docker rend le déploiement cohérent à travers les environnements. Que vous construisiez un microservice ou un backend complet, cette architecture s'adapte bien à mesure que votre projet grandit.
Discutez de votre projet avec nous
Nous sommes ici pour vous aider avec vos besoins en développement Web. Planifiez un appel pour discuter de votre projet et comment nous pouvons vous aider.
Trouvons les meilleures solutions pour vos besoins.
Articles connexes

Créer et déployer une API serverless avec Cloudflare Workers, Hono et D1
Apprenez à construire une API REST prête pour la production avec Cloudflare Workers, le framework Hono et la base de données D1 — de la configuration initiale au déploiement mondial.

Construire une API GraphQL typesafe avec Next.js App Router, Yoga et Pothos
Apprenez à construire une API GraphQL entièrement typesafe avec Next.js 15 App Router, GraphQL Yoga et le constructeur de schémas Pothos. Ce tutoriel pratique couvre la conception de schémas, les requêtes, les mutations, le middleware d'authentification et un client React avec urql.

Mistral AI API avec TypeScript : Créer des Applications Intelligentes
Apprenez à utiliser l'API Mistral AI avec TypeScript pour créer des applications intelligentes : chat, génération de texte structuré, appels de fonctions et RAG.