Déployer une app Node.js sur un VPS avec GitHub Actions et PM2

Comment automatiser le déploiement d’une application Node.js en production sur un VPS Debian ? L’idée est simple : à chaque push sur main, GitHub Actions se connecte en SSH au serveur, met à jour le code, installe les dépendances et (re)lance l’app via PM2.

Créer un utilisateur dédié pour le déploiement

Comme nous allons déployer l’application directement sur le serveur et la faire tourner avec PM2, il est préférable d’avoir un utilisateur dédié avec des privilèges limités. En cas de faille dans l’application, un attaquant sera limité sans les droits administrateur.

Créons un utilisateur nommé githubci, qui recevra les connexions SSH déclenchées par GitHub Actions et hébergera le code de l’application dans son répertoire personnel.

adduser githubci

Organiser les fichiers de l’application

Nous allons placer le code de l’application dans le répertoire personnel de githubci, sous un dossier www. Chaque projet aura son sous-dossier. Par exemple pour une app todo, nous utiliserons /home/githubci/www/todolist/.

Ce répertoire sera cloné depuis GitHub au premier déploiement, puis mis à jour à chaque push sur main grâce au script exécuté par la pipeline GitHub.

Les données qui ne doivent pas être versionnées (fichier SQLite, fichier .env, etc.) seront stockées dans un répertoire dédié. Pour l’app todo, ce sera .todolist, soit /home/githubci/.todolist/.

Inutile de créer ces dossiers à la main : le script de déploiement s’en chargera au premier passage. Retenons simplement la structure :

Rôle Chemin
Code (clone Git) /home/githubci/www/todolist/
Données / config /home/githubci/.todolist/

Installer NVM sur le serveur

Nous allons utiliser NVM (Node Version Manager). Cela permetra d’installer node spécifiquement pour le user githubci.

A chaque déploiement, le script exécuté par la pipeline GitHub chargera NVM, lancera nvm install --lts et nvm use --lts, puis installera PM2 en global.

Pour commencer il faut se connecter au serveur en SSH, et passer sur le compte de l’utilisateur githubci :

su githubci

Télécharger et exécuter le script d’installation officiel de NVM. Consulter la documentation NVM pour la dernière version

Exemple avec une version fixe :

curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash

Le script clone NVM dans ~/.nvm et ajoute quelques lignes dans ~/.bashrc pour charger NVM à chaque ouverture de session. Recharger la configuration du shell :

source ~/.bashrc

Vérifier que NVM répond :

nvm --version

À ce stade, ne pas lancer nvm install --lts à la main. Le script de déploiement s’en chargera à chaque déploiement.

Configuration des clées SSH pour GitHub

Afin que GitHub puisse se connecter au VPS en tant que githubci, nous devons disposer d’une paire de clés SSH :

  • La clé privée est stockée dans les secrets GitHub (elle ne doit jamais être committée).
  • La clé publique est enregistrée :
    • sur le VPS, dans ~/.ssh/authorized_keys (pour autoriser la connexion),
    • sur GitHub, en Deploy key (pour autoriser le git clone git@github.com:... depuis le VPS).

La clé privée est comme une clé personnelle : elle doit rester secrète. Ici, on l’enregistre dans GitHub Secrets (chiffré) pour que la pipeline puisse se connecter au VPS. La clé publique, elle, peut être partagée.

Générer une paire de clés sur le serveur, en tant que githubci :

mkdir -p ~/.ssh
chmod 700 ~/.ssh
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N ""

La commande crée deux fichiers : ~/.ssh/id_ed25519 (clé privée) et ~/.ssh/id_ed25519.pub (clé publique).

Pour que les connexions SSH vers githubci fonctionnent, nous devons ajouter la clé publique dans le fichier des clés autorisées :

cat ~/.ssh/id_ed25519.pub >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys

Nous aurons besoin dans les prochaines étapes :

  • du contenu de la clé privée (pour le secret GitHub),
  • du contenu de la clé publique (pour la Deploy key).

Le contenu des clées peut-être affiché avec :

cat ~/.ssh/id_ed25519
cat ~/.ssh/id_ed25519.pub

Configurer le dépôt GitHub

Nous allons maintenant indiquer à GitHub comment se connecter au serveur et où trouver l’adresse du VPS. Tout se configure dans les paramètres du dépôt.

Ouvrir le dépôt sur GitHub, puis aller dans SettingsSecrets and variablesActions.

Ajouter le secret contenant la clé privée SSH

Cliquer sur New repository secret. Donner le nom SSH_PRIVATE_KEY et coller le contenu complet du fichier ~/.ssh/id_ed25519 (la clé privée de l’utilisateur githubci). Enregistrer.

Cette clé permettra à la GitHub Action de se connecter au VPS en SSH sans mot de passe.

Ajouter la variable contenant l’adresse du VPS

Aller dans l’onglet Variables (toujours dans Secrets and variables → Actions). Cliquer sur New repository variable. Donner le nom SSH_HOST et la valeur : l’adresse IP ou le nom d’hôte de votre VPS (par ex. vps.example.com). Enregistrer.

Ajouter la clé publique en Deploy key

Afin que le serveur puisse cloner le dépôt avec git clone git@github.com:votre-org/votre-repo.git, GitHub doit accepter la clé publique de githubci. Pour cela, nous devons l’ajoutons comme Deploy key du dépôt.

Aller dans SettingsDeploy keys. Cliquer sur Add deploy key. Donner un titre (par ex. « VPS githubci ») et coller le contenu de ~/.ssh/id_ed25519.pub (la clé publique). Cocher Allow write access uniquement si le script doit pousser des modifications (pour notre cas, la lecture suffit). Enregistrer.

Une fois ces trois éléments en place (secret SSH_PRIVATE_KEY, variable SSH_HOST, Deploy key avec la clé publique), le workflow pourra se connecter au VPS et le serveur pourra cloner le dépôt.

Ajouter le workflow GitHub Actions et le fichier PM2

Nous allons ajouter deux fichiers au dépôt : le workflow qui décrit ce que fait la GitHub Action à chaque push sur main, et le fichier de configuration PM2 qui décrit comment lancer l’application sur le serveur.

Créer le fichier de workflow

À la racine du dépôt, créer le dossier .github/workflows s’il n’existe pas, puis le fichier .github/workflows/deploy.yml avec le contenu suivant. Adapter le nom todolist et le chemin si le projet a un autre nom :

name: Deploy todolist

on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Deploy to server
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ vars.SSH_HOST }}
          username: githubci
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            echo "Starting deployment..."
            
            # Charger NVM et utiliser Node.js
            export NVM_DIR="$HOME/.nvm"
            [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
            nvm install --lts
            nvm use --lts
            
            echo "Node version: $(node -v)"
            echo "NPM version: $(npm -v)"
            
            # Définir le répertoire de déploiement
            DEPLOY_DIR="$HOME/www/todolist"
            REPO_URL="git@github.com:${{ github.repository }}.git"
            
            echo "Deploy directory: $DEPLOY_DIR"
            
            # Cloner ou mettre à jour le dépôt
            if [ ! -d "$DEPLOY_DIR/.git" ]; then
              echo "Cloning repository..."
              git clone "$REPO_URL" "$DEPLOY_DIR"
            fi
            
            cd "$DEPLOY_DIR"
            git fetch origin
            git reset --hard origin/main
            
            # Installer les dépendances
            echo "Installing dependencies..."
            npm install --production
            
            # Gérer l'application avec PM2
            echo "Managing app with PM2..."
            npm install -g pm2
            pm2 stop todolist || true
            pm2 delete todolist || true
            NODE_ENV=production pm2 start ecosystem.config.js --name todolist
            pm2 save
            pm2 show todolist
            
            echo "Deployment completed!" 

Ce workflow se déclenche à chaque push sur la branche main. Il fait un checkout du dépôt (nécessaire pour que github.repository soit disponible), puis lance l’action appleboy/ssh-action qui se connecte au VPS avec : l’hôte (SSH_HOST), l’utilisateur githubci et la clé privée (SSH_PRIVATE_KEY).

Le bloc script est exécuté sur le serveur : il charge NVM, installe ou active node LTS, clone ou met à jour le dépôt dans ~/www/todolist, installe les dépendances, puis relance l’application avec PM2 en utilisant le fichier ecosystem.config.js.

Créer le fichier ecosystem.config.js

À la racine du projet (à côté de package.json), créer le fichier ecosystem.config.js. PM2 s’en sert pour savoir quel fichier lancer, dans quel répertoire et avec quelles variables d’environnement. Adapter le nom du script (index.js ou server.js) et le chemin cwd si besoin :

module.exports = {
  apps: [{
    name: 'todolist',
    script: 'index.js',
    cwd: '/home/githubci/www/todolist',
    watch: false,
    env: {
      NODE_ENV: 'production',
      PORT: 3002
    },
    instances: 1,
    autorestart: true,
  }],
};

Le champ name est le nom du processus dans PM2 (pour pm2 logs todolist, pm2 restart todolist, etc.). Le champ script est le point d’entrée de l’application. Le champ cwd est le répertoire de travail sur le serveur. Le champ env définit les variables d’environnement passées à l’application (par ex. PORT dans le cas où on exposerait plusieurs applications). instances à 1 suffit pour une petite app ; autorestart permet à PM2 de relancer l’app en cas de crash.

Enregistrer le fichier et le commiter avec le workflow : les deux doivent être versionnés dans le dépôt.

Premier déploiement

Une fois le workflow et le fichier ecosystem.config.js ajoutés au dépôt, pousser les modifications sur la branche main :

git add .github/workflows/deploy.yml ecosystem.config.js
git commit -m "Add GitHub Actions deploy workflow and PM2 config"
git push origin main

Ouvrir l’onglet Actions du dépôt sur GitHub. L’execution du workflow « Deploy todolist » doit apparaître en cours, puis en succès lorsque l’execution de la pipeline est terminée et OK.

En cas d’échec, consulter les logs de l’exécution : ils affichent la sortie du script sur le serveur. Vérifier que le secret SSH_PRIVATE_KEY, la variable SSH_HOST et la Deploy key sont correctement configurés, et que NVM est bien installé pour le compte githubci.

Pour vérifier côté serveur que l’application tourne, se connecter en SSH en tant que githubci et lancer :

pm2 list
pm2 logs todolist

Ces commandes permettent d’afficher le processus todolist et les logs de l’application.

Faire redémarrer l’application après un reboot du VPS

Par défaut, les processus gérés par PM2 ne redémarrent pas seuls après un reboot du serveur. L’application sera de nouveau up lors du prochain déploiement (le workflow relance pm2 start) ou si l’application est relancée manuellement.

Pour que l’application redémarre automatiquement au boot du serveur, il est nécessaire de configurer le démarrage système de PM2. En tant que githubci, lancer :

pm2 startup

PM2 affiche une commande à exécuter en root (du type sudo env PATH=... pm2 startup systemd -u githubci --hp /home/githubci). Exécuter cette commande sur le serveur, puis en tant que githubci :

pm2 save

Après cela, à chaque reboot du serveur, les processus enregistrés avec pm2 save redémarreront automatiquement.

Bonus PM2 (utile en produdction)

Rotation des logs

Par défaut, les logs PM2 peuvent grossir indéfiniment. Pour éviter de remplir le disque :

pm2 install pm2-logrotate

Quelques commandes à connaître

pm2 list
pm2 logs todolist
pm2 show todolist
pm2 restart todolist
pm2 reload todolist

Conclusion

Nous avons mis en place un déploiement automatique d’une application node sur un VPS Debian : un utilisateur dédié githubci, NVM pour node, une paire de clés SSH (clé privée dans les secrets GitHub, clé publique en Deploy key), un workflow GitHub Actions qui se connecte en SSH et exécute un script de déploiement (git, npm, pm2), et PM2 pour faire tourner l’application en arrière-plan et la relancer en cas de crash.

À chaque push sur main, la relivraison se fait sans intervention manuelle.

Pour aller plus loin :