Exercices — Module 10

Déploiement Docker & CI/CD · 5 exercices pratiques

Ex 01 ⭐ Facile

Dockerfile multi-stage optimisé

Écrivez un Dockerfile multi-stage pour une app NestJS qui : utilise node:20-alpine, crée un utilisateur non-root, optimise les layers de cache npm, et minimise la taille finale de l'image.

Voir la solution
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production && cp -R node_modules /tmp/prod_modules
RUN npm ci

FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:20-alpine AS runner
RUN addgroup -g 1001 -S nodejs && adduser -S nestjs -u 1001
WORKDIR /app

COPY --from=builder --chown=nestjs:nodejs /app/dist ./dist
COPY --from=deps --chown=nestjs:nodejs /tmp/prod_modules ./node_modules
COPY --from=builder --chown=nestjs:nodejs /app/package.json ./

USER nestjs
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
  CMD wget -qO- http://localhost:3000/health || exit 1
CMD ["node", "dist/main"]
Ex 02 ⭐ Facile

Docker Compose complet

Écrivez un docker-compose.yml qui orchestre : l'API NestJS, PostgreSQL avec healthcheck, Redis, et pgAdmin (interface admin DB). Configurez les volumes persistants et le réseau.

Voir la solution
version: '3.9'
services:
  api:
    build: { context: ., target: runner }
    ports: ['3000:3000']
    env_file: .env
    depends_on:
      postgres: { condition: service_healthy }
      redis: { condition: service_started }
    networks: [app]
    restart: unless-stopped

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASS}
      POSTGRES_DB: ${DB_NAME}
    volumes: [pg_data:/var/lib/postgresql/data]
    ports: ['5432:5432']
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
      interval: 10s; timeout: 5s; retries: 5
    networks: [app]

  redis:
    image: redis:7-alpine
    volumes: [redis_data:/data]
    ports: ['6379:6379']
    command: redis-server --appendonly yes
    networks: [app]

  pgadmin:
    image: dpage/pgadmin4:latest
    environment:
      PGADMIN_DEFAULT_EMAIL: admin@admin.com
      PGADMIN_DEFAULT_PASSWORD: admin
    ports: ['5050:80']
    depends_on: [postgres]
    networks: [app]

volumes: { pg_data: {}, redis_data: {} }
networks: { app: { driver: bridge } }
Ex 03 ⭐⭐ Moyen

Validation des variables d'environnement

Configurez un schéma Joi complet qui valide toutes les variables d'environnement requises au démarrage. L'application doit refuser de démarrer si une variable obligatoire est manquante ou invalide.

Voir la solution
const envValidationSchema = Joi.object({
  NODE_ENV: Joi.string()
    .valid('development', 'production', 'test')
    .default('development'),
  PORT: Joi.number().integer().min(1).max(65535).default(3000),

  // Database
  DATABASE_URL: Joi.string().uri().required(),

  // JWT — secrets longs requis en production
  JWT_SECRET: Joi.string()
    .when('NODE_ENV', { is: 'production', then: Joi.min(32).required(), otherwise: Joi.required() }),
  JWT_REFRESH_SECRET: Joi.string()
    .when('NODE_ENV', { is: 'production', then: Joi.min(32).required(), otherwise: Joi.required() }),
  JWT_EXPIRES_IN: Joi.string().default('15m'),
  JWT_REFRESH_EXPIRES_IN: Joi.string().default('7d'),

  // Redis
  REDIS_HOST: Joi.string().hostname().default('localhost'),
  REDIS_PORT: Joi.number().integer().default(6379),

  // Mail
  MAIL_HOST: Joi.string().hostname().required(),
  MAIL_PORT: Joi.number().integer().default(587),
  MAIL_USER: Joi.string().email().required(),
  MAIL_PASS: Joi.string().required(),
}).options({ allowUnknown: true, abortEarly: false });

// Dans AppModule
ConfigModule.forRoot({
  isGlobal: true,
  validationSchema: envValidationSchema,
  validationOptions: { abortEarly: false },
})
Ex 04 ⭐⭐ Moyen

Health checks avec Terminus

Implémentez un endpoint GET /health complet vérifiant : DB PostgreSQL, Redis, mémoire heap (< 200MB), et espace disque (< 90%). Retournez le détail de chaque indicateur.

Voir la solution
@Controller('health')
export class HealthController {
  constructor(
    private health: HealthCheckService,
    private db: TypeOrmHealthIndicator,
    private memory: MemoryHealthIndicator,
    private disk: DiskHealthIndicator,
    @InjectRedis() private redis: Redis,
  ) {}

  @Get()
  @HealthCheck()
  check() {
    return this.health.check([
      // Base de données
      () => this.db.pingCheck('database', { timeout: 3000 }),

      // Redis custom
      async () => {
        try {
          await this.redis.ping();
          return { redis: { status: 'up' } };
        } catch {
          return { redis: { status: 'down' } };
        }
      },

      // Mémoire
      () => this.memory.checkHeap('memory_heap', 200 * 1024 * 1024),
      () => this.memory.checkRSS('memory_rss', 512 * 1024 * 1024),

      // Disque
      () => this.disk.checkStorage('disk', {
        path: '/',
        thresholdPercent: 0.9,
      }),
    ]);
  }
}
Ex 05 ⭐⭐⭐ Difficile

Pipeline GitHub Actions complet

Écrivez un workflow GitHub Actions qui : lint → test (avec Postgres/Redis) → build Docker → push GHCR → deploy via SSH. Utilisez le cache GitHub Actions pour npm et Docker layers.

Voir la solution
name: CI/CD
on:
  push: { branches: [main] }
  pull_request: { branches: [main] }

jobs:
  ci:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16-alpine
        env: { POSTGRES_PASSWORD: test, POSTGRES_DB: test_db }
        options: --health-cmd pg_isready --health-interval 10s
        ports: ['5432:5432']
      redis:
        image: redis:7-alpine
        ports: ['6379:6379']

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci
      - run: npm run lint
      - run: npm run test:cov
        env:
          DATABASE_URL: postgresql://postgres:test@localhost:5432/test_db
          JWT_SECRET: test-secret-32-chars-minimum-key!
          JWT_REFRESH_SECRET: refresh-secret-32-chars-minimum!!

  docker:
    needs: ci
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with: { registry: ghcr.io, username: ${{ github.actor }}, password: ${{ secrets.GITHUB_TOKEN }} }
      - uses: docker/build-push-action@v5
        with:
          push: true
          tags: ghcr.io/${{ github.repository }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy:
    needs: docker
    runs-on: ubuntu-latest
    steps:
      - uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.HOST }}
          username: ${{ secrets.USER }}
          key: ${{ secrets.SSH_KEY }}
          script: |
            cd /opt/app && docker compose pull && docker compose up -d
← Cours ▶ Mini-projet 🏆 Projet Final →