
Credit : Logo officiel
Deployer une application avec GitHub Actions
Le vendredi soir qui m'a fait basculer dans le CI/CD
Vendredi 18h47. Je deploie une mise a jour mineure sur le site d'un client e-commerce. SSH, git pull, redemarrage de PM2, je ferme le terminal et je file. Sauf que j'avais oublie le npm ci apres avoir bumpe une dependance. Resultat : site casse pendant tout le weekend, 200 commandes perdues, un appel furieux le lundi matin. Plus jamais.
Depuis ce jour-la, chaque projet que je touche a un pipeline GitHub Actions. Pas par mode, pas par perfectionnisme, juste parce que les humains se trompent et les machines beaucoup moins. Ce guide est le retour d'experience concret de ce que j'utilise au quotidien sur des dizaines de serveurs IONOS, OVH ou Hetzner.
GitHub Actions est gratuit pour les depots publics et offre 2000 minutes par mois pour les depots prives sur les comptes Free. Pour 99 % de mes clients, c'est largement suffisant. Et ca remplace un Jenkins ou un GitLab CI auto-heberge sans la maintenance qui va avec.
Anatomie d'un workflow GitHub Actions
Les workflows vivent dans .github/workflows/ au format YAML. Un fichier = un workflow. Voici la structure minimale d'un pipeline qui teste et deploie une application Node.js sur push :
# .github/workflows/deploy.yml
name: Deploy to Production
on:
push:
branches: [main]
workflow_dispatch: # Declenchement manuel depuis l'interface
env:
NODE_VERSION: '20'
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Run linter
run: npm run lint
deploy:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /var/www/monapp
git pull origin main
npm ci --production
npx prisma migrate deploy
pm2 reload monapp --update-env
Le job test tourne en premier. Le job deploy ne demarre que si test reussit (needs: test). Le if: github.ref empeche un deploy depuis une branche secondaire. Et workflow_dispatch me permet de relancer le pipeline a la main quand un client me demande un deploy en urgence.
Pourquoi npm ci plutot que npm install
Ca parait detail mais ca change tout. npm install peut modifier le package-lock.json selon ce qu'il trouve dispo sur npm. npm ci (clean install) installe strictement ce qui est dans le lockfile. Builds reproductibles garantis, et en general 30 a 50 % plus rapide.
Gestion des secrets : les 3 regles de base
Un workflow YAML est versionne dans Git. Donc tout ce qui s'y trouve est public (ou au moins accessible a quiconque a un acces lecture au repo). Les credentials n'y ont rien a faire.
GitHub fournit le coffre-fort Settings > Secrets and variables > Actions. On y met :
SERVER_HOST: l'IP ou le hostname du serveur cibleSERVER_USER: l'utilisateur SSH dedie au deploy (jamais root)SSH_PRIVATE_KEY: le contenu de la cle priveeDOCKER_TOKEN: le PAT Docker HubSLACK_WEBHOOK: l'URL de webhook Slack pour les notifs
Generez une cle SSH dediee a GitHub Actions, jamais votre cle perso :
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/github_deploy
Deposez la cle publique sur le serveur cible :
ssh-copy-id -i ~/.ssh/github_deploy.pub deploy@monserveur.fr
Et restreignez ce qu'elle peut faire dans ~/.ssh/authorized_keys du compte deploy :
command="/usr/local/bin/deploy.sh",no-port-forwarding,no-X11-forwarding ssh-ed25519 AAAA...
Ca force la cle a executer un seul script, meme si elle fuite. Ceinture et bretelles.
Deploiement Docker : ma config preferee
Quand l'application est conteneurisee, je build l'image dans GitHub Actions, je la pousse sur Docker Hub (ou GHCR), et le serveur la pull. Plus propre, plus rapide, plus rollback-friendly.
name: Docker Deploy
on:
push:
branches: [main]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: |
monuser/monapp:latest
monuser/monapp:${{ github.sha }}
- name: Deploy on server
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /opt/monapp
docker pull monuser/monapp:${{ github.sha }}
export IMAGE_TAG=${{ github.sha }}
docker compose up -d
docker image prune -f
Le tag par SHA de commit (${{ github.sha }}) c'est le truc qui sauve le vendredi soir. Quand un deploy casse la prod, vous savez exactement quelle version tournait avant et vous pouvez revenir en arriere en une commande :
docker pull monuser/monapp:a1b2c3d
docker tag monuser/monapp:a1b2c3d monuser/monapp:latest
docker compose up -d
Le cache GHA (cache-from: type=gha) divise le temps de build par 3 a 5 sur les builds suivants. Indispensable pour les images Node ou Python avec beaucoup de dependances.
Notifications Slack et badges README
Une fois le pipeline en place, vous voulez savoir quand ca casse. Et vous voulez que vos clients voient le statut des builds.
- name: Notify on failure
if: failure()
uses: 8398a7/action-slack@v3
with:
status: failure
fields: repo,commit,author,workflow
text: "Deploiement echoue sur ${{ github.repository }}"
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
- name: Notify on success
if: success()
uses: 8398a7/action-slack@v3
with:
status: success
fields: repo,commit,author
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
Et le badge dans README.md :

Workflows reutilisables : DRY pour les equipes
Quand vous gerez 10 ou 20 sites avec la meme stack, dupliquer le YAML c'est l'enfer. GitHub a ajoute les reusable workflows qui resolvent ca :
# .github/workflows/_deploy-template.yml
name: Deploy Template
on:
workflow_call:
inputs:
app_path:
required: true
type: string
secrets:
SSH_KEY:
required: true
Et dans le projet qui consomme :
jobs:
deploy:
uses: monorg/.github/.github/workflows/_deploy-template.yml@main
with:
app_path: /var/www/clientA
secrets:
SSH_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
Une seule source de verite pour le pipeline, mise a jour partout d'un coup. Game changer pour une agence.
Strategies de deploiement zero downtime
Un deploiement n'est pas qu'un git pull rapide. Pour les sites a fort trafic ou les SaaS, vous voulez zero interruption pendant la mise a jour. Les deux approches que j'utilise en production :
Blue-green deployment
Deux environnements identiques (blue et green). Vous deployez sur l'inactif, vous testez, puis vous bascullez le trafic en redirigeant le reverse proxy. Si ca casse, retour instantane sur l'ancien.
- name: Deploy to inactive slot
run: |
ssh deploy@server "cd /var/www/app-green && git pull && npm ci"
ssh deploy@server "curl -f http://localhost:3001/health"
ssh deploy@server "sudo ln -sf /etc/nginx/sites-available/app-green.conf /etc/nginx/sites-enabled/app.conf"
ssh deploy@server "sudo nginx -s reload"
Rolling update avec Docker Swarm ou Kubernetes
Si vous etes sur Docker Swarm ou K8s, le rolling update natif suffit :
- name: Rolling update
run: |
ssh deploy@server "docker service update --image monuser/monapp:${{ github.sha }} --update-parallelism 1 --update-delay 10s monapp"
Swarm remplace les replicas un par un en attendant un health check OK avant de passer au suivant. L'utilisateur ne voit jamais d'interruption.
Self-hosted runners pour les builds lourds
Les runners GitHub gratuits sont en ubuntu-latest avec 2 vCPU et 7 Go RAM. Pour des builds Node ou Docker complexes, c'est limite et ca consomme vos minutes gratuites.
Un self-hosted runner s'installe sur votre propre serveur (un VPS dedie, ou meme une machine au bureau) :
mkdir actions-runner && cd actions-runner
curl -o actions-runner-linux-x64-2.319.0.tar.gz -L \
https://github.com/actions/runner/releases/download/v2.319.0/actions-runner-linux-x64-2.319.0.tar.gz
tar xzf actions-runner-linux-x64-2.319.0.tar.gz
./config.sh --url https://github.com/monorg/monrepo --token AAAA...
sudo ./svc.sh install
sudo ./svc.sh start
Dans votre workflow :
jobs:
build:
runs-on: self-hosted
Attention : un runner self-hosted execute du code defini dans le repo. Ne l'activez jamais sur un repo public sans mecanisme d'isolation (Docker, ephemeral runners) car n'importe qui peut soumettre une PR malveillante.
Erreurs courantes et leur fix
Permission denied (publickey) sur la step SSH. Cause numero 1 : la cle privee dans le secret a perdu ses sauts de ligne. Verifiez que le secret contient bien le -----BEGIN OPENSSH PRIVATE KEY----- au debut, le bloc base64 et le -----END OPENSSH PRIVATE KEY----- a la fin, sans espaces parasites.
Le job test reussit mais ne build pas en local. Vous avez probablement un node_modules mis en cache localement avec un module installe a la main. Faites rm -rf node_modules package-lock.json && npm install puis commitez le nouveau lockfile.
Workflow stuck en Queued pendant 10 minutes. Vous avez epuise vos minutes gratuites. Regardez Settings > Billing > Plans and usage. Solution : passez en runs-on: self-hosted sur un runner que vous hebergez.
Le docker pull echoue avec rate limit. Docker Hub limite a 100 pulls anonymes par 6h et 200 authentifies. Authentifiez-vous toujours dans le workflow, meme pour pull une image publique.
Le deploy reussit mais l'app sert l'ancienne version. PM2 ou Node a garde le code en memoire. Ajoutez pm2 reload monapp --update-env au lieu de pm2 restart. Pour Docker, verifiez que docker compose pull est bien execute avant le up -d.
Les variables d'environnement ne sont pas mises a jour. PM2 mémorise les variables au premier start. Forcez la mise a jour avec pm2 reload monapp --update-env ou redemarrez le daemon avec pm2 kill && pm2 resurrect apres un changement de .env.
"Resource not accessible by integration" sur des actions GitHub. Le GITHUB_TOKEN du workflow n'a pas les droits necessaires (par defaut, lecture seule depuis 2023 sur les nouveaux repos). Allez dans Settings > Actions > General > Workflow permissions et passez a Read and write permissions, ou ajoutez permissions: granulaires dans le workflow.
Build Docker qui prend 15 minutes. Vous n'avez pas de cache layer. Reorganisez votre Dockerfile pour mettre COPY package*.json puis npm ci avant le COPY . ., ce qui invalide le cache des dependances seulement si les package*.json changent.
Pour aller plus loin
- Bases de Git pour les developpeurs web
- Securiser SSH avec sshd_config
- Tunnels SSH pour acceder a des services distants
- Docker pour debutants avec une app Node.js
- Ansible pour automatiser la config serveur
Le pipeline qui te rend ton vendredi soir
Une fois que vous avez goute au deploy automatise, revenir au ssh + git pull manuel devient impensable. Le temps que vous investissez dans le pipeline est rentabilise des le premier mois : moins d'erreurs humaines, moins de stress, et la possibilite de pousser en production sans toucher au serveur. Commencez petit avec un workflow basique, ajoutez les tests, puis le Docker, puis les notifs. En quelques iterations vous aurez un systeme aussi robuste que ce que les grosses boites paient une fortune a construire.
MAJ 2026 : les reusable workflows et les composite actions permettent maintenant de packager toute votre logique CI/CD comme une lib. Si vous gerez plusieurs projets, regardez aussi les GitHub Environments pour ajouter des approbations manuelles avant un deploy en production.