Preview Environments with Helm: Deploy Multi-Service Apps Per PR with Bunnyshell
GuideMarch 20, 202613 min read

Preview Environments with Helm: Deploy Multi-Service Apps Per PR with Bunnyshell

Why Preview Environments with Helm?

Helm is the de facto package manager for Kubernetes. If your team already uses Helm charts to deploy to staging and production, you don't want to maintain a separate deployment mechanism for preview environments. You want the same charts, same values structure, same release process — just automated per pull request.

But Helm alone doesn't solve the orchestration problem. You still need something to:

  • Trigger a Helm install when a PR is opened
  • Inject dynamic values (hostnames, image tags, connection strings) per environment
  • Manage the full lifecycle — install, upgrade on push, uninstall on merge
  • Coordinate multiple releases (your app chart + database chart + cache chart) as a single environment

That's where Bunnyshell comes in. Bunnyshell's kind: Helm component type wraps Helm operations into a managed lifecycle. You define your charts, values overrides, and dependencies in bunnyshell.yaml, and Bunnyshell handles the rest — including building Docker images, injecting dynamic URLs, and cleaning up on PR close.

Bunnyshell supports Helm v3 natively. You define Helm components with deploy, destroy, start, and stop scripts that run helm install, helm uninstall, kubectl scale, etc. The runner image provides both helm and kubectl.

With Bunnyshell + Helm, you get:

  • Chart reuse — Same Helm charts for dev, staging, production, and preview environments
  • Values-driven configuration — Override only what differs per environment (hostnames, replicas, image tags)
  • Dependency coordination — Database charts install before app charts, automatically
  • Automatic cleanuphelm uninstall runs when the PR is merged or closed
  • Image building — Bunnyshell builds Docker images from your Dockerfile and passes the image reference to Helm values

How Bunnyshell Uses Helm Charts

When you define a kind: Helm component in bunnyshell.yaml, Bunnyshell:

  1. Spins up a runner pod with the specified runnerImage (typically dtzar/helm-kubectl)
  2. Clones your Git repository (if the chart is in Git)
  3. Executes the deploy script — your helm upgrade --install commands with values
  4. Captures exported variables — endpoints, hostnames, or any output you define
  5. On environment delete, executes the destroy script — helm uninstall

The runner has access to helm, kubectl, and the Kubernetes namespace for the environment. Bunnyshell provides a post-renderer that adds tracking labels to all Kubernetes resources so it can show logs, resource status, and manage lifecycle.

Component Lifecycle

Text
1Environment Deploy
2  └── Helm Component
3        ├── Clone git repo (if chart is in git)
4        ├── Generate values file from bunnyshell.yaml interpolation
5        ├── helm upgrade --install --namespace {{ env.k8s.namespace }}
6        │     --post-renderer /bns/helpers/helm/bns_post_renderer
7        └── Export variables (endpoints, etc.)
8
9Environment Delete
10  └── Helm Component
11        └── helm uninstall <release-name> --namespace {{ env.k8s.namespace }}

Always include --post-renderer /bns/helpers/helm/bns_post_renderer in your helm upgrade --install commands. Without it, Bunnyshell can't track the deployed resources — you won't see logs, pod status, or resource details in the UI.


Prerequisites

Before setting up Helm-based preview environments:

  • A Bunnyshell account with a connected Kubernetes cluster — sign up free
  • Helm 3 knowledge (chart structure, values, releases)
  • Helm charts — either from a public repository (Bitnami, etc.) or custom charts in your Git repo
  • A Dockerfile for your application (Bunnyshell builds the image and passes it to Helm)

Helm Runner Image

Bunnyshell Helm components use a Docker image as the runner environment. The standard choice is:

YAML
runnerImage: 'dtzar/helm-kubectl:3.14'

This image includes both helm (v3.14) and kubectl, pre-configured to talk to the environment's Kubernetes namespace. You can use any image that has helm and kubectl installed.


Approach A: Public Chart Repositories

The fastest way to add databases, caches, and other infrastructure to your preview environments is using public Helm charts. Bitnami maintains production-quality charts for PostgreSQL, MySQL, Redis, MongoDB, RabbitMQ, and dozens more.

Example: PostgreSQL via Bitnami Chart

YAML
1kind: Environment
2name: helm-preview
3type: primary
4
5environmentVariables:
6  DB_PASSWORD: SECRET["preview-db-password"]
7  DB_DATABASE: appdb
8  DB_USERNAME: appuser
9
10components:
11  # ── PostgreSQL via Bitnami Helm Chart ──
12  - kind: Helm
13    name: postgresql
14    runnerImage: 'dtzar/helm-kubectl:3.14'
15    deploy:
16      - |
17        cat << EOF > pg_values.yaml
18        global:
19          storageClass: bns-network-sc
20        auth:
21          postgresPassword: {{ env.vars.DB_PASSWORD }}
22          database: {{ env.vars.DB_DATABASE }}
23          username: {{ env.vars.DB_USERNAME }}
24          password: {{ env.vars.DB_PASSWORD }}
25        primary:
26          persistence:
27            size: 1Gi
28          resources:
29            requests:
30              memory: 128Mi
31              cpu: 100m
32            limits:
33              memory: 256Mi
34              cpu: 250m
35        EOF
36      - 'helm repo add bitnami https://charts.bitnami.com/bitnami'
37      - 'helm repo update'
38      - 'helm upgrade --install postgresql bitnami/postgresql
39          --version 14.0.5
40          --namespace {{ env.k8s.namespace }}
41          --post-renderer /bns/helpers/helm/bns_post_renderer
42          -f pg_values.yaml
43          --wait --timeout 5m'
44      - |
45        PG_HOST="postgresql.{{ env.k8s.namespace }}.svc.cluster.local"
46        PG_PORT="5432"
47    destroy:
48      - 'helm uninstall postgresql --namespace {{ env.k8s.namespace }}'
49    start:
50      - 'kubectl scale statefulset postgresql --replicas=1
51          --namespace {{ env.k8s.namespace }}'
52    stop:
53      - 'kubectl scale statefulset postgresql --replicas=0
54          --namespace {{ env.k8s.namespace }}'
55    exportVariables:
56      - PG_HOST
57      - PG_PORT
58
59  # ── Redis via Bitnami Helm Chart ──
60  - kind: Helm
61    name: redis
62    runnerImage: 'dtzar/helm-kubectl:3.14'
63    deploy:
64      - |
65        cat << EOF > redis_values.yaml
66        global:
67          storageClass: bns-network-sc
68        architecture: standalone
69        auth:
70          enabled: false
71        master:
72          persistence:
73            size: 512Mi
74          resources:
75            requests:
76              memory: 64Mi
77              cpu: 50m
78            limits:
79              memory: 128Mi
80              cpu: 100m
81        EOF
82      - 'helm repo add bitnami https://charts.bitnami.com/bitnami'
83      - 'helm upgrade --install redis bitnami/redis
84          --version 18.6.1
85          --namespace {{ env.k8s.namespace }}
86          --post-renderer /bns/helpers/helm/bns_post_renderer
87          -f redis_values.yaml
88          --wait --timeout 5m'
89      - |
90        REDIS_HOST="redis-master.{{ env.k8s.namespace }}.svc.cluster.local"
91        REDIS_PORT="6379"
92    destroy:
93      - 'helm uninstall redis --namespace {{ env.k8s.namespace }}'
94    start:
95      - 'kubectl scale statefulset redis-master --replicas=1
96          --namespace {{ env.k8s.namespace }}'
97    stop:
98      - 'kubectl scale statefulset redis-master --replicas=0
99          --namespace {{ env.k8s.namespace }}'
100    exportVariables:
101      - REDIS_HOST
102      - REDIS_PORT

Always pin chart versions (e.g., --version 14.0.5) in your Bunnyshell configuration. Unpinned versions will install the latest chart on each deploy, which can introduce breaking changes in preview environments.

Key Details for Public Charts

  • Storage class: Use bns-network-sc (Bunnyshell's default) or your cluster's storage class for persistent volumes
  • Wait flag: --wait --timeout 5m ensures the Helm component reports success only after all pods are ready
  • Service DNS: The in-cluster DNS name follows the pattern <release-name>.<namespace>.svc.cluster.local
  • Resource limits: Set modest limits for preview environments — you don't need production-level resources for PR reviews

Approach B: Custom Helm Charts from Git

For your application code, you'll typically have custom Helm charts stored in the same Git repository. Bunnyshell clones the repo and runs helm install against the local chart path.

Chart Structure

Text
1your-repo/
2├── src/                    # Application source code
3├── Dockerfile
4└── helm/
5    └── app/
6        ├── Chart.yaml
7        ├── values.yaml
8        └── templates/
9            ├── _helpers.tpl
10            ├── deployment.yaml
11            ├── service.yaml
12            ├── ingress.yaml
13            └── hpa.yaml

Chart.yaml

YAML
1apiVersion: v2
2name: my-app
3description: A Helm chart for the application
4type: application
5version: 0.1.0
6appVersion: "1.0.0"

values.yaml

YAML
1replicaCount: 1
2
3image:
4  repository: ""
5  tag: "latest"
6  pullPolicy: IfNotPresent
7
8service:
9  type: ClusterIP
10  port: 3000
11
12ingress:
13  enabled: true
14  className: bns-nginx
15  host: ""
16  tls: true
17
18env:
19  APP_ENV: production
20  DATABASE_URL: ""
21  REDIS_URL: ""
22  S3_BUCKET: ""
23
24resources:
25  requests:
26    memory: 128Mi
27    cpu: 100m
28  limits:
29    memory: 512Mi
30    cpu: 500m
31
32autoscaling:
33  enabled: false
34  minReplicas: 1
35  maxReplicas: 3
36  targetCPUUtilization: 80

Deployment Template

YAML
1# helm/app/templates/deployment.yaml
2apiVersion: apps/v1
3kind: Deployment
4metadata:
5  name: {{ include "my-app.fullname" . }}
6  labels:
7    {{- include "my-app.labels" . | nindent 4 }}
8spec:
9  replicas: {{ .Values.replicaCount }}
10  selector:
11    matchLabels:
12      {{- include "my-app.selectorLabels" . | nindent 6 }}
13  template:
14    metadata:
15      labels:
16        {{- include "my-app.selectorLabels" . | nindent 8 }}
17    spec:
18      containers:
19        - name: {{ .Chart.Name }}
20          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
21          imagePullPolicy: {{ .Values.image.pullPolicy }}
22          ports:
23            - name: http
24              containerPort: {{ .Values.service.port }}
25          env:
26            {{- range $key, $value := .Values.env }}
27            - name: {{ $key }}
28              value: {{ $value | quote }}
29            {{- end }}
30          resources:
31            {{- toYaml .Values.resources | nindent 12 }}
32          livenessProbe:
33            httpGet:
34              path: /health
35              port: http
36            initialDelaySeconds: 15
37            periodSeconds: 10
38          readinessProbe:
39            httpGet:
40              path: /health
41              port: http
42            initialDelaySeconds: 5
43            periodSeconds: 5

Bunnyshell Configuration with Custom Chart

YAML
1components:
2  # ── Build Docker Image ──
3  - kind: DockerImage
4    name: app-image
5    context: /
6    dockerfile: Dockerfile
7    gitRepo: 'https://github.com/your-org/your-repo.git'
8    gitBranch: main
9    gitApplicationPath: /
10
11  # ── Deploy App via Custom Helm Chart ──
12  - kind: Helm
13    name: app
14    runnerImage: 'dtzar/helm-kubectl:3.14'
15    deploy:
16      - |
17        cat << EOF > app_values.yaml
18        replicaCount: 1
19        image:
20          repository: {{ components.app-image.image }}
21          tag: latest
22          pullPolicy: Always
23        service:
24          type: ClusterIP
25          port: 3000
26        ingress:
27          enabled: true
28          className: bns-nginx
29          host: app-{{ env.base_domain }}
30          tls: true
31        env:
32          APP_ENV: production
33          APP_URL: "https://app-{{ env.base_domain }}"
34          DATABASE_URL: "postgres://{{ env.vars.DB_USERNAME }}:{{ env.vars.DB_PASSWORD }}@{{ components.postgresql.exported.PG_HOST }}:{{ components.postgresql.exported.PG_PORT }}/{{ env.vars.DB_DATABASE }}"
35          REDIS_URL: "redis://{{ components.redis.exported.REDIS_HOST }}:{{ components.redis.exported.REDIS_PORT }}"
36        resources:
37          requests:
38            memory: 128Mi
39            cpu: 100m
40          limits:
41            memory: 512Mi
42            cpu: 500m
43        EOF
44      - 'helm upgrade --install app-{{ env.unique }}
45          ./helm/app
46          --namespace {{ env.k8s.namespace }}
47          --post-renderer /bns/helpers/helm/bns_post_renderer
48          -f app_values.yaml
49          --wait --timeout 5m'
50    destroy:
51      - 'helm uninstall app-{{ env.unique }}
52          --namespace {{ env.k8s.namespace }}'
53    start:
54      - 'helm upgrade app-{{ env.unique }}
55          ./helm/app
56          --namespace {{ env.k8s.namespace }}
57          --post-renderer /bns/helpers/helm/bns_post_renderer
58          --reuse-values --set replicaCount=1'
59    stop:
60      - 'helm upgrade app-{{ env.unique }}
61          ./helm/app
62          --namespace {{ env.k8s.namespace }}
63          --post-renderer /bns/helpers/helm/bns_post_renderer
64          --reuse-values --set replicaCount=0'
65    gitRepo: 'https://github.com/your-org/your-repo.git'
66    gitBranch: main
67    gitApplicationPath: /helm/app
68    dependsOn:
69      - app-image
70      - postgresql
71      - redis
72    hosts:
73      - hostname: 'app-{{ env.base_domain }}'
74        path: /
75        servicePort: 3000

The {{ components.app-image.image }} interpolation resolves to the full Docker image reference (registry/repo:tag) that Bunnyshell built from your Dockerfile. This is how built images flow into Helm values without any manual image tagging.

Release Naming

Notice the release name uses {{ env.unique }}:

YAML
'helm upgrade --install app-{{ env.unique }} ./helm/app ...'

This ensures each preview environment gets a unique Helm release name. Without it, two preview environments would try to manage the same release, causing conflicts. The pattern app-{{ env.unique }} produces names like app-env-abc123, app-env-def456, etc.


Approach C: Umbrella Charts for Multi-Service Apps

For applications with multiple microservices, an umbrella chart bundles everything into a single Helm release. This simplifies the Bunnyshell configuration — one Helm component instead of many.

Umbrella Chart Structure

Text
1helm/
2└── umbrella/
3    ├── Chart.yaml
4    ├── values.yaml
5    └── charts/
6        ├── api/
7        │   ├── Chart.yaml
8        │   ├── values.yaml
9        │   └── templates/
10        │       ├── deployment.yaml
11        │       ├── service.yaml
12        │       └── ingress.yaml
13        ├── frontend/
14        │   ├── Chart.yaml
15        │   ├── values.yaml
16        │   └── templates/
17        │       ├── deployment.yaml
18        │       ├── service.yaml
19        │       └── ingress.yaml
20        └── worker/
21            ├── Chart.yaml
22            ├── values.yaml
23            └── templates/
24                └── deployment.yaml

Umbrella Chart.yaml

YAML
1apiVersion: v2
2name: my-platform
3description: Umbrella chart for the full platform
4type: application
5version: 0.1.0
6
7dependencies:
8  - name: api
9    version: "0.1.0"
10    condition: api.enabled
11  - name: frontend
12    version: "0.1.0"
13    condition: frontend.enabled
14  - name: worker
15    version: "0.1.0"
16    condition: worker.enabled

Umbrella values.yaml

YAML
1# Global values shared across all subcharts
2global:
3  domain: ""
4  env: production
5
6api:
7  enabled: true
8  replicaCount: 1
9  image:
10    repository: ""
11    tag: latest
12  service:
13    port: 3000
14  ingress:
15    enabled: true
16    host: ""
17  env:
18    DATABASE_URL: ""
19    REDIS_URL: ""
20
21frontend:
22  enabled: true
23  replicaCount: 1
24  image:
25    repository: ""
26    tag: latest
27  service:
28    port: 8080
29  ingress:
30    enabled: true
31    host: ""
32  env:
33    API_URL: ""
34
35worker:
36  enabled: true
37  replicaCount: 1
38  image:
39    repository: ""
40    tag: latest
41  env:
42    DATABASE_URL: ""
43    REDIS_URL: ""
44    QUEUE_NAME: "default"

Bunnyshell Configuration with Umbrella Chart

YAML
1kind: Environment
2name: helm-umbrella-preview
3type: primary
4
5environmentVariables:
6  DB_PASSWORD: SECRET["preview-db-password"]
7  DB_DATABASE: appdb
8  DB_USERNAME: appuser
9
10components:
11  # ── Build Images ──
12  - kind: DockerImage
13    name: api-image
14    context: /services/api
15    dockerfile: services/api/Dockerfile
16    gitRepo: 'https://github.com/your-org/your-repo.git'
17    gitBranch: main
18    gitApplicationPath: /services/api
19
20  - kind: DockerImage
21    name: frontend-image
22    context: /services/frontend
23    dockerfile: services/frontend/Dockerfile
24    gitRepo: 'https://github.com/your-org/your-repo.git'
25    gitBranch: main
26    gitApplicationPath: /services/frontend
27
28  - kind: DockerImage
29    name: worker-image
30    context: /services/worker
31    dockerfile: services/worker/Dockerfile
32    gitRepo: 'https://github.com/your-org/your-repo.git'
33    gitBranch: main
34    gitApplicationPath: /services/worker
35
36  # ── PostgreSQL (Bitnami) ──
37  - kind: Helm
38    name: postgresql
39    runnerImage: 'dtzar/helm-kubectl:3.14'
40    deploy:
41      - |
42        cat << EOF > pg_values.yaml
43        global:
44          storageClass: bns-network-sc
45        auth:
46          postgresPassword: {{ env.vars.DB_PASSWORD }}
47          database: {{ env.vars.DB_DATABASE }}
48          username: {{ env.vars.DB_USERNAME }}
49          password: {{ env.vars.DB_PASSWORD }}
50        primary:
51          persistence:
52            size: 1Gi
53        EOF
54      - 'helm repo add bitnami https://charts.bitnami.com/bitnami'
55      - 'helm upgrade --install postgresql bitnami/postgresql
56          --version 14.0.5
57          --namespace {{ env.k8s.namespace }}
58          --post-renderer /bns/helpers/helm/bns_post_renderer
59          -f pg_values.yaml
60          --wait --timeout 5m'
61      - |
62        PG_HOST="postgresql.{{ env.k8s.namespace }}.svc.cluster.local"
63    destroy:
64      - 'helm uninstall postgresql --namespace {{ env.k8s.namespace }}'
65    start:
66      - 'kubectl scale statefulset postgresql --replicas=1
67          --namespace {{ env.k8s.namespace }}'
68    stop:
69      - 'kubectl scale statefulset postgresql --replicas=0
70          --namespace {{ env.k8s.namespace }}'
71    exportVariables:
72      - PG_HOST
73
74  # ── Redis (Bitnami) ──
75  - kind: Helm
76    name: redis
77    runnerImage: 'dtzar/helm-kubectl:3.14'
78    deploy:
79      - |
80        cat << EOF > redis_values.yaml
81        global:
82          storageClass: bns-network-sc
83        architecture: standalone
84        auth:
85          enabled: false
86        master:
87          persistence:
88            size: 512Mi
89        EOF
90      - 'helm repo add bitnami https://charts.bitnami.com/bitnami'
91      - 'helm upgrade --install redis bitnami/redis
92          --version 18.6.1
93          --namespace {{ env.k8s.namespace }}
94          --post-renderer /bns/helpers/helm/bns_post_renderer
95          -f redis_values.yaml
96          --wait --timeout 5m'
97      - |
98        REDIS_HOST="redis-master.{{ env.k8s.namespace }}.svc.cluster.local"
99    destroy:
100      - 'helm uninstall redis --namespace {{ env.k8s.namespace }}'
101    start:
102      - 'kubectl scale statefulset redis-master --replicas=1
103          --namespace {{ env.k8s.namespace }}'
104    stop:
105      - 'kubectl scale statefulset redis-master --replicas=0
106          --namespace {{ env.k8s.namespace }}'
107    exportVariables:
108      - REDIS_HOST
109
110  # ── Platform Umbrella Chart ──
111  - kind: Helm
112    name: platform
113    runnerImage: 'dtzar/helm-kubectl:3.14'
114    deploy:
115      - |
116        cat << EOF > platform_values.yaml
117        global:
118          domain: {{ env.base_domain }}
119          env: preview
120
121        api:
122          enabled: true
123          replicaCount: 1
124          image:
125            repository: {{ components.api-image.image }}
126            tag: latest
127            pullPolicy: Always
128          service:
129            port: 3000
130          ingress:
131            enabled: true
132            host: api-{{ env.base_domain }}
133          env:
134            DATABASE_URL: "postgres://{{ env.vars.DB_USERNAME }}:{{ env.vars.DB_PASSWORD }}@{{ components.postgresql.exported.PG_HOST }}:5432/{{ env.vars.DB_DATABASE }}"
135            REDIS_URL: "redis://{{ components.redis.exported.REDIS_HOST }}:6379"
136
137        frontend:
138          enabled: true
139          replicaCount: 1
140          image:
141            repository: {{ components.frontend-image.image }}
142            tag: latest
143            pullPolicy: Always
144          service:
145            port: 8080
146          ingress:
147            enabled: true
148            host: app-{{ env.base_domain }}
149          env:
150            API_URL: "https://api-{{ env.base_domain }}"
151
152        worker:
153          enabled: true
154          replicaCount: 1
155          image:
156            repository: {{ components.worker-image.image }}
157            tag: latest
158            pullPolicy: Always
159          env:
160            DATABASE_URL: "postgres://{{ env.vars.DB_USERNAME }}:{{ env.vars.DB_PASSWORD }}@{{ components.postgresql.exported.PG_HOST }}:5432/{{ env.vars.DB_DATABASE }}"
161            REDIS_URL: "redis://{{ components.redis.exported.REDIS_HOST }}:6379"
162            QUEUE_NAME: "default"
163        EOF
164      - 'helm dependency update ./helm/umbrella'
165      - 'helm upgrade --install platform-{{ env.unique }}
166          ./helm/umbrella
167          --namespace {{ env.k8s.namespace }}
168          --post-renderer /bns/helpers/helm/bns_post_renderer
169          -f platform_values.yaml
170          --wait --timeout 10m'
171    destroy:
172      - 'helm uninstall platform-{{ env.unique }}
173          --namespace {{ env.k8s.namespace }}'
174    start:
175      - 'helm upgrade platform-{{ env.unique }}
176          ./helm/umbrella
177          --namespace {{ env.k8s.namespace }}
178          --post-renderer /bns/helpers/helm/bns_post_renderer
179          --reuse-values
180          --set api.replicaCount=1
181          --set frontend.replicaCount=1
182          --set worker.replicaCount=1'
183    stop:
184      - 'helm upgrade platform-{{ env.unique }}
185          ./helm/umbrella
186          --namespace {{ env.k8s.namespace }}
187          --post-renderer /bns/helpers/helm/bns_post_renderer
188          --reuse-values
189          --set api.replicaCount=0
190          --set frontend.replicaCount=0
191          --set worker.replicaCount=0'
192    gitRepo: 'https://github.com/your-org/your-repo.git'
193    gitBranch: main
194    gitApplicationPath: /helm/umbrella
195    dependsOn:
196      - api-image
197      - frontend-image
198      - worker-image
199      - postgresql
200      - redis
201    hosts:
202      - hostname: 'api-{{ env.base_domain }}'
203        path: /
204        servicePort: 3000
205      - hostname: 'app-{{ env.base_domain }}'
206        path: /
207        servicePort: 8080

Umbrella charts are ideal when your services are tightly coupled and always deployed together. If services are independently deployable (different release cadences), use separate Helm components instead — Bunnyshell will coordinate them through the dependency graph.


Values Overrides in bunnyshell.yaml

The pattern for injecting Bunnyshell-specific values into Helm charts is consistent across all approaches:

1. Generate a Values File with Interpolation

YAML
1deploy:
2  - |
3    cat << EOF > values.yaml
4    image:
5      repository: {{ components.app-image.image }}
6    ingress:
7      host: app-{{ env.base_domain }}
8    env:
9      DATABASE_URL: "postgres://{{ env.vars.DB_USERNAME }}:{{ env.vars.DB_PASSWORD }}@{{ components.postgresql.exported.PG_HOST }}:5432/{{ env.vars.DB_DATABASE }}"
10    EOF
11  - 'helm upgrade --install ... -f values.yaml'

2. Use --set for Simple Overrides

YAML
1deploy:
2  - 'helm upgrade --install myapp ./helm/app
3      --set image.repository={{ components.app-image.image }}
4      --set ingress.host=app-{{ env.base_domain }}'

3. Combine Multiple Values Files

YAML
1deploy:
2  - 'helm upgrade --install myapp ./helm/app
3      -f ./helm/app/values.yaml
4      -f ./helm/app/values-preview.yaml
5      -f generated_values.yaml'

Helm merges values files left to right — later files override earlier ones. A common pattern is:

  • values.yaml — defaults for all environments
  • values-preview.yaml — preview-specific overrides (lower replicas, debug enabled)
  • generated_values.yaml — Bunnyshell-generated dynamic values (images, URLs)

When using --set with values that contain special characters (dots, commas, slashes), use --set-string instead. Helm's --set parser interprets dots as nested keys, which can produce unexpected results with URLs or connection strings.


Chart Dependencies and Subcharts

Helm's dependency management lets you declare infrastructure requirements in Chart.yaml:

YAML
1# helm/app/Chart.yaml
2apiVersion: v2
3name: my-app
4version: 0.1.0
5
6dependencies:
7  - name: postgresql
8    version: "14.0.5"
9    repository: "https://charts.bitnami.com/bitnami"
10    condition: postgresql.enabled
11  - name: redis
12    version: "18.6.1"
13    repository: "https://charts.bitnami.com/bitnami"
14    condition: redis.enabled

When to Use Chart Dependencies vs. Separate Bunnyshell Components

Use chart dependencies when...Use separate Bunnyshell components when...
Database is always co-deployed with the appDatabase is shared across multiple apps
You want a single helm install for everythingYou need independent lifecycle control
Chart versions are locked and rarely changeDifferent teams manage different services
Simple single-service applicationsComplex multi-service architectures

For preview environments, separate Bunnyshell components are usually better because:

  • You get individual deploy/destroy status per component
  • Failures are isolated — a database issue doesn't block app image build
  • Exported variables create clear data flow between components
  • Start/stop can be granular (stop the app but keep the database)

If you do use chart dependencies, make sure to run helm dependency update before install:

YAML
1deploy:
2  - 'helm dependency update ./helm/app'
3  - 'helm upgrade --install ...'

Helm Hooks and Post-Install Jobs

Helm hooks let you run tasks at specific points in the release lifecycle — database migrations, cache warming, seed data loading.

Migration Job Hook

YAML
1# helm/app/templates/migration-job.yaml
2apiVersion: batch/v1
3kind: Job
4metadata:
5  name: {{ include "my-app.fullname" . }}-migrate
6  annotations:
7    "helm.sh/hook": post-install,post-upgrade
8    "helm.sh/hook-weight": "0"
9    "helm.sh/hook-delete-policy": hook-succeeded
10spec:
11  backoffLimit: 3
12  template:
13    spec:
14      restartPolicy: Never
15      containers:
16        - name: migrate
17          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
18          command: ["./migrate.sh"]
19          env:
20            {{- range $key, $value := .Values.env }}
21            - name: {{ $key }}
22              value: {{ $value | quote }}
23            {{- end }}

This Job runs after helm install (post-install) and after helm upgrade (post-upgrade). It uses the same Docker image and environment variables as the main application, ensuring migrations run against the correct database.

Seed Data Hook (Preview Only)

For preview environments, you might want to seed demo data:

YAML
1# helm/app/templates/seed-job.yaml
2{{- if eq .Values.global.env "preview" }}
3apiVersion: batch/v1
4kind: Job
5metadata:
6  name: {{ include "my-app.fullname" . }}-seed
7  annotations:
8    "helm.sh/hook": post-install
9    "helm.sh/hook-weight": "1"
10    "helm.sh/hook-delete-policy": hook-succeeded
11spec:
12  backoffLimit: 1
13  template:
14    spec:
15      restartPolicy: Never
16      containers:
17        - name: seed
18          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
19          command: ["./seed.sh"]
20          env:
21            {{- range $key, $value := .Values.env }}
22            - name: {{ $key }}
23              value: {{ $value | quote }}
24            {{- end }}
25{{- end }}

The {{- if eq .Values.global.env "preview" }} conditional ensures this Job only runs in preview environments. Set global.env: preview in your Bunnyshell values override to activate it.

Helm hooks with hook-delete-policy: hook-succeeded automatically clean up completed Jobs, keeping your namespace tidy. Use hook-succeeded,hook-failed to clean up regardless of outcome (useful for preview environments where you don't need to debug failed seed jobs).

Alternative: Run Migrations from Deploy Script

If you prefer running migrations outside of Helm hooks, use the Bunnyshell deploy script:

YAML
1deploy:
2  - 'helm upgrade --install ... --wait --timeout 5m'
3  - 'kubectl exec -n {{ env.k8s.namespace }}
4      deploy/app-{{ env.unique }}-my-app
5      -- ./migrate.sh'

This runs after the Helm release is healthy, giving you more control and better error visibility in the Bunnyshell logs.


Enabling Preview Environments

Once your primary environment is deployed and running with Helm components:

  1. Ensure your environment status shows Running or Stopped
  2. Go to Settings in the environment view
  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

The PR workflow:

  1. Developer opens a PR
  2. Bunnyshell receives the webhook, clones the environment config
  3. DockerImage components build new images from the PR branch
  4. Helm components deploy charts with the new images and unique release names
  5. Bunnyshell posts a comment on the PR with live URLs
  6. Developer pushes more commits — Bunnyshell redeploys with updated code
  7. PR is merged — Bunnyshell runs helm uninstall for all components

No GitHub Actions. No GitLab CI. Bunnyshell manages the webhooks and the full lifecycle.

How Image Tags Work in Preview Environments

When a preview environment is created from a PR:

  • DockerImage components build from the PR's branch (not main)
  • The built image gets a unique tag tied to the environment
  • {{ components.app-image.image }} resolves to the newly built image
  • Helm values automatically reference the correct image for the PR

This means every PR gets images built from its own code, injected into Helm charts that deploy to an isolated namespace. Full isolation, zero configuration per PR.


Debugging Helm Releases in Preview Envs

When things go wrong in a preview environment, here's how to diagnose:

Check Release Status

Bash
1# List all releases in the environment namespace
2bns exec HELM_COMPONENT_ID -- helm list --namespace NAMESPACE
3
4# Get detailed release info
5bns exec HELM_COMPONENT_ID -- helm status RELEASE_NAME --namespace NAMESPACE
6
7# See what values were applied
8bns exec HELM_COMPONENT_ID -- helm get values RELEASE_NAME --namespace NAMESPACE
9
10# View the rendered manifests
11bns exec HELM_COMPONENT_ID -- helm get manifest RELEASE_NAME --namespace NAMESPACE

Check Kubernetes Resources

Bash
1# Pods and their status
2bns exec HELM_COMPONENT_ID -- kubectl get pods --namespace NAMESPACE
3
4# Describe a failing pod
5bns exec HELM_COMPONENT_ID -- kubectl describe pod POD_NAME --namespace NAMESPACE
6
7# Pod logs
8bns exec HELM_COMPONENT_ID -- kubectl logs POD_NAME --namespace NAMESPACE
9
10# Events (sorted by time)
11bns exec HELM_COMPONENT_ID -- kubectl get events --sort-by='.lastTimestamp' --namespace NAMESPACE

Dry Run Before Deploy

To test your Helm values without actually deploying, add a dry-run step:

YAML
1deploy:
2  - 'helm upgrade --install app-{{ env.unique }}
3      ./helm/app
4      --namespace {{ env.k8s.namespace }}
5      -f app_values.yaml
6      --dry-run --debug'
7  - 'helm upgrade --install app-{{ env.unique }}
8      ./helm/app
9      --namespace {{ env.k8s.namespace }}
10      --post-renderer /bns/helpers/helm/bns_post_renderer
11      -f app_values.yaml
12      --wait --timeout 5m'

The first command renders templates and validates without applying. The second does the actual install. If the dry run fails, the deploy stops before creating any resources.

The --dry-run command should NOT include the --post-renderer flag. The post-renderer modifies resources for Bunnyshell tracking and should only run during actual deployment.


Troubleshooting

IssueSolution
"release not found" on upgradeThe release name changed between deploys. Ensure you use {{ env.unique }} consistently in release names across deploy, destroy, start, and stop scripts.
Pods stuck in ImagePullBackOffThe Docker image wasn't built or the registry is unreachable. Check that the DockerImage component completed successfully and {{ components.app-image.image }} resolves to a valid reference.
"timed out waiting for the condition"Pods aren't becoming ready within the --timeout period. Check readiness probes, resource limits, and startup time. Increase timeout if the app needs more time.
Helm values not appliedValues files are merged left to right. Check the order of -f flags. Later files override earlier ones. Use helm get values RELEASE to verify what was actually applied.
"cannot re-use a name that is still in use"A previous deploy left a failed release. Run helm uninstall RELEASE --namespace NS manually, then redeploy.
Ingress not working / 404Check that ingress.className matches your cluster's ingress controller (usually bns-nginx for Bunnyshell). Verify the host value matches the hostname in hosts on the Bunnyshell component.
PVC stuck in PendingStorage class doesn't exist or has no available provisioner. Use bns-network-sc for Bunnyshell clusters or check kubectl get sc.
Hook job failsCheck job logs: kubectl logs job/RELEASE-migrate -n NAMESPACE. Common issues: migration syntax errors, database not reachable (dependency not ready), or incorrect DATABASE_URL.
Resources not tracked in Bunnyshell UIMissing --post-renderer /bns/helpers/helm/bns_post_renderer. Add it to all helm upgrade --install commands.
"Error: UPGRADE FAILED: another operation is in progress"A previous Helm operation didn't finish cleanly. Run helm rollback RELEASE 0 --namespace NS to reset, then retry.

What's Next?

  • Add Prometheus monitoring — Deploy kube-prometheus-stack as a Helm component for per-environment metrics
  • Use OCI registries for charts — Push your custom charts to an OCI-compatible registry (ECR, GCR, ACR) for versioned chart management
  • Implement chart testing — Add helm lint and helm template to your CI pipeline to catch errors before preview deployment
  • Add Ingress annotations — Configure rate limiting, CORS, or authentication per preview environment using Helm values
  • Combine with Terraform — Use Terraform components for managed infrastructure (RDS, ElastiCache) alongside Helm for application deployment

Ship faster starting today.

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