Preview Environments for Laravel: Automated Per-PR Deployments with Bunnyshell
GuideMarch 19, 202618 min read

Preview Environments for Laravel: Automated Per-PR Deployments with Bunnyshell

Why Preview Environments for Laravel?

Every Laravel team knows the drill: you write a migration, test it locally against SQLite, push to staging — and it breaks because staging uses MySQL with different charset defaults. Or someone else deployed their queue worker changes to staging right before your demo. Or the Horizon dashboard shows jobs piling up because two feature branches are fighting over the same Redis instance.

Preview environments solve this. Every pull request gets its own isolated deployment — Laravel app with PHP-FPM and Nginx, MySQL database, Redis for cache and queues — running in Kubernetes with production-like configuration. Reviewers click a link and see the actual running app, not just the diff.

With Bunnyshell, you get:

  • Automatic deployment — A new environment spins up for every PR
  • Production parity — Same Docker images, same database engine, same queue driver
  • Isolation — Each PR environment is fully independent, no shared staging conflicts
  • Automatic cleanup — Environments are destroyed when the PR is merged or closed

Choose Your Approach

Bunnyshell supports three ways to set up preview environments for Laravel. Pick the one that fits your workflow:

ApproachBest forComplexityCI/CD maintenance
Approach A: Bunnyshell UITeams that want the fastest setup with zero pipeline maintenanceEasiestNone — Bunnyshell manages webhooks automatically
Approach B: Docker Compose ImportTeams already using docker-compose.yml for local developmentEasyNone — import converts to Bunnyshell config automatically
Approach C: Helm ChartsTeams with existing Helm infrastructure or complex K8s needsAdvancedOptional — can use CLI or Bunnyshell UI

All three approaches end the same way: a toggle in Bunnyshell Settings that enables automatic preview environments for every PR. No GitHub Actions, no GitLab CI pipelines to maintain — Bunnyshell adds webhooks to your Git provider and listens for PR events.

Prerequisites: Prepare Your Laravel App

Regardless of which approach you choose, your Laravel app needs a proper Docker setup and the right configuration for running behind a Kubernetes ingress.

1. Create a Production-Ready Dockerfile

Laravel typically runs with PHP-FPM + Nginx in production. Here's a multi-stage Dockerfile:

Dockerfile
1FROM php:8.3-fpm-alpine AS base
2
3# Install system dependencies and PHP extensions
4RUN apk add --no-cache \
5    libpq-dev \
6    libzip-dev \
7    freetype-dev \
8    libjpeg-turbo-dev \
9    libpng-dev \
10    oniguruma-dev \
11    icu-dev \
12    && apk add --virtual .build-deps $PHPIZE_DEPS \
13    && pecl install redis \
14    && docker-php-ext-enable redis \
15    && docker-php-ext-configure gd --with-freetype --with-jpeg \
16    && docker-php-ext-install \
17        pdo_mysql \
18        pdo_pgsql \
19        zip \
20        gd \
21        mbstring \
22        intl \
23        opcache \
24        pcntl \
25    && apk del .build-deps
26
27WORKDIR /var/www
28
29# Install Composer
30COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
31
32# Install PHP dependencies
33COPY composer.json composer.lock ./
34RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist
35
36# Copy application code
37COPY . .
38
39# Generate optimized autoloader and cache
40RUN composer dump-autoload --optimize \
41    && php artisan config:clear \
42    && php artisan route:clear \
43    && php artisan view:clear
44
45# Set permissions
46RUN chown -R www-data:www-data /var/www/storage /var/www/bootstrap/cache
47
48EXPOSE 9000
49CMD ["php-fpm"]

Important: Laravel with PHP-FPM listens on port 9000 (FastCGI), not HTTP. You need an Nginx sidecar to handle HTTP requests and proxy to PHP-FPM. We'll configure this in the Bunnyshell environment.

2. Create the Nginx Configuration

Create docker/nginx/Dockerfile:

Dockerfile
1FROM nginx:1.25-alpine
2
3COPY default.conf /etc/nginx/conf.d/default.conf
4
5EXPOSE 8080

And docker/nginx/default.conf:

Nginx
1server {
2    listen 8080;
3    server_name _;
4    root /var/www/public;
5    index index.php;
6
7    client_max_body_size 50M;
8
9    location / {
10        try_files $uri $uri/ /index.php?$query_string;
11    }
12
13    location ~ \.php$ {
14        fastcgi_pass localhost:9000;
15        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
16        include fastcgi_params;
17        fastcgi_buffers 16 16k;
18        fastcgi_buffer_size 32k;
19    }
20
21    location ~ /\.(?!well-known).* {
22        deny all;
23    }
24}

Key detail: fastcgi_pass localhost:9000 — in Kubernetes, PHP-FPM and Nginx run as sidecar containers in the same pod, so they share localhost. This is different from Docker Compose where you'd use the service name.

3. Configure Laravel for Kubernetes

Laravel needs specific settings to work correctly behind a Kubernetes ingress (which terminates TLS):

PHP
1// bootstrap/app.php (Laravel 11+)
2return Application::configure(basePath: dirname(__DIR__))
3    ->withMiddleware(function (Middleware $middleware) {
4        // Trust all proxies — K8s ingress terminates TLS
5        $middleware->trustProxies(at: '*');
6    })
7    ->create();

For Laravel 10 and earlier, edit app/Http/Middleware/TrustProxies.php:

PHP
1protected $proxies = '*';
2protected $headers = Request::HEADER_X_FORWARDED_FOR |
3                     Request::HEADER_X_FORWARDED_HOST |
4                     Request::HEADER_X_FORWARDED_PORT |
5                     Request::HEADER_X_FORWARDED_PROTO;

Without this, Laravel generates http:// URLs instead of https://, causing mixed-content errors, broken redirects, and infinite redirect loops.

4. Environment Variables

Update your .env.example to include Bunnyshell-friendly defaults:

.env
1APP_KEY=
2APP_URL=https://example.com
3APP_ENV=production
4APP_DEBUG=false
5
6DB_CONNECTION=mysql
7DB_HOST=mysql
8DB_PORT=3306
9DB_DATABASE=laravel
10DB_USERNAME=laravel
11DB_PASSWORD=
12
13CACHE_STORE=redis
14SESSION_DRIVER=redis
15QUEUE_CONNECTION=redis
16
17REDIS_HOST=redis
18REDIS_PORT=6379

Laravel Deployment Checklist

  • Dockerfile includes all required PHP extensions (pdo_mysql, redis, gd, mbstring, intl, opcache, pcntl)
  • pecl extensions (redis) installed with $PHPIZE_DEPS
  • trustProxies middleware configured for K8s ingress TLS termination
  • APP_KEY will be set via environment variable
  • SESSION_DRIVER set to redis (not database — avoids needing sessions migration)
  • Nginx sidecar configuration uses localhost:9000 for FastCGI
  • APP_URL will use Bunnyshell interpolation for dynamic URLs
  • File permissions set for storage/ and bootstrap/cache/

Approach A: Bunnyshell UI — Zero CI/CD Maintenance

This is the easiest approach. You connect your repo, paste a YAML config, deploy, and flip a toggle. No CI/CD pipelines to write or maintain — Bunnyshell automatically adds webhooks to your Git provider and creates/destroys preview environments when PRs are opened/closed.

Step 1: Create a Project and Environment

  1. Log into Bunnyshell
  2. Click Create project and name it (e.g., "Laravel App")
  3. Inside the project, click Create environment and name it (e.g., "laravel-main")

Step 2: Define the Environment Configuration

Click Configuration in your environment view and paste this bunnyshell.yaml:

YAML
1kind: Environment
2name: laravel-preview
3type: primary
4
5environmentVariables:
6  APP_KEY: SECRET["base64:your-generated-app-key"]
7  DB_PASSWORD: SECRET["your-db-password"]
8
9components:
10  # ── Nginx Sidecar ──
11  - kind: SidecarContainer
12    name: nginx
13    gitRepo: 'https://github.com/your-org/your-laravel-repo.git'
14    gitBranch: main
15    gitApplicationPath: docker/nginx
16    dockerCompose:
17      build:
18        context: docker/nginx
19      ports:
20        - '8080:8080'
21
22  # ── Laravel Application (PHP-FPM) ──
23  - kind: Application
24    name: laravel-app
25    gitRepo: 'https://github.com/your-org/your-laravel-repo.git'
26    gitBranch: main
27    gitApplicationPath: /
28    dockerCompose:
29      build:
30        context: .
31        dockerfile: Dockerfile
32      environment:
33        APP_KEY: '{{ env.vars.APP_KEY }}'
34        APP_URL: 'https://{{ components.laravel-app.ingress.hosts[0] }}'
35        APP_ENV: production
36        APP_DEBUG: 'false'
37        DB_CONNECTION: mysql
38        DB_HOST: mysql
39        DB_PORT: '3306'
40        DB_DATABASE: laravel
41        DB_USERNAME: laravel
42        DB_PASSWORD: '{{ env.vars.DB_PASSWORD }}'
43        CACHE_STORE: redis
44        SESSION_DRIVER: redis
45        QUEUE_CONNECTION: redis
46        REDIS_HOST: redis
47        REDIS_PORT: '6379'
48      ports:
49        - '9000:9000'
50        - '8080:8080'
51    pod:
52      sidecar_containers:
53        - from: nginx
54          name: nginx
55          shared_paths:
56            - path: /var/www
57              target:
58                path: /var/www
59                container: '@parent'
60              initial_contents: '@target'
61    dependsOn:
62      - mysql
63      - redis
64    hosts:
65      - hostname: 'app-{{ env.base_domain }}'
66        path: /
67        servicePort: 8080
68
69  # ── MySQL Database ──
70  - kind: Database
71    name: mysql
72    dockerCompose:
73      image: 'mysql:8.0'
74      environment:
75        MYSQL_ROOT_PASSWORD: '{{ env.vars.DB_PASSWORD }}'
76        MYSQL_DATABASE: laravel
77        MYSQL_USER: laravel
78        MYSQL_PASSWORD: '{{ env.vars.DB_PASSWORD }}'
79      ports:
80        - '3306:3306'
81
82  # ── Redis Cache & Queue ──
83  - kind: Service
84    name: redis
85    dockerCompose:
86      image: 'redis:7-alpine'
87      ports:
88        - '6379:6379'
89
90volumes:
91  - name: mysql-data
92    mount:
93      component: mysql
94      containerPath: /var/lib/mysql
95    size: 1Gi

Key architecture notes:

  • PHP-FPM + Nginx sidecar — They run in the same pod, sharing the /var/www directory. Nginx handles HTTP on port 8080, proxies PHP requests to localhost:9000 (PHP-FPM)
  • initial_contents: '@target' — Copies the built Laravel app files from the PHP-FPM container to the shared volume, so Nginx can serve static assets
  • Both ports declared on the Application — Port 9000 (PHP-FPM) and 8080 (Nginx sidecar) must both be listed on the parent component because they share the pod network

Replace your-org/your-laravel-repo with your actual repository. Save the configuration.

Step 3: Deploy

Click the Deploy button, select your Kubernetes cluster, and click Deploy Environment. Bunnyshell will:

  1. Build your PHP-FPM Docker image from the Dockerfile
  2. Build the Nginx sidecar image from docker/nginx/
  3. Pull MySQL and Redis images
  4. Deploy everything into an isolated Kubernetes namespace
  5. Generate HTTPS URLs automatically with DNS

Monitor the deployment in the environment detail page. When status shows Running, click Endpoints to access your live Laravel app.

Step 4: Run Post-Deploy Commands

After deployment, run Laravel setup commands via the component's terminal in the Bunnyshell UI, or via CLI:

Bash
1export BUNNYSHELL_TOKEN=your-api-token
2bns components list --environment ENV_ID --output json | jq '._embedded.item[] | {id, name}'
3
4# Run migrations
5bns exec COMPONENT_ID -c laravel-app -- php artisan migrate --force
6
7# Clear and rebuild caches
8bns exec COMPONENT_ID -c laravel-app -- php artisan config:cache
9bns exec COMPONENT_ID -c laravel-app -- php artisan route:cache
10bns exec COMPONENT_ID -c laravel-app -- php artisan view:cache
11
12# Link storage (for file uploads)
13bns exec COMPONENT_ID -c laravel-app -- php artisan storage:link

Important: Always specify -c laravel-app to target the PHP-FPM container. Without it, bns exec may connect to the Nginx sidecar and hang waiting for interactive input.

Step 5: Enable Automatic Preview Environments

This is the magic step — no CI/CD configuration needed:

  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 Kubernetes cluster for ephemeral environments

That's it. Bunnyshell automatically adds a webhook to your Git provider (GitHub, GitLab, or Bitbucket). From now on:

  • Open a PR → Bunnyshell creates an ephemeral environment with the PR's branch
  • Push to PR → The environment redeploys with the latest changes
  • Bunnyshell posts a comment on the PR with a link to the live deployment
  • Merge or close the PR → The ephemeral environment is automatically destroyed

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


Approach B: Docker Compose Import

Already have a docker-compose.yml for local development? Most Laravel projects do. Bunnyshell can import it directly and convert it to its environment format. No manual YAML writing required.

Step 1: Add a docker-compose.yml to Your Repo

If you don't already have one, create docker-compose.yml in your repo root:

YAML
1version: '3.8'
2
3services:
4  laravel-app:
5    build:
6      context: .
7      dockerfile: Dockerfile
8    volumes:
9      - .:/var/www
10    ports:
11      - '9000:9000'
12    environment:
13      APP_KEY: 'base64:your-key-here'
14      APP_URL: 'http://localhost:8080'
15      APP_ENV: local
16      DB_CONNECTION: mysql
17      DB_HOST: mysql
18      DB_PORT: '3306'
19      DB_DATABASE: laravel
20      DB_USERNAME: laravel
21      DB_PASSWORD: secret
22      CACHE_STORE: redis
23      SESSION_DRIVER: redis
24      QUEUE_CONNECTION: redis
25      REDIS_HOST: redis
26    depends_on:
27      - mysql
28      - redis
29
30  nginx:
31    build:
32      context: docker/nginx
33    ports:
34      - '8080:8080'
35    volumes:
36      - .:/var/www
37    depends_on:
38      - laravel-app
39
40  mysql:
41    image: mysql:8.0
42    environment:
43      MYSQL_ROOT_PASSWORD: secret
44      MYSQL_DATABASE: laravel
45      MYSQL_USER: laravel
46      MYSQL_PASSWORD: secret
47    volumes:
48      - mysql-data:/var/lib/mysql
49    ports:
50      - '3306:3306'
51
52  redis:
53    image: redis:7-alpine
54    ports:
55      - '6379:6379'
56
57volumes:
58  mysql-data:

Step 2: Import into Bunnyshell

  1. Create a Project and Environment in Bunnyshell (same as Approach A, Step 1)
  2. Click Define environment
  3. Select your Git account and repository
  4. Set the branch (e.g., main) and the path to docker-compose.yml (use / if it's in the root)
  5. Click Continue — Bunnyshell parses and validates your Docker Compose file

Bunnyshell automatically detects:

  • All services (laravel-app, nginx, mysql, redis)
  • Exposed ports
  • Build configurations (Dockerfiles)
  • Volumes
  • Environment variables

It converts everything into a bunnyshell.yaml environment definition.

Important: The docker-compose.yml is only read during the initial import. Subsequent changes to the file won't auto-propagate — edit the environment configuration in Bunnyshell instead.

Step 3: Adjust the Configuration

After import, go to Configuration in the environment view and update:

Replace hardcoded secrets with SECRET["..."] syntax:

YAML
1environmentVariables:
2  APP_KEY: SECRET["base64:your-app-key"]
3  DB_PASSWORD: SECRET["your-db-password"]

Add dynamic URLs using Bunnyshell interpolation:

YAML
APP_URL: 'https://{{ components.laravel-app.ingress.hosts[0] }}'

Convert Nginx to a sidecar — The Docker Compose import may create Nginx as a separate component. For proper Laravel operation, convert it to a sidecar container on the PHP-FPM component (see the sidecar pattern in Approach A). This ensures Nginx and PHP-FPM share the same pod and can communicate via localhost:9000.

Update Nginx config — Change fastcgi_pass from the Docker Compose service name to localhost:9000:

Nginx
# Docker Compose: fastcgi_pass laravel-app:9000;
# Bunnyshell (sidecar): fastcgi_pass localhost:9000;

Step 4: Deploy and Enable Preview Environments

Same as Approach A — click Deploy, then go to Settings and toggle on ephemeral environments.

Best Practices for Docker Compose with Bunnyshell

  • Sidecar vs. separate service — In Docker Compose, Nginx is a separate service. In Bunnyshell (Kubernetes), Nginx should be a sidecar on the PHP-FPM pod. This avoids cross-pod FastCGI overhead and simplifies networking
  • Remove local volumesvolumes: ['.:/var/www'] is for local dev (live reload). Remove these in Bunnyshell — the Docker image already contains the built app
  • Use Bunnyshell interpolation for dynamic values like URLs:
YAML
1# Local docker-compose.yml
2APP_URL: http://localhost:8080
3
4# Bunnyshell environment config (after import)
5APP_URL: 'https://{{ components.laravel-app.ingress.hosts[0] }}'
  • Design for startup resilience — Kubernetes doesn't guarantee depends_on ordering. Make your Laravel app retry database connections on startup (Laravel handles this gracefully by default, but verify with your queue workers)

Approach C: Helm Charts

For teams with existing Helm infrastructure or complex Kubernetes requirements (custom ingress, service mesh, advanced scaling, Horizon with auto-scaling workers). Helm gives you full control over every Kubernetes resource.

Step 1: Create a Helm Chart

Structure your Laravel Helm chart in your repo:

Text
1helm/laravel/
2├── Chart.yaml
3├── values.yaml
4└── templates/
5    ├── deployment.yaml
6    ├── service.yaml
7    ├── ingress.yaml
8    ├── configmap.yaml
9    └── migration-job.yaml

A minimal values.yaml:

YAML
1replicaCount: 1
2image:
3  repository: ""
4  tag: latest
5nginx:
6  image:
7    repository: ""
8    tag: latest
9service:
10  port: 8080
11ingress:
12  enabled: true
13  className: bns-nginx
14  host: ""
15env:
16  APP_KEY: ""
17  APP_URL: ""
18  DB_HOST: ""
19  DB_DATABASE: laravel
20  DB_USERNAME: laravel
21  DB_PASSWORD: ""
22  REDIS_HOST: ""
23  CACHE_STORE: redis
24  SESSION_DRIVER: redis
25  QUEUE_CONNECTION: redis

Step 2: Define the Bunnyshell Configuration

Create a bunnyshell.yaml using Helm components:

YAML
1kind: Environment
2name: laravel-helm
3type: primary
4
5environmentVariables:
6  APP_KEY: SECRET["base64:your-app-key"]
7  DB_PASSWORD: SECRET["your-db-password"]
8  MYSQL_DATABASE: laravel
9  MYSQL_USER: laravel
10
11components:
12  # ── Docker Image Builds ──
13  - kind: DockerImage
14    name: laravel-image
15    context: /
16    dockerfile: Dockerfile
17    gitRepo: 'https://github.com/your-org/your-laravel-repo.git'
18    gitBranch: main
19    gitApplicationPath: /
20
21  - kind: DockerImage
22    name: nginx-image
23    context: /docker/nginx
24    dockerfile: docker/nginx/Dockerfile
25    gitRepo: 'https://github.com/your-org/your-laravel-repo.git'
26    gitBranch: main
27    gitApplicationPath: /docker/nginx
28
29  # ── MySQL via Helm (Bitnami) ──
30  - kind: Helm
31    name: mysql
32    runnerImage: 'dtzar/helm-kubectl:3.8.2'
33    deploy:
34      - |
35        cat << EOF > mysql_values.yaml
36          global:
37            storageClass: bns-network-sc
38          auth:
39            rootPassword: {{ env.vars.DB_PASSWORD }}
40            database: {{ env.vars.MYSQL_DATABASE }}
41            username: {{ env.vars.MYSQL_USER }}
42            password: {{ env.vars.DB_PASSWORD }}
43        EOF
44      - 'helm repo add bitnami https://charts.bitnami.com/bitnami'
45      - 'helm upgrade --install --namespace {{ env.k8s.namespace }}
46        --post-renderer /bns/helpers/helm/bns_post_renderer
47        -f mysql_values.yaml mysql bitnami/mysql --version 9.14.4'
48      - |
49        MYSQL_HOST="mysql.{{ env.k8s.namespace }}.svc.cluster.local"
50    destroy:
51      - 'helm uninstall mysql --namespace {{ env.k8s.namespace }}'
52    start:
53      - 'kubectl scale --replicas=1 --namespace {{ env.k8s.namespace }}
54        statefulset/mysql'
55    stop:
56      - 'kubectl scale --replicas=0 --namespace {{ env.k8s.namespace }}
57        statefulset/mysql'
58    exportVariables:
59      - MYSQL_HOST
60
61  # ── Laravel App via Helm ──
62  - kind: Helm
63    name: laravel-app
64    runnerImage: 'dtzar/helm-kubectl:3.8.2'
65    deploy:
66      - |
67        cat << EOF > laravel_values.yaml
68          replicaCount: 1
69          image:
70            repository: {{ components.laravel-image.image }}
71          nginx:
72            image:
73              repository: {{ components.nginx-image.image }}
74          service:
75            port: 8080
76          ingress:
77            enabled: true
78            className: bns-nginx
79            host: app-{{ env.base_domain }}
80          env:
81            APP_KEY: '{{ env.vars.APP_KEY }}'
82            APP_URL: 'https://app-{{ env.base_domain }}'
83            APP_ENV: production
84            DB_CONNECTION: mysql
85            DB_HOST: '{{ components.mysql.exported.MYSQL_HOST }}'
86            DB_DATABASE: '{{ env.vars.MYSQL_DATABASE }}'
87            DB_USERNAME: '{{ env.vars.MYSQL_USER }}'
88            DB_PASSWORD: '{{ env.vars.DB_PASSWORD }}'
89            CACHE_STORE: redis
90            SESSION_DRIVER: redis
91            QUEUE_CONNECTION: redis
92            REDIS_HOST: redis
93        EOF
94      - 'helm upgrade --install --namespace {{ env.k8s.namespace }}
95        --post-renderer /bns/helpers/helm/bns_post_renderer
96        -f laravel_values.yaml laravel-{{ env.unique }} ./helm/laravel'
97    destroy:
98      - 'helm uninstall laravel-{{ env.unique }} --namespace {{ env.k8s.namespace }}'
99    start:
100      - 'helm upgrade --namespace {{ env.k8s.namespace }}
101        --post-renderer /bns/helpers/helm/bns_post_renderer
102        --reuse-values --set replicaCount=1 laravel-{{ env.unique }} ./helm/laravel'
103    stop:
104      - 'helm upgrade --namespace {{ env.k8s.namespace }}
105        --post-renderer /bns/helpers/helm/bns_post_renderer
106        --reuse-values --set replicaCount=0 laravel-{{ env.unique }} ./helm/laravel'
107    gitRepo: 'https://github.com/your-org/your-laravel-repo.git'
108    gitBranch: main
109    gitApplicationPath: /helm/laravel
110
111  # ── Redis ──
112  - kind: Service
113    name: redis
114    dockerCompose:
115      image: 'redis:7-alpine'
116      ports:
117        - '6379:6379'

Key: Always include --post-renderer /bns/helpers/helm/bns_post_renderer in your helm commands. This adds labels so Bunnyshell can track resources, show logs, and manage component lifecycle.

Step 3: Deploy and Enable Preview Environments

Same flow: paste the config in Configuration, hit Deploy, then enable ephemeral environments in Settings.


Enabling Preview Environments (All Approaches)

Regardless of which approach you used, enabling automatic preview environments is the same:

  1. Ensure your primary environment has been deployed at least once (Running or Stopped status)
  2. Go to Settings in your environment
  3. Toggle "Create ephemeral environments on pull request" → ON
  4. Toggle "Destroy environment after merge or close pull request" → ON
  5. Select the target Kubernetes cluster

What happens next:

  • Bunnyshell adds a webhook to your Git provider automatically
  • When a developer opens a PR, Bunnyshell creates an ephemeral environment cloned from the primary, using the PR's branch
  • Bunnyshell posts a comment on the PR with a direct link to the running deployment
  • When the PR is merged or closed, the ephemeral environment is automatically destroyed

No GitHub Actions. No GitLab CI pipelines. No maintenance. It just works.

Optional: CI/CD Integration via CLI

If you prefer to control preview environments from your CI/CD pipeline (e.g., for custom migration or seed scripts), you can use the Bunnyshell CLI:

Bash
1# Install
2brew install bunnyshell/tap/bunnyshell-cli
3
4# Authenticate
5export BUNNYSHELL_TOKEN=your-api-token
6
7# Create, deploy, and run migrations in one flow
8bns environments create --from-path bunnyshell.yaml --name "pr-123" --project PROJECT_ID --k8s CLUSTER_ID
9bns environments deploy --id ENV_ID --wait
10bns exec COMPONENT_ID -c laravel-app -- php artisan migrate --force
11bns exec COMPONENT_ID -c laravel-app -- php artisan config:cache

Remote Development and Debugging

Bunnyshell makes it easy to develop and debug directly against any environment — primary or ephemeral:

Port Forwarding

Connect your local tools to the remote database:

Bash
1# Forward MySQL to local port 13306
2bns port-forward 13306:3306 --component MYSQL_COMPONENT_ID
3
4# Connect with MySQL client, TablePlus, or any DB tool
5mysql -h 127.0.0.1 -P 13306 -u laravel -p laravel
6
7# Forward Redis to local port 16379
8bns port-forward 16379:6379 --component REDIS_COMPONENT_ID
9redis-cli -p 16379

Execute Laravel Commands

Bash
1# Migrations
2bns exec COMPONENT_ID -c laravel-app -- php artisan migrate --force
3bns exec COMPONENT_ID -c laravel-app -- php artisan migrate:status
4
5# Tinker (interactive REPL)
6bns exec COMPONENT_ID -c laravel-app -- php artisan tinker
7
8# Queue management
9bns exec COMPONENT_ID -c laravel-app -- php artisan queue:work --once
10bns exec COMPONENT_ID -c laravel-app -- php artisan horizon:status
11
12# Cache management
13bns exec COMPONENT_ID -c laravel-app -- php artisan cache:clear
14bns exec COMPONENT_ID -c laravel-app -- php artisan config:cache
15bns exec COMPONENT_ID -c laravel-app -- php artisan route:cache
16
17# Seed data
18bns exec COMPONENT_ID -c laravel-app -- php artisan db:seed
19bns exec COMPONENT_ID -c laravel-app -- php artisan db:seed --class=DemoSeeder
20
21# Storage link
22bns exec COMPONENT_ID -c laravel-app -- php artisan storage:link
23
24# Passport keys (if using Laravel Passport)
25bns exec COMPONENT_ID -c laravel-app -- php artisan passport:keys

Always use -c laravel-app to target the PHP-FPM container. Without -c, bns exec may connect to the Nginx sidecar, which doesn't have PHP installed.

Live Logs

Bash
1# Stream logs in real time
2bns logs --component COMPONENT_ID -f
3
4# Last 200 lines
5bns logs --component COMPONENT_ID --tail 200
6
7# Logs from the last 5 minutes
8bns logs --component COMPONENT_ID --since 5m

Live Code Sync

For active development, sync your local code changes to the remote container in real time:

Bash
1bns remote-development up --component COMPONENT_ID
2# Edit files locally — changes sync automatically
3# When done:
4bns remote-development down

This is especially useful for debugging issues that only reproduce in the Kubernetes environment — you get the fast feedback loop of local development with the infrastructure of production.


Advanced: Queue Workers and Horizon

For production-grade Laravel apps, you likely need queue workers. Add a worker component to your bunnyshell.yaml:

YAML
1  # ── Queue Worker ──
2  - kind: Service
3    name: queue-worker
4    gitRepo: 'https://github.com/your-org/your-laravel-repo.git'
5    gitBranch: main
6    gitApplicationPath: /
7    dockerCompose:
8      build:
9        context: .
10        dockerfile: Dockerfile
11      command: ['php', 'artisan', 'queue:work', '--sleep=3', '--tries=3', '--max-time=3600']
12      environment:
13        APP_KEY: '{{ env.vars.APP_KEY }}'
14        DB_CONNECTION: mysql
15        DB_HOST: mysql
16        DB_DATABASE: laravel
17        DB_USERNAME: laravel
18        DB_PASSWORD: '{{ env.vars.DB_PASSWORD }}'
19        CACHE_STORE: redis
20        SESSION_DRIVER: redis
21        QUEUE_CONNECTION: redis
22        REDIS_HOST: redis
23    dependsOn:
24      - mysql
25      - redis

For Laravel Horizon, replace the command:

YAML
command: ['php', 'artisan', 'horizon']

Advanced: Task Scheduling (Cron)

If your Laravel app uses Schedule (task scheduling), add a cron job to the PHP-FPM component:

YAML
1  - kind: Application
2    name: laravel-app
3    # ... existing config ...
4    cronJobs:
5      - name: laravel-scheduler
6        schedule: '* * * * *'
7        command: ['php', '/var/www/artisan', 'schedule:run']
8        containers: [laravel-app]

Troubleshooting

IssueSolution
502 Bad GatewayPHP-FPM isn't running, or Nginx can't reach it. Check that fastcgi_pass uses localhost:9000 (not a service name). Verify both ports (9000 and 8080) are declared on the Application component.
Mixed content / HTTPS errorsMissing trustProxies middleware. Add $middleware->trustProxies(at: '*') in bootstrap/app.php.
"The page has expired" (419 CSRF)APP_URL doesn't match the actual URL. Use interpolation: 'https://{{ components.laravel-app.ingress.hosts[0] }}'
Blank page / 500 errorCheck APP_KEY is set. Generate with php artisan key:generate --show and add as SECRET["base64:..."]. Also check storage/ permissions.
Static assets not loadingNginx sidecar doesn't have the Laravel files. Verify shared_paths with initial_contents: '@target' copies files from PHP-FPM to Nginx.
Session not persistingSESSION_DRIVER set to database but sessions table doesn't exist. Use redis or file instead.
Queue jobs not processingMissing queue worker component. Add a separate Service component with php artisan queue:work. Ensure Redis is reachable.
Migrations failCheck DB_HOST points to mysql (the component name), not localhost. Verify MySQL is running before running migrations.
Connection refused to MySQLMySQL container not ready yet. Wait for the environment status to show Running before executing commands.
Passport keys errorAuto-generate keys in entrypoint: php artisan passport:keys with file permissions 660 owned by www-data.
522 Connection timed outCluster may be behind a firewall. Verify Cloudflare IPs are whitelisted on the ingress controller.

What's Next?

  • Add Laravel Horizon — Monitor and manage your queue workers with a dedicated dashboard
  • Add Mailpit — Test email sending with a local SMTP server (axllent/mailpit as a Service component)
  • Seed test data — Run bns exec <ID> -c laravel-app -- php artisan db:seed --class=DemoSeeder post-deploy
  • Add MinIO — S3-compatible object storage for file uploads (minio/minio as a Service component)
  • Monitor with Telescope — Add TELESCOPE_ENABLED=true for request/query debugging in preview environments