Preview Environments from Pull Requests
Preview environments on GitHub Actions run on three transitions: create on open, update on push, destroy on close. Workflow YAML, concurrency, and cost.
Last verified:
A preview environment on GitHub Actions is a deployment of one pull request branch that lives exactly as long as the pull request does, driven by three transitions: create when the pull request opens, update on every push to the head branch, destroy when the pull request closes. The third transition is the one that decides the bill, because a teardown job that gets skipped leaves the environment running on your cloud account until a person notices it.
This guide covers the failure modes that leave environments alive, the workflow keys that fix them, a full workflow with the three triggers and a concurrency group keyed on the pull request number, and a cost model at 260 pull requests a month that separates runner minutes from environment hours.
Diagnosis
Read the workflow's on: block and its job-level if: conditions before anything else. Five shapes account for most preview environment trouble, and each puts the cost somewhere different.
| Symptom | Mechanism | Where the cost lands |
|---|---|---|
| Environments outlive their pull requests | on: pull_request fires on opened, synchronize, and reopened by default, so closed never arrives unless it is listed | Environment hours on your cloud bill, with no upper bound |
| Teardown is skipped after a failed deploy | The teardown job carries needs: deploy, and a failed dependency skips the dependent job | A half-created environment holding cluster resources, a database, and a load balancer |
| A stale commit wins the preview URL | Two pushes deploy in parallel and the slower run finishes last | The preview serves an older commit than the pull request head |
| Every preview queues behind one lane | The concurrency.group string resolves to the same value for all pull requests | Deploys serialize across unrelated pull requests |
| Fork pull requests fail at credential exchange | A pull_request run from a fork gets a read-only GITHUB_TOKEN and no repository secrets | Red checks on external contributions |
The activity type list for pull_request is in GitHub's events reference, checked on 2026-08-13. The first row is the expensive one, and it hides because the two halves of the cost show up in two different places. Runner minutes appear on the Actions side and stop when the job ends. Environment hours appear on your cloud invoice and keep accruing while the workflow does nothing at all, which is why an orphaned preview stays invisible on the surface where people look for pipeline spend. The per pull request view of the first half is in GitHub Actions cost per pull request.
The fourth row is a naming problem. GitHub matches the resolved group string across the repository rather than per workflow file, so group: preview is a single global lane for every open pull request. The resolution rules are in the concurrency group glossary entry.
One thing that is rarely the cause: platform capacity. Run as many jobs as your workflows need. Generally available Linux and Windows runners do not have plan-level concurrency caps of WarpBuild runners, and capacity adjusts dynamically, so 40 open pull requests deploying at the same time is a decision you write into the YAML rather than a limit you inherit.
Fix
Declare all four activity types
types: [opened, reopened, synchronize, closed] maps the three lifecycle transitions onto events. opened and reopened create, synchronize fires on every push to the head branch and updates, closed destroys. Merging counts as closing, so one destroy path covers both merged and abandoned pull requests.
Key the concurrency group on the pull request number
group: preview-${{ github.event.pull_request.number }} gives each pull request its own lane, and cancel-in-progress: true means a new push kills the deploy that the push just made obsolete. Because the same group covers the close event, closing a pull request cancels an in-flight deploy for that pull request instead of racing it to the finish. Nothing follows a close except a reopen, so the teardown run reaches its own end.
Make the teardown unskippable
Put the destroy job in the same workflow file, gate it at job level on if: github.event.action == 'closed', and give it no needs: edge at all. A job with no dependencies cannot be skipped by an upstream failure, which matters most in the case where the deploy died partway through creating resources.
Add a scheduled reaper as the backstop. It lists the environments your naming scheme creates, asks the API for the state of the matching pull request, and destroys anything whose pull request is closed or whose pull request has been idle past your threshold. A daily reaper run is under a minute of runner time and catches the workflow runs that were cancelled, the ones that hit an expired credential, and the environments somebody created by hand.
Size the deploy runner and keep the rows separable
Preview deploys build an image, push it, and apply a manifest, so they sit in the middle of the size range. Rates and shapes come from the cloud runners documentation and the pricing page, checked on 2026-08-13.
| runs-on label | OS | vCPU | RAM | Storage | Rate per minute |
|---|---|---|---|---|---|
| warp-ubuntu-latest-x64-2x | Ubuntu 24.04 | 2 | 8 GB | 150GB SSD | $0.004 |
| warp-ubuntu-latest-x64-4x | Ubuntu 24.04 | 4 | 16 GB | 150GB SSD | $0.008 |
| warp-ubuntu-latest-x64-8x | Ubuntu 24.04 | 8 | 32 GB | 150GB SSD | $0.016 |
| warp-ubuntu-latest-x64-16x | Ubuntu 24.04 | 16 | 64 GB | 150GB SSD | $0.032 |
A preview that ships a mobile client alongside the service runs the mobile leg on a macOS label while the deploy stays on Linux. Name the jobs preview-deploy and preview-destroy: the CI tab of the Reports page writes one row per job execution carrying repository, job name, runner label, stack, execution time, billed time, and the cost split, and it filters on job name, so the preview line separates from the rest of the pipeline with no extra instrumentation.
Configuration
One workflow file carries all three transitions. The deploy job and the destroy job share the concurrency group and share nothing else.
name: preview
on:
pull_request:
types: [opened, reopened, synchronize, closed]
concurrency:
group: preview-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
contents: read
id-token: write
pull-requests: write
jobs:
preview-deploy:
if: >-
github.event.action != 'closed' &&
github.event.pull_request.head.repo.full_name == github.repository
runs-on: warp-ubuntu-latest-x64-4x
environment:
name: preview-${{ github.event.pull_request.number }}
url: https://pr-${{ github.event.pull_request.number }}.preview.example.com
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111122223333:role/gha-preview
aws-region: us-east-1
- uses: aws-actions/amazon-ecr-login@v2
id: ecr
- uses: Warpbuilds/build-push-action@v6
id: push
with:
context: .
push: true
profile-name: api-builder
tags: ${{ steps.ecr.outputs.registry }}/api:pr-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }}
- name: Apply the preview environment
run: ./preview/apply.sh "pr-${{ github.event.pull_request.number }}" "${{ steps.push.outputs.digest }}"
- name: Publish the preview URL
env:
GH_TOKEN: ${{ github.token }}
run: |
gh pr comment "${{ github.event.pull_request.number }}" \
--edit-last --create-if-none \
--body "Preview: https://pr-${{ github.event.pull_request.number }}.preview.example.com"
preview-destroy:
if: github.event.action == 'closed'
runs-on: warp-ubuntu-latest-x64-2x
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: preview/
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111122223333:role/gha-preview
aws-region: us-east-1
- name: Destroy the preview environment
run: ./preview/destroy.sh "pr-${{ github.event.pull_request.number }}"Five details carry the behavior. The types: list includes closed, so the destroy path exists. preview-destroy has no needs: edge, so a deploy that failed three pushes ago cannot skip it. The concurrency group resolves to preview-1487 for every event on pull request 1487 and to a different string for every other pull request, so pushes supersede their own predecessors and leave other previews alone. The head repository check keeps fork pull requests out of the credential exchange, which is the read-only-token row from the diagnosis table. And the environment name matches the resource prefix the destroy script takes, so the reaper can rebuild the mapping from a list of resources alone.
The reaper is a second file, and it is the reason the teardown holds even when a run never happens:
name: preview-reaper
on:
schedule:
- cron: "0 6 * * *"
workflow_dispatch:
jobs:
reap:
runs-on: warp-ubuntu-latest-x64-2x
permissions:
contents: read
pull-requests: read
id-token: write
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: preview/
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111122223333:role/gha-preview
aws-region: us-east-1
- name: Destroy previews whose pull request is closed
env:
GH_TOKEN: ${{ github.token }}
MAX_IDLE_HOURS: "168"
run: ./preview/reap.shreap.sh lists every resource group with the pr- prefix, resolves the number back to a pull request through gh pr view, and calls the same destroy.sh for anything closed or idle past MAX_IDLE_HOURS. Using the same destroy script in both places keeps one code path for resource removal. If the target is a Kubernetes cluster, apply.sh and destroy.sh wrap the two commands from the Kubernetes deployments guide, and the credential and digest handling around them follows the deployment jobs guide.
Cost or Time Model
Assumptions
| Input | Value | Source |
|---|---|---|
| Pull requests opened per month | 260 | Your pull request history |
| Deploys per pull request | 6 (one open, five pushes) | Your workflow run history |
| Deploy job duration | 4.0 minutes | Your run logs |
| Destroy job duration | 1.2 minutes | Your run logs |
| Median time a pull request stays open | 19 hours | Your pull request history |
| Runner rates | $0.004 to $0.032 per minute | cloud runners docs and pricing, checked 2026-08-13 |
Runner minutes
| Transition | Runs per month | Label | Minutes each | Minutes | Rate | Cost |
|---|---|---|---|---|---|---|
| Create on open | 260 | warp-ubuntu-latest-x64-4x | 4.0 | 1,040 | $0.008 | $8.32 |
| Update on push | 1,300 | warp-ubuntu-latest-x64-4x | 4.0 | 5,200 | $0.008 | $41.60 |
| Destroy on close | 260 | warp-ubuntu-latest-x64-2x | 1.2 | 312 | $0.004 | $1.25 |
| Reaper, daily | 30 | warp-ubuntu-latest-x64-2x | 0.8 | 24 | $0.004 | $0.10 |
| Total | 1,850 | 6,576 | $51.27 |
The concurrency group changes the second row. A push that lands while the previous deploy is still running cancels it, so a superseded run bills the minutes it actually used. At 18 percent of update pushes superseded after an average 1.5 minutes, that row falls from 5,200 minutes to 4,615 minutes and $36.92, and the monthly total to $46.58.
Those two deploy rows carry the list-price arithmetic. warp-ubuntu-latest-x64-4x (4 vCPU, 16 GB) costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner (4 vCPU, 16 GB): 33 percent lower list price, with the GitHub list price checked on 2026-08-13 in the minute multipliers reference. The 5,655 deploy minutes above bill $45.24 at the WarpBuild rate against $67.86 at the GitHub list rate, a difference of $22.62 a month at an identical machine shape.
Environment hours
Runner minutes are the smaller half. The environment bills on your own cloud account for every hour it exists, and the destroy job is the thing that stops that meter.
| Teardown behavior | Environments per month | Average lifetime | Environment hours |
|---|---|---|---|
| Destroy on close, every time | 260 | 19 h | 4,940 |
| Destroy on close, 8 percent skipped, weekly manual sweep | 239 at 19 h, 21 at 103 h | 26 h | 6,704 |
| No destroy job, cleanup at month end | 260 | 360 h | 93,600 |
Skipping 21 teardowns out of 260 adds 1,764 environment hours, a 36 percent increase over a month where every teardown lands. Dropping the destroy job entirely takes the same 260 pull requests to 93,600 hours.
Multiply the last column by your own hourly rate for one environment. At $0.05 an hour the three rows are $247, $335, and $4,680 a month. At $0.40 an hour they are $1,976, $2,682, and $37,440. The runner minutes that create, update, and destroy those environments stay at $46.58 in all three rows, which is the shape of the problem: the workflow is cheap and the thing it leaves behind is not.
Pricing on WarpBuild is purely usage based. Ordering this against the rest of the bill is covered in cutting GitHub Actions costs.
FAQ
Why does my preview environment survive after the pull request is merged?
Because on: pull_request defaults to the opened, synchronize, and reopened activity types, and closed sits outside that default set (events reference, checked on 2026-08-13). List all four in types: and the destroy path starts firing. Merging a pull request closes it, so one closed handler covers merged and abandoned alike.
Should the teardown job depend on the deploy job?
No. A needs: deploy edge makes a failed or cancelled deploy skip the teardown, which is the case where an environment is most likely to be half created and still holding resources. Gate the teardown at job level on if: github.event.action == 'closed' with no needs: edge, and keep a scheduled reaper as the backstop.
What concurrency group should a preview workflow use?
One keyed on the pull request number, such as preview-${{ github.event.pull_request.number }} with cancel-in-progress: true. GitHub resolves the group string across the whole repository, so a group keyed on a constant or on the workflow name serializes every preview in the repository behind one lane. The resolution rules are in the concurrency group glossary entry.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.