Automated PR Environments for E2E Testing: Open, Deploy, Test, Destroy
The Problem: E2E Tests Need a Real Environment, Not a Promise of One
Every team that adopts end-to-end testing eventually hits the same wall. The tests themselves are not the hard part — Cypress, Playwright, and Selenium are mature, well-documented, and reasonably pleasant to write. The hard part is: where do the tests run?
Three answers exist, and all three are bad:
- Run them against a shared staging environment. Now your E2E suite is racing every other PR's E2E suite for the same database, the same feature flags, the same running processes. A flaky failure on Tuesday might be your code, or it might be someone else's migration that ran an hour ago and never got rolled back.
- Run them against localhost in CI. This works until your app needs more than a single container — a real database with real data, a message queue, a second service, an external API you cannot mock convincingly.
docker-compose upin a GitHub Actions runner gets you partway, then falls over on anything non-trivial. - Skip E2E testing under time pressure and rely on unit + integration tests. This is the silent failure mode. Nobody decides to do this out loud; it just happens because the infrastructure to do it properly was never built.
The actual fix is structural: give every pull request its own full-stack environment, created the moment the PR opens, kept in sync with every push, and destroyed the moment the PR closes. Your E2E suite runs against that environment instead of staging or localhost. No contention, no flakiness from other people's changes, no "works in CI, fails in staging" surprises.
This guide walks through exactly how that flow works in Bunnyshell, using real command output captured from a live environment — not a hypothetical CLI transcript.
The Flow, End to End
Here is the complete lifecycle a pull request goes through:
11. Developer opens PR → Git provider fires a webhook
22. Bunnyshell receives the webhook → Ephemeral environment created from the primary template
33. Environment deploys → Real URL is available (typically under 2 minutes for small apps)
44. CI job runs E2E suite → Points at the ephemeral environment's URL, not localhost
55. Developer pushes new commits → Environment redeploys automatically (webhook-triggered update)
66. PR is approved and merged → Environment is destroyed automaticallyNo step in this flow requires a human to remember to spin something up or tear something down. That is the entire point — the moment automation requires someone to remember a manual step, the system degrades back into "sometimes it works."
Setting Up: The Primary Environment as a Blueprint
Bunnyshell's model is that you define one primary environment — your reference staging-like environment — and configure it to spawn ephemeral copies automatically for every PR. The primary environment acts as a blueprint; every ephemeral copy inherits its component definitions and just overrides the things that differ per-PR (branch, image tag, hostname).
Here is a minimal bunnyshell.yaml for a two-tier app (API + frontend) configured for this flow:
1kind: Environment
2name: my-app-staging
3type: primary
4
5environmentVariablesGroups:
6 app:
7 DATABASE_URL: SECRET["postgres://user:pass@db:5432/app"]
8
9components:
10 - kind: Application
11 name: api
12 gitRepo: 'https://github.com/my-org/my-app.git'
13 gitBranch: main
14 gitApplicationPath: /api
15 dockerCompose:
16 build:
17 context: .
18 dockerfile: Dockerfile
19 environment:
20 DATABASE_URL: '{{ env.varGroups.app.DATABASE_URL }}'
21 hosts:
22 - hostname: 'api-{{ env.base_domain }}'
23 path: /
24 servicePort: 8080
25
26 - kind: Application
27 name: frontend
28 gitRepo: 'https://github.com/my-org/my-app.git'
29 gitBranch: main
30 gitApplicationPath: /frontend
31 dockerCompose:
32 build:
33 context: .
34 hosts:
35 - hostname: 'app-{{ env.base_domain }}'
36 path: /
37 servicePort: 3000
38
39 - kind: Database
40 name: dbThe two settings that matter for PR automation are configured at the environment level, not in the YAML — via the CLI or the UI:
1bns environments update-settings --id <PRIMARY_ENV_ID> \
2 --create-ephemeral-on-pr \
3 --destroy-ephemeral-on-pr-closeOnce these are set, opening a pull request against the connected repository is enough. You never run bns environments create by hand for a PR — Bunnyshell's Git integration does it for you, using the primary environment as the template and the PR's branch as the source.
What Actually Happens Under the Hood (Real Output)
To ground this in something other than documentation prose, here's a real throwaway environment deployed against Bunnyshell's API — a single-component app, deployed the same way a PR-triggered ephemeral environment deploys. Here is the exact, unedited sequence.
Creating and deploying:
1$ bns environments create --from-path bunnyshell.yaml --name "content-demo" \
2 --project 2grgN6gaM9 --k8s OY13OP4Gnr --deploy --no-wait
3
4Environment 5bDn8MrEgm successfully created... deploying...
5EventID | xVb7xZMnJ8
6EnvironmentID | 5bDn8MrEgm
7OrganizationID | NA1Q2kyalg
8Status | new
9Type | env_queuedChecking the pipeline until it finishes:
1$ bns pipeline show --id OG7nYbkwM3
2
3PipelineID | OG7nYbkwM3
4Status | success
5Stages | Name | Duration | Jobs | JobsDone | Status
6 | Stage | 1m36s | 8 | 8 | successEight jobs — pulling images, applying Kubernetes manifests, waiting for pods to report healthy, wiring ingress — all completed in 96 seconds. That is the entire "environment ready for a PR" latency in the flow above. For a real multi-service app with build steps it will run longer, but the mechanism — one command in, a public URL out — does not change.
Getting the real URL your E2E suite would target:
1$ bns environments endpoints --id 5bDn8MrEgm
2
3Name | web
4Endpoints | https://web-jfq6ir.bunnyenv.com/That URL is exactly what you'd feed into a Playwright or Cypress config as baseURL. It is a real, publicly reachable HTTPS endpoint tied to this one environment and no other — nobody else's test run touches it.
Pointing Your E2E Suite at the Ephemeral Environment
Once the environment is up and you have its URL, the CI-side change is small. For Playwright:
1// playwright.config.ts
2export default defineConfig({
3 use: {
4 baseURL: process.env.PREVIEW_URL || 'http://localhost:3000',
5 },
6});And in your CI job:
1# .github/workflows/e2e.yml
2- name: Wait for preview environment
3 run: |
4 until curl -sf "$PREVIEW_URL" > /dev/null; do sleep 5; done
5
6- name: Run E2E suite against the PR environment
7 run: npx playwright test
8 env:
9 PREVIEW_URL: ${{ steps.bunnyshell.outputs.environment_url }}The PREVIEW_URL value comes from Bunnyshell's Git provider integration, which posts the ephemeral environment's URL as a check/comment on the PR (and can expose it as a CI output variable). Your test runner does not know or care that this URL points at a Kubernetes namespace that will not exist tomorrow — it is just a URL.
Debugging a Failing E2E Run Without Guessing
This is where the "one environment per PR" model earns its keep. When a shared staging environment produces a flaky E2E failure, you cannot reproduce the exact conditions — state has already moved on. With a PR environment, the environment that failed the test is still sitting there, untouched, until you close the PR.
You can exec straight into the running container and look around:
1$ bns exec YLGZVV3NGQ -- sh -c "whoami; hostname"
2root
3web-54c4996887-6lcmzOr stream logs from the exact moment the test ran:
1$ bns logs --environment 5bDn8MrEgm --tail 10
2[web/web-54c4996887-6lcmz/web] 2026/09/24 09:03:05 [notice] 1#1: start worker process 34
3[web/web-54c4996887-6lcmz/web] 2026/09/24 09:03:05 [notice] 1#1: start worker process 35Compare that to debugging a shared staging failure by asking in Slack "did anyone deploy in the last hour?" — the PR-environment model turns a guessing game into a bns logs command.
Why This Beats "Just Use a Feature Branch Deploy"
Some teams get partway here with a homegrown script that deploys a feature branch to a subdomain. The gap is almost always in the teardown half. Scripts that create environments get written quickly because someone is motivated to unblock themselves. Scripts that reliably destroy environments on merge, on close, on force-push, and on stale-PR timeout get written much less often, because nobody is blocked by their absence — until the cloud bill shows forty forgotten environments still running three months later.
Because Bunnyshell treats destroyEphemeralOnPrClose as a first-class setting tied to the primary environment (not a script someone has to maintain), the teardown half is exactly as reliable as the creation half. See Auto-Destroy After Merge for the mechanics of that half of the flow, and ChatOps for QA Teams for how to redeploy, reset, or stop one of these environments mid-review without touching the CLI.
Multi-Service Apps: The Real-World Case
The single-component demo above is deliberately simple so the CLI transcript stays readable. Real applications usually need:
- A database seeded with realistic (not empty) data, so E2E assertions about "existing user sees their order history" actually have something to find
- Multiple backend services that depend on each other
- Feature flags that differ from production defaults
None of these change the automation model — they just mean your bunnyshell.yaml has more components entries and a dependsOn chain. The PR-open → deploy → test → PR-close → destroy cycle is identical whether the environment has one component or nineteen.
Handling Flaky Failures: Environment Drift vs. Real Bugs
The single most time-consuming category of E2E failure is the ambiguous one: a test fails, and nobody is sure whether it's a real regression or an artifact of the environment's current state (a half-applied migration, a stuck background job, a cache that never invalidated). With a shared staging environment, this ambiguity is expensive to resolve, because the state that produced the failure has usually already changed by the time someone investigates. With a per-PR ephemeral environment, the state is frozen exactly as it was — nobody else's commits are landing in it, nothing else is running against it — so you can distinguish the two categories directly:
1# Is the API actually healthy right now, independent of the test framework?
2bns exec <api-component-id> -- curl -s localhost:8080/health
3
4# Did the expected migration actually apply?
5bns exec <db-component-id> -- psql -U app -c "SELECT * FROM schema_migrations ORDER BY version DESC LIMIT 5;"If both come back clean and the E2E failure persists, you have much stronger evidence it's a real regression, not environment noise — a distinction that's nearly impossible to make confidently against a shared, constantly-mutating staging environment.
Scaling to Many Concurrent PR Environments
A question that comes up as soon as a team adopts this pattern seriously: what happens when twenty PRs are open simultaneously, each with its own environment? The answer is that this is precisely the scenario the model is designed for — twenty isolated namespaces on a shared cluster, each independently created, updated, and destroyed, with no coordination required between them. The practical considerations at this scale shift from "does automation work" (it does, per-environment) to cluster capacity planning: node autoscaling needs to handle the aggregate resource requests of however many concurrent ephemeral environments your team's PR volume typically produces, and your cloud budget needs to account for that peak, not just the average.
This is also where Auto-Destroy After Merge stops being a nice-to-have and becomes load-bearing — at low PR volume, a handful of forgotten environments are a rounding error; at high PR volume, an equivalent leak rate compounds into a real capacity and cost problem within weeks.
A Realistic Multi-Service Example
Here is a closer-to-real bunnyshell.yaml for a team running a frontend, an API, a worker process, and a database — the shape most teams actually have, versus the single-container demo used earlier for CLI clarity:
1kind: Environment
2name: shop-staging
3type: primary
4
5components:
6 - kind: Database
7 name: postgres
8
9 - kind: Application
10 name: api
11 gitRepo: 'https://github.com/my-org/shop.git'
12 gitApplicationPath: /api
13 dependsOn: [postgres]
14 dockerCompose:
15 build:
16 context: .
17 hosts:
18 - hostname: 'api-{{ env.base_domain }}'
19 servicePort: 8080
20
21 - kind: Application
22 name: worker
23 gitRepo: 'https://github.com/my-org/shop.git'
24 gitApplicationPath: /worker
25 dependsOn: [postgres, api]
26 dockerCompose:
27 build:
28 context: .
29
30 - kind: Application
31 name: frontend
32 gitRepo: 'https://github.com/my-org/shop.git'
33 gitApplicationPath: /frontend
34 dependsOn: [api]
35 dockerCompose:
36 build:
37 context: .
38 hosts:
39 - hostname: 'app-{{ env.base_domain }}'
40 servicePort: 3000The dependsOn chain matters for deploy ordering — the API waits for Postgres to be ready, the worker waits for both, the frontend waits for the API. E2E tests that exercise a full user flow (place an order, see it processed by the worker, see the status update in the frontend) depend on this ordering being correct; a race where the frontend deploys before the API is reachable produces exactly the kind of flaky, hard-to-reproduce failure the previous section is about distinguishing from real bugs.
Checklist: Is Your PR-to-E2E Pipeline Actually Automated?
- Opening a PR creates an environment without anyone running a CLI command
- Pushing a new commit updates the existing environment (not a brand-new one each time)
- The environment's URL is available to the CI job automatically, not pasted in by hand
- Merging or closing the PR destroys the environment without a follow-up ticket
- A stale-PR safety net exists for PRs that sit open for weeks
If any box is unchecked, that is the specific gap to close next — not a sign you need to rebuild the whole pipeline.
See automated PR environments running against your own repo.
14-day full-feature trial. No credit card required. Pay-as-you-go from $0.007/min per environment.
Frequently Asked Questions
What is an automated PR environment?
An automated PR environment is a full-stack, isolated deployment that is created automatically the moment a pull request is opened, kept in sync with every push to that branch, and destroyed automatically when the PR is merged or closed. It exists only for the lifetime of the code review, which is why it is also called an ephemeral environment.
Do I need to write scripts to create and delete these environments myself?
No. With Bunnyshell you define the environment once in a bunnyshell.yaml file and set createEphemeralOnPr and destroyEphemeralOnPrClose to true on the primary environment. Bunnyshell then listens to your Git provider webhooks and handles creation, updates, and teardown without custom CI scripting.
What happens if my E2E tests fail in the PR environment?
The environment stays up (it is not destroyed on test failure, only on PR close or merge), so you can SSH in, check logs, and re-run the failing suite against the exact same environment state that produced the failure — something a shared staging environment cannot offer since state constantly changes underneath you.
Does the environment get destroyed even if I forget to close the PR?
Bunnyshell destroys ephemeral environments on PR merge or PR close, both of which are Git provider events independent of whether a human remembers to clean up manually. You can also set a stale-environment auto-destroy window as a backstop for PRs that sit open indefinitely.
Can I run Cypress, Playwright, or Selenium against these environments?
Yes. Since the environment exposes a real public URL and a real full-stack deployment (frontend, backend, database), any E2E framework that can hit a URL works unmodified — you point your test runner base URL at the environment endpoint instead of localhost or a shared staging domain.