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

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:

ApproachPipeline changesControl levelBest for
Approach A: Webhook-OnlyNoneBunnyshell manages everythingTeams that want the fastest setup
Approach B: Jenkinsfile IntegrationAdd stages to JenkinsfileFull control over lifecycleTeams 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

Text
1PR opened → Git provider webhook → Bunnyshell API
23                                 Create environment
4                                 Build images
5                                 Deploy to K8s
67                                 Post comment on PR
8                                 with preview URL

Bunnyshell 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

Text
1PR opened → Jenkins Multibranch Pipeline detects PR
23          Jenkinsfile stages execute
4          Install Bunnyshell CLI
5          CLI creates/deploys environment
6          CLI retrieves preview URL
78          Post-deploy stages (migrations, seeds, tests)
910          Post preview URL as PR comment
1112          On merge/close: cleanup stage destroys environment

Your 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

  1. Log into Bunnyshell
  2. Navigate to your Project and select your primary environment
  3. Go to Settings > Git Repository
  4. If not already connected, add your Git provider integration (GitHub, GitLab, or Bitbucket) under Settings > Integrations
  5. 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

  1. In your environment, go to Settings
  2. Find the Ephemeral environments section
  3. Toggle "Create ephemeral environments on pull request" to ON
  4. Toggle "Destroy environment after merge or close pull request" to ON
  5. 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

  1. Create a new branch and push a change
  2. Open a pull request
  3. Bunnyshell creates an ephemeral environment cloned from your primary, using the PR branch
  4. Within a few minutes, Bunnyshell posts a comment on the PR with the preview URL
  5. 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:

  1. In Jenkins, click New Item
  2. Enter a name and select Multibranch Pipeline
  3. 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
  4. 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"
  5. Under Build Configuration, set:
    • Mode: "by Jenkinsfile"
    • Script Path: Jenkinsfile
  6. 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

  1. Go to Manage Jenkins > Credentials > System > Global credentials
  2. Click Add Credentials
  3. Select Secret text
  4. Set:
    • Secret: Your Bunnyshell API token
    • ID: bunnyshell-token
    • Description: "Bunnyshell API Token"
  5. Click Create

Optionally, add more credentials:

IDTypeValue
bunnyshell-tokenSecret textAPI token
github-tokenSecret textGitHub 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:

Groovy
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() }:

Groovy
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 number
  • CHANGE_URL — The PR URL
  • CHANGE_TITLE — The PR title
  • CHANGE_AUTHOR — The PR author
  • CHANGE_TARGET — The target branch
  • CHANGE_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:

Groovy
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 { ... } }:

Groovy
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:

Add a stage that runs on the target branch after merge:

Groovy
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:

  1. In Bunnyshell, go to Settings > Ephemeral environments
  2. 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:

Groovy
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

Store sensitive values as Jenkins Credentials:

Credential IDTypeValue
bunnyshell-tokenSecret textBunnyshell API token
github-tokenSecret textGitHub PAT for PR comments

Access them in the Jenkinsfile:

Groovy
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:

  1. Go to Manage Jenkins > System > Global properties
  2. Check Environment variables
  3. Add:
    • BUNNYSHELL_ORG_ID = your org ID
    • BUNNYSHELL_PROJECT_ID = your project ID
    • BUNNYSHELL_CLUSTER_ID = your cluster ID
    • BUNNYSHELL_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:

  1. Create a Jenkins folder for your project
  2. Go to Folder > Credentials > Add Credentials
  3. 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:

Groovy
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

Groovy
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

Groovy
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)

Groovy
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

Groovy
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

Groovy
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:

Groovy
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:

Groovy
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

IssueSolution
CHANGE_ID is emptyThe 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 matchesCheck 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 foundThe 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 blockUse credentials() in the environment block or withCredentials around the sh step. Plain environment variables set with = are not encrypted.
Pipeline hangs during bns execbns exec may hang if it connects to the wrong container (e.g., a sidecar). Always specify the container with -c container-name.
Environment creation timeoutIncrease the --timeout value. Also check if Jenkins agents have network access to the Bunnyshell API (api.bunnyshell.com).
PR environment not destroyed on mergeJenkins 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 PRsVerify 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 PRsThe 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 APIIf 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

Ship faster starting today.

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