Preview Environments with Jenkins: Automated Per-PR Deployments with Bunnyshell
Why Integrate Bunnyshell with Jenkins?
Jenkins is the most widely adopted CI/CD server in the world. If your team runs Jenkins — whether self-hosted, on Kubernetes, or managed — your build and deployment pipelines are defined in Jenkinsfiles. Pull requests are where collaboration happens, but reviewing code in a diff only tells half the story. Reviewers need to see the running application to catch visual regressions, broken workflows, and integration issues.
Bunnyshell creates isolated preview environments for every pull request — a full-stack deployment with your app, databases, and services — running in Kubernetes with a unique HTTPS URL. The question is: how do you connect Bunnyshell to your Jenkins workflow?
Two options:
| Approach | Pipeline changes | Control level | Best for |
|---|---|---|---|
| Approach A: Webhook-Only | None | Bunnyshell manages everything | Teams that want the fastest setup |
| Approach B: Jenkinsfile Integration | Add stages to Jenkinsfile | Full control over lifecycle | Teams needing custom steps (migrations, seeds, tests, cleanup on failure) |
Both approaches produce the same result: an isolated environment per PR with automatic cleanup on merge.
How It Works
Webhook-Only Flow
1PR opened → Git provider webhook → Bunnyshell API
2 ↓
3 Create environment
4 Build images
5 Deploy to K8s
6 ↓
7 Post comment on PR
8 with preview URLBunnyshell registers a webhook directly on your Git provider (GitHub, GitLab, or Bitbucket). When a PR is opened, updated, or closed, the Git provider sends events to Bunnyshell. Jenkins is not involved at all.
Jenkinsfile-Triggered Flow
1PR opened → Jenkins Multibranch Pipeline detects PR
2 ↓
3 Jenkinsfile stages execute
4 Install Bunnyshell CLI
5 CLI creates/deploys environment
6 CLI retrieves preview URL
7 ↓
8 Post-deploy stages (migrations, seeds, tests)
9 ↓
10 Post preview URL as PR comment
11 ↓
12 On merge/close: cleanup stage destroys environmentYour Jenkinsfile controls when and how Bunnyshell environments are created, giving you full control over the lifecycle — including error handling with post { always { ... } } blocks.
Prerequisites
Before you begin, ensure you have:
- A Bunnyshell account — Sign up at bunnyshell.com
- A Bunnyshell project and primary environment — At least one environment deployed and in Running or Stopped state
- A Jenkins instance with the Multibranch Pipeline plugin (included by default in modern Jenkins)
- A Bunnyshell API token — Generate one in Bunnyshell under Settings > API Tokens
- Your Bunnyshell IDs — Organization ID, Project ID, Environment ID, and Cluster ID
- Jenkins credentials — For storing the Bunnyshell token securely
Required Jenkins plugins:
- Multibranch Pipeline (comes with Pipeline plugin suite)
- Git or GitHub Branch Source / Bitbucket Branch Source / GitLab Branch Source
- Credentials (for secure token storage)
- HTTP Request (optional — for posting PR comments)
If you haven't set up a Bunnyshell environment yet, follow one of the framework-specific guides first (e.g., Laravel, Django, Express) to create your primary environment. This guide focuses on the CI/CD integration layer.
Approach A: Webhook-Only (Zero Pipeline Config)
This is the fastest path. Bunnyshell handles everything — no Jenkinsfile changes required. Jenkins doesn't even need to know about Bunnyshell.
Step 1: Connect Your Git Repository
- Log into Bunnyshell
- Navigate to your Project and select your primary environment
- Go to Settings > Git Repository
- If not already connected, add your Git provider integration (GitHub, GitLab, or Bitbucket) under Settings > Integrations
- Authorize Bunnyshell to access your repository
Bunnyshell registers a webhook directly on your Git provider — not on Jenkins. This means Jenkins and Bunnyshell operate independently.
Step 2: Enable Automatic Preview Environments
- In your environment, go to Settings
- Find the Ephemeral environments section
- Toggle "Create ephemeral environments on pull request" to ON
- Toggle "Destroy environment after merge or close pull request" to ON
- Select the target Kubernetes cluster for ephemeral environments
Step 3: Verify the Webhook
Check your Git provider for the registered webhook:
- GitHub: Repository > Settings > Webhooks
- GitLab: Repository > Settings > Webhooks
- Bitbucket: Repository Settings > Webhooks
You should see a Bunnyshell webhook subscribed to pull request events.
Step 4: Test It
- Create a new branch and push a change
- Open a pull request
- Bunnyshell creates an ephemeral environment cloned from your primary, using the PR branch
- Within a few minutes, Bunnyshell posts a comment on the PR with the preview URL
- Merge or close the PR — the environment is destroyed automatically
In this approach, Jenkins continues running your existing pipeline (tests, linting, etc.) completely independently. Bunnyshell adds preview environments on top without touching your CI/CD pipeline.
That's it. No Jenkinsfile changes, no plugins to install, no tokens to store in Jenkins.
Approach B: Jenkinsfile Integration with Bunnyshell CLI
For teams that need custom pre- or post-deploy steps — database migrations, data seeding, smoke tests, or cleanup on pipeline failure — you can drive Bunnyshell directly from your Jenkinsfile.
Step 1: Set Up a Multibranch Pipeline
If you don't already have a Multibranch Pipeline:
- In Jenkins, click New Item
- Enter a name and select Multibranch Pipeline
- Under Branch Sources, add your Git provider:
- GitHub: Add GitHub credentials and the repository URL
- Bitbucket: Add Bitbucket credentials and the repository URL
- GitLab: Add GitLab credentials and the repository URL
- Under Behaviors, ensure Discover pull requests is enabled:
- For GitHub: "Discover pull requests from origin" + "Discover pull requests from forks" (if applicable)
- Strategy: "The current pull request revision"
- Under Build Configuration, set:
- Mode: "by Jenkinsfile"
- Script Path:
Jenkinsfile
- Click Save
Jenkins will scan the repository and create a job for each branch and PR.
The Multibranch Pipeline plugin automatically sets the CHANGE_ID environment variable for pull request builds. This is the PR number — you'll use it to name Bunnyshell environments. If CHANGE_ID is empty, the build was not triggered by a PR.
Step 2: Store the Bunnyshell Token in Jenkins Credentials
- Go to Manage Jenkins > Credentials > System > Global credentials
- Click Add Credentials
- Select Secret text
- Set:
- Secret: Your Bunnyshell API token
- ID:
bunnyshell-token - Description: "Bunnyshell API Token"
- Click Create
Optionally, add more credentials:
| ID | Type | Value |
|---|---|---|
bunnyshell-token | Secret text | API token |
github-token | Secret text | GitHub PAT for posting PR comments (if using GitHub) |
Never hardcode tokens in your Jenkinsfile. Always use Jenkins Credentials and the credentials() binding or withCredentials block. Tokens stored in Jenkins are encrypted at rest.
Step 3: Create the Jenkinsfile
Create or update Jenkinsfile in your repository root:
1// Jenkinsfile (Declarative Pipeline)
2pipeline {
3 agent any
4
5 environment {
6 // Bunnyshell configuration
7 BUNNYSHELL_TOKEN = credentials('bunnyshell-token')
8 BUNNYSHELL_ORG_ID = 'your-org-id'
9 BUNNYSHELL_PROJECT_ID = 'your-project-id'
10 BUNNYSHELL_ENV_ID = 'your-primary-env-id'
11 BUNNYSHELL_CLUSTER_ID = 'your-cluster-id'
12
13 // Derived variables
14 ENV_NAME = "pr-${env.CHANGE_ID ?: 'manual'}"
15 }
16
17 stages {
18 // ── Stage 1: Install Bunnyshell CLI ──
19 stage('Install Bunnyshell CLI') {
20 when {
21 changeRequest()
22 }
23 steps {
24 sh '''
25 curl -sL https://raw.githubusercontent.com/bunnyshell/cli/main/install.sh | bash
26 export PATH="$HOME/.bunnyshell/bin:$PATH"
27 bns version
28 '''
29 }
30 }
31
32 // ── Stage 2: Deploy Preview Environment ──
33 stage('Deploy Preview Environment') {
34 when {
35 changeRequest()
36 }
37 steps {
38 script {
39 // Add CLI to PATH for this stage
40 env.PATH = "${env.HOME}/.bunnyshell/bin:${env.PATH}"
41
42 // Check if environment already exists
43 def existingEnvId = ''
44 try {
45 def listOutput = sh(
46 script: """
47 bns environments list \
48 --organization "${BUNNYSHELL_ORG_ID}" \
49 --project "${BUNNYSHELL_PROJECT_ID}" \
50 --search "${ENV_NAME}" \
51 --output json
52 """,
53 returnStdout: true
54 ).trim()
55
56 existingEnvId = sh(
57 script: """
58 echo '${listOutput}' | python3 -c "
59import sys, json
60items = json.load(sys.stdin).get('_embedded', {}).get('item', [])
61print(items[0]['id'] if items else '')
62"
63 """,
64 returnStdout: true
65 ).trim()
66 } catch (e) {
67 echo "No existing environment found: ${e.message}"
68 }
69
70 if (existingEnvId) {
71 echo "Environment ${ENV_NAME} exists (ID: ${existingEnvId}). Redeploying..."
72 sh """
73 bns environments deploy \
74 --id "${existingEnvId}" \
75 --wait \
76 --timeout 900
77 """
78 env.DEPLOY_ENV_ID = existingEnvId
79 } else {
80 echo "Creating new environment: ${ENV_NAME}"
81 def createOutput = sh(
82 script: """
83 bns environments create \
84 --from-environment "${BUNNYSHELL_ENV_ID}" \
85 --name "${ENV_NAME}" \
86 --k8s "${BUNNYSHELL_CLUSTER_ID}" \
87 --output json
88 """,
89 returnStdout: true
90 ).trim()
91
92 def envId = sh(
93 script: """
94 echo '${createOutput}' | python3 -c "
95import sys, json
96print(json.load(sys.stdin)['id'])
97"
98 """,
99 returnStdout: true
100 ).trim()
101
102 echo "Created environment ID: ${envId}"
103 env.DEPLOY_ENV_ID = envId
104
105 sh """
106 bns environments deploy \
107 --id "${envId}" \
108 --wait \
109 --timeout 900
110 """
111 }
112
113 // Retrieve preview URL
114 def showOutput = sh(
115 script: """
116 bns environments show \
117 --id "${env.DEPLOY_ENV_ID}" \
118 --output json
119 """,
120 returnStdout: true
121 ).trim()
122
123 env.PREVIEW_URL = sh(
124 script: """
125 echo '${showOutput}' | python3 -c "
126import sys, json
127data = json.load(sys.stdin)
128endpoints = data.get('endpoints', [])
129print(endpoints[0].get('url', 'N/A') if endpoints else 'N/A')
130"
131 """,
132 returnStdout: true
133 ).trim()
134
135 echo "Preview URL: ${env.PREVIEW_URL}"
136 }
137 }
138 }
139
140 // ── Stage 3: Post-Deploy Tasks ──
141 stage('Post-Deploy Tasks') {
142 when {
143 changeRequest()
144 expression { env.DEPLOY_ENV_ID != null }
145 }
146 steps {
147 script {
148 env.PATH = "${env.HOME}/.bunnyshell/bin:${env.PATH}"
149
150 // Wait for all components to be fully ready
151 sleep(time: 30, unit: 'SECONDS')
152
153 // Find the app component
154 def componentsOutput = sh(
155 script: """
156 bns components list \
157 --environment "${env.DEPLOY_ENV_ID}" \
158 --output json
159 """,
160 returnStdout: true
161 ).trim()
162
163 def componentId = sh(
164 script: """
165 echo '${componentsOutput}' | python3 -c "
166import sys, json
167items = json.load(sys.stdin).get('_embedded', {}).get('item', [])
168for item in items:
169 if 'app' in item.get('name', '').lower():
170 print(item['id'])
171 break
172"
173 """,
174 returnStdout: true
175 ).trim()
176
177 if (componentId) {
178 echo "Running migrations on component ${componentId}..."
179 sh """
180 bns exec "${componentId}" -- php artisan migrate --force || true
181 bns exec "${componentId}" -- php artisan db:seed --force || true
182 """
183 }
184 }
185 }
186 }
187
188 // ── Stage 4: Post Preview URL as PR Comment ──
189 stage('Notify PR') {
190 when {
191 changeRequest()
192 expression { env.PREVIEW_URL != null && env.PREVIEW_URL != 'N/A' }
193 }
194 steps {
195 script {
196 // For GitHub — uses the GitHub API
197 // Requires 'github-token' credential with repo scope
198 withCredentials([string(credentialsId: 'github-token', variable: 'GITHUB_TOKEN')]) {
199 def repoUrl = env.GIT_URL ?: scm.getUserRemoteConfigs()[0].getUrl()
200 // Extract owner/repo from URL
201 def matcher = repoUrl =~ /github\.com[\/:](.+?)(?:\.git)?$/
202 if (matcher.find()) {
203 def ownerRepo = matcher.group(1)
204 def prNumber = env.CHANGE_ID
205
206 def commentBody = """\
207## Preview Environment Ready
208
209Your preview environment is live:
210
211**URL:** ${env.PREVIEW_URL}
212
213Environment ID: `${env.DEPLOY_ENV_ID}`
214
215---
216_Deployed by Bunnyshell via Jenkins Pipeline_"""
217
218 sh """
219 curl -s -X POST \
220 -H "Authorization: token ${GITHUB_TOKEN}" \
221 -H "Content-Type: application/json" \
222 "https://api.github.com/repos/${ownerRepo}/issues/${prNumber}/comments" \
223 -d '{"body": "${commentBody.replace('\n', '\\n').replace('"', '\\"')}"}'
224 """
225 }
226 }
227 }
228 }
229 }
230 }
231
232 // ── Post Actions: Cleanup on Failure ──
233 post {
234 failure {
235 script {
236 if (env.CHANGE_ID && env.DEPLOY_ENV_ID) {
237 echo "Pipeline failed. Cleaning up preview environment..."
238 env.PATH = "${env.HOME}/.bunnyshell/bin:${env.PATH}"
239 sh """
240 bns environments delete \
241 --id "${env.DEPLOY_ENV_ID}" \
242 --force || true
243 """
244 }
245 }
246 }
247 always {
248 script {
249 if (env.CHANGE_ID && env.PREVIEW_URL && env.PREVIEW_URL != 'N/A') {
250 echo "Preview environment available at: ${env.PREVIEW_URL}"
251 }
252 }
253 }
254 }
255}Understanding the Jenkinsfile
PR detection with when { changeRequest() }:
1when {
2 changeRequest()
3}The changeRequest() condition is true only when the build was triggered by a pull request in a Multibranch Pipeline. This is the Jenkins equivalent of checking if: github.event_name == 'pull_request' in GitHub Actions.
When changeRequest() is true, Jenkins sets these environment variables:
CHANGE_ID— The PR numberCHANGE_URL— The PR URLCHANGE_TITLE— The PR titleCHANGE_AUTHOR— The PR authorCHANGE_TARGET— The target branchCHANGE_BRANCH— The source branch
You can filter by target branch: when { changeRequest target: 'main' } ensures the stage only runs for PRs targeting main.
Credentials binding:
1environment {
2 BUNNYSHELL_TOKEN = credentials('bunnyshell-token')
3}This pulls the secret from Jenkins Credentials and exposes it as an environment variable. The value is masked in console output automatically.
Error handling with post { always { ... } }:
1post {
2 failure {
3 script {
4 // Destroy the environment if the pipeline fails
5 sh "bns environments delete --id '${env.DEPLOY_ENV_ID}' --force || true"
6 }
7 }
8}The post block runs after all stages complete. The failure section runs only when the pipeline fails, cleaning up the environment so you don't leave orphaned resources. The || true ensures the cleanup step doesn't fail the build further.
Multibranch Pipeline behavior:
Jenkins Multibranch Pipelines create separate jobs for:
- Each branch with a Jenkinsfile
- Each open pull request
When a PR is merged and the branch is deleted, Jenkins automatically removes the PR job. But the Bunnyshell environment needs explicit cleanup — either via the post block or via Approach A's webhook-based auto-destroy.
Destroy Environment on Merge
Jenkins doesn't have a native "on PR merge" trigger like GitHub Actions. There are several approaches to handle cleanup:
Option 1: Webhook from Jenkins to Bunnyshell (Recommended for Approach B)
Add a stage that runs on the target branch after merge:
1// In your Jenkinsfile
2stage('Cleanup Merged PR Environments') {
3 when {
4 branch 'main'
5 not { changeRequest() }
6 }
7 steps {
8 script {
9 env.PATH = "${env.HOME}/.bunnyshell/bin:${env.PATH}"
10
11 // List all PR environments and destroy them
12 def listOutput = sh(
13 script: """
14 bns environments list \
15 --project "${BUNNYSHELL_PROJECT_ID}" \
16 --output json
17 """,
18 returnStdout: true
19 ).trim()
20
21 def envIds = sh(
22 script: """
23 echo '${listOutput}' | python3 -c "
24import sys, json
25items = json.load(sys.stdin).get('_embedded', {}).get('item', [])
26for item in items:
27 name = item.get('name', '')
28 if name.startswith('pr-'):
29 print(item['id'])
30"
31 """,
32 returnStdout: true
33 ).trim()
34
35 envIds.split('\n').each { envId ->
36 if (envId.trim()) {
37 echo "Destroying environment ${envId}..."
38 sh "bns environments delete --id '${envId}' --force || true"
39 }
40 }
41 }
42 }
43}This runs whenever code is pushed to main (which includes merge commits). It finds all environments named pr-* and destroys them.
Option 2: Use Approach A's Auto-Destroy
Even if you use Approach B for deployment, you can still enable Bunnyshell's webhook-based auto-destroy:
- In Bunnyshell, go to Settings > Ephemeral environments
- Toggle "Destroy environment after merge or close pull request" to ON
This way, Jenkins handles deployment (with custom steps) and Bunnyshell handles cleanup (via webhook).
Option 3: Scheduled Cleanup Job
Create a separate Jenkins job that runs nightly to clean up orphaned environments:
1// Jenkinsfile-cleanup (separate pipeline)
2pipeline {
3 agent any
4 triggers {
5 cron('0 2 * * *') // Run at 2 AM daily
6 }
7 environment {
8 BUNNYSHELL_TOKEN = credentials('bunnyshell-token')
9 BUNNYSHELL_PROJECT_ID = 'your-project-id'
10 }
11 stages {
12 stage('Cleanup') {
13 steps {
14 sh '''
15 curl -sL https://raw.githubusercontent.com/bunnyshell/cli/main/install.sh | bash
16 export PATH="$HOME/.bunnyshell/bin:$PATH"
17
18 ENVS=$(bns environments list \
19 --project "$BUNNYSHELL_PROJECT_ID" \
20 --output json | \
21 python3 -c "
22import sys, json
23items = json.load(sys.stdin).get('_embedded', {}).get('item', [])
24for item in items:
25 name = item.get('name', '')
26 if name.startswith('pr-'):
27 print(item['id'])
28")
29
30 for ENV_ID in $ENVS; do
31 echo "Destroying environment $ENV_ID..."
32 bns environments delete --id "$ENV_ID" --force || true
33 done
34 '''
35 }
36 }
37 }
38}Option 2 (hybrid) is the most reliable approach: Jenkins controls deployment with custom steps, and Bunnyshell's webhook handles cleanup. This avoids the timing issues of branch-based cleanup.
Secrets and Variables
Jenkins Credentials (Recommended)
Store sensitive values as Jenkins Credentials:
| Credential ID | Type | Value |
|---|---|---|
bunnyshell-token | Secret text | Bunnyshell API token |
github-token | Secret text | GitHub PAT for PR comments |
Access them in the Jenkinsfile:
1// Method 1: environment block (available to all stages)
2environment {
3 BUNNYSHELL_TOKEN = credentials('bunnyshell-token')
4}
5
6// Method 2: withCredentials block (scoped to specific steps)
7withCredentials([string(credentialsId: 'bunnyshell-token', variable: 'BNS_TOKEN')]) {
8 sh "bns environments list --output json"
9}Jenkins Global Properties
For non-sensitive configuration (IDs), use Jenkins Global Properties:
- Go to Manage Jenkins > System > Global properties
- Check Environment variables
- Add:
BUNNYSHELL_ORG_ID= your org IDBUNNYSHELL_PROJECT_ID= your project IDBUNNYSHELL_CLUSTER_ID= your cluster IDBUNNYSHELL_ENV_ID= your primary environment ID
These are available in every pipeline without needing to declare them in the Jenkinsfile.
Folder-Level Credentials
For organizations with multiple projects, store credentials at the folder level:
- Create a Jenkins folder for your project
- Go to Folder > Credentials > Add Credentials
- Add the Bunnyshell token scoped to this folder
This keeps tokens isolated between projects and teams.
Never use Jenkins environment variables for secrets. Always use the Credentials plugin. Environment variables set in environment {} without credentials() are visible in the Jenkins UI and build logs.
PR Comments with Preview URL
GitHub
Use the GitHub API to post a comment:
1withCredentials([string(credentialsId: 'github-token', variable: 'GITHUB_TOKEN')]) {
2 sh """
3 curl -s -X POST \
4 -H "Authorization: token ${GITHUB_TOKEN}" \
5 -H "Content-Type: application/json" \
6 "https://api.github.com/repos/OWNER/REPO/issues/${env.CHANGE_ID}/comments" \
7 -d '{"body": "## Preview Ready\\n\\n**URL:** ${env.PREVIEW_URL}\\n\\n_Deployed by Bunnyshell via Jenkins_"}'
8 """
9}Bitbucket
1withCredentials([usernamePassword(credentialsId: 'bitbucket-creds', usernameVariable: 'BB_USER', passwordVariable: 'BB_PASS')]) {
2 sh """
3 curl -s -X POST \
4 -u "${BB_USER}:${BB_PASS}" \
5 -H "Content-Type: application/json" \
6 "https://api.bitbucket.org/2.0/repositories/WORKSPACE/REPO/pullrequests/${env.CHANGE_ID}/comments" \
7 -d '{"content":{"raw":"## Preview Ready\\n\\n**URL:** ${env.PREVIEW_URL}\\n\\n_Deployed by Bunnyshell via Jenkins_"}}'
8 """
9}GitLab
1withCredentials([string(credentialsId: 'gitlab-token', variable: 'GITLAB_TOKEN')]) {
2 sh """
3 curl -s -X POST \
4 -H "PRIVATE-TOKEN: ${GITLAB_TOKEN}" \
5 -H "Content-Type: application/json" \
6 "https://gitlab.com/api/v4/projects/PROJECT_ID/merge_requests/${env.CHANGE_ID}/notes" \
7 -d '{"body": "## Preview Ready\\n\\n**URL:** ${env.PREVIEW_URL}\\n\\n_Deployed by Bunnyshell via Jenkins_"}'
8 """
9}For cleaner PR comments, use the Jenkins HTTP Request plugin instead of curl. It provides better error handling and response parsing.
Pipeline Examples
Minimal Jenkinsfile (Deploy Only)
1pipeline {
2 agent any
3 environment {
4 BUNNYSHELL_TOKEN = credentials('bunnyshell-token')
5 BUNNYSHELL_ENV_ID = 'your-primary-env-id'
6 BUNNYSHELL_CLUSTER_ID = 'your-cluster-id'
7 BUNNYSHELL_PROJECT_ID = 'your-project-id'
8 }
9 stages {
10 stage('Deploy Preview') {
11 when { changeRequest() }
12 steps {
13 sh '''
14 curl -sL https://raw.githubusercontent.com/bunnyshell/cli/main/install.sh | bash
15 export PATH="$HOME/.bunnyshell/bin:$PATH"
16
17 ENV_NAME="pr-${CHANGE_ID}"
18
19 bns environments create \
20 --from-environment "$BUNNYSHELL_ENV_ID" \
21 --name "$ENV_NAME" \
22 --k8s "$BUNNYSHELL_CLUSTER_ID" || true
23
24 bns environments deploy \
25 --name "$ENV_NAME" \
26 --project "$BUNNYSHELL_PROJECT_ID" \
27 --wait \
28 --timeout 900
29 '''
30 }
31 }
32 }
33}Deploy with Parallel Tests
1pipeline {
2 agent any
3 environment {
4 BUNNYSHELL_TOKEN = credentials('bunnyshell-token')
5 BUNNYSHELL_ENV_ID = 'your-primary-env-id'
6 BUNNYSHELL_CLUSTER_ID = 'your-cluster-id'
7 BUNNYSHELL_PROJECT_ID = 'your-project-id'
8 }
9 stages {
10 stage('Build and Test') {
11 when { changeRequest() }
12 parallel {
13 stage('Unit Tests') {
14 steps {
15 sh 'npm install && npm test'
16 }
17 }
18 stage('Lint') {
19 steps {
20 sh 'npm install && npm run lint'
21 }
22 }
23 stage('Deploy Preview') {
24 steps {
25 sh '''
26 curl -sL https://raw.githubusercontent.com/bunnyshell/cli/main/install.sh | bash
27 export PATH="$HOME/.bunnyshell/bin:$PATH"
28
29 ENV_NAME="pr-${CHANGE_ID}"
30
31 bns environments create \
32 --from-environment "$BUNNYSHELL_ENV_ID" \
33 --name "$ENV_NAME" \
34 --k8s "$BUNNYSHELL_CLUSTER_ID" || true
35
36 bns environments deploy \
37 --name "$ENV_NAME" \
38 --project "$BUNNYSHELL_PROJECT_ID" \
39 --wait \
40 --timeout 900
41 '''
42 }
43 }
44 }
45 }
46 }
47}Deploy with Target Branch Filter
1pipeline {
2 agent any
3 environment {
4 BUNNYSHELL_TOKEN = credentials('bunnyshell-token')
5 BUNNYSHELL_ENV_ID = 'your-primary-env-id'
6 BUNNYSHELL_CLUSTER_ID = 'your-cluster-id'
7 BUNNYSHELL_PROJECT_ID = 'your-project-id'
8 }
9 stages {
10 stage('Deploy Preview') {
11 when {
12 changeRequest target: 'main'
13 }
14 steps {
15 sh '''
16 curl -sL https://raw.githubusercontent.com/bunnyshell/cli/main/install.sh | bash
17 export PATH="$HOME/.bunnyshell/bin:$PATH"
18
19 ENV_NAME="pr-${CHANGE_ID}"
20
21 bns environments create \
22 --from-environment "$BUNNYSHELL_ENV_ID" \
23 --name "$ENV_NAME" \
24 --k8s "$BUNNYSHELL_CLUSTER_ID" || true
25
26 bns environments deploy \
27 --name "$ENV_NAME" \
28 --project "$BUNNYSHELL_PROJECT_ID" \
29 --wait \
30 --timeout 900
31 '''
32 }
33 }
34 }
35 post {
36 failure {
37 script {
38 if (env.CHANGE_ID) {
39 sh '''
40 export PATH="$HOME/.bunnyshell/bin:$PATH"
41 ENV_NAME="pr-${CHANGE_ID}"
42 bns environments delete \
43 --name "$ENV_NAME" \
44 --project "$BUNNYSHELL_PROJECT_ID" \
45 --force || true
46 '''
47 }
48 }
49 }
50 }
51}Shared Library for Multi-Repo Teams
If your organization has many repositories, extract the Bunnyshell logic into a Jenkins Shared Library:
1// vars/bunnyshellPreview.groovy (in shared library repo)
2def deploy(Map config = [:]) {
3 def envName = "pr-${env.CHANGE_ID}"
4 def timeout = config.timeout ?: 900
5
6 sh """
7 curl -sL https://raw.githubusercontent.com/bunnyshell/cli/main/install.sh | bash
8 export PATH="\$HOME/.bunnyshell/bin:\$PATH"
9
10 bns environments create \
11 --from-environment "${config.envId}" \
12 --name "${envName}" \
13 --k8s "${config.clusterId}" || true
14
15 bns environments deploy \
16 --name "${envName}" \
17 --project "${config.projectId}" \
18 --wait \
19 --timeout ${timeout}
20 """
21}
22
23def destroy() {
24 def envName = "pr-${env.CHANGE_ID}"
25 sh """
26 export PATH="\$HOME/.bunnyshell/bin:\$PATH"
27 bns environments delete \
28 --name "${envName}" \
29 --project "${env.BUNNYSHELL_PROJECT_ID}" \
30 --force || true
31 """
32}Then in each repository's Jenkinsfile:
1@Library('bunnyshell-shared') _
2
3pipeline {
4 agent any
5 environment {
6 BUNNYSHELL_TOKEN = credentials('bunnyshell-token')
7 }
8 stages {
9 stage('Preview') {
10 when { changeRequest() }
11 steps {
12 bunnyshellPreview.deploy(
13 envId: 'your-env-id',
14 clusterId: 'your-cluster-id',
15 projectId: 'your-project-id'
16 )
17 }
18 }
19 }
20 post {
21 failure {
22 bunnyshellPreview.destroy()
23 }
24 }
25}Troubleshooting
| Issue | Solution |
|---|---|
CHANGE_ID is empty | The build was not triggered by a PR. Ensure you're using a Multibranch Pipeline with PR discovery enabled, not a regular Pipeline job. |
changeRequest() condition never matches | Check that the Branch Source plugin (GitHub, Bitbucket, or GitLab) is configured to discover pull requests. Go to the Multibranch Pipeline config > Branch Sources > Behaviors. |
bns: command not found | The CLI install and the bns command run in different sh steps, which have separate environments. Either install and use in the same sh block, or add $HOME/.bunnyshell/bin to PATH in the environment block. |
Credentials not available in sh block | Use credentials() in the environment block or withCredentials around the sh step. Plain environment variables set with = are not encrypted. |
Pipeline hangs during bns exec | bns exec may hang if it connects to the wrong container (e.g., a sidecar). Always specify the container with -c container-name. |
| Environment creation timeout | Increase the --timeout value. Also check if Jenkins agents have network access to the Bunnyshell API (api.bunnyshell.com). |
| PR environment not destroyed on merge | Jenkins doesn't have a native "on merge" event. Use Option 2 (Bunnyshell webhook auto-destroy) or Option 1 (cleanup stage on main branch). |
| Multibranch scan doesn't find PRs | Verify the Git provider credentials have the right scopes: repo for GitHub, repository:read for Bitbucket. Also check the scan log for errors. |
| Pipeline runs on every branch, not just PRs | The when { changeRequest() } condition prevents non-PR branches from running the preview stages. Make sure this condition is on every Bunnyshell-related stage. |
| Jenkins agent can't reach Bunnyshell API | If your Jenkins is behind a corporate firewall, configure the HTTP proxy in Jenkins Global Settings or set HTTP_PROXY / HTTPS_PROXY environment variables. |
What's Next?
- Jenkins Shared Libraries — Extract Bunnyshell steps into a shared library for use across all repositories
- Multibranch Pipeline for monorepos — Use
when { changeset "services/api/**" }to deploy only when specific services change - Blue Ocean UI — Visualize preview environment stages in Jenkins Blue Ocean
- Jenkins Kubernetes plugin — Run the Bunnyshell CLI on dynamic Kubernetes agents for better resource utilization
- Integration tests — Add a stage after deployment to run Cypress, Playwright, or API tests against the preview URL
- Slack/Teams notifications — Use the Jenkins Slack plugin to notify channels when preview environments are ready
Related Resources
- Bunnyshell CLI Reference
- Bunnyshell API Documentation
- Jenkins Pipeline Syntax
- Jenkins Multibranch Pipeline
- Ephemeral Environments — Learn more about the concept
- All Guides — More technical guides
Ship faster starting today.
14-day full-feature trial. No credit card required. Pay-as-you-go from $0.007/min per environment.