← Blog

Heroku Review Apps durch Pull-Request-Previews ersetzen

Railyard

15. Juli 2026 · 6 Min. Lesezeit

Folge dem auf deinem eigenen Server. Kostenlos starten, keine Karte nötig

Herokus Review Apps starten für jeden Pull Request eine vollständige, wegwerfbare App, was für Reviewer wirklich nützlich ist: einen Link klicken statt einen Diff zu lesen und sich vorzustellen, was er tut. Was man leicht vergisst: app.json verlangt addons und eine Region, jede Review App läuft zu Herokus normalen Dyno- und Add-on-Preisen, und der Billing-Owner einer Organisation zahlt für jeden offenen PR dieselbe App-Rechnung wie für eine Produktions-App, sekundengenau anteilig, aber nicht kostenlos. Diese Anleitung baut dasselbe Workflow selbst, mit GitHub Actions und Docker auf einem einzigen Server, und zeigt dann, wie eine gehostete Version davon aussieht, sobald sie das eigentliche Kostenproblem berücksichtigt: untätig herumstehende Preview-Apps.

Am Ende hast du ein funktionierendes Preview-System pro PR, das du selbst gebaut hast und vollständig verstehst, und weißt genau, welchen Trade-off ein gehostetes Preview-Feature eigentlich in deinem Namen trifft.

Was du brauchst

  • Einen Server, auf dem deine Haupt-App (oder eine Staging-Kopie davon) bereits als Docker-Image deployt. Ein Hetzner CX23 (2 vCPU, 4 GB RAM, 5,99 €/Monat exkl. MwSt.) bewältigt eine Handvoll gleichzeitiger Preview-Umgebungen für eine typische Web-App.
  • Ein GitHub-Repository mit aktivierten Actions und einem Weg, ein Docker-Image pro Pull Request zu bauen und zu pushen (die meisten CI-Setups tun das bereits für den Main-Branch; diese Anleitung taggt statt dessen nach PR-Nummer).
  • Einen Wildcard-DNS-Eintrag oder eine Subdomain pro Preview (diese Anleitung verwendet pr-<number>.review.example.com) und Caddy, das auf dem Server bereits für HTTPS läuft.
  • SSH-Zugang von GitHub Actions zum Server (ein Deploy Key reicht; diese Anleitung behandelt das Einrichten nicht, da die meisten Teams dafür bereits einen für ihren Haupt-Deploy haben).

Schritt 1: Ein Image pro Pull Request bauen und taggen

Was auch immer dein Produktions-Image baut, funktioniert hier bereits; die einzige Änderung ist der Tag:

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 }}

Nach PR-Nummer statt nach Commit-SHA zu taggen bedeutet, dass jeder Push zum selben PR denselben Tag überschreibt, genau das Verhalten, das eine Review App hat: Die Preview folgt dem Branch, nicht einem eingefrorenen Commit.

Schritt 2: Die Compose-Vorlage pro PR schreiben

services:
  app:
    image: ghcr.io/you/app:pr-${PR_NUMBER}
    restart: unless-stopped
    env_file:
      - .env
    ports:
      - "127.0.0.1:${APP_PORT}:3000"

Das Binden an 127.0.0.1 hält den Container direkt vom Internet unerreichbar; Caddy ist der einzige öffentliche Einstiegspunkt, so wie ein einzelner Produktionsserver mit mehreren Apps sie üblicherweise auf einem Host voneinander isoliert.

Schritt 3: Das Deploy-Skript schreiben

Das läuft auf dem Server selbst, ausgelöst über SSH aus dem Actions-Workflow:

#!/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

APP_PORT aus der PR-Nummer abzuleiten (40000 + PR_NUMBER) ist ein einfacher Weg, Port-Kollisionen zwischen gleichzeitig offenen PRs zu vermeiden, ohne eine separate Registry, die mitführt, was schon belegt ist; das funktioniert, bis du mehr als ein paar Tausend PRs gleichzeitig offen hast, was für fast kein Team eine echte Einschränkung ist.

Die Haupt-Caddyfile braucht einmalig vorab eine zusätzliche Zeile, damit sie jede generierte Site-Datei automatisch aufnimmt:

import /etc/caddy/sites/*.caddyfile

Schritt 4: In den GitHub-Actions-Workflow einbinden

  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 }}"

Füge einen entsprechenden pull_request: types: [closed]-Job hinzu, der statt dessen ein Teardown-Skript ausführt: docker compose down, das pr-<number>-Verzeichnis entfernen, die generierte Caddyfile löschen und Caddy neu laden. Ein geschlossener PR mit einem herumliegenden Container ist das Review-App-Äquivalent eines Servers, den niemand ausgeschaltet hat.

Schritt 5: Von Anfang bis Ende bestätigen

Öffne einen Test-PR, warte, bis der Workflow fertig ist, und besuche https://pr-<number>.review.example.com; du solltest den Branch dieses PRs laufen sehen, nicht die Produktions-App. Pushe einen neuen Commit auf denselben PR und bestätige, dass das Image neu baut und dieselbe URL jetzt den neuen Commit bedient. Schließe den PR und bestätige, dass Container und Caddy-Site-Datei innerhalb einer Minute beide weg sind.

Die Abkürzung: mit Railyard

Alles oben, der Build pro PR, der dynamische Port, die generierte Caddy-Konfiguration, der Teardown beim Schließen, ist das, was Railyards Preview-Umgebungen-Feature automatisch erledigt, sobald GitHub verbunden ist und Previews für eine App aktiviert sind. Jeder Pull Request bekommt automatisch seine eigene vollständige Kopie, gleicher Build, gleiche Prozesstypen, eigene URL, entfernt in dem Moment, in dem der PR schließt, ohne dass du selbst eine Workflow-Datei oder ein Deploy-Skript pflegen musst.

Der Teil, der besonders erwähnenswert ist, weil es das tatsächliche Problem mit Herokus Review Apps ist und nicht nur ein Detail: Neue Preview- und Staging-Apps schlafen standardmäßig nach 30 Minuten ohne Traffic ein und wachen beim nächsten Request wieder auf. Ein PR, der zwei Wochen offen ist, während jemand im Urlaub ist, ist kein Container, der die ganze Zeit zum vollen Preis vor sich hin läuft; er schläft fast die gesamte Zeit. Das lässt sich pro App unter Settings → General konfigurieren (nie, 10 Minuten, 30 Minuten, 1 Stunde, 6 Stunden oder einen Tag ohne Traffic, oder statt dessen bestimmte Wachzeiten), und es ist das eine Ding, das dir das manuelle Setup aus den Schritten 1 bis 5 nicht gibt, ohne dass du es selbst baust, denn nichts in diesem Skript hindert einen Container daran, zum vollen Preis zu laufen, solange der PR offen bleibt. Es gibt ein Limit von 5 gleichzeitigen Previews pro App, was für die Anzahl offener PRs eines typischen Teams selten eine Rolle spielt; vollständige Referenz: Environments and previews.

Troubleshooting

Die Preview-URL liefert 404, obwohl das Deploy-Skript gelaufen ist. Prüfe, ob docker exec caddy caddy reload --config /etc/caddy/Caddyfile tatsächlich erfolgreich war; ein Syntaxfehler in einer generierten Site-Datei lässt das Reload stillschweigend fehlschlagen, wenn du nicht den Exit-Code prüfst, sodass Caddy mit der vorherigen Konfiguration weiterläuft.

Zwei PRs liefern denselben Inhalt. APP_PORT ist kollidiert, meist weil die Port-Ableitungsformel geändert wurde, ohne zuerst bestehende laufende Previews zu prüfen; docker ps --format '{{.Names}} {{.Ports}}' zeigt, was tatsächlich wo gebunden ist.

Container stapeln sich, nachdem PRs geschlossen wurden. Der Teardown-Workflow ist entweder nicht für den closed-PR-Typ konfiguriert, oder GitHub hat ihn nicht ausgelöst, weil der PR geschlossen wurde, ohne über einen Pfad zu mergen, den dein Workflow-Filter types nicht abdeckt; bestätige, dass types: [closed] sowohl gemergte als auch einfach geschlossene PRs abfängt, da GitHub für beide dasselbe Event feuert.

Die Preview zeigt veralteten Code nach einem Push. Bestätige, dass der Image-Tag in der Compose-Vorlage tatsächlich zu dem passt, was Schritt 1 gerade gepusht hat; docker compose pull vor up -d ist erforderlich, da Compose kein neues Image für einen bestehenden Tag zieht, wenn man es nicht anweist.

FAQ

Funktioniert das auch mit GitLab statt GitHub? Dasselbe Muster funktioniert mit GitLab CI/CD statt Actions; die Compose-Vorlage pro PR, die Caddy-Konfigurationsgenerierung und das Teardown-Skript ändern sich nicht, nur die Workflow-Syntax, die sie auslöst.

Können Previews eine Datenbank mit der Produktion teilen? Sie können, aber eine Migration, die über die Verbindung einer Preview gegen die Produktionsdatenbank läuft, ist ein echtes Risiko, das sich lohnt zu vermeiden; eine Datenbank pro Preview, selbst eine billige, wegwerfbare, aus einem Snapshot befüllte, ist sicherer und genau das, was Railyards Environments-Feature standardmäßig aus genau diesem Grund tut.

Was passiert mit den Secrets, die jede Preview braucht? Die .env-Vorlage des manuellen Setups ist eine echte Lücke: Sie braucht echte Werte für alles, was die App benötigt (API-Keys, eine Datenbank-URL), und Produktionssecrets in jede Preview zu kopieren ist eine eigene Sicherheitsfrage, die man separat durchdenken sollte, meist indem man Preview-only-Credentials von Anfang an eingrenzt.

Reicht ein CX23 wirklich für mehrere Previews gleichzeitig? Für eine Handvoll leichtgewichtiger Previews, die dieselbe App als einen Prozess jeweils laufen lassen, ja; das ist genau die Art von Workload, bei der eine Sleep-on-Idle-Policy am meisten hilft, da die meisten offenen PRs zu keinem gegebenen Zeitpunkt aktiv angeklickt werden.

Herokus Review Apps haben eines richtig gemacht, das sich zu behalten lohnt: Ein Reviewer sollte einen laufenden Link bekommen, keinen Diff zum Vorstellen. Falsch macht es, jeden offenen PR wie einen Produktionsserver zu berechnen, unabhängig davon, ob ihn gerade jemand tatsächlich ansieht, was die Schritte 1 bis 5 oben exakt reproduzieren, außer du baust die Sleep-Logik ebenfalls selbst. Das ist der eine Punkt, den man ehrlich bedenken sollte, bevor man irgendein Preview-Umgebungs-Feature kopiert, gehostet oder selbst gebaut.

Warum Leute sich dafür Railyard entscheiden

  • Dein eigenes Cloud-Konto. Deploye auf DigitalOcean, Hetzner, Vultr, AWS, OVHcloud oder jedem Linux-Server per SSH, direkt von deinem Anbieter abgerechnet, ohne Aufschlag.
  • Ein fester Preis pro Team. Free ist für immer kostenlos: ein Server plus ein Datenbankserver, drei Apps, keine Karte. Pro kostet 19 $ im Monat für bis zu fünf Server und unbegrenzte Apps. Team kostet 49 $ im Monat mit unbegrenzten Servern. Keine Kosten pro App, pro Deployment, pro Sitz oder pro Gigabyte.
  • Templates mit einem Klick. Deploye Open-Source-Apps, wie Datenbanken, Analytics und Dev-Tools, mit einem Klick auf deinem eigenen Server.
  • Verwaltete Datenbanken. Postgres, MySQL und Redis mit geplanten, wiederherstellungsgeprüften Backups und Point-in-Time-Recovery.
  • Push-to-Deploy. Deploye von GitHub, GitLab oder der CLI, mit automatischem HTTPS und Releases ohne Ausfallzeit.

Previews für eine App aktivieren

Verbinde GitHub, schalte eine Einstellung um, und jeder PR danach bekommt seine eigene Kopie, die einschläft, wenn niemand hinschaut.

Kostenloser Plan, keine Kreditkarte, und 14 Tage Pro für jedes neue Team. Ein fester Preis pro Team.