Exercices — Module 10
Déploiement Docker & CI/CD · 5 exercices pratiques
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"]
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 } }
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 },
})
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,
}),
]);
}
}
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