Skip to content

J11 · Finalisation et mise en production ​

Jour 11 · mardi 24 novembre · Livrable : Image Docker, pipeline CI, doc OpenAPI

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 /docs avec @nestjs/swagger et 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 ​

SpringNestJSÀ retenir
springdoc-openapi@nestjs/swagger + plugin CLILe plugin lit les types à la compilation, pas à l'exécution
@Schema, @Parameter@ApiProperty, @ApiPropertyOptional, @ApiQueryInutiles 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/readyVous écrivez le contrôleur et choisissez les indicateurs
En-têtes par défaut de Spring Securityhelmet()Middleware Express, à activer
CorsConfigurationSource, @CrossOriginapp.enableCors({ origin })Fermé si origin: false
Bucket4j, passerelle d'API@nestjs/throttlerCompteurs en mémoire, par instance
Image JRE + jar (Jib, buildpacks)Dockerfile multi-stage node:24-alpinePas de jar unique : dist/ + node_modules de production
mvn verify dans GitLab CInpm ci, npm run lint, npm run build, npm run test:cov, npm run test:e2eChaque 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) :

ts
// 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 :

IndicateurVérificationÉchoue quand
TypeOrmHealthIndicator.pingCheck('database', { timeout })Requête de test sur la basePas 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 disqueAu-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 :

ts
// 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), puis nest build ;
  • étape runtime : npm ci --omit=dev installe seulement les dependencies (et leurs propres dépendances), puis on copie dist/ depuis l'étape précédente. Jest, oxlint et le Nest CLI ne sont pas dans l'image finale. TypeScript, si : @nestjs/swagger 12 en dépend, et TypeORM 1.1 dépend de ts-node. Les node_modules de 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 utilisateur node non root. Une faille dans l'application ne donne pas les droits root dans le conteneur.
  • CMD ["node", "dist/main.js"] (forme exec) : node est le processus principal et reçoit directement SIGTERM à l'arrêt du conteneur. Avec enableShutdownHooks(), 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 planJob de .gitlab-ci.ymlCommandes
installbefore_script de chaque jobnpm ci --cache .npm --prefer-offline
lintlint-and-buildnpm run lint (oxlint), npm run typecheck, puis npm run build (nest build), sur chaque branche
testunit-tests, e2e-testsnpm run test:cov, npm run test:e2e
builddocker-image, branche par défautdocker build puis docker push vers le registre GitLab

Cas TaskFlow ​

Swagger sur /docs ​

Le plugin est déclaré dans la configuration du CLI :

json
{
  "$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 :

ts
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));
}
ts
// 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 /docs ou /docs-json, puis le garde en mémoire.
  • /docs est servi dans tous les environnements, production comprise. Pour le masquer, appelez setupSwagger() sous condition de NODE_ENV.
  • Les contrôleurs portent @ApiTags() et @ApiBearerAuth() :
ts
@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 :

json
{
  "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/projects documente search, archived, page et limit. Les deux derniers viennent de PaginationQueryDto, dans src/common/dto/pagination.dto.ts : le suffixe .dto.ts est ce qui fait lire le fichier au plugin. Nommé pagination.ts, il serait ignoré sans message et page et limit disparaîtraient de la documentation, alors que la validation fonctionnerait toujours.
  • Le schéma User (atteint par les relations de Project) ne contient pas passwordHash. Le champ porte deux décorateurs, un par usage :
ts
@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 ​

ts
// /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 :

json
{
  "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.

json
{
  "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 ​

dockerfile
# 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"]
text
node_modules
dist
coverage
.env*
!.env.example
.git
test
*.log

Le service app du Compose construit cette image, sous le profil app :

yaml
# 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 .env du dossier). Si elle manque, elle est vide : l'application refuse de démarrer (validateEnv exige 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, pas localhost.
  • depends_on avec condition: service_healthy attend que pg_isready réponde.
  • NODE_ENV: production : pas de module de démonstration, logs JSON au niveau info.

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 :

bash
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/live

run --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 ​

yaml
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 (dont no-floating-promises), tsc --noEmit, puis npm run build, c'est-à-dire nest build avec 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'expression coverage extrait le premier pourcentage de la ligne All files du rapport texte de Jest pour l'afficher dans GitLab.
  • e2e-tests : un service postgres:16-alpine joignable sous le nom db. Les variables du job configurent à la fois le service (POSTGRES_*) et l'application (DB_*, JWT_SECRET, NODE_ENV=test). Pas de fichier .env.test en CI : tout vient des variables.
  • docker-image : Docker-in-Docker (docker:27-dind), connexion au registre GitLab avec les variables prédéfinies CI_REGISTRY_*, deux tags (commit et latest). 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, dont PROJECTS_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 :

JourFichiersChapitre
J1src/main.ts, docker-compose.yml, src/demo/event-loop.controller.tsJ1
J2src/projects/dto/project.dto.ts, src/projects/projects.controller.tsJ2
J3src/projects/projects.store.ts, src/projects/projects.module.ts, src/common/common.module.tsJ3
J4src/projects/entities/, src/projects/typeorm-projects.store.ts, src/database/J4
J5src/tasks/entities/task.entity.ts, src/database/migrations/J5
J6src/common/filters/, src/common/interceptors/, src/common/middleware/J6
J7src/config/, src/app.module.tsJ7
J8src/auth/, src/users/J8
J9src/tasks/events/, src/tasks/tasks.scheduler.ts, src/notifications/J9
J10src/**/*.spec.ts, test/app.e2e-spec.ts, jest.config.tsJ10
J11src/health/, src/app.setup.ts, Dockerfile, .gitlab-ci.ymlJ11

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/swagger dans nest-cli.json (Swagger).
  • Un champ apparaît dans /docs alors 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églez trust proxy pour 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:prod avant up (Dockerfile et Compose).
  • Une merge request doit être bloquée si le build ou un test échoue → étape verify du 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 :

ts
// 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.ts et *.entity.ts sont traités, sans message pour les autres : d'où le nom pagination.dto.ts dans 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 sur passwordHash.
  • Corps du 503 réécrit par le filtre global. Le détail Terminus n'est que dans les logs (/health).
  • CMD ["npm", "start"]. npm s'intercale entre Docker et Node et complique la transmission de SIGTERM ; dans TaskFlow, npm start lance en plus nest start, absent de l'image. Lancez node directement.
  • Migrations oubliées au premier lancement du conteneur. L'image ne les applique pas au démarrage : lancez npm run migration:run:prod dans un conteneur ponctuel avant up (Dockerfile et Compose).
  • npm run migration:run dans l'image. Ce script passe par ts-node et src/, absents de l'image ; utilisez migration:run:prod, qui lit dist/.
  • Fichier .env copié dans l'image. .dockerignore exclut .env* ; vérifiez-le avant d'ajouter un COPY . ..
  • Compteurs du throttler par instance. Pour une limite globale, il faut un stockage partagé (option storage de ThrottlerModule).
  • CSP personnalisée. La configuration par défaut de helmet() laisse Swagger UI fonctionner ; si vous durcissez contentSecurityPolicy, rouvrez /docs pour vérifier.

Exercices ​

  • [ ] Installer @nestjs/swagger, déclarer le plugin dans nest-cli.json, exposer Swagger sur /docs et vérifier dans /docs-json que name est obligatoire avec maxLength: 100.
  • [ ] Ajouter @ApiTags() et @ApiBearerAuth() sur les contrôleurs, s'authentifier avec « Authorize » et appeler GET /v1/projects depuis Swagger UI.
  • [ ] Dans /docs-json, vérifier que GET /v1/projects documente page et limit, et que passwordHash n'apparaît pas. Retirer temporairement @ApiHideProperty() et observer le champ revenir dans le schéma.
  • [ ] /health basé sur Terminus (readiness : base, mémoire, disque) et /health/live (liveness). Arrêter la base (docker compose stop db) : /health répond 503, /health/live 200.
  • [ ] Activer helmet, CORS par CORS_ORIGIN et le throttler ; vérifier le 429 au 6e login dans la minute.
  • [ ] Écrire le Dockerfile multi-stage et le service app du Compose ; appliquer les migrations avec npm run migration:run:prod dans un conteneur ponctuel, lancer docker compose --profile app up -d et vérifier que le processus tourne sous l'utilisateur node. Recommencer sans JWT_SECRET et lire l'erreur.
  • [ ] Écrire .gitlab-ci.yml (install, lint, test, build) et observer le pipeline sur une merge request.
  • [ ] Revue finale : relire LEARNINGS.md et compléter le README.
  • [ ] git tag jour-11.

Pour aller plus loin ​