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:
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:
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: 30001# 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" --forceThe 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:
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:
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.
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/monthThe 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.
Related Reading
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.
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.postdeploydid inapp.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.