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 }}" || trueThe 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: 3000Handling 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 migrationsShared 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
doneSchedule this to run nightly. Stale review apps from merged-but-not-closed PRs get cleaned up automatically.