Heroku Review Apps to Preview Environments
GuideSeptember 29, 202611 min read

Heroku Review Apps to Preview Environments

The Short Version

Review Apps are frequently the single feature Heroku users are most reluctant to give up, and for good reason: a disposable, fully working environment for every pull request, created and destroyed automatically, is a genuinely valuable workflow. The workflow itself doesn't need to change when moving off Heroku. What changes is what's underneath it: Bunnyshell's ephemeral, per-PR environments do the same job, driven by a CI workflow and a bunnyshell.yaml template instead of app.json's environments.review block, running on infrastructure with per-minute pricing instead of Heroku's per-dyno rates.

What Review Apps Actually Configured

A typical app.json enabling Review Apps looks like this:

JSON
1{
2  "environments": {
3    "review": {
4      "addons": ["heroku-postgresql"],
5      "env": {
6        "SEED_DATABASE": "true"
7      },
8      "scripts": {
9        "postdeploy": "bundle exec rake db:seed"
10      }
11    }
12  }
13}

This tells Heroku: when a Review App is created for a PR, attach a fresh Postgres add-on, set this one extra env var, and run this seed script once the app finishes deploying. Combined with the GitHub integration (enabled per-repository in Heroku's dashboard), opening a pull request automatically triggered this entire sequence with no manual step.

The Direct Equivalent

The same sequence, create on PR open, deploy from the PR's branch, seed fresh data, expose a URL, translates into a standard ephemeral environment template plus a CI workflow that drives its lifecycle:

YAML
1kind: Environment
2name: pr-preview
3type: ephemeral
4
5components:
6  - kind: Database
7    name: postgres
8    dockerCompose:
9      environment:
10        POSTGRES_PASSWORD: SECRET['review-db-pass']
11
12  - kind: GenericComponent
13    name: seed
14    dependsOn: [postgres]
15    runnerImage: '@self'
16    deploy:
17      - 'bundle exec rake db:seed'
18
19  - kind: Application
20    name: web
21    dependsOn: [seed]
22    gitBranch: '{{ component.gitBranch }}'
23    hosts:
24      - hostname: 'pr-{{ env.unique }}-{{ env.base_domain }}'
25        servicePort: 3000
YAML
1# CI workflow, triggered on PR events
2on:
3  pull_request:
4    types: [opened, synchronize, closed]
5
6jobs:
7  preview:
8    if: github.event.action != 'closed'
9    runs-on: ubuntu-latest
10    steps:
11      - run: |
12          bns environments create --from-path bunnyshell.yaml \
13            --name "pr-${{ github.event.number }}" \
14            --project <PROJECT_ID> --k8s <CLUSTER_ID> --deploy
15
16  destroy:
17    if: github.event.action == 'closed'
18    runs-on: ubuntu-latest
19    steps:
20      - run: bns environments delete --id "$PR_ENV_ID" --force

The seed component here plays exactly the role Heroku's postdeploy script did: it runs once, after the database is available and before the application starts serving, giving every fresh PR environment the same populated starting state Review Apps provided through scripts.postdeploy.

Posting the URL Back to the PR

Heroku's GitHub integration automatically commented the Review App's URL on the pull request once it was ready. The same behavior is a short CI step at the end of the deploy job, and it's worth keeping since it's the actual mechanism that makes reviewers discover and use the preview link without asking for it:

YAML
1- run: |
2    ENV_URL=$(bns environments show --id "$PR_ENV_ID" -o json | \
3      python3 -c "import json,sys; d=json.load(sys.stdin); print(d['components'][0]['hosts'][0]['url'])")
4    gh pr comment "${{ github.event.number }}" --body "Preview environment ready: ${ENV_URL}"

See Exposing Preview URLs Automatically for Every Environment for the fuller pattern this builds on, including keeping the comment current across every subsequent push to the same PR rather than only the first one.

Replacing Inactivity-Based Auto-Destroy

Heroku destroyed a Review App automatically after a fixed period of inactivity, in addition to destroying it when the PR closed. The PR-close trigger translates directly, shown in the CI workflow above. The inactivity-based cleanup needs its own, explicit mechanism, since there's no equivalent built-in timer, typically a scheduled audit checking each ephemeral environment's last deploy or pipeline activity against a threshold:

Bash
1$ bns environments list --project <PROJECT_ID> -o json | \
2    python3 -c "
3import json, sys
4from datetime import datetime, timedelta, timezone
5d = json.load(sys.stdin)
6cutoff = datetime.now(timezone.utc) - timedelta(days=7)
7stale = [e for e in d if e['type'] == 'ephemeral' and e['name'].startswith('pr-')]
8print(f'{len(stale)} PR preview environments currently exist')
9"

See Auto-Destroy After Merge for the merge-triggered half of this, and the broader stale-environment audit pattern for catching a PR environment left behind by a branch that never formally closed, which Heroku's fixed inactivity window handled automatically and Bunnyshell handles through an explicit, more configurable scheduled check.

What's Actually Better About the Bunnyshell Version

Beyond avoiding Heroku's per-dyno cost for every concurrent Review App, three specific gaps close in this migration. Full Kubernetes access means a preview environment can run anything the production stack needs, a Helm chart, a raw manifest, a Terraform-provisioned resource, none of which a Heroku Review App's dyno-and-buildpack model could ever support. No add-on cost multiplication: Heroku attached a fresh, billed Postgres add-on to every single Review App; a self-hosted database component (or a lightweight shared one for preview purposes specifically) removes that per-environment add-on cost entirely. Configurable cleanup criteria: rather than one fixed inactivity window applied identically to every Review App, the stale-environment audit's threshold and criteria are explicit and adjustable per project, rather than a platform-wide default a team has no control over.

A Practical Migration Sequence

Moving this specific workflow, rather than an entire app, is a reasonably contained piece of work: wire the CI workflow above to create environments in parallel with Heroku's existing Review Apps for a few weeks, confirm the resulting environments behave equivalently (same seed data, same working URL, same teardown behavior on PR close), then disable Heroku's Review Apps integration and rely on the new workflow exclusively. Running both in parallel briefly is worth the small overhead, since it catches any gap in the translated seed script or component configuration before reviewers are depending on the new workflow exclusively.

A Cost Comparison Worked Example

Making the cost difference concrete: a team averaging eight concurrently open pull requests, each with a Review App running a Standard-1X web dyno ($25/month, prorated) plus a Heroku Postgres add-on ($9/month for the smallest tier), spends roughly $272/month just on Review App infrastructure, assuming Review Apps stay up continuously rather than being destroyed promptly. The equivalent eight ephemeral Bunnyshell environments, billed per minute at $0.007 and typically alive only during active review rather than continuously, cost a small fraction of that even under generous assumptions about how long each one stays up, since the meter only runs while the environment is actually deployed rather than accruing a flat monthly rate per Review App regardless of how much it's actually used.

Text
1Heroku: 8 Review Apps × ($25 dyno + $9 Postgres add-on) = ~$272/month
2Bunnyshell: 8 ephemeral environments, ~4 hours active review time each,
3  20 PR cycles/month × 4 hours × $0.42/hour equivalent ≈ well under $100/month

The exact numbers depend heavily on actual PR volume and how long environments stay up before being destroyed, but the structural difference, a flat per-dyno-per-month charge regardless of usage versus a per-minute charge that scales with actual active time, is what makes the gap widen further as PR volume grows, which is exactly the direction most teams' PR volume is heading, especially with AI coding assistants increasing PR throughput.

For the full lifecycle this PR-triggered workflow builds on, see Automated PR Environments for E2E Testing. For the broader migration context this specific workflow fits into, see Migrating from Heroku to Bunnyshell.

Handling Review Apps' Fork-PR Behavior

Heroku's Review Apps, by default, work for pull requests from forks the same way they work for branch PRs within the same repository, which is convenient and also worth pausing on: a Review App deploying a fork's unreviewed code carries the same risk profile discussed for any automated fork-PR deployment. Migrating this workflow is a natural moment to add a control Heroku's Review Apps didn't provide by default: IP-restricting the resulting environment, and gating deployment behind a first-time-contributor approval step, rather than carrying over automatic, unrestricted deployment for fork PRs by default just because that was the prior behavior.

YAML
1security:
2  access:
3    allowedIps:
4      - '203.0.113.0/24'

See IP Allowlisting for Fork-PR Preview Environments for the full pattern this adds, which is a genuine security improvement available specifically because the migration is already touching this part of the workflow, not an unrelated extra task bolted on.

Checklist

  • The CI workflow creates an environment on PR open and destroys it on PR close, matching Review Apps' lifecycle
  • A seed step replicates whatever scripts.postdeploy did in app.json, running after the database is ready
  • The environment's URL is posted back to the PR automatically, kept current across subsequent pushes
  • A scheduled stale-environment audit replaces Heroku's fixed inactivity-based auto-destroy
  • Both systems run in parallel for a validation period before Heroku's Review Apps integration is disabled
✦✦

Keep the Review Apps workflow. Lose the dyno bill.

14-day full-feature trial. No credit card required. Pay-as-you-go from $0.007/min per environment.

Frequently Asked Questions

What did Heroku Review Apps actually do?

When enabled via app.json, Heroku automatically created a new, temporary app for each pull request, built from the PR's branch, with its own dynos and add-ons as described in the environments.review section of app.json, and destroyed it automatically after the PR closed or after a period of inactivity.

Is the automatic per-PR creation behavior the same on Bunnyshell?

Yes, functionally: a webhook or CI step creates a new environment on PR open, redeploys it on subsequent pushes, and destroys it on merge or close, which is the same lifecycle Review Apps followed, just configured through a CI workflow and bunnyshell.yaml instead of app.json's environments.review block.

Does Bunnyshell support Review Apps' postdeploy script for seeding data into each review app?

The equivalent is a seed step in the deploy pipeline, run as its own component or as part of an application component's deploy script, achieving the same goal, a fresh, populated database for every PR environment, through the same lifecycle-script mechanism used for every other deploy step.

What about Review Apps' automatic inactivity-based destruction?

The direct equivalent is a scheduled stale-environment audit, checking each ephemeral environment's last activity and destroying anything past a threshold, rather than a built-in platform feature, giving a team more control over the exact threshold and criteria than Heroku's fixed inactivity window did.

Do I lose the automatic PR comment showing the review app's URL?

No, the same behavior, posting the environment's URL as a PR comment once it's ready, is straightforward to replicate as a CI step at the end of the deploy pipeline, and can include more detail than Heroku's default comment did, since it's a plain script rather than a fixed platform feature.