J11 · Finalisation et mise en production
Dernier jour : rendre TaskFlow exploitable par d'autres. Documentation OpenAPI générée à partir des DTO, sondes de santé pour l'orchestrateur, en-têtes de sécurité et limitation de débit, image Docker minimale, pipeline GitLab. Spring Boot en fournit une partie d'office (Actuator, Spring Security) ; avec Nest, chaque brique s'ajoute explicitement. La journée se termine par une revue du projet, de LEARNINGS.md et du README.
Objectifs
- Publier la documentation OpenAPI sur
/docsavec@nestjs/swaggeret son plugin CLI, et savoir quand écrire@ApiProperty,@ApiTags,@ApiBearerAuth. - Exposer des health checks Terminus (base, mémoire, disque), séparés en liveness et readiness.
- Activer
helmet, CORS et la limitation de débit (@nestjs/throttler). - Construire une image Docker multi-stage, non root, avec
npm ci --omit=dev. - Écrire un pipeline GitLab CI : installation, lint, tests, build de l'image.
- Faire la revue finale : relire
LEARNINGS.md, compléter le README.
Spring → NestJS
| Spring | NestJS | À retenir |
|---|---|---|
| springdoc-openapi | @nestjs/swagger + plugin CLI | Le plugin lit les types à la compilation, pas à l'exécution |
@Schema, @Parameter | @ApiProperty, @ApiPropertyOptional, @ApiQuery | Inutiles pour la plupart des DTO avec le plugin |
@Tag | @ApiTags('projects') | Regroupe les routes dans l'interface |
@SecurityRequirement | @ApiBearerAuth() + .addBearerAuth() | Bouton « Authorize » dans Swagger UI |
Actuator /actuator/health/liveness, /readiness | @nestjs/terminus : /health/live, /health/ready | Vous écrivez le contrôleur et choisissez les indicateurs |
| En-têtes par défaut de Spring Security | helmet() | Middleware Express, à activer |
CorsConfigurationSource, @CrossOrigin | app.enableCors({ origin }) | Fermé si origin: false |
| Bucket4j, passerelle d'API | @nestjs/throttler | Compteurs en mémoire, par instance |
| Image JRE + jar (Jib, buildpacks) | Dockerfile multi-stage node:24-alpine | Pas de jar unique : dist/ + node_modules de production |
mvn verify dans GitLab CI | npm ci, npm run lint, npm run build, npm run test:cov, npm run test:e2e | Chaque job repart d'un conteneur vide |
Comprendre
Swagger et OpenAPI avec @nestjs/swagger
DocumentBuilder décrit l'API (titre, version, schéma d'authentification), SwaggerModule.createDocument() parcourt les contrôleurs et leurs métadonnées, SwaggerModule.setup('docs', ...) sert l'interface sur /docs, le JSON sur /docs-json et le YAML sur /docs-yaml.
Le problème à résoudre : à l'exécution, name: string n'existe plus. Sans aide, chaque propriété de DTO doit porter un @ApiProperty() qui répète le type et les contraintes. Le plugin CLI, déclaré dans nest-cli.json, s'exécute pendant nest build / nest start : il lit les types TypeScript, le ? des propriétés optionnelles et, avec classValidatorShim, les décorateurs de class-validator, puis ajoute les métadonnées OpenAPI au code compilé. Vérifié sur TaskFlow : CreateProjectDto produit name obligatoire avec maxLength: 100. Avec introspectComments, les commentaires /** ... */ deviennent des descriptions.
Par défaut, le plugin ne traite que les fichiers *.dto.ts et *.entity.ts : un DTO rangé dans un autre fichier est ignoré sans message. C'est pourquoi le DTO de pagination de TaskFlow s'appelle pagination.dto.ts (Swagger sur /docs). Les décorateurs explicites restent utiles pour un exemple, une description, un fichier non traité ou un champ à masquer (@ApiHideProperty(), vu plus bas) :
// Exemple hors TaskFlow
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateProjectDto {
@ApiProperty({ example: 'Website', description: 'Nom affiché du projet' })
@IsString()
@IsNotEmpty()
@MaxLength(100)
name: string;
}
// Dans un fichier que le plugin ne lit pas (query.ts, sans suffixe .dto.ts) : tout est à écrire
export class PaginationQueryDto {
@ApiPropertyOptional({ default: 1, minimum: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page = 1;
}@ApiTags('projects') regroupe les routes d'un contrôleur ; @ApiBearerAuth(), avec .addBearerAuth() dans DocumentBuilder, indique qu'elles attendent un JWT et ajoute le bouton « Authorize ». Une fois Swagger installé, importez PartialType et consorts depuis @nestjs/swagger (mapped types).
Où l'analogie casse : springdoc lit les annotations Bean Validation par réflexion, à l'exécution. Ici, l'information n'existe qu'à la compilation : si le code n'est pas compilé par nest build / nest start (un simple tsc, ts-node, ts-jest), le plugin ne s'exécute pas et la documentation est vide.
Health checks avec @nestjs/terminus
TerminusModule fournit HealthCheckService et des indicateurs. health.check([...]) exécute les indicateurs et renvoie { status, info, error, details } ; si l'un échoue, il lève une ServiceUnavailableException (503) qui porte ce même objet. Les indicateurs utilisés par TaskFlow :
| Indicateur | Vérification | Échoue quand |
|---|---|---|
TypeOrmHealthIndicator.pingCheck('database', { timeout }) | Requête de test sur la base | Pas de réponse dans le délai |
MemoryHealthIndicator.checkHeap('memory_heap', octets) | Tas V8 utilisé | Au-delà du seuil (512 Mio ici) |
DiskHealthIndicator.checkStorage('disk', { path, thresholdPercent }) | Occupation du disque | Au-delà de 90 % ici |
Deux sondes, deux questions :
- liveness : le processus répond-il ? En cas d'échec, l'orchestrateur redémarre le conteneur. Elle ne doit dépendre de rien d'externe : si elle testait la base, une panne PostgreSQL redémarrerait toutes les instances sans rien réparer.
- readiness : l'instance peut-elle recevoir du trafic ? En cas d'échec, elle est retirée du load balancer le temps que la base revienne.
C'est la séparation de Spring Boot (/actuator/health/liveness et /readiness). Où l'analogie casse : Actuator se configure tout seul à partir du classpath ; ici, vous écrivez le contrôleur et choisissez les indicateurs. Le contrôleur sort du versioning avec version: VERSION_NEUTRAL (/health, pas /v1/health, voir versioning), est @Public() (J8) et @SkipThrottle(). Le décorateur @HealthCheck() ajoute l'en-tête Cache-Control: no-cache, no-store, must-revalidate et documente les réponses dans Swagger.
Sécurité : helmet, CORS, rate limiting
helmet est un middleware Express qui pose des en-têtes de sécurité : Content-Security-Policy, Strict-Transport-Security, X-Content-Type-Options: nosniff, X-Frame-Options: SAMEORIGIN, entre autres, et retire X-Powered-By. C'est l'équivalent des en-têtes que Spring Security ajoute par défaut. Vérifié dans un navigateur : Swagger UI s'affiche avec la configuration par défaut de helmet().
CORS ne concerne que les navigateurs : ils bloquent une réponse venant d'une autre origine sans Access-Control-Allow-Origin. curl et les appels serveur à serveur ne sont pas concernés. app.enableCors({ origin }) accepte une liste d'origines ; origin: false n'envoie aucun en-tête CORS, ce qui ferme l'API aux autres origines.
Rate limiting : ThrottlerModule.forRoot([{ ttl: 60_000, limit: 100 }]) (ttl en millisecondes) définit la limite par défaut, appliquée par ThrottlerGuard, déclaré en APP_GUARD avant le guard JWT (guard global). @Throttle() la change pour une route, @SkipThrottle() la retire. Au-delà, le guard lève une ThrottlerException : 429, en-tête Retry-After, et dans TaskFlow le corps problem+json du filtre global avec "code": "TOO_MANY_REQUESTS". Spring n'a pas d'équivalent intégré (Bucket4j, Resilience4j ou la passerelle d'API).
Le client est identifié par son IP (req.ip) et les compteurs sont gardés en mémoire, par instance : avec trois réplicas, un client peut faire trois fois la limite. Plusieurs limites nommées peuvent cohabiter :
// Exemple hors TaskFlow : deux fenêtres, puis une surcharge sur une route
ThrottlerModule.forRoot([
{ name: 'short', ttl: 1_000, limit: 3 },
{ name: 'long', ttl: 60_000, limit: 100 },
]);
@Throttle({ short: { limit: 1, ttl: 1_000 } })
@Post('exports')
createExport() { /* ... */ }Dockerfile multi-stage
Côté Java, l'image finale contient un JRE et un jar. Une application Node n'a pas d'artefact unique : il faut le code compilé (dist/) et les dépendances de production (node_modules). Le multi-stage sépare les deux besoins :
- étape
build: toutes les dépendances (TypeScript, Nest CLI, plugin Swagger), puisnest build; - étape
runtime:npm ci --omit=devinstalle seulement lesdependencies(et leurs propres dépendances), puis on copiedist/depuis l'étape précédente. Jest, oxlint et le Nest CLI ne sont pas dans l'image finale. TypeScript, si :@nestjs/swagger12 en dépend, et TypeORM 1.1 dépend dets-node. Lesnode_modulesde production de TaskFlow pèsent environ 169 Mo.
npm ci installe exactement ce que fixe package-lock.json et échoue si package.json ne correspond pas au lockfile : c'est la commande de CI et d'image, npm install celle du poste de développement. Copier package.json et le lockfile avant le code permet à Docker de réutiliser la couche des dépendances tant qu'elles ne changent pas.
Trois réglages d'exploitation :
USER node: l'image officielle fournit un utilisateurnodenon root. Une faille dans l'application ne donne pas les droits root dans le conteneur.CMD ["node", "dist/main.js"](forme exec) :nodeest le processus principal et reçoit directementSIGTERMà l'arrêt du conteneur. AvecenableShutdownHooks(), Nest ferme proprement le pool de connexions (cycle de vie).HEALTHCHECK: Docker (et Compose) interroge/health/live. Kubernetes ignore cette instruction et utilise ses propres sondes.
.dockerignore exclut du contexte de build node_modules, dist, la couverture, les tests, .git et les fichiers .env* : aucun secret local ne finit dans l'image.
CI GitLab : lint, tests, build de l'image
Un pipeline GitLab est une suite d'étapes (stages) ; les jobs d'une même étape tournent en parallèle, chacun dans un conteneur neuf. D'où npm ci dans before_script (commun à tous les jobs via default) et un cache du dossier .npm indexé sur package-lock.json. Un job peut démarrer des services (ici PostgreSQL pour les tests e2e) et être limité par des rules (ici, l'image n'est construite que sur la branche par défaut).
| Étape du plan | Job de .gitlab-ci.yml | Commandes |
|---|---|---|
| install | before_script de chaque job | npm ci --cache .npm --prefer-offline |
| lint | lint-and-build | npm run lint (oxlint), npm run typecheck, puis npm run build (nest build), sur chaque branche |
| test | unit-tests, e2e-tests | npm run test:cov, npm run test:e2e |
| build | docker-image, branche par défaut | docker build puis docker push vers le registre GitLab |
Cas TaskFlow
Swagger sur /docs
Le plugin est déclaré dans la configuration du CLI :
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"plugins": [
{
"name": "@nestjs/swagger",
"options": { "classValidatorShim": true, "introspectComments": true }
}
]
}
}La documentation est montée par setupSwagger(), que seul main.ts appelle :
export function setupSwagger(app: INestApplication): void {
const config = new DocumentBuilder()
.setTitle('TaskFlow API')
.setVersion('1.0')
.addBearerAuth()
.build();
SwaggerModule.setup('docs', app, () => SwaggerModule.createDocument(app, config));
}// SpringApplication.run(TaskflowApplication.class, args)
async function bootstrap() {
const app = await NestFactory.create(AppModule, { bufferLogs: true });
setupApp(app);
setupSwagger(app);
await app.listen(app.get(ConfigService).getOrThrow<number>('PORT'));
}
// `void` marks the promise as deliberately not awaited (no-floating-promises).
void bootstrap();- Passer une fonction à
SwaggerModule.setup()retarde la construction du document à la première requête sur/docsou/docs-json, puis le garde en mémoire. /docsest servi dans tous les environnements, production comprise. Pour le masquer, appelezsetupSwagger()sous condition deNODE_ENV.- Les contrôleurs portent
@ApiTags()et@ApiBearerAuth():
@ApiTags('users')
@ApiBearerAuth()
@Controller('users')
@Roles(Role.Admin) // every route of this controller: ADMIN only, otherwise 403
export class UsersController {
constructor(private readonly users: UsersService) {}
@Get()
findAll() {
return this.users.findAll();
}
}Le schéma généré pour CreateProjectDto, tel que le plugin le produit à partir des décorateurs class-validator :
{
"type": "object",
"properties": {
"name": { "type": "string", "maxLength": 100 },
"description": { "type": "string", "maxLength": 2000 }
},
"required": ["name"]
}Deux points à vérifier en lisant /docs-json, réglés tous les deux dans TaskFlow :
GET /v1/projectsdocumentesearch,archived,pageetlimit. Les deux derniers viennent dePaginationQueryDto, danssrc/common/dto/pagination.dto.ts: le suffixe.dto.tsest ce qui fait lire le fichier au plugin. Nommépagination.ts, il serait ignoré sans message etpageetlimitdisparaîtraient de la documentation, alors que la validation fonctionnerait toujours.- Le schéma
User(atteint par les relations deProject) ne contient paspasswordHash. Le champ porte deux décorateurs, un par usage :
@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 254, unique: true })
email: string;
// Never serialized: ClassSerializerInterceptor applies @Exclude on responses.
// @ApiHideProperty keeps it out of the OpenAPI schema as well.
@Exclude()
@ApiHideProperty()
@Column({ name: 'password_hash', type: 'varchar' })
passwordHash: string;
@Column({ type: 'enum', enum: Role, enumName: 'user_role', default: Role.Member })
role: Role;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
}@Exclude() agit à l'exécution : ClassSerializerInterceptor retire le champ du JSON renvoyé (J8). Il ne change rien à la documentation, que le plugin construit à partir des types : sans @ApiHideProperty(), passwordHash serait décrit dans /docs alors qu'aucune réponse ne le contient.
/health avec Terminus : liveness et readiness
// /actuator/health/liveness and /readiness. VERSION_NEUTRAL keeps them at
// /health/... instead of /v1/health/... for load balancers and Kubernetes.
@ApiTags('health')
@Public()
@SkipThrottle()
@Controller({ path: 'health', version: VERSION_NEUTRAL })
export class HealthController {
constructor(
private readonly health: HealthCheckService,
private readonly db: TypeOrmHealthIndicator,
private readonly memory: MemoryHealthIndicator,
private readonly disk: DiskHealthIndicator,
) {}
/** Liveness: the process answers. No dependency, or a DB outage restarts every pod. */
@Get('live')
@HealthCheck()
live() {
return this.health.check([]);
}
/** Readiness: ready to receive traffic. */
@Get(['', 'ready'])
@HealthCheck()
ready() {
return this.health.check([
() => this.db.pingCheck('database', { timeout: 1_500 }),
() => this.memory.checkHeap('memory_heap', 512 * 1024 * 1024),
() => this.disk.checkStorage('disk', { path: '/', thresholdPercent: 0.9 }),
]);
}
}@Get(['', 'ready']) sert la readiness sur /health et /health/ready. Réponse quand tout va bien :
{
"status": "ok",
"info": {
"database": { "status": "up" },
"memory_heap": { "status": "up" },
"disk": { "status": "up" }
},
"error": {},
"details": {
"database": { "status": "up" },
"memory_heap": { "status": "up" },
"disk": { "status": "up" }
}
}/health/live ne vérifie rien d'externe : {"status":"ok","info":{},"error":{},"details":{}}.
Quand un indicateur échoue, Terminus lève une ServiceUnavailableException et journalise « Health Check has failed! » avec le détail. Dans TaskFlow, le filtre global transforme cette exception comme toutes les autres : la réponse est un 503 application/problem+json, et le détail des indicateurs n'apparaît que dans les logs.
{
"status": 503,
"code": "SERVICE_UNAVAILABLE",
"message": "Service Unavailable Exception",
"path": "/health",
"timestamp": "2026-11-24T10:41:07.215Z",
"correlationId": "c4e1a7d2-5b3f-4e8a-9d61-0f2b7a9c3e55"
}Pour un orchestrateur, seul le code HTTP compte. Si un humain doit lire quel indicateur est tombé dans la réponse, laissez passer le corps Terminus pour ce contrôleur. Le test e2e vérifie /health (base up) et /health/live.
Dockerfile et service app dans le Compose
# syntax=docker/dockerfile:1
# Stage 1: install everything and compile TypeScript to dist/.
FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json tsconfig.build.json nest-cli.json ./
COPY src ./src
RUN npm run build
# Stage 2: production dependencies and the compiled code only.
FROM node:24-alpine AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
# The official image ships a non-root "node" user.
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
CMD wget -qO- http://127.0.0.1:3000/health/live || exit 1
CMD ["node", "dist/main.js"]node_modules
dist
coverage
.env*
!.env.example
.git
test
*.logLe service app du Compose construit cette image, sous le profil app :
# docker compose up -d -> PostgreSQL + Adminer for `npm run start:dev`
# docker compose --profile queue up -d -> + Redis for the BullMQ queue (J9)
# docker compose --profile app up -d -> + the API itself, built from the Dockerfile (J11)
services:
db:
image: postgres:16-alpine
container_name: taskflow-db
environment:
POSTGRES_USER: taskflow
POSTGRES_PASSWORD: taskflow
POSTGRES_DB: taskflow
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U taskflow -d taskflow"]
interval: 5s
timeout: 3s
retries: 10
# Throwaway database for e2e tests: data lives in memory (tmpfs).
db-test:
image: postgres:16-alpine
container_name: taskflow-db-test
environment:
POSTGRES_USER: taskflow
POSTGRES_PASSWORD: taskflow
POSTGRES_DB: taskflow_test
ports:
- "5433:5432"
tmpfs:
- /var/lib/postgresql/data
adminer:
image: adminer
ports:
- "8081:8080"
depends_on:
- db
redis:
image: redis:7-alpine
profiles: [queue]
ports:
- "6379:6379"
app:
build: .
profiles: [app]
environment:
NODE_ENV: production
PORT: 3000
DB_HOST: db
DB_PORT: 5432
DB_USER: taskflow
DB_PASSWORD: taskflow
DB_NAME: taskflow
# Empty unless exported: the app then refuses to start (validateEnv), while
# the other services still work without it.
JWT_SECRET: ${JWT_SECRET:-}
ports:
- "3000:3000"
depends_on:
db:
condition: service_healthy
volumes:
pgdata:JWT_SECRET: ${JWT_SECRET:-}: la valeur vient du shell (ou du fichier.envdu dossier). Si elle manque, elle est vide : l'application refuse de démarrer (validateEnvexige au moins 8 caractères), tandis que les autres services du Compose fonctionnent.DB_HOST: db: le nom du service sur le réseau Compose, paslocalhost.depends_onaveccondition: service_healthyattend quepg_isreadyréponde.NODE_ENV: production: pas de module de démonstration, logs JSON au niveauinfo.
L'image n'applique pas les migrations au démarrage : c'est une étape séparée, décrite dans le README de TaskFlow. Le script migration:run passe par ts-node et src/database/data-source.ts, absents de l'image ; npm run migration:run:prod lance le CLI TypeORM sur la DataSource compilée (typeorm migration:run -d dist/database/data-source.js), qui charge les migrations compilées de dist/. On l'exécute dans un conteneur ponctuel de la même image, avant de démarrer l'API :
export JWT_SECRET=$(openssl rand -hex 32) # sinon le conteneur app refuse de démarrer
docker compose --profile app build
docker compose --profile app run --rm app npm run migration:run:prod
docker compose --profile app up -d
curl http://localhost:3000/health/liverun --rm démarre db (par depends_on), exécute la commande à la place du CMD, puis supprime le conteneur. Le JWT_SECRET exporté ne sert qu'au conteneur app : docker compose up -d (bases et Adminer) fonctionne sans lui. Sans ce secret, le conteneur app s'arrête aussitôt sur l'erreur de validateEnv (« property JWT_SECRET has failed the following constraints: minLength, isString », J7).
Vérifié sur TaskFlow : docker build réussit ; sur une base vide, migration:run:prod crée toutes les tables, puis le conteneur démarre ; il tourne sous l'utilisateur node ; /health et /health/live répondent ok face à PostgreSQL ; /demo/ping répond 404 en production (le module de démonstration n'est pas chargé) ; les logs sont des lignes JSON.
Pipeline .gitlab-ci.yml
stages: [verify, build]
default:
image: node:24-alpine
cache:
key:
files: [package-lock.json]
paths: [.npm/]
before_script:
- npm ci --cache .npm --prefer-offline
lint-and-build:
stage: verify
script:
- npm run lint
- npm run typecheck
- npm run build # nest build, with the Swagger plugin, on every branch
unit-tests:
stage: verify
script:
- npm run test:cov
coverage: '/All files[^|]*\|[^|]*\s+([\d.]+)/'
e2e-tests:
stage: verify
services:
- name: postgres:16-alpine
alias: db
variables:
POSTGRES_USER: taskflow
POSTGRES_PASSWORD: taskflow
POSTGRES_DB: taskflow_test
NODE_ENV: test
DB_HOST: db
DB_PORT: "5432"
DB_USER: taskflow
DB_PASSWORD: taskflow
DB_NAME: taskflow_test
JWT_SECRET: ci-only-secret-value
script:
- npm run test:e2e
docker-image:
stage: build
image: docker:27
services: [docker:27-dind]
before_script: []
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
script:
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
- docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" -t "$CI_REGISTRY_IMAGE:latest" .
- docker push --all-tags "$CI_REGISTRY_IMAGE"lint-and-build: oxlint (dontno-floating-promises),tsc --noEmit, puisnpm run build, c'est-à-direnest buildavec le plugin Swagger, sur chaque branche. Une erreur de build apparaît dès la merge request, sans attendre la construction de l'image.unit-tests:npm run test:covéchoue sous le seuil de couverture (couverture) ; l'expressioncoverageextrait le premier pourcentage de la ligneAll filesdu rapport texte de Jest pour l'afficher dans GitLab.e2e-tests: un servicepostgres:16-alpinejoignable sous le nomdb. Les variables du job configurent à la fois le service (POSTGRES_*) et l'application (DB_*,JWT_SECRET,NODE_ENV=test). Pas de fichier.env.testen CI : tout vient des variables.docker-image: Docker-in-Docker (docker:27-dind), connexion au registre GitLab avec les variables prédéfiniesCI_REGISTRY_*, deux tags (commit etlatest). Le runner doit autoriser Docker-in-Docker (mode privilégié).
Non vérifié
Ce pipeline n'a pas été exécuté sur un runner GitLab lors de la vérification. Les commandes qu'il lance (npm run lint, npm run typecheck, npm run build, npm run test:cov, npm run test:e2e) passent en local, et l'image se construit avec docker build.
Revue finale : LEARNINGS.md et README
Relisez LEARNINGS.md du début à la fin. Le modèle prévoit pour chaque jour « Différence avec Spring », « Piège rencontré », « À creuser » (journal). Complétez les jours manquants, puis dégagez en tête de fichier les cinq différences avec Spring qui vous ont le plus coûté. Comparez-les à la liste des pièges fréquents.
Complétez le README pour qu'un nouveau venu démarre sans vous. Celui de TaskFlow couvre déjà le démarrage, les scripts, l'option Redis, la séquence Docker (secret, build, migration:run:prod, puis up) et la structure. Il lui manque :
- un tableau des variables d'environnement (celles de
.env.example, dontPROJECTS_STORE,REDIS_URL,CORS_ORIGIN) ; - les prérequis des tests e2e (
db-test, contenu de.env.test) ; - les URL d'exploitation :
/docs,/health,/health/live,/health/ready; - le rôle de chaque job CI.
Où retrouver chaque journée dans TaskFlow :
| Jour | Fichiers | Chapitre |
|---|---|---|
| J1 | src/main.ts, docker-compose.yml, src/demo/event-loop.controller.ts | J1 |
| J2 | src/projects/dto/project.dto.ts, src/projects/projects.controller.ts | J2 |
| J3 | src/projects/projects.store.ts, src/projects/projects.module.ts, src/common/common.module.ts | J3 |
| J4 | src/projects/entities/, src/projects/typeorm-projects.store.ts, src/database/ | J4 |
| J5 | src/tasks/entities/task.entity.ts, src/database/migrations/ | J5 |
| J6 | src/common/filters/, src/common/interceptors/, src/common/middleware/ | J6 |
| J7 | src/config/, src/app.module.ts | J7 |
| J8 | src/auth/, src/users/ | J8 |
| J9 | src/tasks/events/, src/tasks/tasks.scheduler.ts, src/notifications/ | J9 |
| J10 | src/**/*.spec.ts, test/app.e2e-spec.ts, jest.config.ts | J10 |
| J11 | src/health/, src/app.setup.ts, Dockerfile, .gitlab-ci.yml | J11 |
Dernière vérification avant le tag : npm run lint, npm run typecheck, npm test, npm run test:e2e, npm run build, puis git tag jour-11.
Situations
- Vous devez documenter l'API sans annoter chaque champ → plugin CLI
@nestjs/swaggerdansnest-cli.json(Swagger). - Un champ apparaît dans
/docsalors qu'il n'est jamais renvoyé →@ApiHideProperty()(Swagger sur /docs). - Kubernetes doit redémarrer un pod bloqué sans réagir aux pannes de base → liveness sans dépendance, readiness avec la base (health checks).
- Un front sur une autre origine doit appeler l'API →
CORS_ORIGIN=https://app.example.com(sécurité). - Le login doit résister au brute force →
@Throttle({ default: { limit: 5, ttl: 60_000 } })sur la route ; derrière un reverse proxy, régleztrust proxypour compter par client (sécurité, pièges). - Vous voulez une image légère qui ne tourne pas en root → multi-stage,
npm ci --omit=dev,USER node(Dockerfile). - Le conteneur doit trouver un schéma à jour →
docker compose --profile app run --rm app npm run migration:run:prodavantup(Dockerfile et Compose). - Une merge request doit être bloquée si le build ou un test échoue → étape
verifydu pipeline (CI).
Pièges
Liveness qui teste la base
Une panne PostgreSQL ferait échouer la liveness de toutes les instances, que l'orchestrateur redémarrerait en boucle sans rien réparer. La base appartient à la readiness.
Rate limiting derrière un proxy
Le throttler identifie le client par req.ip. Derrière un load balancer, c'est l'IP du proxy : tous les clients partagent le même compteur. Indiquez à Express de faire confiance au proxy :
// Exemple hors TaskFlow, dans main.ts
// import type { NestExpressApplication } from '@nestjs/platform-express';
const app = await NestFactory.create<NestExpressApplication>(AppModule, { bufferLogs: true });
app.set('trust proxy', 1); // un proxy devant l'application- Plugin Swagger et noms de fichiers. Seuls
*.dto.tset*.entity.tssont traités, sans message pour les autres : d'où le nompagination.dto.tsdans TaskFlow. Pour un fichier nommé autrement, décorez à la main (@ApiPropertyOptional()). @Exclude()ne masque pas un champ dans Swagger. Il agit sur le JSON renvoyé ; pour le schéma OpenAPI, ajoutez@ApiHideProperty(), comme surpasswordHash.- Corps du 503 réécrit par le filtre global. Le détail Terminus n'est que dans les logs (/health).
CMD ["npm", "start"].npms'intercale entre Docker et Node et complique la transmission deSIGTERM; dans TaskFlow,npm startlance en plusnest start, absent de l'image. Lanceznodedirectement.- Migrations oubliées au premier lancement du conteneur. L'image ne les applique pas au démarrage : lancez
npm run migration:run:proddans un conteneur ponctuel avantup(Dockerfile et Compose). npm run migration:rundans l'image. Ce script passe par ts-node etsrc/, absents de l'image ; utilisezmigration:run:prod, qui litdist/.- Fichier
.envcopié dans l'image..dockerignoreexclut.env*; vérifiez-le avant d'ajouter unCOPY . .. - Compteurs du throttler par instance. Pour une limite globale, il faut un stockage partagé (option
storagedeThrottlerModule). - CSP personnalisée. La configuration par défaut de
helmet()laisse Swagger UI fonctionner ; si vous durcissezcontentSecurityPolicy, rouvrez/docspour vérifier.
Exercices
- [ ] Installer
@nestjs/swagger, déclarer le plugin dansnest-cli.json, exposer Swagger sur/docset vérifier dans/docs-jsonquenameest obligatoire avecmaxLength: 100. - [ ] Ajouter
@ApiTags()et@ApiBearerAuth()sur les contrôleurs, s'authentifier avec « Authorize » et appelerGET /v1/projectsdepuis Swagger UI. - [ ] Dans
/docs-json, vérifier queGET /v1/projectsdocumentepageetlimit, et quepasswordHashn'apparaît pas. Retirer temporairement@ApiHideProperty()et observer le champ revenir dans le schéma. - [ ]
/healthbasé sur Terminus (readiness : base, mémoire, disque) et/health/live(liveness). Arrêter la base (docker compose stop db) :/healthrépond 503,/health/live200. - [ ] Activer
helmet, CORS parCORS_ORIGINet le throttler ; vérifier le 429 au 6e login dans la minute. - [ ] Écrire le
Dockerfilemulti-stage et le serviceappdu Compose ; appliquer les migrations avecnpm run migration:run:proddans un conteneur ponctuel, lancerdocker compose --profile app up -det vérifier que le processus tourne sous l'utilisateurnode. Recommencer sansJWT_SECRETet lire l'erreur. - [ ] Écrire
.gitlab-ci.yml(install, lint, test, build) et observer le pipeline sur une merge request. - [ ] Revue finale : relire
LEARNINGS.mdet compléter le README. - [ ]
git tag jour-11.