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.

On prend comme exemple une app « todolist » (Node.js + SQLite), mais la démarche est identique pour n’importe quelle app Node.

Cette méthode est très pratique pour des mini‑projets perso : une seule machine, peu de dépendances, une mise en production rapide et reproductible, des backups simples. Mais peu adapté pour de véritables contextes de production. Si l’on souhaite du multi‑serveur, du scaling poussé et des environnements complexes, on basculera plutôt sur d’autres approches (conteneurs, orchestrateur, etc.).

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’utiliser un utilisateur dédié avec des privilèges limités. En cas de faille dans l’application, un attaquant n’aurait ainsi pas 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 pour garder les choses claires. Chaque projet aura son sous-dossier : 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 GitHub Action.

Les données qui ne doivent pas être versionnées (fichier SQLite, fichier .env, etc.) seront stockées dans un répertoire caché dont le nom suit le projet. Pour l’app todo, ce sera .todolist, soit /home/githubci/.todolist/. L’application lira ce chemin via une variable d’environnement (par ex. DATA_DIR) que nous définirons dans le fichier de configuration PM2.

Pour l’instant, 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

Pour exécuter l’application Node.js, le serveur doit disposer de Node.js. Nous n’allons pas l’installer via les paquets Debian ; nous utiliserons NVM (Node Version Manager), qui permet d’installer et de changer de version de Node facilement.

NVM doit être installé une seule fois dans le compte githubci. À chaque déploiement, le script exécuté par la GitHub Action chargera NVM, lancera nvm install --lts et nvm use --lts, puis installera PM2 en global. Ainsi, nous n’avons pas besoin d’installer Node ni PM2 à la main après cette étape.

Connectons-nous au serveur en SSH, puis passons sous l’utilisateur githubci :

su githubci

Téléchargeons et exécutons le script d’installation officiel de NVM. Consultez la documentation NVM pour la dernière version ; voici un 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. Rechargeons la configuration du shell :

source ~/.bashrc

Vérifions que NVM répond :

nvm --version

À ce stade, ne lancez pas nvm install --lts à la main. Le script de déploiement s’en chargera à chaque déploiement, ce qui garantit une version Node cohérente.

Configurer une clé SSH pour GitHub Actions et le clone du dépôt

Pour que GitHub Actions 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 votre clé personnelle : elle doit rester secrète. Ici, on l’enregistre dans GitHub Secrets (chiffré) pour que l’automatisation puisse se connecter au VPS. La clé publique, elle, peut être partagée.

Générons 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).

Vous pouvez les afficher 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.

Ouvrez votre dépôt sur GitHub, puis allez dans SettingsSecrets and variablesActions.

Ajouter le secret contenant la clé privée SSH

Cliquez sur New repository secret. Donnez le nom SSH_PRIVATE_KEY et collez le contenu complet du fichier ~/.ssh/id_ed25519 (la clé privée de l’utilisateur githubci). Enregistrez.

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

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

Ajouter la clé publique en Deploy key

Pour 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 l’ajoutons comme Deploy key du dépôt.

Allez dans SettingsDeploy keys. Cliquez sur Add deploy key. Donnez un titre (par ex. « VPS githubci ») et collez le contenu de ~/.ssh/id_ed25519.pub (la clé publique). Cochez Allow write access uniquement si le script doit pousser des modifications (pour notre cas, la lecture suffit). Enregistrez.

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éez le dossier .github/workflows s’il n’existe pas, puis le fichier .github/workflows/deploy.yml avec le contenu suivant. Adaptez le nom todolist et le chemin si votre 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..."

            export NVM_DIR="$HOME/.nvm"
            [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
            nvm install --lts
            nvm use --lts

            APP_NAME="todolist"
            DEPLOY_DIR="$HOME/www/$APP_NAME"
            REPO_URL="git@github.com:${{ github.repository }}.git"

            mkdir -p "$DEPLOY_DIR"

            # Éviter le prompt interactif "The authenticity of host 'github.com'..."
            mkdir -p "$HOME/.ssh"
            touch "$HOME/.ssh/known_hosts"
            ssh-keygen -F github.com >/dev/null 2>&1 || ssh-keyscan -H github.com >> "$HOME/.ssh/known_hosts" 2>/dev/null

            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

            echo "Installing dependencies..."
            if [ -f package-lock.json ]; then
              npm ci --omit=dev
            else
              npm install --production
            fi

            echo "Managing app with PM2..."
            command -v pm2 >/dev/null 2>&1 || npm install -g pm2
            if pm2 describe "$APP_NAME" >/dev/null 2>&1; then
              pm2 reload "$APP_NAME" --update-env
            else
              NODE_ENV=production pm2 start ecosystem.config.js --only "$APP_NAME" --update-env
            fi
            pm2 save
            pm2 show $APP_NAME

            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.js LTS, clone ou met à jour le dépôt dans ~/www/todolist, installe les dépendances (idéalement npm ci --omit=dev si package-lock.json existe, sinon npm install --production), 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éez le fichier ecosystem.config.js. PM2 s’en sert pour savoir quel fichier lancer, dans quel répertoire et avec quelles variables d’environnement. Adaptez 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',
      DATA_DIR: '/home/githubci/.todolist',
    },
    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. DATA_DIR pour le répertoire des données). instances à 1 suffit pour une petite app ; autorestart permet à PM2 de relancer l’app en cas de crash.

Enregistrez le fichier et commitez-le 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, poussez 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

Ouvrez l’onglet Actions de votre dépôt sur GitHub. Vous devriez voir une exécution du workflow « Deploy todolist » en cours, puis en succès (coche verte) si la connexion SSH, le clone (ou la mise à jour) du dépôt, l’installation des dépendances et le démarrage PM2 se sont bien passés.

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

Pour vérifier côté serveur que l’application tourne, connectez-vous en SSH en tant que githubci et lancez :

pm2 list
pm2 logs todolist

Vous devriez voir le processus todolist dans la liste et les logs de l’application.

Faire redémarrer l’application après un reboot du serveur (optionnel)

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 en marche au prochain déploiement (le workflow relance pm2 start) ou si vous vous connectez en SSH et relancez l’app à la main.

Si vous souhaitez que l’application redémarre automatiquement au boot du serveur, vous pouvez configurer une fois le démarrage système de PM2. En tant que githubci, lancez :

pm2 startup

PM2 affiche une commande à exécuter en root (du type sudo env PATH=... pm2 startup systemd -u githubci --hp /home/githubci). Exécutez 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 prod)

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.js sur un VPS Debian : un utilisateur dédié githubci, NVM pour Node.js, 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 :