# 11 — CI/CD avec GitHub Actions Pipeline complet : commit → build Docker → push GHCR → déploiement k3s → vérification. --- ## Architecture du pipeline ``` Push sur main │ ├── Job: test │ ├── npm run backend:lint │ ├── npm run backend:test │ └── npm run frontend:lint │ ├── Job: build (si tests OK) │ ├── docker buildx build backend → ghcr.io//xpeditis-backend:sha + :latest │ └── docker buildx build frontend → ghcr.io//xpeditis-frontend:sha + :latest │ └── Job: deploy (si build OK) ├── kubectl set image deployment/xpeditis-backend ... ├── kubectl set image deployment/xpeditis-frontend ... ├── kubectl rollout status ... └── Health check final ``` --- ## Secrets GitHub à configurer Dans votre repo GitHub → Settings → Secrets and variables → Actions → New repository secret : | Secret | Valeur | Usage | |---|---|---| | `HETZNER_KUBECONFIG` | Contenu de `~/.kube/kubeconfig-xpeditis-prod` (base64) | Accès kubectl | | `GHCR_TOKEN` | Personal Access Token GitHub (scope: `write:packages`) | Push images | | `SLACK_WEBHOOK_URL` | URL webhook Slack (optionnel) | Notifications | ```bash # Encoder le kubeconfig en base64 pour GitHub Secrets cat ~/.kube/kubeconfig-xpeditis-prod | base64 -w 0 # Copier le résultat dans HETZNER_KUBECONFIG # Créer le Personal Access Token GitHub # https://github.com/settings/tokens/new # Scopes : write:packages, read:packages, delete:packages ``` --- ## Workflow principal — `.github/workflows/deploy.yml` ```yaml # .github/workflows/deploy.yml name: Build & Deploy to Hetzner on: push: branches: - main pull_request: branches: - main env: REGISTRY: ghcr.io IMAGE_BACKEND: ghcr.io/${{ github.repository_owner }}/xpeditis-backend IMAGE_FRONTEND: ghcr.io/${{ github.repository_owner }}/xpeditis-frontend jobs: # ============================================================ # JOB 1 : Tests & Lint # ============================================================ test: name: Tests & Lint runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" - name: Install dependencies run: npm run install:all - name: Lint backend run: npm run backend:lint - name: Lint frontend run: npm run frontend:lint - name: Test backend (unit) run: npm run backend:test -- --passWithNoTests - name: TypeScript check frontend run: | cd apps/frontend npm run type-check # ============================================================ # JOB 2 : Build & Push Docker Images # ============================================================ build: name: Build Docker Images runs-on: ubuntu-latest needs: test if: github.event_name == 'push' && github.ref == 'refs/heads/main' permissions: contents: read packages: write outputs: backend_tag: ${{ steps.meta-backend.outputs.version }} frontend_tag: ${{ steps.meta-frontend.outputs.version }} steps: - name: Checkout uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to GitHub Container Registry uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} # ── Backend ── - name: Extract metadata (backend) id: meta-backend uses: docker/metadata-action@v5 with: images: ${{ env.IMAGE_BACKEND }} tags: | type=sha,prefix=sha-,format=short type=raw,value=latest,enable={{is_default_branch}} type=raw,value={{date 'YYYY-MM-DD'}},enable={{is_default_branch}} - name: Build & Push backend uses: docker/build-push-action@v5 with: context: . file: apps/backend/Dockerfile push: true tags: ${{ steps.meta-backend.outputs.tags }} labels: ${{ steps.meta-backend.outputs.labels }} cache-from: type=gha,scope=backend cache-to: type=gha,mode=max,scope=backend platforms: linux/amd64 # Changer en linux/amd64,linux/arm64 si vous utilisez des CAX # ── Frontend ── - name: Extract metadata (frontend) id: meta-frontend uses: docker/metadata-action@v5 with: images: ${{ env.IMAGE_FRONTEND }} tags: | type=sha,prefix=sha-,format=short type=raw,value=latest,enable={{is_default_branch}} type=raw,value={{date 'YYYY-MM-DD'}},enable={{is_default_branch}} - name: Build & Push frontend uses: docker/build-push-action@v5 with: context: . file: apps/frontend/Dockerfile push: true tags: ${{ steps.meta-frontend.outputs.tags }} labels: ${{ steps.meta-frontend.outputs.labels }} cache-from: type=gha,scope=frontend cache-to: type=gha,mode=max,scope=frontend platforms: linux/amd64 build-args: | NEXT_PUBLIC_API_URL=https://api.xpeditis.com NEXT_PUBLIC_APP_URL=https://app.xpeditis.com NEXT_PUBLIC_API_PREFIX=api/v1 # ============================================================ # JOB 3 : Deploy vers k3s Hetzner # ============================================================ deploy: name: Deploy to Hetzner k3s runs-on: ubuntu-latest needs: build if: github.event_name == 'push' && github.ref == 'refs/heads/main' environment: name: production url: https://app.xpeditis.com steps: - name: Checkout uses: actions/checkout@v4 - name: Configure kubectl run: | mkdir -p ~/.kube echo "${{ secrets.HETZNER_KUBECONFIG }}" | base64 -d > ~/.kube/config chmod 600 ~/.kube/config kubectl cluster-info - name: Deploy Backend run: | BACKEND_TAG="sha-$(echo ${{ github.sha }} | cut -c1-7)" kubectl set image deployment/xpeditis-backend \ backend=${{ env.IMAGE_BACKEND }}:${BACKEND_TAG} \ -n xpeditis-prod kubectl rollout status deployment/xpeditis-backend \ -n xpeditis-prod \ --timeout=300s echo "✅ Backend deployed: ${BACKEND_TAG}" - name: Deploy Frontend run: | FRONTEND_TAG="sha-$(echo ${{ github.sha }} | cut -c1-7)" kubectl set image deployment/xpeditis-frontend \ frontend=${{ env.IMAGE_FRONTEND }}:${FRONTEND_TAG} \ -n xpeditis-prod kubectl rollout status deployment/xpeditis-frontend \ -n xpeditis-prod \ --timeout=300s echo "✅ Frontend deployed: ${FRONTEND_TAG}" - name: Health Check run: | sleep 15 # Laisser le temps au LB de propager # Test API backend STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \ https://api.xpeditis.com/api/v1/health) if [ "$STATUS" != "200" ]; then echo "❌ Backend health check failed (HTTP $STATUS)" exit 1 fi echo "✅ Backend healthy (HTTP $STATUS)" # Test frontend STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \ https://app.xpeditis.com/) if [ "$STATUS" != "200" ]; then echo "❌ Frontend health check failed (HTTP $STATUS)" exit 1 fi echo "✅ Frontend healthy (HTTP $STATUS)" - name: Notify Slack (success) if: success() run: | if [ -n "${{ secrets.SLACK_WEBHOOK_URL }}" ]; then curl -X POST "${{ secrets.SLACK_WEBHOOK_URL }}" \ -H 'Content-type: application/json' \ --data '{ "text": "✅ Xpeditis déployé en production", "attachments": [{ "color": "good", "fields": [ {"title": "Commit", "value": "${{ github.sha }}", "short": true}, {"title": "Auteur", "value": "${{ github.actor }}", "short": true}, {"title": "Message", "value": "${{ github.event.head_commit.message }}", "short": false} ] }] }' fi - name: Notify Slack (failure) if: failure() run: | if [ -n "${{ secrets.SLACK_WEBHOOK_URL }}" ]; then curl -X POST "${{ secrets.SLACK_WEBHOOK_URL }}" \ -H 'Content-type: application/json' \ --data '{ "text": "❌ Échec du déploiement Xpeditis", "attachments": [{ "color": "danger", "fields": [ {"title": "Commit", "value": "${{ github.sha }}", "short": true}, {"title": "Job", "value": "${{ github.workflow }}", "short": true} ] }] }' fi - name: Rollback on failure if: failure() run: | echo "⏮️ Rollback en cours..." kubectl rollout undo deployment/xpeditis-backend -n xpeditis-prod kubectl rollout undo deployment/xpeditis-frontend -n xpeditis-prod kubectl rollout status deployment/xpeditis-backend -n xpeditis-prod --timeout=120s echo "✅ Rollback terminé" ``` --- ## Workflow de staging (PR preview) — `.github/workflows/staging.yml` ```yaml # .github/workflows/staging.yml name: Deploy to Staging on: pull_request: branches: - main jobs: build-staging: name: Build Staging runs-on: ubuntu-latest if: github.event_name == 'pull_request' 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 }} - name: Build & Push (staging tag) uses: docker/build-push-action@v5 with: context: . file: apps/backend/Dockerfile push: true tags: ghcr.io/${{ github.repository_owner }}/xpeditis-backend:pr-${{ github.event.pull_request.number }} build-args: NODE_ENV=staging - name: Comment PR uses: actions/github-script@v7 with: script: | github.rest.issues.createComment({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, body: '🐳 Image Docker staging buildée : `pr-${{ github.event.pull_request.number }}`' }) ``` --- ## Mise à jour des manifests Kubernetes Alternativement, vous pouvez mettre à jour les fichiers YAML dans Git et les appliquer : ```bash # Dans le workflow CI, mettre à jour le tag d'image dans les manifests - name: Update image in manifests run: | IMAGE_TAG="sha-$(echo ${{ github.sha }} | cut -c1-7)" # Mettre à jour les fichiers YAML sed -i "s|image: ghcr.io/.*/xpeditis-backend:.*|image: ${{ env.IMAGE_BACKEND }}:${IMAGE_TAG}|g" \ k8s/03-backend-deployment.yaml sed -i "s|image: ghcr.io/.*/xpeditis-frontend:.*|image: ${{ env.IMAGE_FRONTEND }}:${IMAGE_TAG}|g" \ k8s/05-frontend-deployment.yaml # Committer les changements (GitOps) git config user.name "GitHub Actions" git config user.email "actions@github.com" git add k8s/ git commit -m "chore: update image tags to ${IMAGE_TAG} [skip ci]" git push ``` --- ## Dockerfile final — Backend ```dockerfile # apps/backend/Dockerfile FROM node:20-alpine AS deps RUN apk add --no-cache python3 make g++ WORKDIR /app COPY package*.json ./ COPY apps/backend/package*.json apps/backend/ RUN npm ci --workspace=apps/backend FROM node:20-alpine AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/apps/backend/node_modules ./apps/backend/node_modules COPY . . RUN cd apps/backend && npm run build FROM node:20-alpine AS runner RUN apk add --no-cache dumb-init RUN addgroup -g 1001 -S nodejs && adduser -S nestjs -u 1001 WORKDIR /app COPY --from=builder --chown=nestjs:nodejs /app/apps/backend/dist ./dist COPY --from=builder --chown=nestjs:nodejs /app/apps/backend/node_modules ./node_modules COPY --from=builder --chown=nestjs:nodejs /app/apps/backend/package.json ./ USER nestjs EXPOSE 4000 HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ CMD wget -qO- http://localhost:4000/api/v1/health || exit 1 ENTRYPOINT ["dumb-init", "--"] CMD ["node", "dist/main.js"] ``` ## Dockerfile final — Frontend ```dockerfile # apps/frontend/Dockerfile FROM node:20-alpine AS deps WORKDIR /app COPY package*.json ./ COPY apps/frontend/package*.json apps/frontend/ RUN npm ci --workspace=apps/frontend FROM node:20-alpine AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/apps/frontend/node_modules ./apps/frontend/node_modules COPY . . ARG NEXT_PUBLIC_API_URL ARG NEXT_PUBLIC_APP_URL ARG NEXT_PUBLIC_API_PREFIX=api/v1 ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \ NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL \ NEXT_PUBLIC_API_PREFIX=$NEXT_PUBLIC_API_PREFIX \ NEXT_TELEMETRY_DISABLED=1 RUN cd apps/frontend && npm run build FROM node:20-alpine AS runner RUN apk add --no-cache dumb-init RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001 WORKDIR /app COPY --from=builder --chown=nextjs:nodejs /app/apps/frontend/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/apps/frontend/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/apps/frontend/public ./public USER nextjs EXPOSE 3000 ENV NODE_ENV=production PORT=3000 HOSTNAME="0.0.0.0" NEXT_TELEMETRY_DISABLED=1 ENTRYPOINT ["dumb-init", "--"] CMD ["node", "server.js"] ``` --- ## Test du pipeline en local ```bash # Simuler le build Docker localement docker build \ -f apps/backend/Dockerfile \ -t xpeditis-backend:local \ . docker build \ -f apps/frontend/Dockerfile \ -t xpeditis-frontend:local \ --build-arg NEXT_PUBLIC_API_URL=http://localhost:4000 \ --build-arg NEXT_PUBLIC_APP_URL=http://localhost:3000 \ . # Tester l'image backend docker run --rm -p 4000:4000 \ -e NODE_ENV=production \ -e DATABASE_HOST= \ -e DATABASE_USER=xpeditis \ -e DATABASE_PASSWORD= \ -e DATABASE_NAME=xpeditis \ -e REDIS_HOST= \ -e REDIS_PASSWORD= \ -e JWT_SECRET=test-secret \ -e SMTP_HOST=localhost \ -e SMTP_PORT=25 \ -e SMTP_USER=test \ -e SMTP_PASS=test \ xpeditis-backend:local curl http://localhost:4000/api/v1/health ```