Preview Environments with GitLab CI: Automated Per-MR Deployments with Bunnyshell
GuideMarch 20, 202611 min read

Preview Environments with GitLab CI: Automated Per-MR Deployments with Bunnyshell

Why Integrate Bunnyshell with GitLab CI?

GitLab CI/CD is where your pipelines already run — builds, tests, linting, security scans. Adding preview environments to that pipeline means every merge request gets a live, isolated deployment that reviewers can click and test. No more "it works on my machine" or fighting over a shared staging server.

With Bunnyshell + GitLab CI, you get:

  • Automatic deployment — A preview environment spins up for every merge request
  • Pipeline visibility — Environment status appears directly in your GitLab pipeline
  • GitLab Environments integration — Track deployments in GitLab's built-in Environments page
  • Automatic cleanup — Environments are destroyed when the MR 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 pipeline config), or add a few jobs to your .gitlab-ci.yml for full control.

How It Works

There are two models for connecting Bunnyshell to GitLab CI:

ModelHow it triggersPipeline changesBest for
Webhook-OnlyBunnyshell listens to GitLab MR events via webhookNoneTeams that want zero CI/CD maintenance
Pipeline Integration.gitlab-ci.yml jobs call the Bunnyshell CLIAdd deploy/cleanup stagesTeams that want full pipeline control and visibility

Webhook-Only: Bunnyshell adds a webhook to your GitLab project. When a merge request is opened, Bunnyshell automatically creates and deploys a preview environment. When the MR is merged or closed, the environment is destroyed. Your .gitlab-ci.yml stays untouched.

Pipeline Integration: You add jobs to your .gitlab-ci.yml 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 pipeline.

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 GitLab project with CI/CD enabled
  • 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 pipeline 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 merge request automatically gets a preview environment.

Step 1: Connect Your GitLab Repository

  1. Log into Bunnyshell
  2. Go to Settings > Git Repositories
  3. Click Connect a Git Provider and select GitLab
  4. Authorize Bunnyshell to access your GitLab group or project
  5. Select the repository you want to enable preview environments for

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 merge request" to ON
  5. Toggle "Destroy environment after merge or close merge request" to ON
  6. Select the Kubernetes cluster for preview environments

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

What Happens Now

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

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

No .gitlab-ci.yml changes. No pipeline jobs. No maintenance. If this is all you need, you're done.


Approach B: GitLab CI Pipeline Integration

For teams that want full control — trigger Bunnyshell from within your pipeline, run tests against the preview URL, and see deployment status in GitLab's Environments page.

Step 1: Store the Bunnyshell Token

Add your Bunnyshell API token as a CI/CD variable in GitLab:

  1. Go to your project in GitLab
  2. Navigate to Settings > CI/CD > Variables
  3. Click Add variable:
    • Key: BUNNYSHELL_TOKEN
    • Value: your Bunnyshell API token
    • Type: Variable
    • Flags: Check Mask variable and Protect variable (uncheck Protect if you need it on unprotected branches for MR previews)

If Protect variable is checked, the token will only be available on protected branches (like main). For preview environments triggered by merge requests on feature branches, you need to uncheck Protect variable — or use GitLab's Group-level variables for broader access.

Add additional variables for your Bunnyshell project and environment IDs:

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

Step 2: Install the Bunnyshell CLI in Your Pipeline

Add a hidden job template that installs the CLI:

YAML
1.install-bns-cli:
2  before_script:
3    - curl -fsSL https://raw.githubusercontent.com/bunnyshell/cli/main/install.sh | sh
4    - export PATH="$HOME/.bunnyshell/bin:$PATH"
5    - bns version
6    - bns configure set token "$BUNNYSHELL_TOKEN"

Step 3: Define Environment Name Convention

Use a consistent naming scheme so you can find and manage environments easily:

YAML
variables:
  BNS_ENV_NAME: "preview-mr-${CI_MERGE_REQUEST_IID}"

This creates environment names like preview-mr-42, preview-mr-103, etc.


.gitlab-ci.yml Configuration

Here's a complete .gitlab-ci.yml with preview environment stages:

YAML
1stages:
2  - build
3  - deploy-preview
4  - test-preview
5  - cleanup
6
7variables:
8  BNS_ENV_NAME: "preview-mr-${CI_MERGE_REQUEST_IID}"
9
10# ── Hidden job: install Bunnyshell CLI ──
11.install-bns-cli:
12  before_script:
13    - curl -fsSL https://raw.githubusercontent.com/bunnyshell/cli/main/install.sh | sh
14    - export PATH="$HOME/.bunnyshell/bin:$PATH"
15    - bns configure set token "$BUNNYSHELL_TOKEN"
16
17# ── Build stage ──
18build:
19  stage: build
20  image: docker:24
21  services:
22    - docker:24-dind
23  script:
24    - echo "Building application..."
25    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
26    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
27  rules:
28    - if: '$CI_MERGE_REQUEST_IID'
29    - if: '$CI_COMMIT_BRANCH == "main"'
30
31# ── Deploy preview environment on MR open/update ──
32deploy-preview:
33  stage: deploy-preview
34  image: ubuntu:22.04
35  extends: .install-bns-cli
36  script:
37    # Check if environment already exists
38    - |
39      EXISTING_ENV=$(bns environments list \
40        --project "$BUNNYSHELL_PROJECT_ID" \
41        --name "$BNS_ENV_NAME" \
42        --output json 2>/dev/null | jq -r '._embedded.item[0].id // empty')
43
44    # Create or update the environment
45    - |
46      if [ -z "$EXISTING_ENV" ]; then
47        echo "Creating new preview environment: $BNS_ENV_NAME"
48        ENV_ID=$(bns environments create \
49          --from-environment "$BUNNYSHELL_ENVIRONMENT_ID" \
50          --name "$BNS_ENV_NAME" \
51          --project "$BUNNYSHELL_PROJECT_ID" \
52          --k8s "$BUNNYSHELL_CLUSTER_ID" \
53          --output json | jq -r '.id')
54      else
55        echo "Environment already exists: $EXISTING_ENV"
56        ENV_ID="$EXISTING_ENV"
57      fi
58
59    # Update the branch to the MR source branch
60    - |
61      bns environments update-configuration \
62        --id "$ENV_ID" \
63        --from-git-branch "$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME"
64
65    # Deploy and wait for completion
66    - |
67      echo "Deploying environment $ENV_ID..."
68      bns environments deploy --id "$ENV_ID" --wait
69
70    # Extract the preview URL
71    - |
72      PREVIEW_URL=$(bns environments show \
73        --id "$ENV_ID" \
74        --output json | jq -r '._embedded.components[0].endpoints[0].url // "pending"')
75      echo "PREVIEW_URL=$PREVIEW_URL" >> deploy.env
76      echo "BNS_ENV_ID=$ENV_ID" >> deploy.env
77      echo "Preview URL: $PREVIEW_URL"
78
79  artifacts:
80    reports:
81      dotenv: deploy.env
82  environment:
83    name: preview/mr-$CI_MERGE_REQUEST_IID
84    url: $PREVIEW_URL
85    on_stop: cleanup-preview
86    auto_stop_in: 1 week
87  rules:
88    - if: '$CI_MERGE_REQUEST_IID'
89
90# ── Run tests against the preview environment ──
91test-preview:
92  stage: test-preview
93  image: node:20-slim
94  needs:
95    - deploy-preview
96  script:
97    - echo "Running tests against $PREVIEW_URL"
98    - |
99      # Wait for the preview environment to be fully ready
100      for i in $(seq 1 30); do
101        STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$PREVIEW_URL" || echo "000")
102        if [ "$STATUS" = "200" ]; then
103          echo "Preview environment is ready!"
104          break
105        fi
106        echo "Waiting for preview environment... (attempt $i/30, status: $STATUS)"
107        sleep 10
108      done
109
110    # Run your test suite against the live URL
111    - |
112      echo "Running smoke tests..."
113      curl -sf "$PREVIEW_URL" > /dev/null && echo "Homepage: OK"
114      curl -sf "$PREVIEW_URL/api/health" > /dev/null && echo "Health check: OK"
115
116    # Example: Run Cypress/Playwright tests
117    # - npx cypress run --config baseUrl=$PREVIEW_URL
118    # - npx playwright test --config=playwright.ci.config.ts
119  rules:
120    - if: '$CI_MERGE_REQUEST_IID'
121
122# ── Cleanup: destroy preview environment on MR merge/close ──
123cleanup-preview:
124  stage: cleanup
125  image: ubuntu:22.04
126  extends: .install-bns-cli
127  script:
128    - |
129      EXISTING_ENV=$(bns environments list \
130        --project "$BUNNYSHELL_PROJECT_ID" \
131        --name "$BNS_ENV_NAME" \
132        --output json 2>/dev/null | jq -r '._embedded.item[0].id // empty')
133
134    - |
135      if [ -n "$EXISTING_ENV" ]; then
136        echo "Destroying preview environment: $EXISTING_ENV"
137        bns environments delete --id "$EXISTING_ENV" --wait
138        echo "Environment destroyed."
139      else
140        echo "No environment found for $BNS_ENV_NAME — nothing to clean up."
141      fi
142  environment:
143    name: preview/mr-$CI_MERGE_REQUEST_IID
144    action: stop
145  rules:
146    - if: '$CI_MERGE_REQUEST_IID'
147      when: manual
148      allow_failure: true

The environment.on_stop: cleanup-preview directive tells GitLab to run the cleanup job when the environment is stopped — either manually from the Environments page or automatically when the MR is merged/closed.


Pipeline Examples

Deploy on Merge Request Open

When a developer creates a merge request, the pipeline triggers automatically:

Text
1MR #42 opened (feature/add-user-profile → main)
2  ├── build .............. Build and push Docker image
3  ├── deploy-preview ..... Create Bunnyshell environment "preview-mr-42"
4  │   └── Posts preview URL to GitLab environment
5  └── test-preview ....... Run smoke tests against the live URL

The developer and reviewers see the preview environment link directly in the MR:

Text
1🔗 Environment: preview/mr-42
2   URL: https://preview-mr-42-app.bunnyshell.dev
3   Status: Available

Destroy on Merge Request Merge/Close

When the MR is merged, GitLab triggers the stop action:

Text
MR #42 merged
  └── cleanup-preview .... Destroy Bunnyshell environment "preview-mr-42"

Resources are freed immediately. No orphaned environments consuming cluster capacity.

Push Updates to an Open MR

When a developer pushes new commits to a branch with an open MR:

Text
1MR #42 updated (new commits pushed)
2  ├── build .............. Rebuild Docker image with latest code
3  ├── deploy-preview ..... Redeploy existing environment with new image
4  └── test-preview ....... Re-run tests against updated preview

The existing environment is updated in-place — no new environment created, no URL change.


GitLab CI Variables and Bunnyshell

Passing GitLab CI Variables to Bunnyshell

You can pass pipeline variables to Bunnyshell environment variables using the CLI:

YAML
1deploy-preview:
2  script:
3    # Set environment variables in Bunnyshell from GitLab CI
4    - |
5      bns environments update-configuration \
6        --id "$ENV_ID" \
7        --set-var "GIT_COMMIT_SHA=$CI_COMMIT_SHA" \
8        --set-var "GIT_BRANCH=$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME" \
9        --set-var "IMAGE_TAG=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA"

Available GitLab CI Variables

These GitLab CI predefined variables are useful for Bunnyshell integration:

GitLab CI VariableDescriptionExample Value
CI_MERGE_REQUEST_IIDMR number (project-scoped)42
CI_MERGE_REQUEST_SOURCE_BRANCH_NAMESource branchfeature/add-profile
CI_MERGE_REQUEST_TARGET_BRANCH_NAMETarget branchmain
CI_COMMIT_SHAFull commit hasha1b2c3d4e5f6...
CI_COMMIT_SHORT_SHAShort commit hasha1b2c3d4
CI_REGISTRY_IMAGEContainer registry pathregistry.gitlab.com/org/repo
CI_PROJECT_URLProject URLhttps://gitlab.com/org/repo
CI_MERGE_REQUEST_TITLEMR titleAdd user profile page

Using GitLab Group-Level Variables

For organizations with multiple projects, store Bunnyshell credentials at the group level:

  1. Go to your GitLab Group > Settings > CI/CD > Variables
  2. Add BUNNYSHELL_TOKEN — it will be inherited by all projects in the group
  3. Add project-specific variables (like BUNNYSHELL_PROJECT_ID) at the project level

This keeps your API token in one place and avoids duplication across projects.


MR Comments with Preview URL

To post the preview URL as a comment on the merge request, add this to your deploy job:

YAML
1deploy-preview:
2  script:
3    # ... (deploy steps from above) ...
4
5    # Post preview URL as MR comment
6    - |
7      if [ -n "$PREVIEW_URL" ] && [ "$PREVIEW_URL" != "pending" ]; then
8        curl --request POST \
9          --header "PRIVATE-TOKEN: $GITLAB_API_TOKEN" \
10          --header "Content-Type: application/json" \
11          --data "{\"body\": \"## Preview Environment Ready\\n\\nYour preview environment is live:\\n\\n**URL:** $PREVIEW_URL\\n\\n**Environment:** \`$BNS_ENV_NAME\`\\n**Commit:** \`$CI_COMMIT_SHORT_SHA\`\\n\\n---\\n_Deployed by Bunnyshell via GitLab CI_\"}" \
12          "$CI_API_V4_URL/projects/$CI_PROJECT_ID/merge_requests/$CI_MERGE_REQUEST_IID/notes"
13      fi

You'll need a GITLAB_API_TOKEN CI/CD variable with api scope for posting MR comments. Alternatively, you can use the built-in CI_JOB_TOKEN if your project allows it — go to Settings > CI/CD > Token Access and enable the api scope.

This posts a formatted comment with the preview URL, environment name, and commit hash directly on the MR for reviewers.


Running Tests Against Preview Environments

Smoke Tests

The simplest approach — verify the deployment is healthy:

YAML
1test-preview:
2  stage: test-preview
3  image: curlimages/curl:latest
4  needs:
5    - deploy-preview
6  script:
7    - curl -sf "$PREVIEW_URL" > /dev/null
8    - curl -sf "$PREVIEW_URL/api/health" > /dev/null
9    - echo "All smoke tests passed"
10  rules:
11    - if: '$CI_MERGE_REQUEST_IID'

End-to-End Tests with Cypress

YAML
1e2e-tests:
2  stage: test-preview
3  image: cypress/included:13.6.0
4  needs:
5    - deploy-preview
6  script:
7    - cypress run --config baseUrl=$PREVIEW_URL
8  artifacts:
9    when: always
10    paths:
11      - cypress/screenshots
12      - cypress/videos
13    expire_in: 7 days
14  rules:
15    - if: '$CI_MERGE_REQUEST_IID'

End-to-End Tests with Playwright

YAML
1e2e-tests:
2  stage: test-preview
3  image: mcr.microsoft.com/playwright:v1.42.0-focal
4  needs:
5    - deploy-preview
6  script:
7    - npx playwright test --config=playwright.ci.config.ts
8  variables:
9    BASE_URL: "$PREVIEW_URL"
10  artifacts:
11    when: always
12    paths:
13      - playwright-report
14    expire_in: 7 days
15  rules:
16    - if: '$CI_MERGE_REQUEST_IID'

API Tests with Newman (Postman)

YAML
1api-tests:
2  stage: test-preview
3  image: postman/newman:6-alpine
4  needs:
5    - deploy-preview
6  script:
7    - |
8      newman run tests/postman-collection.json \
9        --env-var "baseUrl=$PREVIEW_URL" \
10        --reporters cli,junit \
11        --reporter-junit-export results.xml
12  artifacts:
13    when: always
14    reports:
15      junit: results.xml
16  rules:
17    - if: '$CI_MERGE_REQUEST_IID'

Troubleshooting

IssueCauseSolution
BUNNYSHELL_TOKEN not foundVariable is protected but branch is notUncheck Protect variable in CI/CD Variables, or add the branch to protected branches
bns: command not foundCLI not installed or not in PATHVerify the install script ran. Add export PATH="$HOME/.bunnyshell/bin:$PATH" to your script
Environment creation failsPrimary environment not deployedDeploy the primary environment first. It must be in Running or Stopped status
Pipeline hangs on --waitDeployment is slow or stuckAdd a timeout: bns environments deploy --id $ENV_ID --wait --timeout 1800 (30 min)
Cleanup job doesn't runMR merged without triggering stopEnable auto_stop_in on the GitLab environment, or use Bunnyshell's webhook approach for automatic cleanup
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 in the example)
MR comment not postedMissing API token or permissionsVerify GITLAB_API_TOKEN has api scope. Check the project's token access settings
Branch not found in BunnyshellRepository not connected or wrong branch nameVerify the GitLab repository is connected in Bunnyshell Settings. Check CI_MERGE_REQUEST_SOURCE_BRANCH_NAME is correct
Rate limiting on Bunnyshell APIToo many concurrent deploymentsAdd resource_group: preview-deployments to your deploy job to serialize deployments

What's Next?

  • Add E2E tests — Run Cypress, Playwright, or Selenium tests against the preview URL in your pipeline
  • GitLab Environments dashboard — Use the environment: directive to track all preview deployments in GitLab's built-in Environments page
  • Review Apps — Combine Bunnyshell preview environments with GitLab Review Apps for a unified workflow
  • Merge request approvals — Require successful preview deployment before allowing merge
  • Notifications — Post preview URLs to Slack or Microsoft Teams using GitLab CI webhooks
  • Multi-project pipelines — Trigger preview environments across microservices using GitLab's trigger: directive

Ship faster starting today.

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