Déployer une application NestJS

Docker + GitHub Actions = déploiement automatisé et reproductible

Containeriser l'application NestJS avec Docker garantit un environnement identique entre dev, staging et production. GitHub Actions automatise les tests et le déploiement.

Dockerfile de base

# Dockerfile simple (développement)
FROM node:20-alpine

WORKDIR /app

# Copier les fichiers de dépendances en premier (cache layer)
COPY package*.json ./
RUN npm ci

COPY . .

RUN npm run build

EXPOSE 3000

CMD ["node", "dist/main"]

.dockerignore

node_modules
dist
.git
*.md
.env
.env.*
!.env.example
coverage
test
*.spec.ts

Multi-stage build — production

# ── Stage 1 : Builder ────────────────────────────────
FROM node:20 AS builder

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build

# Supprimer les devDependencies
RUN npm prune --production

# ── Stage 2 : Runner (image finale légère) ────────────
FROM node:20-alpine AS runner

RUN addgroup -g 1001 -S nodejs && \
    adduser -S nestjs -u 1001

WORKDIR /app

# Copier uniquement ce qui est nécessaire
COPY --from=builder --chown=nestjs:nodejs /app/dist ./dist
COPY --from=builder --chown=nestjs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nestjs:nodejs /app/package.json ./

# Utiliser un utilisateur non-root (sécurité)
USER nestjs

EXPOSE 3000

# Démarrer l'application
CMD ["node", "dist/main"]
Taille de l'image : Le multi-stage build réduit typiquement l'image de ~800MB (avec devDependencies) à ~150MB (production only). L'image alpine est 3× plus petite que l'image debian.

Docker Compose

docker-compose.yml — développement

version: '3.9'

services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
      target: builder  # Utiliser le stage builder pour le dev
    ports:
      - "3000:3000"
    volumes:
      - .:/app
      - /app/node_modules  # Volume anonyme pour protéger node_modules
    environment:
      - NODE_ENV=development
    env_file:
      - .env
    command: npm run start:dev
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_started
    networks:
      - app-network

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

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data
    networks:
      - app-network

volumes:
  postgres_data:
  redis_data:

networks:
  app-network:
    driver: bridge

docker-compose.prod.yml

version: '3.9'

services:
  api:
    build:
      context: .
      target: runner  # Stage production uniquement
    restart: unless-stopped
    environment:
      NODE_ENV: production
    env_file:
      - .env.production
    deploy:
      replicas: 2
      resources:
        limits:
          memory: 512m
      restart_policy:
        condition: on-failure
        max_attempts: 3

Variables d'environnement

.env.example

# Application
NODE_ENV=development
PORT=3000

# Base de données
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASS=postgres
DB_NAME=nestjs_db
DATABASE_URL=postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${DB_NAME}

# JWT
JWT_SECRET=change-this-to-a-random-256-bit-secret
JWT_REFRESH_SECRET=change-this-to-another-random-256-bit-secret
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d

# Redis
REDIS_HOST=localhost
REDIS_PORT=6379

# Mail (SMTP)
MAIL_HOST=smtp.mailgun.org
MAIL_PORT=587
MAIL_USER=postmaster@domain.com
MAIL_PASS=secret

Validation de la config avec Joi

npm install joi
// app.module.ts — valider les variables d'env au démarrage
ConfigModule.forRoot({
  isGlobal: true,
  validationSchema: Joi.object({
    NODE_ENV: Joi.string().valid('development', 'production', 'test').default('development'),
    PORT: Joi.number().default(3000),
    DATABASE_URL: Joi.string().required(),
    JWT_SECRET: Joi.string().min(32).required(),
    JWT_REFRESH_SECRET: Joi.string().min(32).required(),
    REDIS_HOST: Joi.string().default('localhost'),
    REDIS_PORT: Joi.number().default(6379),
  }),
  validationOptions: {
    allowUnknown: true,
    abortEarly: false,  // Afficher toutes les erreurs, pas juste la première
  },
})

Health checks

npm install @nestjs/terminus @nestjs/axios
// health.module.ts
import { TerminusModule } from '@nestjs/terminus';

@Module({
  imports: [TerminusModule],
  controllers: [HealthController],
})
export class HealthModule {}

// health.controller.ts
@Controller('health')
export class HealthController {
  constructor(
    private health: HealthCheckService,
    private db: TypeOrmHealthIndicator,
    private http: HttpHealthIndicator,
    private memory: MemoryHealthIndicator,
    private disk: DiskHealthIndicator,
  ) {}

  @Get()
  @HealthCheck()
  check() {
    return this.health.check([
      () => this.db.pingCheck('database'),
      () => this.memory.checkHeap('memory_heap', 200 * 1024 * 1024), // 200 MB
      () => this.http.pingCheck('external-api', 'https://api.example.com/health'),
    ]);
  }
}

// Réponse JSON
// GET /health
// {
//   "status": "ok",
//   "info": { "database": { "status": "up" }, "memory_heap": { "status": "up" } },
//   "error": {},
//   "details": { ... }
// }

GitHub Actions CI/CD

# .github/workflows/ci.yml
name: CI/CD NestJS

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  # ── Job 1 : Tests ──────────────────────────
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: nestjs_test
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 5432:5432

      redis:
        image: redis:7-alpine
        ports:
          - 6379:6379

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js 20
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Lint
        run: npm run lint

      - name: Unit tests
        run: npm run test:cov
        env:
          NODE_ENV: test
          DATABASE_URL: postgresql://test:test@localhost:5432/nestjs_test
          JWT_SECRET: test-secret-key-for-ci-at-least-32-chars
          JWT_REFRESH_SECRET: refresh-test-secret-key-for-ci-32chars

      - name: E2E tests
        run: npm run test:e2e
        env:
          NODE_ENV: test
          DATABASE_URL: postgresql://test:test@localhost:5432/nestjs_test

      - name: Upload coverage
        uses: codecov/codecov-action@v4

  # ── Job 2 : Build & Push Docker ────────────
  build-push:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'

    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=sha-
            type=raw,value=latest

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  # ── Job 3 : Deploy ─────────────────────────
  deploy:
    needs: build-push
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    environment: production

    steps:
      - name: Deploy to production
        uses: appleboy/ssh-action@v1.0.0
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.DEPLOY_SSH_KEY }}
          script: |
            cd /opt/nestjs-app
            docker-compose pull
            docker-compose up -d --no-deps --build api
            docker system prune -f

Monitoring & Observabilité

Métriques avec Prometheus

npm install @willsoto/nestjs-prometheus prom-client
// app.module.ts
import { PrometheusModule } from '@willsoto/nestjs-prometheus';

@Module({
  imports: [PrometheusModule.register()],
})
export class AppModule {}

// Métriques dans un service
import { Counter, Histogram } from 'prom-client';
import { InjectMetric } from '@willsoto/nestjs-prometheus';

@Injectable()
export class MetricsService {
  constructor(
    @InjectMetric('http_requests_total')
    private requestsCounter: Counter<string>,
    @InjectMetric('http_request_duration_seconds')
    private requestDuration: Histogram<string>,
  ) {}

  trackRequest(method: string, route: string, statusCode: number, duration: number) {
    this.requestsCounter.inc({ method, route, status: statusCode });
    this.requestDuration.observe({ method, route }, duration / 1000);
  }
}
Stack complète : Prometheus (collecte métriques) + Grafana (dashboards) + Loki (logs) + Tempo (traces) = observabilité complète. Docker Compose les intègre facilement.
✏️ Exercices du module ▶ Mini-projet 🏆 Projet Final →