Remplacer les review apps de Heroku par des aperçus de pull request
Suivez ça sur votre propre serveur. Commencer gratuitement, sans carte
Les review apps de Heroku font démarrer une application jetable complète pour chaque pull request, ce qui est réellement utile pour les relecteurs : cliquer sur un lien plutôt que lire un diff en imaginant ce qu’il fait. Ce qui est facile à oublier, c’est que app.json exige des addons et une région, chaque review app tourne sur la tarification normale des dynos et des add-ons de Heroku, et le responsable de la facturation d’une organisation paie pour l’application de chaque PR ouverte comme pour une application de production, au prorata à la seconde mais pas gratuit. Ce guide construit le même flux de travail vous-même avec GitHub Actions et Docker sur un seul serveur, puis montre à quoi ressemble une version hébergée de ça une fois qu’elle tient compte du vrai problème de coût : les applications d’aperçu inactives.
À la fin, vous aurez un système d’aperçu par PR fonctionnel que vous avez construit et comprenez complètement, et vous saurez exactement quel compromis une fonctionnalité d’aperçu hébergée fait réellement en votre nom.
Ce qu’il vous faut
- Un serveur où votre application principale (ou une copie de staging) se déploie déjà comme image Docker. Un Hetzner CX23 (2 vCPU, 4 Go de RAM, 5,99 € par mois avant taxes) gère une poignée d’environnements d’aperçu simultanés pour une application web typique.
- Un dépôt GitHub avec les Actions activées, et une façon de construire et pousser une image Docker étiquetée par pull request (la plupart des configurations de CI le font déjà pour la branche principale ; ce guide étiquette plutôt par numéro de PR).
- Un enregistrement DNS générique ou un sous-domaine par aperçu (ce guide utilise
pr-<numéro>.review.example.com), et Caddy déjà en marche sur le serveur pour le HTTPS. - Un accès SSH depuis GitHub Actions vers le serveur (une clé de déploiement suffit ; ce guide ne couvre pas sa configuration, puisque la plupart des équipes en ont déjà une pour leur déploiement principal).
Étape 1 : construire et étiqueter une image par pull request
Ce qui construit déjà votre image de production fonctionne ici ; le seul changement est l’étiquette :
name: Build PR image
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build and push
run: |
docker build -t ghcr.io/you/app:pr-${{ github.event.pull_request.number }} .
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
docker push ghcr.io/you/app:pr-${{ github.event.pull_request.number }}
Étiqueter par numéro de PR plutôt que par SHA de commit signifie que chaque push vers la même PR écrase la même étiquette, ce qui est exactement le comportement d’une review app : l’aperçu suit la branche, pas un commit figé.
Étape 2 : écrire le gabarit Compose par PR
services:
app:
image: ghcr.io/you/app:pr-${PR_NUMBER}
restart: unless-stopped
env_file:
- .env
ports:
- "127.0.0.1:${APP_PORT}:3000"
Lier à 127.0.0.1 garde le conteneur inaccessible directement depuis internet ; Caddy est le seul point d’entrée public, comme un seul serveur de production avec plusieurs applications les isole habituellement les unes des autres sur un même hôte.
Étape 3 : écrire le script de déploiement
Ceci tourne sur le serveur lui-même, déclenché via SSH depuis le workflow Actions :
#!/usr/bin/env bash
set -euo pipefail
PR_NUMBER="$1"
APP_PORT=$((40000 + PR_NUMBER))
DIR="/opt/previews/pr-$PR_NUMBER"
mkdir -p "$DIR"
cp /opt/previews/template/docker-compose.yml "$DIR/"
cp /opt/previews/template/.env "$DIR/.env"
export PR_NUMBER APP_PORT
docker compose -f "$DIR/docker-compose.yml" --project-directory "$DIR" pull
docker compose -f "$DIR/docker-compose.yml" --project-directory "$DIR" up -d
cat > "/etc/caddy/sites/pr-$PR_NUMBER.caddyfile" <<EOF
pr-$PR_NUMBER.review.example.com {
reverse_proxy 127.0.0.1:$APP_PORT
}
EOF
docker exec caddy caddy reload --config /etc/caddy/Caddyfile
Dériver APP_PORT du numéro de PR (40000 + PR_NUMBER) est une façon simple d’éviter les collisions de port entre des PR ouvertes simultanément sans registre séparé pour suivre ce qui est pris ; ça fonctionne jusqu’à avoir plus de quelques milliers de PR ouvertes en même temps, ce qui n’est pas une contrainte réelle pour presque aucune équipe.
Le Caddyfile principal a besoin d’une ligne ajoutée une fois, au départ, pour qu’il reprenne automatiquement chaque fichier de site généré :
import /etc/caddy/sites/*.caddyfile
Étape 4 : le brancher dans le workflow GitHub Actions
deploy-preview:
needs: build
runs-on: ubuntu-latest
steps:
- name: Deploy preview
run: |
ssh deploy@your-server "/opt/previews/deploy.sh ${{ github.event.pull_request.number }}"
Ajoutez une tâche correspondante pull_request: types: [closed] qui exécute plutôt un script de démontage : docker compose down, retirer le répertoire pr-<numéro>, supprimer le Caddyfile généré, et recharger Caddy. Une PR fermée avec un conteneur qui traîne est l’équivalent review-app d’un serveur que personne n’a pensé à éteindre.
Étape 5 : confirmer de bout en bout
Ouvrez une PR de test, attendez que le workflow termine, et visitez https://pr-<numéro>.review.example.com ; vous devriez voir tourner la branche de cette PR, pas l’application de production. Poussez un nouveau commit sur la même PR et confirmez que l’image se reconstruit et que la même URL sert maintenant le nouveau commit. Fermez la PR et confirmez que le conteneur et le fichier de site Caddy ont tous deux disparu en moins d’une minute.
Le raccourci : faites-le avec Railyard
Tout ce qui précède, le build par PR, le port dynamique, la config Caddy générée, le démontage à la fermeture, est ce que la fonctionnalité d’environnements d’aperçu de Railyard fait automatiquement une fois GitHub connecté et les aperçus activés pour une application. Chaque pull request obtient sa propre copie complète automatiquement, même build, mêmes types de processus, sa propre URL, retirée au moment où la PR se ferme, sans fichier de workflow ni script de déploiement à maintenir vous-même.
L’élément qui vaut la peine d’être souligné spécifiquement, puisque c’est le vrai problème des review apps de Heroku plutôt qu’un détail mineur : les nouvelles applications d’aperçu et de staging se mettent en veille par défaut après 30 minutes sans trafic, se réveillant à la prochaine requête. Une PR ouverte pendant deux semaines pendant que quelqu’un est en vacances n’est pas un conteneur qui tourne à plein coût tout ce temps ; il est endormi presque tout le temps. C’est configurable par application sous Settings → General (jamais, 10 minutes, 30 minutes, 1 heure, 6 heures, ou une journée sans trafic, ou des heures d’éveil spécifiques à la place), et c’est la seule chose que la configuration manuelle des étapes 1 à 5 ne vous donne pas sans la construire vous-même, puisque rien dans ce script n’empêche un conteneur de tourner à plein coût aussi longtemps que la PR reste ouverte. Il y a une limite de 5 aperçus simultanés par application, ce qui compte rarement pour le nombre de PR ouvertes d’une équipe typique ; référence complète : Environnements et aperçus.
Dépannage
L’URL d’aperçu retourne une 404 même si le script de déploiement a tourné. Vérifiez que docker exec caddy caddy reload --config /etc/caddy/Caddyfile a réellement réussi ; une erreur de syntaxe dans un fichier de site généré fait échouer le rechargement silencieusement à moins de vérifier son code de sortie, laissant Caddy tourner avec la configuration précédente.
Deux PR servent le même contenu. APP_PORT est entré en collision, habituellement parce que la formule de dérivation du port a été changée sans vérifier d’abord les aperçus déjà en marche ; docker ps --format '{{.Names}} {{.Ports}}' montre ce qui est réellement lié où.
Les conteneurs s’accumulent après la fermeture des PR. Le workflow de démontage n’est pas configuré pour le type de PR closed, ou GitHub ne l’a pas déclenché parce que la PR a été fermée sans fusion par un chemin que le filtre types de votre workflow ne couvre pas ; confirmez que types: [closed] capture à la fois les PR fusionnées et simplement fermées, puisque GitHub déclenche le même événement pour les deux.
L’aperçu montre du code périmé après un push. Confirmez que l’étiquette d’image dans le gabarit Compose correspond réellement à ce que l’étape 1 vient de pousser ; docker compose pull avant up -d est nécessaire, puisque Compose ne récupère pas une nouvelle image pour une étiquette existante sans qu’on le lui demande.
FAQ
Ça marche avec GitLab plutôt que GitHub ? Le même patron fonctionne avec GitLab CI/CD plutôt qu’Actions ; le gabarit Compose par PR, la génération de config Caddy et le script de démontage ne changent pas, seule la syntaxe de workflow qui les déclenche change.
Les aperçus peuvent-ils partager une base de données avec la production ? Ils peuvent, mais une migration qui tourne contre la connexion d’un aperçu à la base de données de production est un vrai risque qui vaut la peine d’être évité ; une base de données par aperçu, même une jetable bon marché amorcée depuis un instantané, est plus sûre et c’est ce que fait par défaut la fonctionnalité d’environnements de Railyard, exactement pour cette raison.
Qu’arrive-t-il aux secrets dont chaque aperçu a besoin ? Le gabarit .env de la configuration manuelle est une vraie lacune : il a besoin de vraies valeurs pour tout ce que l’application requiert (clés API, URL de base de données), et copier les secrets de production dans chaque aperçu est sa propre question de sécurité qui vaut la peine d’être réfléchie séparément, habituellement en délimitant des identifiants réservés aux aperçus dès le départ.
Un CX23 suffit-il vraiment pour plusieurs aperçus à la fois ? Pour une poignée d’aperçus légers faisant tourner la même application comme un seul processus chacun, oui ; c’est exactement le genre de charge de travail qu’une politique de mise en veille à l’inactivité aide le plus, puisque la plupart des PR ouvertes ne sont pas activement consultées à un moment donné.
Les review apps de Heroku ont bien fait une chose qui vaut la peine d’être gardée : un relecteur devrait recevoir un lien qui tourne, pas un diff à imaginer. Ce qu’elles font mal, c’est facturer chaque PR ouverte comme un serveur de production peu importe si quelqu’un la regarde réellement, ce que les étapes 1 à 5 ci-dessus reproduisent exactement à moins de construire aussi la logique de mise en veille vous-même. C’est l’élément qui vaut la peine d’être honnête avant de copier n’importe quelle fonctionnalité d’environnement d’aperçu, hébergée ou construite soi-même.
Pourquoi les gens choisissent Railyard pour ça
- Votre propre compte infonuagique. Déployez sur DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, ou n'importe quel serveur Linux par SSH, facturé directement par votre fournisseur, sans majoration.
- Un prix fixe par équipe. L'offre gratuite est gratuite pour toujours : un serveur plus un serveur de base de données, trois applications, sans carte. Pro coûte 19 $ par mois pour jusqu'à cinq serveurs et un nombre illimité d'applications. Team coûte 49 $ par mois avec des serveurs illimités. Aucuns frais par application, par déploiement, par siège ou par gigaoctet.
- Des gabarits en un clic. Déployez des applications open source, comme des bases de données, des outils d'analyse et des outils de développement, sur votre propre serveur en un clic.
- Des bases de données gérées. Postgres, MySQL et Redis, avec des sauvegardes planifiées, vérifiées par restauration, et la récupération à un point dans le temps.
- Déploiement par push. Déployez depuis GitHub, GitLab ou la CLI, avec HTTPS automatique et des mises en production sans interruption.