Leçon 7/9 12 min

Déployer automatiquement

git push = en ligne. Clé SSH dédiée, secrets GitHub, et le VPS Docker pour recevoir ton image Symfony.

Le déploiement à la main : jamais pareil deux fois

Le rituel, tu le connais. Tu ouvres un terminal, tu te connectes en SSH, tu fais git pull, tu lances composer install, tu vides le cache Symfony, tu redémarres php-fpm. Six étapes. Sauf qu'un mardi tu oublies le git pull et tu déploies rien. Un mercredi composer install accroche parce que la connexion a coupé. Et un vendredi, tu oublies de stopper les workers Messenger : ils tournent tout le week-end sur l'ancien code, les messages s'accumulent en erreur, et tu l'apprends le lundi matin.

Le problème, ce n'est pas que les étapes soient compliquées. C'est qu'elles sont manuelles : elles dépendent de toi, de ton attention du moment, de l'ordre dans lequel tu les exécutes. Et sur dix déploiements, il y en a toujours un qui se passe différemment des neuf autres.

L'objectif de cette leçon : un git push fait tout, toujours dans le même ordre. La leçon 6 a construit l'image Docker et l'a poussée sur GHCR. Ici, le job deploy se connecte au VPS en SSH et lui demande de descendre cette image et de basculer. Tests rouges = déploiement bloqué. Image absente = déploiement bloqué. Et une vraie requête HTTP vérifie que l'app répond, pas juste que le conteneur a démarré.

Cette leçon s'appuie sur la pipeline CI vue en leçon 6. Le job build a produit une image ghcr.io/toi/ton-projet:main et un tag sha-... sur GHCR. On ajoute ici un job deploy qui ne se déclenche que si build a réussi. L'outil côté runner est appleboy/ssh-action, une GitHub Action maintenue qui ouvre une session SSH et exécute des commandes sur le serveur distant.

Qu'est-ce que le VPS doit savoir faire, au final ? Docker. C'est tout. Ni PHP, ni Composer, ni git sur l'hôte : tout vit DANS l'image, construite et testée par la CI. C'est le grand bénéfice du conteneur : le serveur devient bête et discipliné. (Si un jour tu choisis la voie classique PHP-FPM sans Docker, là il te faudrait le kit : git, php8.3-fpm et ses extensions, composer.)

Les clés du royaume, proprement

Pour que GitHub Actions puisse se connecter à ton VPS, il faut lui donner un accès SSH. Mais pas n'importe lequel : une clé dédiée, jamais ta clé personnelle.

Étape 1 : génère une paire de clés dédiée (sur ta machine locale, pas sur le VPS) :

ssh-keygen -t ed25519 -C "github-actions-deploy" -f deploy_key

Tu obtiens deux fichiers : deploy_key (la clé privée) et deploy_key.pub (la clé publique).

Étape 2 : la clé publique va sur le VPS. Connecte-toi et ajoute-la dans /home/deploy/.ssh/authorized_keys. C'est l'utilisateur deploy créé en leçon 2 : limité, sans accès root.

cat deploy_key.pub >> /home/deploy/.ssh/authorized_keys

Étape 3 : la clé privée va dans les secrets GitHub. Dans ton repo : Settings → Secrets and variables → Actions → New repository secret. Crée trois secrets :

  • VPS_SSH_KEY : le contenu entier de deploy_key (clé privée)
  • VPS_HOST : l'IP publique de ton VPS
  • VPS_USER : deploy

Secrets et historique git : un secret ne va jamais dans le fichier YAML ni dans le code. Même "supprimé depuis", il reste dans l'historique git pour toujours : chaque commit est immuable, et git log le retrouvera. GitHub masque ${{ secrets.X }} dans les logs des Actions, mais uniquement quand la valeur transite par ce mécanisme. Une valeur copiée en clair dans le YAML n'est pas masquée. Si tu commets une clé ou un mot de passe, la bonne réaction est de le révoquer immédiatement, pas de supprimer le commit.

Pourquoi une clé dédiée ? Si cette clé fuite (secret exposé dans un log, repo forké par erreur), elle ne donne accès qu'à l'utilisateur deploy limité sur ce seul VPS. Tu la révoques en supprimant une ligne dans authorized_keys et tu en génères une nouvelle. Ta clé personnelle, elle, ouvre tous tes accès : GitHub, autres serveurs, tout.

Le job deploy : le YAML complet

Voici le job deploy, à ajouter dans .github/workflows/deploy.yml à la suite des jobs test et build vus en leçon 6. Chaque ligne est expliquée.

  deploy:
    needs: build                       # l'image doit exister
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          fingerprint: ${{ secrets.VPS_FINGERPRINT }}   # empreinte du serveur, relevée une fois
          script: |
            set -e
            cd /opt/app
            echo ${{ secrets.GHCR_TOKEN }} | docker login ghcr.io -u toi --password-stdin
            docker compose pull                 # descend la nouvelle image (tag main)
            docker compose up -d --wait         # bascule, et ATTEND que le conteneur soit healthy
            docker compose exec -T php php bin/console doctrine:migrations:migrate -n
            docker compose exec -T php php bin/console messenger:stop-workers
            docker image prune -f               # ménage des vieilles images
            curl --fail https://ton-projet.fr/health

Décortiquons les points clés :

  • needs: build : la chaîne complète. Le job deploy ne démarre que si build a réussi, lui-même conditionné à test. Tests rouges, build cassé : le VPS ne voit rien.
  • environment: production : indique à GitHub que ce job touche la production. Tu peux y ajouter des règles de protection (approbation manuelle, délai) depuis Settings → Environments.
  • fingerprint : l'empreinte de la clé d'hôte du VPS, que tu relèves une seule fois sur le serveur (ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub) et que tu mets dans les secrets. À chaque déploiement, l'action vérifie que le serveur qui répond présente bien cette empreinte : si quelqu'un se fait passer pour ton VPS, la connexion est refusée.
  • GHCR_TOKEN : un Personal Access Token GitHub avec le scope read:packages, créé une fois dans ton profil et mis dans les secrets. Le VPS doit pouvoir lire le garage à images sur GHCR pour faire docker compose pull. (Pour une image publique, pas besoin de login : tu peux supprimer ce step.)
  • docker compose pull : le VPS demande à GHCR la dernière image taguée main. Il la télécharge. Le conteneur actuel tourne encore.
  • docker compose up -d --wait : bascule vers la nouvelle image et attend que le HEALTHCHECK de l'image dise « healthy ». Le déploiement n'est déclaré fini que quand le conteneur s'auto-diagnostique en bonne santé. Si le conteneur reste unhealthy, --wait sort en erreur : la pipeline devient rouge.
  • doctrine:migrations:migrate -n : les migrations Doctrine sont lancées APRES la bascule, directement dans le conteneur en cours. Le -n supprime la question de confirmation.
  • messenger:stop-workers : les workers Symfony Messenger reçoivent un signal de stop propre. Ils finissent leur message en cours, puis sortent. Le concierge Docker (restart: unless-stopped dans le compose) les relance aussitôt sur la nouvelle image.
  • docker image prune -f : supprime les vieilles images non utilisées. Sans ça, le disque se remplit à chaque déploiement.
  • curl --fail https://ton-projet.fr/health : la preuve finale depuis l'extérieur. Une vraie requête HTTP publique. Si l'app ne répond pas, la pipeline devient rouge et tu es alerté avant tes utilisateurs.

Où sont passés composer install et cache:clear ? Dans le Dockerfile, à la construction (leçon 6). Le déploiement ne compile plus rien : il échange une image contre une autre. L'image arrivée sur le VPS est déjà prête, testée, optimisée.

Un piège subtil : l'app bascule AVANT les migrations. Relis l'ordre du script : up -d --wait déclare le conteneur healthy, puis seulement les migrations tournent. Pendant quelques secondes, le nouveau code sert du trafic sur l'ancien schéma de base. Et si la migration échoue, set -e met la pipeline en rouge, mais l'app tourne déjà sur la nouvelle image. C'est exactement pour ça que la règle d'or de la prochaine leçon existe : des migrations rétro-compatibles (le nouveau code doit fonctionner sur l'ancien schéma), et savoir revenir en arrière en quinze secondes.

Pourquoi pas un ssh-keyscan au moment du déploiement ? Beaucoup de tutoriels ajoutent un step ssh-keyscan ... >> ~/.ssh/known_hosts. Double problème : avec appleboy/ssh-action, qui tourne dans son propre conteneur, ce fichier n'est même pas lu ; et surtout, demander son empreinte au serveur au moment de se connecter, c'est demander sa carte d'identité à l'inconnu qui se présente : un attaquant en man-in-the-middle fournirait la sienne. L'empreinte doit venir d'un relevé fait à l'avance, par un canal de confiance (ta première connexion SSH vérifiée, ou la console du fournisseur).

Pipeline verte ≠ app qui marche. Le runner GitHub Actions n'est pas ton VPS. Il a ses propres versions des outils, ses propres variables d'environnement, ses propres librairies système. Une pipeline verte prouve que les tests passent dans l'environnement du runner. Elle ne prouve pas que l'app fonctionne en prod avec ton vrai .env, ta vraie BDD, tes vraies dépendances système. Seule une vraie requête HTTP sur l'app le prouve. C'est exactement ce que fait le healthcheck final.

Prédis avant de lire

Pourquoi utiliser une clé SSH dédiée au déploiement plutôt que ta clé personnelle dans les secrets GitHub ?

Voir la réponse

Ta clé personnelle ouvre tous tes accès : autres serveurs, GitHub, tout. Si elle fuite, tout tombe en même temps. La clé dédiée n'ouvre qu'un compte deploy limité sur ce seul VPS. Si elle est compromise, tu la révoques en supprimant une ligne dans authorized_keys et tu en génères une nouvelle, sans toucher à rien d'autre.

À toi : génère les clés et déclenche le déploiement

Le terminal simulé ci-dessous reprend les trois gestes essentiels : générer la paire de clés dédiée, vérifier la clé publique (celle qui ira sur le VPS), puis déclencher le déploiement complet via un simple git push. Les secrets sont déjà en place dans ce scénario.

🖥️ Terminal simulé · déploiement automatique
$

Le flux complet de bout en bout :

Le voyage des clés et le flux de déploiement : sur ta machine, ssh-keygen génère deploy_key (privée) et deploy_key.pub (publique). La clé publique va dans authorized_keys sur le VPS. La clé privée va dans les secrets GitHub (VPS_SSH_KEY). En dessous : git push déclenche le job test, puis build, puis le job deploy qui se connecte en SSH au VPS et fait docker compose pull + up, suivi du healthcheck 200 OK. 💻 Ta machine ssh-keygen deploy_key / deploy_key.pub clé publique 🖥️ VPS authorized_keys de deploy clé privée GitHub Secrets 🔒 VPS_SSH_KEY VPS_HOST · VPS_USER git push main test ✓ phpunit needs build ✓ image GHCR needs deploy → SSH pull · up --wait · migrate healthcheck 200 ✓ curl --fail /health flux de déploiement
La clé publique va sur le VPS, la clé privée dans GitHub Secrets. Au push : test → build image → deploy SSH → healthcheck.

Si le healthcheck échoue (app qui ne répond pas sur /health, port fermé, crash au démarrage, migrations en erreur), le step curl --fail sort en erreur et la pipeline devient rouge. C'est le comportement voulu : tu es alerté immédiatement, avant que les utilisateurs ne remarquent quoi que ce soit.

Deploying by hand: never quite the same twice

You know the ritual. Open a terminal, SSH in, run git pull, run composer install, clear the Symfony cache, restart php-fpm. Six steps. Except one Tuesday you forget the git pull and deploy nothing. One Wednesday composer install stalls because the connection dropped. And one Friday you forget to stop the Messenger workers: they run all weekend on the old code, messages pile up as errors, and you find out Monday morning.

The problem isn't that the steps are hard. It's that they're manual: they depend on you, on your focus in the moment, on the order you happen to run them. And out of ten deployments, there's always one that goes differently from the other nine.

The goal of this lesson: a git push does everything, always in the same order. Lesson 6 built the Docker image and pushed it to GHCR. Here, the deploy job SSHes into the VPS and tells it to pull that image and switch over. Red tests = blocked deploy. Missing image = blocked deploy. And a real HTTP request checks that the app answers — not just that the container started.

This lesson builds on the CI pipeline from lesson 6. The build job produced a ghcr.io/you/your-project:main image and a sha-... tag on GHCR. We're adding a deploy job here that only fires if build succeeded. The tool on the runner side is appleboy/ssh-action — a maintained GitHub Action that opens an SSH session and runs commands on the remote server.

What does the VPS need to know how to do, in the end? Docker. That's it. No PHP, no Composer, no git on the host: everything lives INSIDE the image, built and tested by the CI. That's the big benefit of containers: the server becomes dumb and disciplined. (If you ever go the classic PHP-FPM route without Docker, then you'd need the full kit: git, php8.3-fpm and its extensions, composer.)

The keys to the kingdom, done right

For GitHub Actions to connect to your VPS, it needs SSH access. But not just any access: a dedicated key, never your personal one.

Step 1 — generate a dedicated key pair (on your local machine, not on the VPS):

ssh-keygen -t ed25519 -C "github-actions-deploy" -f deploy_key

You get two files: deploy_key (the private key) and deploy_key.pub (the public key).

Step 2 — the public key goes on the VPS. Connect and add it to /home/deploy/.ssh/authorized_keys — the limited deploy user created in lesson 2, with no root access:

cat deploy_key.pub >> /home/deploy/.ssh/authorized_keys

Step 3 — the private key goes into GitHub Secrets. In your repo: Settings → Secrets and variables → Actions → New repository secret. Create three secrets:

  • VPS_SSH_KEY — the full contents of deploy_key (private key)
  • VPS_HOST — your VPS public IP
  • VPS_USERdeploy

Secrets and git history: a secret never goes into the YAML file or code. Even if "deleted since", it stays in git history forever — each commit is immutable, and git log will find it. GitHub masks ${{ secrets.X }} in Actions logs, but only when the value flows through that mechanism: a value copied in plain text into the YAML is not masked. If you commit a key or password, the right move is to revoke it immediately, not delete the commit.

Why a dedicated key? If it leaks — secret exposed in a log, repo accidentally forked — it only grants access to the limited deploy user on this one VPS. You revoke it by deleting one line from authorized_keys and generating a new one. Your personal key, by contrast, opens all your access: GitHub, other servers, everything.

The deploy job: the full YAML

Here's the deploy job, to add in .github/workflows/deploy.yml after the test and build jobs from lesson 6. Each line is explained.

  deploy:
    needs: build                       # the image must exist
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          fingerprint: ${{ secrets.VPS_FINGERPRINT }}   # server fingerprint, recorded once
          script: |
            set -e
            cd /opt/app
            echo ${{ secrets.GHCR_TOKEN }} | docker login ghcr.io -u you --password-stdin
            docker compose pull                 # pull the new image (main tag)
            docker compose up -d --wait         # switch over, and WAIT until the container is healthy
            docker compose exec -T php php bin/console doctrine:migrations:migrate -n
            docker compose exec -T php php bin/console messenger:stop-workers
            docker image prune -f               # clean up old images
            curl --fail https://your-project.fr/health

The key points:

  • needs: build — the full chain. The deploy job only starts if build succeeded, which itself depends on test. Red tests, broken build: the VPS sees nothing.
  • environment: production — tells GitHub this job touches production. You can add protection rules (manual approval, delay) from Settings → Environments.
  • fingerprint — the VPS host key fingerprint, which you record once on the server (ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub) and store in the secrets. On every deploy, the action checks that the responding server presents this exact fingerprint: if someone impersonates your VPS, the connection is refused.
  • GHCR_TOKEN — a GitHub Personal Access Token with the read:packages scope, created once in your profile and stored in secrets. The VPS needs it to read the image registry on GHCR for docker compose pull. (For a public image, you don't need login at all — you can drop this step.)
  • docker compose pull — the VPS asks GHCR for the latest image tagged main. It downloads it. The current container is still running.
  • docker compose up -d --wait — switches to the new image and waits for the container's HEALTHCHECK to report "healthy". The deployment is only declared done when the container self-diagnoses as healthy. If it stays unhealthy, --wait exits with an error and the pipeline turns red.
  • doctrine:migrations:migrate -n — Doctrine migrations run AFTER the switch, directly inside the running container. The -n suppresses the confirmation prompt.
  • messenger:stop-workers — Symfony Messenger workers receive a clean stop signal. They finish their current message, then exit. Docker's restart policy (restart: unless-stopped in the compose file) immediately relaunches them on the new image.
  • docker image prune -f — removes unused old images. Without this, disk space fills up with every deploy.
  • curl --fail https://your-project.fr/health — the final proof from the outside. A real public HTTP request. If the app doesn't respond, the pipeline turns red and you're alerted before your users are.

Where did composer install and cache:clear go? Into the Dockerfile, at build time (lesson 6). The deploy step no longer compiles anything: it swaps one image for another. The image that arrives on the VPS is already ready, tested, and optimized.

A subtle trap: the app switches over BEFORE the migrations. Re-read the script's order: up -d --wait declares the container healthy, and only then do the migrations run. For a few seconds, the new code serves traffic on the old database schema. And if a migration fails, set -e turns the pipeline red, but the app is already running on the new image. That's exactly why the next lesson's golden rule exists: backward-compatible migrations (new code must work on the old schema), and knowing how to roll back in fifteen seconds.

Why not run ssh-keyscan at deploy time? Many tutorials add a ssh-keyscan ... >> ~/.ssh/known_hosts step. Two problems: appleboy/ssh-action runs in its own container and never reads that file; and above all, asking the server for its fingerprint at connection time is like asking the stranger at your door for his own ID card — a man-in-the-middle attacker would simply present his. The fingerprint must come from a reading made in advance, over a trusted channel (your first verified SSH connection, or the provider's console).

Green pipeline ≠ app that works: the GitHub Actions runner is not your VPS. It has its own tool versions, its own environment variables, its own system libraries. A green pipeline proves the tests pass in the runner's environment — not that the app works in prod with your real .env, your real database, your real system dependencies. Only a real HTTP request on the app proves that. That's exactly what the final healthcheck does.

Predict before reading on

Why use a dedicated SSH key for deployment rather than your personal key in GitHub Secrets?

Show the answer

Your personal key opens all your access: other servers, GitHub, everything. If it leaks, everything falls at once. The dedicated key only opens a limited deploy account on this one VPS. If it's compromised, you revoke it by deleting one line from authorized_keys and generate a new one — without touching anything else.

Your turn: generate the keys and trigger the deploy

The simulated terminal below covers the three essential moves: generate the dedicated key pair, check the public key (the one that goes on the VPS), then trigger the full deployment with a simple git push — secrets already in place in this scenario.

🖥️ Simulated terminal · automatic deployment
$

The full end-to-end flow:

The key journey and deployment flow: on your machine, ssh-keygen produces deploy_key (private) and deploy_key.pub (public). The public key goes into authorized_keys on the VPS. The private key goes into GitHub Secrets (VPS_SSH_KEY). Below: git push triggers the test job, then build, then the deploy job which SSHes into the VPS and runs docker compose pull + up, followed by healthcheck 200 OK. 💻 Your machine ssh-keygen deploy_key / deploy_key.pub public key 🖥️ VPS authorized_keys of deploy private key GitHub Secrets 🔒 VPS_SSH_KEY VPS_HOST · VPS_USER git push main test ✓ phpunit needs build ✓ image GHCR needs deploy → SSH pull · up --wait · migrate healthcheck 200 ✓ curl --fail /health deployment flow
Public key on the VPS, private key in GitHub Secrets. On push: test → build image → SSH deploy → healthcheck.

If the healthcheck fails (app not responding on /health, closed port, crash on start, migrations erroring out), the curl --fail step exits with an error and the pipeline turns red. That's exactly the intended behaviour: you're alerted immediately, before any user notices anything.

🎯 Pratique

S'entraîner (clique pour ouvrir) :

💬 Ré-explique sans regarder
Ré-explique sans regarder

Explique le circuit : qui détient quoi, et que se passe-t-il exactement au git push ?

Une bonne explication dit : la clé publique va sur le VPS (authorized_keys de deploy) ; la clé privée va dans GitHub Secrets (VPS_SSH_KEY). Au push : testbuild (image sur GHCR) → deploy (needs: build) ouvre une session SSH, fait docker compose pull puis up -d --wait, lance les migrations Doctrine, stoppe les workers Messenger, puis vérifie avec curl --fail /health.
🧠 Rappel libre
Rappel libre

Sans remonter : pourquoi la CI verte ne suffit pas à prouver que l'app fonctionne en prod ?

L'environnement du runner GitHub Actions n'est pas ton VPS : il a ses propres versions de Node/Python/PHP, ses propres variables d'environnement, ses propres librairies système. La CI verte prouve que les tests passent dans cet environnement isolé. Elle ne prouve pas que l'app tourne correctement en prod avec ton vrai .env, ta vraie BDD, tes vraies dépendances système. Un crash au démarrage, un .env manquant, un port fermé : rien de tout ça n'est visible dans la CI. Seule une vraie requête HTTP (curl --fail /health) sur le VPS le prouve.
⚖️ Juge le conseil de l'IA
Accepter ou rejeter le conseil de l'IA

L'IA génère un workflow de déploiement. Dans le YAML elle écrit : host: 51.210.34.12, username: root, password: "MonM0tDePasse!" en clair. Tu acceptes, ou tu rejettes ?

À rejeter, doublement. 1) Secret en clair dans le YAML : le mot de passe est dans l'historique git pour toujours, car un commit est immuable. Chaque fork, chaque clone expose la valeur. Supprimer le commit ne suffit pas : il faut changer le mot de passe (le révoquer). 2) Connexion en root : le compte root a tous les droits sur la machine. Si la pipeline est compromise, c'est la machine entière. La base : secrets dans GitHub Secrets (${{ secrets.X }}) + utilisateur deploy limité + clé SSH dédiée.
Où va la clé privée de déploiement ?
À quoi sert needs: build sur le job deploy ?
Que garantit docker compose up -d --wait que up -d seul ne garantit pas ?
Tu trouves password: hunter2 dans le YAML d'un vieux commit, "supprimé depuis". État des lieux ?
Prochaine étape

Le déploiement est automatisé. Mais que se passe-t-il quand la nouvelle image casse la prod ? À la leçon 8, on construit le rollback par tag : chaque déploiement garde l'image précédente sur GHCR, et revenir en arrière se fait en 15 secondes, sans git revert, sans stress.

Leçon 8 : Robustesse et rollback →
Besoin d'un développeur pour votre projet ?

Réponse sous 24h · Sans engagement