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.

SymptomMechanismWhere the cost lands
Environments outlive their pull requestson: pull_request fires on opened, synchronize, and reopened by default, so closed never arrives unless it is listedEnvironment hours on your cloud bill, with no upper bound
Teardown is skipped after a failed deployThe teardown job carries needs: deploy, and a failed dependency skips the dependent jobA half-created environment holding cluster resources, a database, and a load balancer
A stale commit wins the preview URLTwo pushes deploy in parallel and the slower run finishes lastThe preview serves an older commit than the pull request head
Every preview queues behind one laneThe concurrency.group string resolves to the same value for all pull requestsDeploys serialize across unrelated pull requests
Fork pull requests fail at credential exchangeA pull_request run from a fork gets a read-only GITHUB_TOKEN and no repository secretsRed 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 labelOSvCPURAMStorageRate per minute
warp-ubuntu-latest-x64-2xUbuntu 24.0428 GB150GB SSD$0.004
warp-ubuntu-latest-x64-4xUbuntu 24.04416 GB150GB SSD$0.008
warp-ubuntu-latest-x64-8xUbuntu 24.04832 GB150GB SSD$0.016
warp-ubuntu-latest-x64-16xUbuntu 24.041664 GB150GB 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.sh

reap.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

InputValueSource
Pull requests opened per month260Your pull request history
Deploys per pull request6 (one open, five pushes)Your workflow run history
Deploy job duration4.0 minutesYour run logs
Destroy job duration1.2 minutesYour run logs
Median time a pull request stays open19 hoursYour pull request history
Runner rates$0.004 to $0.032 per minutecloud runners docs and pricing, checked 2026-08-13

Runner minutes

TransitionRuns per monthLabelMinutes eachMinutesRateCost
Create on open260warp-ubuntu-latest-x64-4x4.01,040$0.008$8.32
Update on push1,300warp-ubuntu-latest-x64-4x4.05,200$0.008$41.60
Destroy on close260warp-ubuntu-latest-x64-2x1.2312$0.004$1.25
Reaper, daily30warp-ubuntu-latest-x64-2x0.824$0.004$0.10
Total1,8506,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 behaviorEnvironments per monthAverage lifetimeEnvironment hours
Destroy on close, every time26019 h4,940
Destroy on close, 8 percent skipped, weekly manual sweep239 at 19 h, 21 at 103 h26 h6,704
No destroy job, cleanup at month end260360 h93,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.