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:
| Model | How it triggers | Pipeline changes | Best for |
|---|---|---|---|
| Webhook-Only | Bunnyshell listens to GitLab MR events via webhook | None | Teams that want zero CI/CD maintenance |
| Pipeline Integration | .gitlab-ci.yml jobs call the Bunnyshell CLI | Add deploy/cleanup stages | Teams 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.yamlin your repository (see our framework-specific guides for Laravel, Django, Express, etc.) - The Bunnyshell CLI installed locally for testing (optional but recommended):
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 versionIf 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
- Log into Bunnyshell
- Go to Settings > Git Repositories
- Click Connect a Git Provider and select GitLab
- Authorize Bunnyshell to access your GitLab group or project
- Select the repository you want to enable preview environments for
Step 2: Enable Automatic Preview Environments
- Navigate to your primary environment (the one you've already deployed)
- Go to Settings
- Find the Ephemeral environments section
- Toggle "Create ephemeral environments on merge request" to ON
- Toggle "Destroy environment after merge or close merge request" to ON
- 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:
- Go to your project in GitLab
- Navigate to Settings > CI/CD > Variables
- 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)
- Key:
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:
| Variable | Description | Example |
|---|---|---|
BUNNYSHELL_TOKEN | API authentication token | bns_pat_xxxxxxxxxxxx |
BUNNYSHELL_PROJECT_ID | Your Bunnyshell project ID | proj_abc123 |
BUNNYSHELL_ENVIRONMENT_ID | Primary environment ID (template) | env_def456 |
BUNNYSHELL_CLUSTER_ID | Target Kubernetes cluster | cluster_ghi789 |
Step 2: Install the Bunnyshell CLI in Your Pipeline
Add a hidden job template that installs the CLI:
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:
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:
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: trueThe 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:
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 URLThe developer and reviewers see the preview environment link directly in the MR:
1🔗 Environment: preview/mr-42
2 URL: https://preview-mr-42-app.bunnyshell.dev
3 Status: AvailableDestroy on Merge Request Merge/Close
When the MR is merged, GitLab triggers the stop action:
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:
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 previewThe 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:
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 Variable | Description | Example Value |
|---|---|---|
CI_MERGE_REQUEST_IID | MR number (project-scoped) | 42 |
CI_MERGE_REQUEST_SOURCE_BRANCH_NAME | Source branch | feature/add-profile |
CI_MERGE_REQUEST_TARGET_BRANCH_NAME | Target branch | main |
CI_COMMIT_SHA | Full commit hash | a1b2c3d4e5f6... |
CI_COMMIT_SHORT_SHA | Short commit hash | a1b2c3d4 |
CI_REGISTRY_IMAGE | Container registry path | registry.gitlab.com/org/repo |
CI_PROJECT_URL | Project URL | https://gitlab.com/org/repo |
CI_MERGE_REQUEST_TITLE | MR title | Add user profile page |
Using GitLab Group-Level Variables
For organizations with multiple projects, store Bunnyshell credentials at the group level:
- Go to your GitLab Group > Settings > CI/CD > Variables
- Add
BUNNYSHELL_TOKEN— it will be inherited by all projects in the group - 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:
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 fiYou'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:
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
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
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)
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
| Issue | Cause | Solution |
|---|---|---|
BUNNYSHELL_TOKEN not found | Variable is protected but branch is not | Uncheck Protect variable in CI/CD Variables, or add the branch to protected branches |
bns: command not found | CLI not installed or not in PATH | Verify the install script ran. Add export PATH="$HOME/.bunnyshell/bin:$PATH" to your script |
| Environment creation fails | Primary environment not deployed | Deploy the primary environment first. It must be in Running or Stopped status |
Pipeline hangs on --wait | Deployment is slow or stuck | Add a timeout: bns environments deploy --id $ENV_ID --wait --timeout 1800 (30 min) |
| Cleanup job doesn't run | MR merged without triggering stop | Enable auto_stop_in on the GitLab environment, or use Bunnyshell's webhook approach for automatic cleanup |
| Preview URL returns 502 | Application not fully started | Add a readiness check loop in your test job. The app may need 30-60 seconds after deploy completes |
| Duplicate environments | Job retried without checking existence | Always check for existing environments by name before creating (see the EXISTING_ENV check in the example) |
| MR comment not posted | Missing API token or permissions | Verify GITLAB_API_TOKEN has api scope. Check the project's token access settings |
| Branch not found in Bunnyshell | Repository not connected or wrong branch name | Verify the GitLab repository is connected in Bunnyshell Settings. Check CI_MERGE_REQUEST_SOURCE_BRANCH_NAME is correct |
| Rate limiting on Bunnyshell API | Too many concurrent deployments | Add 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.