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

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

Why Preview Environments for Angular?

Angular apps are deceptively complex to deploy. A single-page application looks simple — build it, serve the files — but then you hit SPA routing issues behind a reverse proxy, environment-specific API URLs baked into the production build, and Angular 17+ SSR that needs a Node server instead of static files. On a shared staging server, these problems compound.

One developer is testing the new standalone component architecture. Another pushed Angular Universal SSR changes that switch the entire deployment model. Someone updated environment.prod.ts with an API URL that only works for their feature branch. The QA team can't tell which PR broke the staging deploy because three branches landed in the last hour.

Preview environments solve this. Every pull request gets its own isolated deployment — whether that's a static SPA served by Nginx or an SSR app running on Node — 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 build configuration, same API backends
  • 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 Angular. 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 Angular App

Regardless of which approach you choose, your Angular app needs a proper Docker setup. Angular has two deployment modes, and we'll cover both.

Understanding Angular's Deployment Modes

Angular apps can be deployed in two ways:

  • Static SPA mode: ng build --configuration production outputs static HTML/CSS/JS to dist/your-app/browser/. Served by Nginx. No Node runtime needed in production. This is the default for most Angular apps.
  • SSR mode (Angular 17+ with @angular/ssr): Runs a Node.js server that renders pages on the server. Outputs to dist/your-app/server/server.mjs. Needed for SEO-critical pages, faster first contentful paint, and server-side data fetching.

Never use ng serve in production. It's a development server with no security hardening, no caching, and no compression. For production: use Nginx for static SPA, or node dist/your-app/server/server.mjs for SSR.

Option 1: Dockerfile for Static SPA (Nginx)

The most common deployment — build the Angular app and serve it with Nginx:

Dockerfile
1# ── Stage 1: Build ──
2FROM node:20-alpine AS builder
3
4WORKDIR /app
5
6COPY package.json package-lock.json ./
7RUN npm ci
8
9COPY . .
10
11RUN npx ng build --configuration production
12
13# ── Stage 2: Serve ──
14FROM nginx:1.25-alpine
15
16COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf
17COPY --from=builder /app/dist/*/browser /usr/share/nginx/html
18
19EXPOSE 8080
20
21CMD ["nginx", "-g", "daemon off;"]

Create docker/nginx/default.conf:

Nginx
1server {
2    listen 8080;
3    server_name _;
4    root /usr/share/nginx/html;
5    index index.html;
6
7    # SPA routing — all routes fall back to index.html
8    location / {
9        try_files $uri $uri/ /index.html;
10    }
11
12    # Cache static assets aggressively
13    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
14        expires 1y;
15        add_header Cache-Control "public, immutable";
16    }
17
18    # Security headers
19    add_header X-Frame-Options "SAMEORIGIN" always;
20    add_header X-Content-Type-Options "nosniff" always;
21
22    # Gzip compression
23    gzip on;
24    gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript;
25    gzip_min_length 1000;
26}

The try_files $uri $uri/ /index.html; directive is critical for Angular SPA routing. Without it, refreshing any route other than / returns a 404 because Nginx looks for a file matching the URL path. This directive falls back to index.html and lets Angular's router handle the URL client-side.

Option 2: Dockerfile for SSR Mode (Node)

For Angular 17+ apps with @angular/ssr:

Bash
ng add @angular/ssr

Then the Dockerfile:

Dockerfile
1# ── Stage 1: Build ──
2FROM node:20-alpine AS builder
3
4WORKDIR /app
5
6COPY package.json package-lock.json ./
7RUN npm ci
8
9COPY . .
10
11RUN npx ng build --configuration production
12
13# ── Stage 2: Run SSR Server ──
14FROM node:20-alpine
15
16WORKDIR /app
17
18COPY --from=builder /app/dist ./dist
19COPY --from=builder /app/node_modules ./node_modules
20COPY --from=builder /app/package.json ./
21
22ENV PORT=4000
23
24EXPOSE 4000
25
26CMD ["node", "dist/*/server/server.mjs"]

The SSR server entry point path depends on your Angular project name. If your project is called my-app, the path is dist/my-app/server/server.mjs. The wildcard dist/*/server/server.mjs works in Dockerfile CMD but may not work in all shells. Verify the exact path in your dist/ output after building.

Environment Configuration for Preview Environments

Angular's traditional environment.ts / environment.prod.ts approach bakes API URLs into the build at compile time. This is a problem for preview environments because each environment has a unique URL that isn't known until deployment.

Solution: Runtime configuration via an /app-config endpoint or window.__env.

Approach 1: Config endpoint served by Nginx

Create docker/nginx/app-config.json.template:

JSON
1{
2  "apiUrl": "${API_URL}",
3  "authUrl": "${AUTH_URL}",
4  "environment": "preview"
5}

Update docker/nginx/default.conf to serve it:

Nginx
1server {
2    listen 8080;
3    server_name _;
4    root /usr/share/nginx/html;
5    index index.html;
6
7    # Runtime config endpoint — values injected at container startup
8    location /app-config {
9        alias /usr/share/nginx/html/app-config.json;
10        add_header Cache-Control "no-cache";
11        default_type application/json;
12    }
13
14    location / {
15        try_files $uri $uri/ /index.html;
16    }
17
18    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
19        expires 1y;
20        add_header Cache-Control "public, immutable";
21    }
22
23    gzip on;
24    gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript;
25    gzip_min_length 1000;
26}

Update the Dockerfile to use envsubst at startup:

Dockerfile
1FROM nginx:1.25-alpine
2
3COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf
4COPY docker/nginx/app-config.json.template /etc/nginx/templates/app-config.json.template
5COPY --from=builder /app/dist/*/browser /usr/share/nginx/html
6
7EXPOSE 8080
8
9CMD ["/bin/sh", "-c", "envsubst < /etc/nginx/templates/app-config.json.template > /usr/share/nginx/html/app-config.json && nginx -g 'daemon off;'"]

Then in your Angular app, create a config service:

TypeScript
1// src/app/config.service.ts
2import { Injectable } from '@angular/core';
3import { HttpClient } from '@angular/common/http';
4import { firstValueFrom } from 'rxjs';
5
6export interface AppConfig {
7  apiUrl: string;
8  authUrl: string;
9  environment: string;
10}
11
12@Injectable({ providedIn: 'root' })
13export class ConfigService {
14  private config!: AppConfig;
15
16  constructor(private http: HttpClient) {}
17
18  async load(): Promise<void> {
19    this.config = await firstValueFrom(
20      this.http.get<AppConfig>('/app-config')
21    );
22  }
23
24  get apiUrl(): string {
25    return this.config.apiUrl;
26  }
27
28  get authUrl(): string {
29    return this.config.authUrl;
30  }
31}

Initialize it in app.config.ts:

TypeScript
1// src/app/app.config.ts
2import { APP_INITIALIZER, ApplicationConfig } from '@angular/core';
3import { provideHttpClient } from '@angular/common/http';
4import { ConfigService } from './config.service';
5
6function initializeApp(configService: ConfigService) {
7  return () => configService.load();
8}
9
10export const appConfig: ApplicationConfig = {
11  providers: [
12    provideHttpClient(),
13    {
14      provide: APP_INITIALIZER,
15      useFactory: initializeApp,
16      deps: [ConfigService],
17      multi: true,
18    },
19  ],
20};

Approach 2: window.__env injection

A simpler alternative — inject configuration via a script tag in index.html:

Create docker/env.js.template:

JavaScript
1window.__env = {
2  apiUrl: "${API_URL}",
3  authUrl: "${AUTH_URL}",
4  environment: "preview"
5};

Add to your Dockerfile CMD:

Dockerfile
CMD ["/bin/sh", "-c", "envsubst < /etc/nginx/templates/env.js.template > /usr/share/nginx/html/env.js && nginx -g 'daemon off;'"]

Add the script to src/index.html (before the Angular bundle):

HTML
<script src="/env.js"></script>

Then access it anywhere:

TypeScript
const apiUrl = (window as any).__env?.apiUrl || 'http://localhost:3000';

The window.__env approach is simpler but less type-safe. The /app-config endpoint approach integrates better with Angular's dependency injection and APP_INITIALIZER pattern. Choose based on your team's preference — both work equally well in preview environments.

Angular Deployment Checklist

  • Decided on deployment mode: static SPA (Nginx) or SSR (Node)
  • ng build --configuration production used — never ng serve
  • Nginx config includes try_files $uri $uri/ /index.html; for SPA routing
  • Runtime configuration via /app-config endpoint or window.__env — not compile-time environment.ts
  • envsubst or equivalent used to inject env vars at container startup
  • Multi-stage Docker build to keep image size small
  • Gzip compression enabled in Nginx for JS/CSS/JSON
  • Static assets cached with immutable headers

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., "Angular App")
  3. Inside the project, click Create environment and name it (e.g., "angular-main")

Step 2: Define the Environment Configuration (SPA Mode)

Click Configuration in your environment view and paste this bunnyshell.yaml for an Angular SPA with an API backend:

YAML
1kind: Environment
2name: angular-spa-preview
3type: primary
4
5environmentVariables:
6  DB_PASSWORD: SECRET["your-db-password"]
7  JWT_SECRET: SECRET["your-jwt-secret"]
8
9components:
10  # ── Angular Frontend (Nginx) ──
11  - kind: Application
12    name: angular-app
13    gitRepo: 'https://github.com/your-org/your-angular-repo.git'
14    gitBranch: main
15    gitApplicationPath: /
16    dockerCompose:
17      build:
18        context: .
19        dockerfile: Dockerfile
20      environment:
21        API_URL: 'https://api-{{ env.base_domain }}'
22        AUTH_URL: 'https://api-{{ env.base_domain }}/auth'
23      ports:
24        - '8080:8080'
25    dependsOn:
26      - api-backend
27    hosts:
28      - hostname: 'app-{{ env.base_domain }}'
29        path: /
30        servicePort: 8080
31
32  # ── API Backend (Node/Express example) ──
33  - kind: Application
34    name: api-backend
35    gitRepo: 'https://github.com/your-org/your-api-repo.git'
36    gitBranch: main
37    gitApplicationPath: /
38    dockerCompose:
39      build:
40        context: .
41        dockerfile: Dockerfile
42      environment:
43        PORT: '3000'
44        DATABASE_URL: 'postgresql://angular:{{ env.vars.DB_PASSWORD }}@postgres:5432/angular'
45        JWT_SECRET: '{{ env.vars.JWT_SECRET }}'
46        CORS_ORIGIN: 'https://app-{{ env.base_domain }}'
47      ports:
48        - '3000:3000'
49    dependsOn:
50      - postgres
51    hosts:
52      - hostname: 'api-{{ env.base_domain }}'
53        path: /
54        servicePort: 3000
55
56  # ── PostgreSQL Database ──
57  - kind: Database
58    name: postgres
59    dockerCompose:
60      image: 'postgres:16-alpine'
61      environment:
62        POSTGRES_DB: angular
63        POSTGRES_USER: angular
64        POSTGRES_PASSWORD: '{{ env.vars.DB_PASSWORD }}'
65      ports:
66        - '5432:5432'
67
68volumes:
69  - name: postgres-data
70    mount:
71      component: postgres
72      containerPath: /var/lib/postgresql/data
73    size: 1Gi

Key architecture notes:

  • Angular frontend + API backend — Two separate components with independent builds and deploys. The Angular app talks to the API via the API_URL environment variable, injected at container startup via envsubst.
  • CORS configuration — The API backend must allow requests from the Angular app's dynamic URL. Use CORS_ORIGIN: 'https://app-{{ env.base_domain }}' so each preview environment has the correct CORS origin.
  • Runtime configAPI_URL and AUTH_URL are set as environment variables on the Angular container, injected into the Nginx-served config endpoint at startup.

Replace your-org/your-angular-repo and your-org/your-api-repo with your actual repositories. Save the configuration.

Step 2 (Alternative): SSR Mode Configuration

If your Angular app uses SSR with @angular/ssr, replace the Angular frontend component:

YAML
1  # ── Angular SSR Application (Node) ──
2  - kind: Application
3    name: angular-app
4    gitRepo: 'https://github.com/your-org/your-angular-repo.git'
5    gitBranch: main
6    gitApplicationPath: /
7    dockerCompose:
8      build:
9        context: .
10        dockerfile: Dockerfile
11      environment:
12        PORT: '4000'
13        API_URL: 'https://api-{{ env.base_domain }}'
14        AUTH_URL: 'https://api-{{ env.base_domain }}/auth'
15      ports:
16        - '4000:4000'
17    dependsOn:
18      - api-backend
19    hosts:
20      - hostname: 'app-{{ env.base_domain }}'
21        path: /
22        servicePort: 4000

Step 3: Deploy

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

  1. Build your Angular Docker image (multi-stage: Node build + Nginx serve, or Node SSR)
  2. Build the API backend Docker image
  3. Pull the PostgreSQL image
  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 Angular app.

Step 4: 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

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? 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  angular-app:
5    build:
6      context: .
7      dockerfile: Dockerfile
8    environment:
9      API_URL: 'http://localhost:3000'
10      AUTH_URL: 'http://localhost:3000/auth'
11    ports:
12      - '8080:8080'
13    depends_on:
14      - api-backend
15
16  api-backend:
17    build:
18      context: ./api
19      dockerfile: Dockerfile
20    environment:
21      PORT: '3000'
22      DATABASE_URL: 'postgresql://angular:secret@postgres:5432/angular'
23      JWT_SECRET: 'dev-secret-change-me'
24      CORS_ORIGIN: 'http://localhost:8080'
25    ports:
26      - '3000:3000'
27    depends_on:
28      - postgres
29
30  postgres:
31    image: postgres:16-alpine
32    environment:
33      POSTGRES_DB: angular
34      POSTGRES_USER: angular
35      POSTGRES_PASSWORD: secret
36    volumes:
37      - postgres-data:/var/lib/postgresql/data
38    ports:
39      - '5432:5432'
40
41volumes:
42  postgres-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, exposed ports, build configurations, and volumes.

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  DB_PASSWORD: SECRET["your-db-password"]
3  JWT_SECRET: SECRET["your-jwt-secret"]

Add dynamic URLs using Bunnyshell interpolation:

YAML
1# Angular app — runtime config
2environment:
3  API_URL: 'https://api-{{ env.base_domain }}'
4  AUTH_URL: 'https://api-{{ env.base_domain }}/auth'
5
6# API backend — CORS
7environment:
8  CORS_ORIGIN: 'https://app-{{ env.base_domain }}'

Add ingress hosts if not auto-detected:

YAML
1# Angular app
2hosts:
3  - hostname: 'app-{{ env.base_domain }}'
4    path: /
5    servicePort: 8080
6
7# API backend
8hosts:
9  - hostname: 'api-{{ env.base_domain }}'
10    path: /
11    servicePort: 3000

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

  • Remove local volumesvolumes: ['.:/app'] is for local dev with ng serve. Remove these in Bunnyshell — the Docker image already contains the built app
  • Use Bunnyshell interpolation for dynamic values:
YAML
1# Local docker-compose.yml
2API_URL: http://localhost:3000
3
4# Bunnyshell environment config (after import)
5API_URL: 'https://api-{{ env.base_domain }}'
  • Ensure CORS matches — The API backend's CORS_ORIGIN must match the Angular app's ingress URL. Use Bunnyshell interpolation so both resolve to the same base domain
  • Design for startup resilience — Kubernetes doesn't guarantee depends_on ordering. Make your API backend retry database connections on startup

Approach C: Helm Charts

For teams with existing Helm infrastructure or complex Kubernetes requirements (custom ingress, service mesh, multiple microservices). Helm gives you full control over every Kubernetes resource.

Step 1: Create a Helm Chart

Structure your Angular Helm chart in your repo:

Text
1helm/angular/
2├── Chart.yaml
3├── values.yaml
4└── templates/
5    ├── deployment.yaml
6    ├── service.yaml
7    └── ingress.yaml

A minimal values.yaml:

YAML
1replicaCount: 1
2image:
3  repository: ""
4  tag: latest
5service:
6  port: 8080  # Nginx for SPA, 4000 for SSR
7ingress:
8  enabled: true
9  className: bns-nginx
10  host: ""
11env:
12  API_URL: ""
13  AUTH_URL: ""
14  PORT: "4000"

Step 2: Define the Bunnyshell Configuration

Create a bunnyshell.yaml using Helm components:

YAML
1kind: Environment
2name: angular-helm
3type: primary
4
5environmentVariables:
6  DB_PASSWORD: SECRET["your-db-password"]
7  JWT_SECRET: SECRET["your-jwt-secret"]
8
9components:
10  # ── Docker Image Builds ──
11  - kind: DockerImage
12    name: angular-image
13    context: /
14    dockerfile: Dockerfile
15    gitRepo: 'https://github.com/your-org/your-angular-repo.git'
16    gitBranch: main
17    gitApplicationPath: /
18
19  - kind: DockerImage
20    name: api-image
21    context: /api
22    dockerfile: api/Dockerfile
23    gitRepo: 'https://github.com/your-org/your-angular-repo.git'
24    gitBranch: main
25    gitApplicationPath: /api
26
27  # ── PostgreSQL via Helm (Bitnami) ──
28  - kind: Helm
29    name: postgres
30    runnerImage: 'dtzar/helm-kubectl:3.8.2'
31    deploy:
32      - |
33        cat << EOF > pg_values.yaml
34          global:
35            storageClass: bns-network-sc
36          auth:
37            database: angular
38            username: angular
39            password: {{ env.vars.DB_PASSWORD }}
40        EOF
41      - 'helm repo add bitnami https://charts.bitnami.com/bitnami'
42      - 'helm upgrade --install --namespace {{ env.k8s.namespace }}
43        --post-renderer /bns/helpers/helm/bns_post_renderer
44        -f pg_values.yaml postgres bitnami/postgresql --version 13.4.4'
45      - |
46        PG_HOST="postgres-postgresql.{{ env.k8s.namespace }}.svc.cluster.local"
47    destroy:
48      - 'helm uninstall postgres --namespace {{ env.k8s.namespace }}'
49    start:
50      - 'kubectl scale --replicas=1 --namespace {{ env.k8s.namespace }}
51        statefulset/postgres-postgresql'
52    stop:
53      - 'kubectl scale --replicas=0 --namespace {{ env.k8s.namespace }}
54        statefulset/postgres-postgresql'
55    exportVariables:
56      - PG_HOST
57
58  # ── Angular App via Helm ──
59  - kind: Helm
60    name: angular-app
61    runnerImage: 'dtzar/helm-kubectl:3.8.2'
62    deploy:
63      - |
64        cat << EOF > angular_values.yaml
65          replicaCount: 1
66          image:
67            repository: {{ components.angular-image.image }}
68          service:
69            port: 8080
70          ingress:
71            enabled: true
72            className: bns-nginx
73            host: app-{{ env.base_domain }}
74          env:
75            API_URL: 'https://api-{{ env.base_domain }}'
76            AUTH_URL: 'https://api-{{ env.base_domain }}/auth'
77        EOF
78      - 'helm upgrade --install --namespace {{ env.k8s.namespace }}
79        --post-renderer /bns/helpers/helm/bns_post_renderer
80        -f angular_values.yaml angular-{{ env.unique }} ./helm/angular'
81    destroy:
82      - 'helm uninstall angular-{{ env.unique }} --namespace {{ env.k8s.namespace }}'
83    start:
84      - 'helm upgrade --namespace {{ env.k8s.namespace }}
85        --post-renderer /bns/helpers/helm/bns_post_renderer
86        --reuse-values --set replicaCount=1 angular-{{ env.unique }} ./helm/angular'
87    stop:
88      - 'helm upgrade --namespace {{ env.k8s.namespace }}
89        --post-renderer /bns/helpers/helm/bns_post_renderer
90        --reuse-values --set replicaCount=0 angular-{{ env.unique }} ./helm/angular'
91    gitRepo: 'https://github.com/your-org/your-angular-repo.git'
92    gitBranch: main
93    gitApplicationPath: /helm/angular
94
95  # ── API Backend via Helm ──
96  - kind: Helm
97    name: api-backend
98    runnerImage: 'dtzar/helm-kubectl:3.8.2'
99    deploy:
100      - |
101        cat << EOF > api_values.yaml
102          replicaCount: 1
103          image:
104            repository: {{ components.api-image.image }}
105          service:
106            port: 3000
107          ingress:
108            enabled: true
109            className: bns-nginx
110            host: api-{{ env.base_domain }}
111          env:
112            PORT: '3000'
113            DATABASE_URL: 'postgresql://angular:{{ env.vars.DB_PASSWORD }}@{{ components.postgres.exported.PG_HOST }}:5432/angular'
114            JWT_SECRET: '{{ env.vars.JWT_SECRET }}'
115            CORS_ORIGIN: 'https://app-{{ env.base_domain }}'
116        EOF
117      - 'helm upgrade --install --namespace {{ env.k8s.namespace }}
118        --post-renderer /bns/helpers/helm/bns_post_renderer
119        -f api_values.yaml api-{{ env.unique }} ./helm/api'
120    destroy:
121      - 'helm uninstall api-{{ env.unique }} --namespace {{ env.k8s.namespace }}'
122    start:
123      - 'helm upgrade --namespace {{ env.k8s.namespace }}
124        --post-renderer /bns/helpers/helm/bns_post_renderer
125        --reuse-values --set replicaCount=1 api-{{ env.unique }} ./helm/api'
126    stop:
127      - 'helm upgrade --namespace {{ env.k8s.namespace }}
128        --post-renderer /bns/helpers/helm/bns_post_renderer
129        --reuse-values --set replicaCount=0 api-{{ env.unique }} ./helm/api'
130    gitRepo: 'https://github.com/your-org/your-angular-repo.git'
131    gitBranch: main
132    gitApplicationPath: /helm/api

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" to ON
  4. Toggle "Destroy environment after merge or close pull request" to 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, 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 and deploy
8bns environments create --from-path bunnyshell.yaml --name "pr-123" --project PROJECT_ID --k8s CLUSTER_ID
9bns environments deploy --id ENV_ID --wait

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 remote services:

Bash
1# Forward PostgreSQL to local port 15432
2bns port-forward 15432:5432 --component POSTGRES_COMPONENT_ID
3
4# Connect with psql or any DB tool
5psql -h 127.0.0.1 -p 15432 -U angular -d angular
6
7# Forward the API backend to local port 13000
8bns port-forward 13000:3000 --component API_COMPONENT_ID

Execute Commands

Bash
1# Check the Angular build output
2bns exec COMPONENT_ID -- ls -la /usr/share/nginx/html
3
4# For SSR: check Node process
5bns exec COMPONENT_ID -- node -e "console.log(process.env.PORT)"
6
7# Run database migrations on the API backend
8bns exec API_COMPONENT_ID -- npx prisma migrate deploy
9
10# Seed test data
11bns exec API_COMPONENT_ID -- node dist/seed.js

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

Live code sync for the Angular SPA (Nginx) container requires a rebuild (ng build) to see changes. For faster iteration, sync against the API backend instead — Node servers typically support hot reload. Alternatively, develop the Angular frontend locally with ng serve pointed at the remote API URL.


Troubleshooting

IssueSolution
404 on route refreshMissing try_files $uri $uri/ /index.html; in Nginx config. Without this, Nginx can't find files for Angular routes and returns 404.
API calls failing (CORS)CORS_ORIGIN on the API backend doesn't match the Angular app's URL. Use Bunnyshell interpolation: 'https://app-{{ env.base_domain }}'.
environment.prod.ts has wrong API URLDon't use compile-time environment files for preview environments. Switch to runtime config via /app-config endpoint or window.__env.
Blank page / white screenCheck browser console. Usually a missing <base href="/"> in index.html, or the Angular build failed silently. Check build logs with bns logs.
SSR server not startingVerify the entry point path: node dist/your-app/server/server.mjs. The project name in the path must match your angular.json project name.
ng serve in productionNever use ng serve in a Dockerfile. It's a dev server. Use Nginx for SPA or node dist/*/server/server.mjs for SSR.
Large Docker image (1GB+)Missing multi-stage build. node_modules from the build stage shouldn't be in the final Nginx image. Only copy dist/*/browser to Nginx.
Slow buildsAdd a .dockerignore with node_modules, dist, .angular, .git. Use npm ci instead of npm install.
Mixed content errorsAPI URLs use http:// but the Angular app is served over HTTPS. Always use https:// in your Bunnyshell interpolation URLs.
502 Bad Gateway (SSR)Node process crashed. Check PORT environment variable matches the exposed port. Verify with bns logs --component COMPONENT_ID.

What's Next?

  • Add authentication — Integrate Auth0, Firebase Auth, or Keycloak as a separate component so each preview environment has its own auth instance
  • Add Storybook — Deploy Storybook as a separate component (npx storybook build + Nginx) for UI component review alongside the full app
  • Add E2E tests — Run Cypress or Playwright against the preview environment URL in your CI pipeline
  • Add a CDN layer — Configure Cloudflare or CloudFront in front of the Nginx service for production-like caching behavior
  • Monitor with Angular DevTools — Angular DevTools browser extension works on preview environments — debug change detection and component trees on any PR

Ship faster starting today.

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