Environment-per-PR with GitHub Actions: Review Apps for Every Pull Request

Environment-per-PR with GitHub Actions: Review Apps for Every Pull Request

Review apps — a dedicated deployment for every pull request — are one of the most effective ways to catch bugs before they reach your main branch. Instead of reviewing code in a diff, reviewers interact with the actual running application. Instead of waiting for tests to run against a shared staging environment, automated tests run against an isolated deployment of exactly what's in the PR.

GitHub Actions makes this workflow accessible without dedicated infrastructure tooling. Here's how to build it.

What Review Apps Give You

A review app is a complete, functional deployment of your application built from a pull request's branch. Every PR gets its own URL. When the PR is merged or closed, the environment is torn down.

The benefits stack up quickly:

  • Reviewers test the actual feature, not a description of it
  • Automated tests run in isolation — no contention with other PRs or staging
  • The deployment process is tested on every PR, so deployment failures are caught before merge
  • Product and design teams can review without checking out code locally
  • QA can run exploratory testing against a real deployment

The workflow has three parts: deploy on PR open/update, run tests against the deployment, clean up on PR close.

The GitHub Actions Workflow

# .github/workflows/review-app.yml
name: Review App

on:
  pull_request:
    types: [opened, synchronize, reopened, closed]

env:
  APP_NAME: myapp-pr-${{ github.event.pull_request.number }}

jobs:
  deploy:
    if: github.event.action != 'closed'
    runs-on: ubuntu-latest
    outputs:
      app_url: ${{ steps.deploy.outputs.app_url }}
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Build and push Docker image
        id: build
        uses: docker/build-push-action@v5
        with:
          push: true
          tags: ghcr.io/${{ github.repository }}:pr-${{ github.event.pull_request.number }}
      
      - name: Deploy to review environment
        id: deploy
        env:
          KUBECONFIG_DATA: ${{ secrets.KUBECONFIG }}
          IMAGE_TAG: pr-${{ github.event.pull_request.number }}
        run: |
          echo "$KUBECONFIG_DATA" | base64 -d > /tmp/kubeconfig
          export KUBECONFIG=/tmp/kubeconfig
          
          # Apply namespace and deployment
          envsubst < k8s/review-app-template.yaml | kubectl apply -f -
          
          # Wait for rollout
          kubectl rollout status deployment/${{ env.APP_NAME }} \
            -n review-apps --timeout=300s
          
          # Get the URL
          APP_URL=$(kubectl get ingress ${{ env.APP_NAME }} \
            -n review-apps \
            -o jsonpath='{.status.loadBalancer.ingress[0].hostname}')
          
          echo "app_url=https://${APP_URL}" >> $GITHUB_OUTPUT
      
      - name: Comment URL on PR
        uses: actions/github-script@v7
        with:
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: `## Review App Deployed\n\n**URL:** ${{ steps.deploy.outputs.app_url }}\n\nThis environment will be torn down when the PR is closed.`
            })

  test:
    needs: deploy
    if: github.event.action != 'closed'
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Run HelpMeTest suite
        env:
          HELPMETEST_API_KEY: ${{ secrets.HELPMETEST_API_KEY }}
          BASE_URL: ${{ needs.deploy.outputs.app_url }}
        run: |
          npx helpmetest run \
            --suite pr-smoke \
            --base-url "$BASE_URL" \
            --tag "pr-${{ github.event.pull_request.number }}"
      
      - name: Comment test results on PR
        if: always()
        uses: actions/github-script@v7
        with:
          script: |
            const status = '${{ job.status }}' === 'success' ? '✅' : '❌';
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: `${status} **Automated tests ${status === '✅' ? 'passed' : 'failed'}** on review app`
            })

  cleanup:
    if: github.event.action == 'closed'
    runs-on: ubuntu-latest
    
    steps:
      - name: Tear down review environment
        env:
          KUBECONFIG_DATA: ${{ secrets.KUBECONFIG }}
        run: |
          echo "$KUBECONFIG_DATA" | base64 -d > /tmp/kubeconfig
          export KUBECONFIG=/tmp/kubeconfig
          
          kubectl delete namespace review-app-pr-${{ github.event.pull_request.number }} \
            --ignore-not-found=true
          
          # Delete the container image
          gh api \
            -X DELETE \
            /orgs/${{ github.repository_owner }}/packages/container/myapp/versions \
            -f tag="pr-${{ github.event.pull_request.number }}" || true

The Kubernetes Template

The deployment template uses envsubst to inject PR-specific values:

# k8s/review-app-template.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: review-app-${APP_NAME}
  labels:
    type: review-app
    pr: "${GITHUB_PR_NUMBER}"
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: ${APP_NAME}
  namespace: review-app-${APP_NAME}
spec:
  replicas: 1
  selector:
    matchLabels:
      app: ${APP_NAME}
  template:
    metadata:
      labels:
        app: ${APP_NAME}
    spec:
      containers:
        - name: app
          image: ghcr.io/${GITHUB_REPOSITORY}:${IMAGE_TAG}
          ports:
            - containerPort: 3000
          env:
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: review-app-db-credentials
                  key: url
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: ${APP_NAME}
  namespace: review-app-${APP_NAME}
  annotations:
    kubernetes.io/ingress.class: nginx
    cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
  rules:
    - host: pr-${GITHUB_PR_NUMBER}.review.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: ${APP_NAME}
                port:
                  number: 3000

Handling Database State

Each review app needs a database. You have three options, in order of isolation:

Shared database with per-PR schema — cheapest, works well for simple apps:

CREATE SCHEMA IF NOT EXISTS review_pr_1234;
SET search_path TO review_pr_1234;
-- run migrations

Shared database with per-PR prefix — good for apps that can't easily switch schemas:

DATABASE_URL="postgres://host/db?options=--search_path%3Dreview_pr_${PR_NUMBER}"

Per-PR database instance — most isolated, highest cost. Worth it for destructive testing (load tests, migration testing):

# Provisioned via Terraform in the deploy step
resource "aws_db_instance" "review_app" {
  identifier     = "review-pr-${var.pr_number}"
  instance_class = "db.t3.micro"
  
  snapshot_identifier = var.base_snapshot_id  # start from a known-good snapshot
  
  skip_final_snapshot = true
  deletion_protection = false
}

Injecting the URL Into Your Test Suite

Your test suite shouldn't hard-code environment URLs. Accept them as a parameter:

// playwright.config.js
module.exports = {
  use: {
    baseURL: process.env.BASE_URL || 'http://localhost:3000',
  },
};
# For Robot Framework / HelpMeTest
# tests/resources/config.robot
${BASE_URL}    %{BASE_URL=http://localhost:3000}

The CI workflow sets BASE_URL to the review app URL. Local development uses the default. The same test suite works everywhere.

Keeping Costs Under Control

Review apps cost money while they run. Enforce TTLs:

# Add to cleanup job, run on schedule too
- name: Clean up stale review apps
  if: github.event_name == 'schedule'
  run: |
    # Delete namespaces with review-app label older than 3 days
    kubectl get namespaces -l type=review-app \
      -o jsonpath='{range .items[*]}{.metadata.name} {.metadata.creationTimestamp}{"\n"}{end}' | \
    while read name created; do
      age=$(( ($(date +%s) - $(date -d "$created" +%s)) / 86400 ))
      if [ "$age" -gt 3 ]; then
        kubectl delete namespace "$name"
      fi
    done

Schedule this to run nightly. Stale review apps from merged-but-not-closed PRs get cleaned up automatically.

Read more