Retour au blog
Journal · 28 juin 2026

Un pipeline CI/CD maisonavec GitHub Actions

7 min de lectureMarius Sergent
Pipeline GitHub Actions : lint, scan Trivy, build Docker vers GHCR, déploiement SSH sur VPS
Pipeline GitHub Actions : lint, scan Trivy, build Docker vers GHCR, déploiement SSH sur VPS

Dans l'article précédent sur l'hébergement d'une app Next.js sur un VPS, j'avais laissé le pipeline de déploiement à l'état d'esquisse : quatre lignes pour dire « ça part en prod tout seul au push ». C'est cette brique-là que je veux ouvrir ici, parce qu'elle fait toute la différence entre un VPS qu'on bichonne à la main et une infra qu'on peut oublier.

Il y a une idée reçue tenace : le CI/CD, ce serait un truc de grosses boîtes, avec une équipe DevOps dédiée et des outils à six chiffres. C'est faux. Le pipeline qui déploie ce portfolio tient dans deux fichiers YAML, il est lisible en cinq minutes, et il me rend exactement le confort que j'aimais chez Vercel : je pousse sur master, je vais me chercher un café, l'app est en ligne quand je reviens. La seule chose que j'ai gagnée au passage, c'est de savoir précisément ce qui se passe entre le git push et le conteneur qui tourne.

Quatre étapes, dans cet ordre

Le déploiement, c'est une chaîne. À chaque push sur master, GitHub Actions enchaîne lint, scan de sécurité, build de l'image, et déploiement. Ce qui compte, c'est le needs : tant qu'une étape échoue, les suivantes ne démarrent pas. Une faille critique détectée au scan, et l'image n'est jamais buildée. Du tout.

jobs:
  lint:        # ESLint
    runs-on: ubuntu-latest
    # ...
  security:    # scan Trivy (workflow réutilisable)
    uses: ./.github/workflows/security.yml
  build-push:  # build l'image Docker → push vers GHCR
    needs: [lint, security]
    # ...
  deploy:      # SSH sur le VPS → docker compose pull && up -d
    needs: [build-push]
    # ...

Lint d'abord, parce que c'est l'étape la moins chère et que ça ne sert à rien de builder une image si ESLint hurle déjà. Le scan ensuite, comme barrière. Puis le build, qui fabrique l'image Docker et la pousse vers GHCR, le registre de conteneurs de GitHub (privé, dans mon cas). Et enfin le déploiement, qui se connecte en SSH au VPS, tire la nouvelle image et relance le conteneur. Quatre maillons, chacun bloque le suivant. C'est tout le secret.

Le scan de sécurité est dans le chemin, pas dans une revue « plus tard »

C'est le point sur lequel je ne transige pas. La sécurité des dépendances, dans beaucoup de projets, c'est un Dependabot qui ouvre des PR qu'on lit le vendredi quand il reste du temps (donc jamais). Moi je l'ai mise là où elle ne peut pas être ignorée : sur le chemin du déploiement. Trivy scanne le système de fichiers du repo à chaque push, et si une CVE critique ou haute traîne, le pipeline échoue avant le build.

- name: Run Trivy vulnerability scanner
  uses: aquasecurity/trivy-action@master
  with:
    scan-type: "fs"
    scan-ref: "."
    severity: "CRITICAL,HIGH"
    ignore-unfixed: true   # ne bloque pas sur l'ininstallable
    exit-code: "1"         # fait échouer le run si un fix existe

La nuance qui rend la chose vivable tient dans ignore-unfixed: true. Sans ce réglage, le scan te bloque sur des vulnérabilités pour lesquelles aucun correctif n'existe encore en amont. Tu te retrouves coincé, incapable de déployer pour une faille que tu ne peux strictement rien corriger, et la tentation devient grande de désactiver le scan « le temps de ». Mauvaise idée, on connaît la suite. Avec ignore-unfixed, la règle est nette : si une faille critique a un correctif disponible, tu la corriges avant de merger, point. Si elle n'en a pas, le pipeline te laisse passer et tu la gardes à l'œil. Bloquer sur l'incorrigeable, ce n'est pas de la rigueur, c'est juste s'auto-saboter.

Concrètement, ça donne des matins où le push échoue avec un rapport Trivy qui pointe une version de Next à mettre à jour. Un yarn up next@<version-corrigée>, un commit du package.json et du lockfile, et c'est reparti. Ce frottement-là, je le veux. C'est lui qui fait que les failles connues ne dorment pas six mois en prod.

Du build au conteneur qui tourne

L'étape de build prend l'image décrite par le Dockerfile (multi-stage, sortie standalone, j'en parlais dans l'article précédent) et la pousse sur GHCR taguée latest et sha-<commit>. Les variables NEXT_PUBLIC_* sont passées en build-args à ce moment-là, parce qu'elles sont inlinées dans le bundle au moment du next build. Tout ce qui doit être visible côté navigateur est figé ici.

Le déploiement, lui, est presque ennuyeux tant c'est simple, et c'est exactement ce qu'on veut d'un déploiement. Une action SSH ouvre une session sur le VPS et y lance trois commandes : un docker login sur GHCR, un docker compose pull pour récupérer l'image fraîche, un up -d pour remplacer le conteneur en cours par le nouveau. Pas de coupure visible, l'ancien tourne jusqu'à ce que le nouveau soit prêt. Aucune de ces commandes ne contient le moindre secret en clair : la clé SSH, les identifiants du registre, l'hôte cible, tout vit dans les secrets GitHub et se résout au runtime. Le repo, lui, ne sait rien. C'est la règle de base, et c'est non négociable : un secret committé est un secret brûlé, même dans un repo privé.

Valider sur la PR, pas découvrir en prod

Voilà le premier raffinement que j'ai ajouté après coup, et que je remettrais sur n'importe quel projet dès le premier jour maintenant. Le pipeline de déploiement ne se déclenche que sur master. Tant que je travaille sur une branche, rien ne build, rien ne part. Le problème, c'est qu'on ne veut pas non plus merger à l'aveugle et découvrir que le build casse une fois sur master, quand l'échec déclenche déjà la machine.

D'où un second workflow, pr-check, complètement séparé, qui se déclenche sur les Pull Requests. Il fait lint, typecheck et build, mais il ne déploie rien.

on:
  pull_request:
    branches: ["master"]

Le typecheck en particulier vaut de l'or : ESLint attrape le style, mais c'est tsc qui attrape les vraies erreurs de type, celles qui font planter un build. Quand une PR est verte, je sais que le code compile, que les types tiennent, et que l'image se construit. Le merge devient une formalité au lieu d'un pari. La prod n'est plus jamais le premier endroit où je découvre qu'un truc ne passe pas.

Deux détails qui évitent des dégâts bêtes

Modifier un README ne devrait jamais relancer un déploiement complet. Reconstruire une image Docker, la pousser, SSH, pull, restart, tout ça pour une faute de frappe dans un fichier markdown, c'est absurde. Un paths-ignore sur le trigger règle la question.

on:
  push:
    branches: ["master"]
    paths-ignore:
      - "**.md"
      - "docs/**"
  workflow_dispatch:

J'ai gardé workflow_dispatch à côté, pour pouvoir forcer un déploiement à la main depuis l'interface GitHub si jamais je modifie vraiment quelque chose qui ne touche que de la doc mais doit quand même partir. La porte de service, en somme.

L'autre détail m'a coûté une vraie frayeur avant que je le mette en place. Imagine deux merges qui s'enchaînent à une minute d'intervalle. Deux pipelines de déploiement partent presque ensemble, buildent chacun leur image, se connectent au VPS dans le désordre. C'est exactement ce qui m'est arrivé : les deux déploiements se sont télescopés, et la course a été gagnée par l'image périmée d'à peine une seconde. Le pull du run le plus lent est arrivé en dernier et a réécrasé la prod avec la version d'avant. Build vert des deux côtés, et pourtant la mauvaise version en ligne. Le genre de bug qui te fait douter de ta propre santé mentale.

La parade tient en trois lignes :

concurrency:
  group: deploy-production
  cancel-in-progress: true

Un seul déploiement de production à la fois. Si un nouveau démarre alors qu'un autre tourne encore, l'ancien est annulé net. Le commit le plus récent gagne toujours, par construction, et plus jamais par hasard. Depuis, la course n'existe plus.

Rien de tout ça n'est sorcier. C'est deux fichiers YAML, quelques garde-fous appris en se cognant un peu, et le même réflexe qu'une plateforme managée : tu pousses, c'est en ligne. La différence, c'est que je sais ce qu'il y a dans la boîte, ce que ça me coûte, et que le jour où je veux changer un maillon de la chaîne, il est là, lisible, à moi.

Si tu veux mettre en place ce genre de pipeline sur ton projet, parlons-en.

Un projet qui vous ressemble ?

Discutons de votre contexte, de vos contraintes et de vos objectifs.

Prendre contact