Retour aux projets
9 juillet 20268 min de lecture

CI/CD : Supprimer SSH de mon Pipeline de Déploiement

#DevOps#GitHub Actions#CI/CD#Podman#Sécurité

Refonte complète de mon déploiement : d'un git pull en SSH vers un build d'image sur GHCR, un runner self-hosted et des conteneurs Podman gérés par systemd.

Illustration de CI/CD : Supprimer SSH de mon Pipeline de Déploiement

🛑 Le point de départ : une clé SSH dans un secret GitHub

Ma première version de pipeline faisait ce que fait à peu près tout le monde. Un workflow GitHub Actions se connectait en SSH à mon serveur avec une clé privée stockée dans les secrets du dépôt, puis exécutait une poignée de commandes à distance : git pull, reconstruction des conteneurs, redémarrage.

Ça marchait. Et pendant longtemps, ça m’a suffi.

Le problème, c’est ce que ce schéma implique réellement quand on le regarde avec des yeux de SecOps :

  1. Une clé privée SSH vit dans un système que je ne contrôle pas. Les secrets GitHub sont chiffrés au repos, mais ils sont déchiffrés en clair dans le runner au moment de l’exécution. Un workflow compromis, une action tierce malveillante, et la clé fuite.
  2. Le port SSH est joignable depuis les plages d’IP GitHub. Or ces plages sont publiques et couvrent des milliers d’adresses. Ma règle de pare-feu ne filtrait donc à peu près rien.
  3. Le serveur devenait un environnement de build. Il clonait le code source, installait les dépendances Node, compilait. Un serveur de production n’a rien à faire avec node_modules.
  4. Aucune reproductibilité. Ce qui tournait en production dépendait de l’état du serveur au moment du build, pas d’un artefact figé et vérifiable.

J’ai donc tout repris.


🎯 L’objectif : que le serveur ne fasse plus qu’une chose

Le principe directeur de la refonte tient en une phrase : le serveur ne construit rien, il ne fait que consommer un artefact immuable.

Concrètement, la responsabilité est coupée en deux.

GitHub construit. Il récupère le code, lance le build Astro dans un conteneur, produit une image Docker, et la pousse sur le registre GHCR (GitHub Container Registry) avec deux étiquettes : latest et le SHA du commit.

Le serveur consomme. Il télécharge cette image et redémarre le service. Il ne voit jamais le code source. Il n’a ni Node, ni npm, ni compilateur.

Le SHA en étiquette n’est pas cosmétique : il me permet de revenir sur n’importe quelle version antérieure en une commande, sans rebuild.


🔑 Le changement clé : un runner self-hosted

Plutôt que d’ouvrir une porte SSH vers mon serveur, j’installe un agent GitHub Actions sur le serveur lui-même. Cet agent maintient une connexion sortante vers GitHub et attend qu’on lui confie un job.

L’inversion est totale :

Avant (SSH) Après (runner self-hosted)
Sens de la connexion GitHub → Serveur (entrant) Serveur → GitHub (sortant)
Port ouvert SSH exposé Aucun
Secret hébergé chez GitHub Clé privée SSH Aucun (token éphémère du runner)
Rôle du serveur Build + Run Run uniquement

Plus aucun secret de longue durée ne quitte ma machine. Le runner s’authentifie avec un jeton temporaire régénéré à chaque job.

La contrepartie, et elle est réelle : un runner self-hosted exécute du code arbitraire venu du dépôt, sur ma machine. Sur un dépôt public, une pull request depuis un fork pourrait déclencher l’exécution de code chez moi. C’est la raison pour laquelle mon dépôt de portfolio est privé, et pour laquelle le runner tourne sous un utilisateur système dédié, sans droits root, avec une liste blanche sudo limitée à trois binaires.

Ce n’est pas un détail : c’est le compromis central de cette architecture. On ne supprime pas le risque, on le déplace vers un endroit qu’on contrôle.


⚙️ Le pipeline en deux temps

1. Le job de build (chez GitHub)

Le build ne pousse plus l’image aveuglément. Il construit d’abord une version locale, la scanne, et ne la publie sur le registre que si le scan passe.

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      security-events: write
    steps:
      - uses: actions/checkout@v4

      - name: Setup Buildx
        uses: docker/setup-buildx-action@v3

      # Build local, sans publication : on scanne avant de rendre l'image publique.
      - name: Build (local)
        uses: docker/build-push-action@v6
        with:
          context: .
          file: ./Dockerfile
          push: false
          load: true
          tags: mon-portfolio:scan
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Scan de vulnérabilités (Trivy)
        uses: aquasecurity/trivy-action@v0.36.0
        with:
          image-ref: mon-portfolio:scan
          exit-code: '1'
          ignore-unfixed: true
          severity: CRITICAL,HIGH

      - name: Login GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Push
        uses: docker/build-push-action@v6
        with:
          context: .
          file: ./Dockerfile
          push: true
          tags: |
            ghcr.io/<user>/mon-portfolio:latest
            ghcr.io/<user>/mon-portfolio:${{ github.sha }}
          cache-from: type=gha

Le GITHUB_TOKEN est fourni automatiquement par la plateforme, sa portée est limitée au dépôt et il expire à la fin du job. Il n’y a aucun secret à créer manuellement.

Le Dockerfile reste un build multi-étages : Node compile le site Astro, puis seul le dossier dist/ est copié dans une image nginx Alpine, exécutée sous un utilisateur non-root. L’image finale ne contient ni source, ni dépendance de build.

2. Le job de déploiement (sur le serveur)

Le déploiement se termine par deux vérifications, pas par un simple echo "OK".

  deploy-local:
    needs: build-and-push
    runs-on: self-hosted
    steps:
      - uses: actions/checkout@v4

      - name: Deploy
        run: |
          sudo cp mon-portfolio.container /etc/containers/systemd/
          sudo systemctl daemon-reload
          echo ${{ secrets.GITHUB_TOKEN }} | sudo podman login ghcr.io -u ${{ github.actor }} --password-stdin
          sudo podman pull ghcr.io/<user>/mon-portfolio:latest
          sudo systemctl restart mon-portfolio
          sudo podman image prune -f

      # Un fichier de configuration vide ne provoque aucune erreur nginx :
      # le conteneur démarre, le healthcheck est vert, le site répond en 200,
      # et pourtant aucun en-tête de sécurité n'est réellement appliqué.
      # Cette vérification a attrapé exactement ce cas de figure une fois.
      - name: Vérifie les en-têtes de sécurité en place
        run: |
          sudo podman exec mon-portfolio nginx -t
          sudo podman exec mon-portfolio nginx -T 2>/dev/null | grep -qi content-security-policy \
            || { echo "CSP absente de la conf nginx chargée"; exit 1; }

      - name: Vérifie que le site répond
        run: |
          sudo podman exec proxyweb_app curl -fsS -o /dev/null http://mon-portfolio:8080/ \
            || { echo "Le conteneur ne répond pas"; exit 1; }

Aucun git pull. Aucune compilation. Récupérer l’image, redémarrer le service, vérifier que ça a réellement fonctionné.


🐳 Podman + Quadlet : les conteneurs comme services systemd

Le second changement majeur, c’est l’abandon de docker compose au profit de Podman en mode Quadlet.

Quadlet permet de décrire un conteneur dans un fichier d’unité, que systemd traduit lui-même en service. Le conteneur devient un citoyen de premier ordre du système : il démarre au boot, redémarre en cas de crash, écrit dans le journal, et se gère avec les commandes que j’utilise déjà pour tout le reste.

/etc/containers/systemd/mon-portfolio.container :

[Unit]
Description=Mon Portfolio
After=network-online.target

[Container]
Image=ghcr.io/<user>/mon-portfolio:latest
ContainerName=mon-portfolio
Network=proxyweb
AutoUpdate=registry

[Service]
Restart=always

[Install]
WantedBy=default.target

Deux points méritent qu’on s’y arrête.

Aucun port n’est publié. Le conteneur rejoint le réseau interne de mon reverse proxy, qui l’atteint par son nom d’hôte (mon-portfolio:8080). Rien de ce site n’écoute directement sur l’hôte. La seule surface exposée reste le proxy, qui termine le TLS.

Pas de daemon. Contrairement à Docker, Podman n’a pas de démon central tournant en permanence. Le fichier d’unité est déclaratif, versionné dans le dépôt, et déployé par la CI comme n’importe quel autre artefact.


🔍 Ce que le scan a trouvé, dès le premier vrai run

Ajouter Trivy n’était pas un exercice théorique. Le tout premier scan exécuté en conditions réelles a remonté 37 vulnérabilités dans l’image de base : 35 HIGH et 2 CRITICAL, toutes dans des paquets système (OpenSSL, libxml2, libpng, musl) hérités d’un tag nginx:alpine que je n’avais pas mis à jour depuis un moment.

Le job a échoué. L’image n’a jamais atteint le registre.

Le correctif a été double : remonter sur une version plus récente de l’image de base, et ajouter une étape de mise à jour explicite des paquets Alpine au moment du build, pour ne plus dépendre uniquement de la fraîcheur du tag Docker Hub :

RUN apk update && apk upgrade --no-cache

C’est la meilleure justification possible de cette étape du pipeline : elle n’a rien bloqué pour la forme, elle a bloqué une vraie image vulnérable avant qu’elle ne serve du trafic public.


📉 Ce que je n’ai toujours pas gagné

Par honnêteté, deux choses que cette architecture n’apporte pas.

Ce n’est pas du zero downtime. Un systemctl restart arrête le conteneur puis le relance. L’interruption dure une à deux secondes. Sur un site vitrine, personne ne la voit — mais l’appeler « zero downtime » serait un abus de langage. Un vrai déploiement sans coupure demanderait deux conteneurs et une bascule côté proxy.

Il n’y a toujours ni test automatisé ni linter sur le code applicatif. Le pipeline vérifie que l’image se construit, qu’elle est exempte de vulnérabilités connues côté système, et que le déploiement répond réellement. Il ne vérifie rien sur la logique du site lui-même. C’est la prochaine étape.


🎯 Ce que ça change au quotidien

Je pousse sur main. Une minute plus tard, le site est à jour — ou le pipeline s’arrête avant, avec une raison précise. Si quelque chose se passe mal, je connais le SHA de l’image précédente et je restaure en une commande.

Mais l’apport réel n’est pas la vitesse. C’est qu’il n’existe plus, nulle part, de clé privée capable d’ouvrir un shell sur ma production — et que le pipeline a déjà prouvé, une fois en conditions réelles, qu’il empêche une image vulnérable d’y arriver.

← Revenir aux projets