Preview Environments with CircleCI: Automated Per-PR Deployments with Bunnyshell
GuideMarch 20, 202611 min read

Preview Environments with CircleCI: Automated Per-PR Deployments with Bunnyshell

Why Integrate Bunnyshell with CircleCI?

CircleCI is built for speed — fast builds, smart caching, parallelism out of the box. Adding preview environments to your CircleCI workflows means every pull request gets a live, isolated deployment that reviewers can test immediately. No shared staging bottleneck, no "let me deploy my branch real quick" Slack messages.

With Bunnyshell + CircleCI, you get:

  • Automatic deployment — A preview environment spins up for every pull request
  • Workflow visibility — Environment status appears in your CircleCI workflow graph
  • GitHub status checks — Preview URL posted directly on the PR
  • Automatic cleanup — Environments are destroyed when the PR is merged or closed
  • Zero infrastructure management — Bunnyshell handles Kubernetes, DNS, and TLS certificates

The integration is lightweight. You either let Bunnyshell handle everything via webhooks (zero workflow config), or add a few jobs to your .circleci/config.yml for full control.

How It Works

There are two models for connecting Bunnyshell to CircleCI:

ModelHow it triggersWorkflow changesBest for
Webhook-OnlyBunnyshell listens to GitHub/GitLab PR events directlyNoneTeams that want zero CI/CD maintenance
Pipeline IntegrationCircleCI jobs call the Bunnyshell CLIAdd deploy/cleanup jobsTeams that want full workflow control and visibility

Webhook-Only: Bunnyshell adds a webhook to your Git provider (GitHub, GitLab, or Bitbucket). When a pull request is opened, Bunnyshell automatically creates and deploys a preview environment. Your .circleci/config.yml stays untouched.

Pipeline Integration: You add jobs to your CircleCI config that call the Bunnyshell CLI (bns). This gives you full control over when environments are created, deployed, and destroyed — and lets you run tests against the live preview URL within the same workflow.

Prerequisites

Before you begin, make sure you have:

  • A Bunnyshell account with at least one project and a primary environment already deployed (the primary environment serves as the template for preview environments)
  • A Bunnyshell API token — generate one from Settings > API Tokens in the Bunnyshell dashboard
  • A CircleCI account connected to your Git provider (GitHub, GitLab, or Bitbucket)
  • A working bunnyshell.yaml in your repository (see our framework-specific guides for Laravel, Django, Express, etc.)
  • The Bunnyshell CLI installed locally for testing (optional but recommended):
Bash
1# macOS
2brew install bunnyshell/tap/bunnyshell-cli
3
4# Linux
5curl -fsSL https://raw.githubusercontent.com/bunnyshell/cli/main/install.sh | sh
6
7# Verify
8bns version

If you already have a primary environment running in Bunnyshell, you can skip straight to Approach A (webhook-only) — no workflow changes needed.


Approach A: Webhook-Only (Zero Pipeline Config)

This is the easiest approach. Bunnyshell manages the entire lifecycle — you just flip a toggle and every pull request automatically gets a preview environment.

Step 1: Connect Your Repository

  1. Log into Bunnyshell
  2. Go to Settings > Git Repositories
  3. Click Connect a Git Provider and select your provider (GitHub, GitLab, or Bitbucket)
  4. Authorize Bunnyshell to access your repository

Step 2: Enable Automatic Preview Environments

  1. Navigate to your primary environment (the one you've already deployed)
  2. Go to Settings
  3. Find the Ephemeral environments section
  4. Toggle "Create ephemeral environments on pull request" to ON
  5. Toggle "Destroy environment after merge or close pull request" to ON
  6. Select the Kubernetes cluster for preview environments

That's it. Bunnyshell automatically adds a webhook to your repository.

What Happens Now

  • Developer opens a PR — Bunnyshell detects the webhook event, clones the primary environment, swaps the branch to the PR's source branch, and deploys
  • Developer pushes to the PR — Bunnyshell redeploys the preview environment with the latest changes
  • Bunnyshell posts a comment on the PR with a direct link to the live deployment
  • PR is merged or closed — The preview environment is automatically destroyed

The primary environment must be in Running or Stopped status before ephemeral environments can be created from it.

No .circleci/config.yml changes. No workflow jobs. No maintenance. If this is all you need, you're done.


Approach B: CircleCI Pipeline Integration

For teams that want full control — trigger Bunnyshell from within your CircleCI workflow, run tests against the preview URL, and track deployment status.

Step 1: Create a CircleCI Context

CircleCI Contexts let you securely share environment variables across projects and workflows:

  1. Go to your CircleCI Organization Settings
  2. Navigate to Contexts
  3. Click Create Context and name it bunnyshell
  4. Add the following environment variables:
VariableDescriptionExample
BUNNYSHELL_TOKENAPI authentication tokenbns_pat_xxxxxxxxxxxx
BUNNYSHELL_PROJECT_IDYour Bunnyshell project IDproj_abc123
BUNNYSHELL_ENVIRONMENT_IDPrimary environment ID (template)env_def456
BUNNYSHELL_CLUSTER_IDTarget Kubernetes clustercluster_ghi789

Contexts provide security boundaries in CircleCI. You can restrict which projects and branches can use the bunnyshell context via Security Groups. This prevents unauthorized branches from creating or destroying environments.

Step 2: Define Reusable Commands

Add reusable commands to your config that install and configure the Bunnyshell CLI:

YAML
1commands:
2  install-bns-cli:
3    description: "Install and configure the Bunnyshell CLI"
4    steps:
5      - run:
6          name: Install Bunnyshell CLI
7          command: |
8            curl -fsSL https://raw.githubusercontent.com/bunnyshell/cli/main/install.sh | sh
9            echo 'export PATH="$HOME/.bunnyshell/bin:$PATH"' >> "$BASH_ENV"
10      - run:
11          name: Configure Bunnyshell CLI
12          command: bns configure set token "$BUNNYSHELL_TOKEN"
13      - run:
14          name: Verify CLI installation
15          command: bns version

.circleci/config.yml Configuration

Here's a complete .circleci/config.yml with preview environment workflows:

YAML
1version: 2.1
2
3# ── Reusable Commands ──
4commands:
5  install-bns-cli:
6    description: "Install and configure the Bunnyshell CLI"
7    steps:
8      - run:
9          name: Install Bunnyshell CLI
10          command: |
11            curl -fsSL https://raw.githubusercontent.com/bunnyshell/cli/main/install.sh | sh
12            echo 'export PATH="$HOME/.bunnyshell/bin:$PATH"' >> "$BASH_ENV"
13      - run:
14          name: Configure Bunnyshell CLI
15          command: bns configure set token "$BUNNYSHELL_TOKEN"
16
17# ── Job Definitions ──
18jobs:
19  build:
20    docker:
21      - image: cimg/base:2024.01
22    steps:
23      - checkout
24      - setup_remote_docker:
25          version: "20.10.24"
26      - run:
27          name: Build Docker image
28          command: |
29            docker build -t $CIRCLE_PROJECT_REPONAME:$CIRCLE_SHA1 .
30            echo "Build complete: $CIRCLE_PROJECT_REPONAME:$CIRCLE_SHA1"
31
32  deploy-preview:
33    docker:
34      - image: cimg/base:2024.01
35    steps:
36      - checkout
37      - install-bns-cli
38      - run:
39          name: Set environment name
40          command: |
41            BNS_ENV_NAME="preview-pr-${CIRCLE_PULL_REQUEST##*/}"
42            echo "export BNS_ENV_NAME=$BNS_ENV_NAME" >> "$BASH_ENV"
43            echo "Environment name: $BNS_ENV_NAME"
44      - run:
45          name: Check for existing environment
46          command: |
47            EXISTING_ENV=$(bns environments list \
48              --project "$BUNNYSHELL_PROJECT_ID" \
49              --name "$BNS_ENV_NAME" \
50              --output json 2>/dev/null | jq -r '._embedded.item[0].id // empty')
51            echo "export EXISTING_ENV=$EXISTING_ENV" >> "$BASH_ENV"
52            echo "Existing environment: ${EXISTING_ENV:-none}"
53      - run:
54          name: Create or update preview environment
55          command: |
56            if [ -z "$EXISTING_ENV" ]; then
57              echo "Creating new preview environment: $BNS_ENV_NAME"
58              ENV_ID=$(bns environments create \
59                --from-environment "$BUNNYSHELL_ENVIRONMENT_ID" \
60                --name "$BNS_ENV_NAME" \
61                --project "$BUNNYSHELL_PROJECT_ID" \
62                --k8s "$BUNNYSHELL_CLUSTER_ID" \
63                --output json | jq -r '.id')
64            else
65              echo "Reusing existing environment: $EXISTING_ENV"
66              ENV_ID="$EXISTING_ENV"
67            fi
68            echo "export BNS_ENV_ID=$ENV_ID" >> "$BASH_ENV"
69      - run:
70          name: Update branch and deploy
71          command: |
72            bns environments update-configuration \
73              --id "$BNS_ENV_ID" \
74              --from-git-branch "$CIRCLE_BRANCH"
75            echo "Deploying environment $BNS_ENV_ID..."
76            bns environments deploy --id "$BNS_ENV_ID" --wait
77          no_output_timeout: 30m
78      - run:
79          name: Extract preview URL
80          command: |
81            PREVIEW_URL=$(bns environments show \
82              --id "$BNS_ENV_ID" \
83              --output json | jq -r '._embedded.components[0].endpoints[0].url // "pending"')
84            echo "export PREVIEW_URL=$PREVIEW_URL" >> "$BASH_ENV"
85            echo "Preview URL: $PREVIEW_URL"
86
87            # Save to workspace for downstream jobs
88            mkdir -p /tmp/bns
89            echo "$PREVIEW_URL" > /tmp/bns/preview-url
90            echo "$BNS_ENV_ID" > /tmp/bns/env-id
91      - persist_to_workspace:
92          root: /tmp/bns
93          paths:
94            - preview-url
95            - env-id
96
97  test-preview:
98    docker:
99      - image: cimg/node:20.11
100    steps:
101      - checkout
102      - attach_workspace:
103          at: /tmp/bns
104      - run:
105          name: Load preview URL
106          command: |
107            PREVIEW_URL=$(cat /tmp/bns/preview-url)
108            echo "export PREVIEW_URL=$PREVIEW_URL" >> "$BASH_ENV"
109            echo "Testing against: $PREVIEW_URL"
110      - run:
111          name: Wait for environment readiness
112          command: |
113            for i in $(seq 1 30); do
114              STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$PREVIEW_URL" || echo "000")
115              if [ "$STATUS" = "200" ]; then
116                echo "Preview environment is ready!"
117                exit 0
118              fi
119              echo "Waiting for preview environment... (attempt $i/30, status: $STATUS)"
120              sleep 10
121            done
122            echo "Timeout waiting for environment" && exit 1
123      - run:
124          name: Run smoke tests
125          command: |
126            echo "Running smoke tests..."
127            curl -sf "$PREVIEW_URL" > /dev/null && echo "Homepage: OK"
128            curl -sf "$PREVIEW_URL/api/health" > /dev/null && echo "Health check: OK"
129      # Uncomment for E2E tests:
130      # - run:
131      #     name: Run Cypress tests
132      #     command: npx cypress run --config baseUrl=$PREVIEW_URL
133      # - store_artifacts:
134      #     path: cypress/screenshots
135      # - store_artifacts:
136      #     path: cypress/videos
137
138  cleanup-preview:
139    docker:
140      - image: cimg/base:2024.01
141    steps:
142      - install-bns-cli
143      - run:
144          name: Destroy preview environment
145          command: |
146            # Extract PR number from the branch or commit message
147            # On merge to main, we clean up all preview environments
148            # matching the merged branch
149            echo "Checking for orphaned preview environments..."
150
151            # List all preview environments
152            ENVS=$(bns environments list \
153              --project "$BUNNYSHELL_PROJECT_ID" \
154              --output json 2>/dev/null | jq -r '._embedded.item[] | select(.name | startswith("preview-pr-")) | .id')
155
156            for ENV_ID in $ENVS; do
157              ENV_STATUS=$(bns environments show \
158                --id "$ENV_ID" \
159                --output json | jq -r '.status')
160              ENV_NAME=$(bns environments show \
161                --id "$ENV_ID" \
162                --output json | jq -r '.name')
163
164              echo "Found: $ENV_NAME (status: $ENV_STATUS)"
165
166              # Check if the corresponding PR is still open
167              PR_NUM=$(echo "$ENV_NAME" | grep -oP '\d+$')
168              if [ -n "$PR_NUM" ]; then
169                # If running on main branch merge, destroy the environment
170                echo "Destroying environment: $ENV_NAME ($ENV_ID)"
171                bns environments delete --id "$ENV_ID" --wait || true
172              fi
173            done
174
175  cleanup-specific:
176    docker:
177      - image: cimg/base:2024.01
178    parameters:
179      pr-number:
180        type: string
181        default: ""
182    steps:
183      - install-bns-cli
184      - run:
185          name: Destroy specific preview environment
186          command: |
187            BNS_ENV_NAME="preview-pr-<< parameters.pr-number >>"
188            EXISTING_ENV=$(bns environments list \
189              --project "$BUNNYSHELL_PROJECT_ID" \
190              --name "$BNS_ENV_NAME" \
191              --output json 2>/dev/null | jq -r '._embedded.item[0].id // empty')
192
193            if [ -n "$EXISTING_ENV" ]; then
194              echo "Destroying preview environment: $BNS_ENV_NAME ($EXISTING_ENV)"
195              bns environments delete --id "$EXISTING_ENV" --wait
196              echo "Environment destroyed."
197            else
198              echo "No environment found for $BNS_ENV_NAME — nothing to clean up."
199            fi
200
201# ── Workflows ──
202workflows:
203  # Preview environment workflow — runs on PRs
204  preview-deploy:
205    jobs:
206      - build:
207          filters:
208            branches:
209              ignore: main
210      - deploy-preview:
211          context: bunnyshell
212          requires:
213            - build
214          filters:
215            branches:
216              ignore: main
217      - test-preview:
218          requires:
219            - deploy-preview
220          filters:
221            branches:
222              ignore: main
223
224  # Cleanup workflow — runs on merge to main
225  preview-cleanup:
226    jobs:
227      - cleanup-preview:
228          context: bunnyshell
229          filters:
230            branches:
231              only: main

CircleCI doesn't have a native "on PR close" trigger like GitHub Actions. The cleanup workflow runs on main branch pushes (which happen after a PR merge) and cleans up orphaned preview environments. For immediate cleanup on PR close, use Bunnyshell's webhook approach (Approach A) alongside the pipeline integration.


Workflow Examples

Deploy on Pull Request

When a developer pushes to a branch with an open PR, CircleCI triggers the preview workflow:

Text
1Branch: feature/add-user-profile
2  preview-deploy workflow:
3    ├── build .............. Build Docker image
4    ├── deploy-preview ..... Create Bunnyshell environment "preview-pr-42"
5    │   └── Persists preview URL to workspace
6    └── test-preview ....... Run smoke tests against the live URL

The preview URL is available in the CircleCI job output and can be posted to the PR via GitHub API.

Destroy on Merge to Main

When a PR is merged, CircleCI runs the cleanup workflow on the main branch:

Text
1Branch: main (after merge of PR #42)
2  preview-cleanup workflow:
3    └── cleanup-preview .... Scan and destroy orphaned preview environments

Workflow Graph

In the CircleCI dashboard, the workflow appears as:

Text
build ──► deploy-preview ──► test-preview

Each job shows its status, duration, and logs — giving full visibility into the preview environment lifecycle.


CircleCI Contexts and Environment Variables

Context Security

CircleCI Contexts provide a security boundary for sensitive variables. Best practices:

YAML
1# Only the deploy and cleanup jobs need Bunnyshell credentials
2deploy-preview:
3  context: bunnyshell    # Has access to BUNNYSHELL_TOKEN
4  # ...
5
6build:
7  # No context — doesn't need Bunnyshell credentials
8  # ...

Available CircleCI Variables

These built-in CircleCI variables are useful for Bunnyshell integration:

CircleCI VariableDescriptionExample Value
CIRCLE_PULL_REQUESTFull URL of the PRhttps://github.com/org/repo/pull/42
CIRCLE_BRANCHCurrent branch namefeature/add-profile
CIRCLE_SHA1Full commit hasha1b2c3d4e5f6...
CIRCLE_PROJECT_REPONAMERepository namemy-app
CIRCLE_PROJECT_USERNAMEOrganization/user namemy-org
CIRCLE_BUILD_URLURL to the CircleCI buildhttps://circleci.com/gh/org/repo/123
CIRCLE_WORKFLOW_IDCurrent workflow ID12345-abcde-...

Extracting PR Number from CircleCI

CircleCI doesn't provide a CIRCLE_PR_NUMBER variable directly. Extract it from CIRCLE_PULL_REQUEST:

YAML
1- run:
2    name: Extract PR number
3    command: |
4      PR_NUMBER="${CIRCLE_PULL_REQUEST##*/}"
5      echo "export PR_NUMBER=$PR_NUMBER" >> "$BASH_ENV"
6      echo "PR number: $PR_NUMBER"

Using Multiple Contexts

For organizations with multiple environments (staging, production), use separate contexts:

YAML
1workflows:
2  preview-deploy:
3    jobs:
4      - deploy-preview:
5          context:
6            - bunnyshell           # Bunnyshell credentials
7            - docker-registry      # Container registry credentials

PR Status Checks with Preview URL

Posting Preview URL to GitHub PR

Add a step to your deploy job to post the preview URL as a PR comment:

YAML
1- run:
2    name: Post preview URL to PR
3    command: |
4      if [ -n "$PREVIEW_URL" ] && [ "$PREVIEW_URL" != "pending" ]; then
5        PR_NUMBER="${CIRCLE_PULL_REQUEST##*/}"
6        curl -s -X POST \
7          -H "Authorization: token $GITHUB_TOKEN" \
8          -H "Content-Type: application/json" \
9          -d "{\"body\": \"## Preview Environment Ready\\n\\nYour preview environment is live:\\n\\n**URL:** $PREVIEW_URL\\n\\n**Environment:** \`preview-pr-$PR_NUMBER\`\\n**Commit:** \`${CIRCLE_SHA1:0:8}\`\\n\\n---\\n_Deployed by Bunnyshell via CircleCI_\"}" \
10          "https://api.github.com/repos/$CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME/issues/$PR_NUMBER/comments"
11      fi

Add a GITHUB_TOKEN with repo scope to your CircleCI context for posting PR comments. If you're using GitLab or Bitbucket, adjust the API endpoint accordingly.

GitHub Status Check

Instead of (or in addition to) a comment, set a commit status with the preview URL:

YAML
1- run:
2    name: Set GitHub commit status
3    command: |
4      curl -s -X POST \
5        -H "Authorization: token $GITHUB_TOKEN" \
6        -H "Content-Type: application/json" \
7        -d "{\"state\": \"success\", \"target_url\": \"$PREVIEW_URL\", \"description\": \"Preview environment ready\", \"context\": \"bunnyshell/preview\"}" \
8        "https://api.github.com/repos/$CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME/statuses/$CIRCLE_SHA1"

This adds a green check with a clickable link to the preview environment in the PR's status checks section.


Troubleshooting

IssueCauseSolution
BUNNYSHELL_TOKEN not foundContext not attached to jobAdd context: bunnyshell to the job definition in the workflow
bns: command not foundCLI not in PATH after installUse echo 'export PATH="$HOME/.bunnyshell/bin:$PATH"' >> "$BASH_ENV" and source it
Environment creation failsPrimary environment not deployedDeploy the primary environment first. It must be in Running or Stopped status
Workflow hangs on deployDeployment is slow or stuckAdd no_output_timeout: 30m to the deploy step
PR number is emptyNo PR associated with branch pushCIRCLE_PULL_REQUEST is only set when a PR exists. Check if the variable is non-empty before proceeding
Cleanup doesn't run on PR closeCircleCI lacks "PR close" triggerUse Bunnyshell webhooks (Approach A) for automatic cleanup on PR close. The pipeline cleanup handles merge-to-main scenarios
Preview URL returns 502Application not fully startedAdd a readiness check loop in your test job. The app may need 30-60 seconds after deploy completes
Duplicate environmentsJob retried without checking existenceAlways check for existing environments by name before creating (see the EXISTING_ENV check)
Workspace data missingpersist_to_workspace / attach_workspace mismatchVerify the root and paths match between persist and attach steps
Context access deniedSecurity group restrictionCheck that your project is included in the context's security group in CircleCI Organization Settings

What's Next?

  • Add E2E tests — Run Cypress, Playwright, or Selenium tests against the preview URL in your workflow
  • CircleCI orbs — Package the Bunnyshell integration as a reusable CircleCI orb for your organization
  • Parallelism — Run multiple test suites in parallel against the same preview environment
  • Scheduled cleanup — Add a scheduled workflow (triggers: - schedule) to clean up stale preview environments nightly
  • Slack notifications — Use the CircleCI Slack orb to notify your team when preview environments are ready
  • Branch-specific configs — Use CircleCI's parameters and when conditions to customize preview environments per branch pattern
  • Resource classes — Use larger CircleCI resource classes for faster builds if preview deployments are in the critical path

Ship faster starting today.

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