Développement Web

Déploiement continu sur un VPS avec GitHub Actions

14 min de lecture

Vous avez une image testée, taguée, publiée sur un registre. Il lui manque la dernière marche : arriver sur un serveur et y tourner. C’est le moment où la chaîne devient un vrai déploiement continu — du commit jusqu’à la production, sans intervention manuelle, mais sous contrôle. Dans ce dernier tutoriel, vous connectez GitHub Actions à un VPS par SSH pour qu’il récupère et lance la nouvelle image de l’API Récolte, derrière une approbation humaine et avec un filet de sécurité pour revenir en arrière.

📍 Article principal de la série : GitHub Actions : le guide CI/CD pour bien démarrer
Ce tutoriel fait partie de la série « CI/CD avec GitHub Actions ». Pour la vue d’ensemble, lisez d’abord le guide principal.

🎯 Ce que vous allez apprendre

  • Préparer un VPS pour recevoir des déploiements automatiques (Docker Compose, utilisateur dédié).
  • Générer une clé SSH de déploiement et la stocker en secret, sans exposer votre clé personnelle.
  • Écrire un workflow qui se connecte en SSH et met à jour le conteneur via docker compose pull.
  • Protéger la production par l’environnement et l’approbation manuelle vus précédemment.
  • Vérifier la santé du service après déploiement et savoir revenir en arrière (rollback).

🛠️ Ce que vous allez construire

On boucle la chaîne. L’image ghcr.io/votre-compte/recolte-api publiée au tutoriel précédent va être déployée sur un VPS : à chaque mise à jour de main (et après votre approbation), GitHub se connecte au serveur, tire la nouvelle image et redémarre le conteneur. Un contrôle de santé confirme que l’API répond, et vous saurez revenir à la version précédente en cas de pépin.

Prérequis

Étape 1 — Préparer le VPS

Le serveur doit savoir quoi lancer avant que GitHub ne lui parle. On y dépose un docker-compose.yml qui référence l’image du registre — pas un build local, mais l’image publiée. C’est tout l’intérêt d’avoir poussé l’image : le serveur ne compile rien, il télécharge un artefact prêt à l’emploi.

# /opt/recolte/docker-compose.yml (sur le VPS)
services:
  api:
    image: ghcr.io/votre-compte/recolte-api:main
    restart: unless-stopped
    ports:
      - "80:3000"
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000/sante"]
      interval: 30s
      timeout: 5s
      retries: 3

Le champ image pointe vers le tag main de votre registre. restart: unless-stopped relance le conteneur après un redémarrage du serveur. Le healthcheck interroge une route /sante que l’API doit exposer (un simple 200 OK) : Docker saura ainsi si le conteneur est réellement opérationnel, pas seulement démarré. Si votre image est privée, le VPS doit d’abord s’authentifier au registre une fois avec docker login ghcr.io ; pour une image publique, le pull fonctionne sans connexion.

Un mot sur le compte qui recevra ces connexions. Évitez de déployer en root : créez un utilisateur dédié, par exemple deploy, et ajoutez-le au groupe docker pour qu’il puisse piloter les conteneurs sans être administrateur de la machine. C’est le même principe de moindre privilège que pour les permissions du GITHUB_TOKEN ou l’utilisateur non-root dans l’image : la session de CI ne dispose que de ce qu’il lui faut, déployer, et de rien d’autre. Si la clé venait à fuiter, le dégât possible reste circonscrit à la gestion des conteneurs, pas à tout le serveur.

Étape 2 — Une clé SSH dédiée au déploiement

GitHub doit pouvoir se connecter au VPS, mais hors de question d’y mettre votre clé personnelle : un secret de CI peut être lu par toute personne qui modifie le workflow. On crée donc une paire de clés dédiée, qu’on pourra révoquer sans impacter le reste. Sur votre machine :

ssh-keygen -t ed25519 -C "deploy-recolte" -f deploy_key
# génère deploy_key (privée) et deploy_key.pub (publique)

On installe la clé publique sur le VPS, dans le fichier des clés autorisées du compte de déploiement, et on garde la clé privée pour GitHub :

# sur le VPS, ajouter la clé publique
cat deploy_key.pub >> ~/.ssh/authorized_keys

Dans les secrets du dépôt (idéalement de l’environnement production), créez trois entrées : VPS_HOST (l’adresse du serveur), VPS_USER (le compte de déploiement) et VPS_SSH_KEY (le contenu intégral de deploy_key, la clé privée). Cette séparation est saine : la clé ne sert qu’au déploiement, vit chiffrée dans GitHub, et se révoque en une ligne sur le VPS si elle venait à fuiter — sans toucher à vos autres accès.

Étape 3 — Le workflow de déploiement (quick win)

Le cœur du tutoriel. On déclenche le déploiement sur les push vers main, on rattache le job à l’environnement production (donc à l’approbation manuelle configurée à l’étape secrets), et on utilise une action éprouvée, appleboy/ssh-action, pour exécuter des commandes à distance.

name: Déploiement

on:
  push:
    branches: [main]

jobs:
  deployer:
    runs-on: ubuntu-latest
    environment: production    # impose l'approbation manuelle
    steps:
      - name: Se connecter au VPS et mettre à jour
        uses: appleboy/ssh-action@v1.2.5
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          script: |
            cd /opt/recolte
            docker compose pull
            docker compose up -d
            docker image prune -f

L’action ouvre une session SSH avec les trois secrets, puis exécute le bloc script sur le serveur, ligne par ligne. docker compose pull télécharge la nouvelle image depuis ghcr.io ; docker compose up -d recrée le conteneur avec cette image, en arrière-plan ; docker image prune -f nettoie les anciennes images pour ne pas saturer le disque. Comme le job cible l’environnement production, il se met en attente d’approbation : rien ne part en production sans votre clic. Poussez sur main, approuvez, et observez le serveur se mettre à jour tout seul.

Une précaution de sécurité mérite qu’on s’y arrête : par défaut, une première connexion SSH accepte aveuglément l’empreinte du serveur. Sur un poste, on vous demande « voulez-vous faire confiance à cet hôte ? » ; en CI, ce dialogue n’existe pas, ce qui ouvre théoriquement la porte à une attaque de l’homme du milieu si quelqu’un détournait l’adresse. La parade propre consiste à fournir l’empreinte attendue du serveur via une option fingerprint de l’action (qu’on récupère sur le VPS avec ssh-keyscan). Pour un projet personnel sur un VPS de confiance, le risque est faible ; pour de la vraie production, épingler l’empreinte est le réflexe à prendre — exactement dans l’esprit « ne rien accepter à l’aveugle » qui guide tout ce parcours.

Point d’étape — Après approbation, le job doit se terminer en vert et votre API répondre avec la nouvelle version. Vérifiez sur le serveur avec docker compose ps que le conteneur tourne et est « healthy ».

Étape 4 — Vérifier la santé après déploiement

Déployer sans vérifier, c’est espérer. Un bon pipeline confirme que le service répond après la mise à jour, et signale l’échec sinon. On ajoute un step qui interroge la route de santé depuis le runner, une fois le conteneur relancé. S’il ne répond pas, le job échoue — et vous êtes prévenu immédiatement.

      - name: Vérifier la santé du service
        run: |
          for i in 1 2 3 4 5; do
            if curl -fsS https://api.recolte.exemple/sante; then
              echo "Service en ligne ✅" && exit 0
            fi
            echo "Tentative $i échouée, nouvel essai dans 5s…"
            sleep 5
          done
          echo "Le service ne répond pas après déploiement" && exit 1

On laisse au conteneur le temps de démarrer en réessayant cinq fois, espacées de cinq secondes. Le -f de curl fait échouer la commande sur une réponse d’erreur HTTP ; combiné au exit 1 final, il garantit que le job devient rouge si l’API reste muette. Ce contrôle transforme un déploiement « lancé » en déploiement « vérifié » : vous ne découvrez plus une panne par un message d’utilisateur mécontent, mais par une notification GitHub, dans la minute.

Étape 5 — Savoir revenir en arrière

Même testée, une version peut se comporter mal en production — une variable d’environnement oubliée, un cas limite réel. Le réflexe à acquérir n’est pas d’éviter l’erreur à tout prix, mais de pouvoir revenir vite à la version d’avant. C’est là que les tags d’image du tutoriel précédent paient. Plutôt que de toujours suivre main, on peut épingler une version précise et la changer en cas de souci.

# sur le VPS, revenir à une version connue comme stable
cd /opt/recolte
# éditer docker-compose.yml : image: ghcr.io/...:sha-9f2c1a8
docker compose pull && docker compose up -d

Comme chaque image porte un tag d’empreinte (sha-…) unique et immuable, revenir en arrière revient à pointer le docker-compose.yml vers le tag de la dernière version saine, puis à relancer. L’opération prend quelques secondes — le temps d’un pull d’une image déjà connue, donc en cache. Pour aller plus loin, on peut faire du rollback une action manuelle déclenchable depuis GitHub avec workflow_dispatch et un champ « tag à déployer », mais le principe reste celui-ci : une version stable est toujours à un up -d de distance.

Notez enfin une alternative pour un autre type de projet : si vous déployiez un site statique (une vitrine, une documentation) plutôt qu’une API, GitHub Actions sait publier directement sur GitHub Pages avec actions/upload-pages-artifact et actions/deploy-pages, sans aucun serveur à gérer. Pour notre API conteneurisée, le déploiement par SSH sur un VPS reste la voie naturelle.

Point d’étape — Vous devez pouvoir, en une minute, repointer le serveur vers un tag d’empreinte antérieur et restaurer le service. Testez-le une fois à froid : c’est le genre de manœuvre qu’on ne veut pas découvrir en pleine panne.

🐞 Pièges fréquents

Symptôme / erreur Cause probable Correctif
Permission denied (publickey) Clé publique absente du VPS ou mauvais utilisateur Vérifier authorized_keys et le secret VPS_USER
Error response from daemon: pull access denied Image privée, VPS non connecté à ghcr.io docker login ghcr.io sur le VPS, ou rendre l’image publique
docker: command not found en SSH Docker absent du PATH du shell non interactif Utiliser le chemin complet ou vérifier l’installation Docker
Le déploiement part sans approbation Job non rattaché à l’environnement protégé Ajouter environment: production au job
Le conteneur tourne mais l’API ne répond pas Mauvais mappage de ports ou route de santé inexistante Vérifier ports: et exposer une route /sante

🌍 Adaptation au contexte ouest-africain

Le déploiement par SSH sur un VPS est, en pratique, la voie la plus accessible quand on n’a pas les moyens d’un cloud managé facturé à la fonction. Un VPS d’entrée de gamme chez un hébergeur international, ou une machine chez un fournisseur local, suffit : le pipeline ne demande qu’un accès SSH et Docker. On obtient un déploiement continu de qualité professionnelle pour le prix d’un petit serveur mensuel — sans verrou propriétaire.

L’approbation manuelle prend ici tout son sens. Dans une petite équipe, le responsable garde la main sur le moment du déploiement, y compris depuis son téléphone, ce qui évite les mises en production hasardeuses quand la connexion est mauvaise ou qu’on n’est pas devant son poste. Et la capacité de rollback en quelques secondes est un filet décisif là où une intervention physique sur le serveur serait lente ou impossible : revenir à la version stable se fait à distance, depuis n’importe où.

✅ Récapitulatif

La chaîne est complète. Vous avez préparé un VPS avec un docker-compose.yml qui consomme l’image du registre, généré une clé SSH dédiée et rangé proprement les secrets de connexion. Vous avez écrit un workflow qui, après votre approbation, se connecte au serveur, tire la nouvelle image et redémarre le service — puis vérifie qu’il répond vraiment. Et vous savez revenir en arrière en pointant un tag d’empreinte stable. Du git push au conteneur en production, chaque maillon est désormais automatisé, vérifié et réversible. C’est exactement ce qu’on attend d’un pipeline de déploiement continu.

🧾 Aide-mémoire

Élément Rôle
image: ghcr.io/…:main Le serveur tire l’image, ne la construit pas
ssh-keygen -t ed25519 Clé de déploiement dédiée
appleboy/ssh-action@v1.2.5 Exécuter des commandes sur le VPS
docker compose pull && up -d Mettre à jour le conteneur
environment: production Approbation manuelle avant déploiement
Route /sante + curl -f Vérifier que le service répond
Tag sha-… Cible de rollback immuable

💪 À vous de jouer

1. Ajoutez un workflow de rollback manuel (workflow_dispatch) qui prend en entrée un tag d’image et le déploie sur le VPS.

2. Faites précéder le déploiement d’un job de tests, pour que rien ne parte sans une suite verte.

Voir une piste pour le défi 1
on:
  workflow_dispatch:
    inputs:
      tag:
        description: "Tag d'image à déployer"
        required: true

jobs:
  rollback:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: appleboy/ssh-action@v1.2.5
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          script: |
            cd /opt/recolte
            IMAGE="ghcr.io/votre-compte/recolte-api:${{ github.event.inputs.tag }}"
            docker compose pull && IMAGE_TAG="$IMAGE" docker compose up -d

Le champ inputs.tag apparaît dans l’interface au lancement manuel : vous y saisissez le tag d’empreinte stable, et le workflow le déploie. À combiner avec une variable d’environnement dans le docker-compose.yml pour piloter le tag proprement.

Tutoriels frères

Pour aller plus loin

FAQ

SSH ou OIDC pour déployer ?
Pour un VPS classique, SSH avec une clé dédiée est le plus simple et le plus répandu. L’OIDC brille face aux fournisseurs cloud (AWS, GCP, Azure) capables de valider une identité fédérée, comme vu au tutoriel sur les secrets.

Faut-il vraiment l’approbation manuelle ?
Pas obligatoire, mais fortement conseillée en début de parcours. Beaucoup d’équipes déploient automatiquement en préproduction et exigent une approbation seulement pour la production. Vous ajustez selon votre niveau de confiance.

Comment gérer les migrations de base de données ?
Ajoutez un step (ou une commande dans le script SSH) qui applique les migrations avant de relancer le conteneur. L’ordre compte : migrer la base, puis basculer l’application.

Le déploiement provoque-t-il une coupure ?
Avec un seul conteneur, up -d entraîne une brève interruption le temps du redémarrage. Pour du zéro-coupure, il faut un répartiteur de charge devant plusieurs instances — un sujet à part entière, au-delà de ce tutoriel.

Et si je n’ai pas de VPS ?
Pour un site statique, GitHub Pages déploie sans serveur via deploy-pages. Pour une API, un petit VPS reste le plus accessible ; certains hébergeurs proposent aussi des plateformes qui tirent directement une image de registre.

Mots-clés : déploiement continu GitHub Actions, déployer sur VPS SSH, appleboy ssh-action, docker compose pull, environnement production approbation, rollback, health check.

Partager