Preview Environments for .NET: Automated Per-PR Deployments with Bunnyshell
GuideMarch 20, 202612 min read

Preview Environments for .NET: Automated Per-PR Deployments with Bunnyshell

Why Preview Environments for .NET?

Every ASP.NET Core team knows the friction: you add an Entity Framework migration, test it locally against LocalDB, push to staging — and dotnet ef database update fails because staging has an older migration baseline. Or someone deployed their authentication refactor to staging right before your payment feature demo. Or two branches are sharing the same connection string and stepping on each other's data.

Preview environments solve this. Every pull request gets its own isolated deployment — ASP.NET Core app, PostgreSQL database, Redis for caching — all running in Kubernetes with production-like configuration. Reviewers click a link and test the actual running application, not just the code diff.

With Bunnyshell, you get:

  • Automatic deployment — A new environment spins up for every PR
  • Production parity — Same Docker images, same database engine, same configuration
  • 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 ASP.NET Core. 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 Azure DevOps pipelines to maintain — Bunnyshell adds webhooks to your Git provider and listens for PR events.

Prerequisites: Prepare Your ASP.NET Core App

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

1. Create a Production-Ready Dockerfile

ASP.NET Core uses a two-stage Dockerfile: the SDK image to build and publish, and the smaller runtime-only image to run:

Dockerfile
1# ── Stage 1: Build and Publish ──
2FROM mcr.microsoft.com/dotnet/sdk:8.0 AS builder
3
4WORKDIR /src
5
6# Copy project file(s) and restore — leverage layer caching
7COPY ["src/MyApp/MyApp.csproj", "src/MyApp/"]
8RUN dotnet restore "src/MyApp/MyApp.csproj"
9
10# Copy remaining source and publish
11COPY . .
12WORKDIR "/src/src/MyApp"
13RUN dotnet publish "MyApp.csproj" \
14    -c Release \
15    -o /app/publish \
16    --no-restore
17
18# ── Stage 2: Runtime ──
19FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
20
21WORKDIR /app
22
23# Non-root user for security
24RUN addgroup --system --gid 1001 dotnet && \
25    adduser --system --uid 1001 --ingroup dotnet dotnet
26USER dotnet
27
28COPY --from=builder --chown=dotnet:dotnet /app/publish .
29
30EXPOSE 8080
31
32ENTRYPOINT ["dotnet", "MyApp.dll"]

Port 8080: The official Microsoft .NET 8 runtime image defaults to port 8080 (changed from 80 in .NET 8). The ASPNETCORE_URLS environment variable controls this — we'll set it explicitly.

2. Configure ASP.NET Core for Kubernetes

ASP.NET Core needs specific settings to work correctly behind a Kubernetes ingress (which terminates TLS). Edit Program.cs:

Csharp
1var builder = WebApplication.CreateBuilder(args);
2
3// ── Services ──
4builder.Services.AddControllers();
5builder.Services.AddEndpointsApiExplorer();
6builder.Services.AddSwaggerGen();
7
8// PostgreSQL via EF Core
9builder.Services.AddDbContext<AppDbContext>(options =>
10    options.UseNpgsql(
11        builder.Configuration.GetConnectionString("DefaultConnection")
12    )
13);
14
15// Health checks
16builder.Services.AddHealthChecks()
17    .AddDbContextCheck<AppDbContext>("database")
18    .AddRedis(
19        builder.Configuration.GetConnectionString("Redis") ?? "redis:6379",
20        name: "redis"
21    );
22
23// ── Configure forwarded headers — required for K8s ingress TLS termination ──
24// Without this, the app sees all requests as HTTP, breaking redirects and
25// absolute URL generation behind the ingress.
26builder.Services.Configure<ForwardedHeadersOptions>(options =>
27{
28    options.ForwardedHeaders =
29        ForwardedHeaders.XForwardedFor |
30        ForwardedHeaders.XForwardedProto |
31        ForwardedHeaders.XForwardedHost;
32    // Trust all proxies in the cluster
33    options.KnownNetworks.Clear();
34    options.KnownProxies.Clear();
35});
36
37var app = builder.Build();
38
39// Apply forwarded headers middleware FIRST — before any other middleware
40app.UseForwardedHeaders();
41
42// Run EF Core migrations at startup
43using (var scope = app.Services.CreateScope())
44{
45    var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
46    db.Database.Migrate();
47}
48
49if (app.Environment.IsDevelopment())
50{
51    app.UseSwagger();
52    app.UseSwaggerUI();
53}
54
55app.UseHttpsRedirection();
56app.UseAuthorization();
57app.MapControllers();
58app.MapHealthChecks("/health");
59
60app.Run();

UseForwardedHeaders() must be called before UseHttpsRedirection() and any other middleware that inspects the request scheme or host. If the order is wrong, the app will see all requests as HTTP and generate incorrect redirect URLs.

3. Set Up Entity Framework Core

Install the required packages:

Bash
1dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL
2dotnet add package Microsoft.EntityFrameworkCore.Design
3dotnet add package AspNetCore.HealthChecks.Npgsql
4dotnet add package AspNetCore.HealthChecks.Redis

Create your AppDbContext:

Csharp
1// Data/AppDbContext.cs
2using Microsoft.EntityFrameworkCore;
3
4public class AppDbContext : DbContext
5{
6    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
7
8    public DbSet<User> Users => Set<User>();
9    // ... other DbSets
10
11    protected override void OnModelCreating(ModelBuilder modelBuilder)
12    {
13        base.OnModelCreating(modelBuilder);
14        // Your model configuration here
15    }
16}

Generate and apply migrations locally:

Bash
1# Generate a migration
2dotnet ef migrations add InitialCreate --project src/MyApp
3
4# Apply locally
5dotnet ef database update --project src/MyApp
6
7# In production (Bunnyshell): db.Database.Migrate() in Program.cs handles this

db.Database.Migrate() at startup is idempotent — it only applies pending migrations and is safe to run on every deploy. It does not drop or reset your database.

4. Environment Variables

Update your appsettings.Production.json and document environment variable overrides:

JSON
1{
2  "Logging": {
3    "LogLevel": {
4      "Default": "Information",
5      "Microsoft.AspNetCore": "Warning"
6    }
7  },
8  "AllowedHosts": "*"
9}

Key environment variables for Kubernetes deployment:

.env
1ASPNETCORE_ENVIRONMENT=Production
2ASPNETCORE_URLS=http://+:8080
3ASPNETCORE_FORWARDEDHEADERS_ENABLED=true
4
5ConnectionStrings__DefaultConnection=Host=postgres;Port=5432;Database=myapp;Username=myapp;Password=...
6ConnectionStrings__Redis=redis:6379
7
8# Your app-specific secrets
9Jwt__SecretKey=your-jwt-secret

ASPNETCORE_FORWARDEDHEADERS_ENABLED=true — Since .NET 6, this environment variable activates forwarded headers processing automatically without code changes. It's an alternative to calling app.UseForwardedHeaders() in Program.cs. Using both is safe and redundant.

ASP.NET Core Deployment Checklist

  • Multi-stage Dockerfile: dotnet/sdk:8.0 builder + dotnet/aspnet:8.0 runtime
  • ASPNETCORE_URLS=http://+:8080 — listens on all interfaces, port 8080
  • ASPNETCORE_FORWARDEDHEADERS_ENABLED=true — trusts K8s ingress proxy headers
  • UseForwardedHeaders() called first in the middleware pipeline
  • db.Database.Migrate() at startup for automatic EF Core migration application
  • Health check endpoint at /health via AddHealthChecks()
  • ConnectionStrings__DefaultConnection set via environment variable (overrides appsettings.json)
  • Non-root user in Dockerfile for security

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

Step 2: Define the Environment Configuration

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

YAML
1kind: Environment
2name: dotnet-preview
3type: primary
4
5environmentVariables:
6  DB_PASSWORD: SECRET["your-db-password"]
7  JWT_SECRET_KEY: SECRET["your-jwt-secret"]
8
9components:
10  # ── ASP.NET Core Application ──
11  - kind: Application
12    name: dotnet-app
13    gitRepo: 'https://github.com/your-org/your-dotnet-repo.git'
14    gitBranch: main
15    gitApplicationPath: /
16    dockerCompose:
17      build:
18        context: .
19        dockerfile: Dockerfile
20      environment:
21        ASPNETCORE_ENVIRONMENT: Production
22        ASPNETCORE_URLS: 'http://+:8080'
23        ASPNETCORE_FORWARDEDHEADERS_ENABLED: 'true'
24        ConnectionStrings__DefaultConnection: 'Host=postgres;Port=5432;Database=myapp;Username=myapp;Password={{ env.vars.DB_PASSWORD }}'
25        ConnectionStrings__Redis: 'redis:6379'
26        Jwt__SecretKey: '{{ env.vars.JWT_SECRET_KEY }}'
27        App__BaseUrl: 'https://{{ components.dotnet-app.ingress.hosts[0] }}'
28      ports:
29        - '8080:8080'
30    dependsOn:
31      - postgres
32      - redis
33    hosts:
34      - hostname: 'app-{{ env.base_domain }}'
35        path: /
36        servicePort: 8080
37
38  # ── PostgreSQL Database ──
39  - kind: Database
40    name: postgres
41    dockerCompose:
42      image: 'postgres:16-alpine'
43      environment:
44        POSTGRES_USER: myapp
45        POSTGRES_PASSWORD: '{{ env.vars.DB_PASSWORD }}'
46        POSTGRES_DB: myapp
47      ports:
48        - '5432:5432'
49
50  # ── Redis Cache ──
51  - kind: Service
52    name: redis
53    dockerCompose:
54      image: 'redis:7-alpine'
55      command: ['redis-server', '--appendonly', 'yes']
56      ports:
57        - '6379:6379'
58
59volumes:
60  - name: postgres-data
61    mount:
62      component: postgres
63      containerPath: /var/lib/postgresql/data
64    size: 1Gi
65  - name: redis-data
66    mount:
67      component: redis
68      containerPath: /data
69    size: 512Mi

Key architecture notes:

  • Single container — ASP.NET Core handles HTTP directly via Kestrel. No reverse proxy sidecar needed (unlike PHP-FPM + Nginx).
  • ConnectionStrings__DefaultConnection — The double underscore __ is ASP.NET Core's convention for nested configuration keys in environment variables. This overrides the ConnectionStrings:DefaultConnection value in appsettings.json.
  • Port 8080http://+:8080 tells Kestrel to listen on all network interfaces on port 8080. The + is equivalent to 0.0.0.0.
  • EF Core migrations — Run automatically at startup via db.Database.Migrate() in Program.cs. No separate migration job needed.

Replace your-org/your-dotnet-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 ASP.NET Core Docker image from the Dockerfile
  2. Pull PostgreSQL and Redis images
  3. Deploy everything into an isolated Kubernetes namespace
  4. Generate HTTPS URLs automatically with DNS

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

Step 4: Verify Migrations and Health

After deployment, verify migrations ran and the health check responds:

Bash
1export BUNNYSHELL_TOKEN=your-api-token
2bns components list --environment ENV_ID --output json | jq '._embedded.item[] | {id, name}'
3
4# Check health endpoint
5curl https://app-YOUR_ENV_DOMAIN.bunnyshell.com/health
6
7# View startup logs to confirm EF Core migrations ran
8bns logs --component COMPONENT_ID --tail 100
9
10# Open a shell for debugging
11bns exec COMPONENT_ID -- sh
12
13# Run EF Core CLI migrations manually (if needed, requires dotnet SDK in image)
14# Alternatively, use a migration script approach
15bns exec COMPONENT_ID -- dotnet ef database update

If you need to run dotnet ef CLI commands in the container, add the SDK to your Dockerfile. For production, the recommended approach is db.Database.Migrate() at app startup — no CLI tools needed at runtime.

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

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  dotnet-app:
5    build:
6      context: .
7      dockerfile: Dockerfile
8    ports:
9      - '8080:8080'
10    environment:
11      ASPNETCORE_ENVIRONMENT: Development
12      ASPNETCORE_URLS: 'http://+:8080'
13      ConnectionStrings__DefaultConnection: 'Host=postgres;Port=5432;Database=myapp;Username=myapp;Password=secret'
14      ConnectionStrings__Redis: 'redis:6379'
15      Jwt__SecretKey: 'local-dev-secret-key-min-32-chars'
16    depends_on:
17      postgres:
18        condition: service_healthy
19      redis:
20        condition: service_started
21
22  postgres:
23    image: postgres:16-alpine
24    environment:
25      POSTGRES_USER: myapp
26      POSTGRES_PASSWORD: secret
27      POSTGRES_DB: myapp
28    volumes:
29      - postgres-data:/var/lib/postgresql/data
30    ports:
31      - '5432:5432'
32    healthcheck:
33      test: ['CMD-SHELL', 'pg_isready -U myapp']
34      interval: 10s
35      timeout: 5s
36      retries: 5
37
38  redis:
39    image: redis:7-alpine
40    command: ['redis-server', '--appendonly', 'yes']
41    volumes:
42      - redis-data:/data
43    ports:
44      - '6379:6379'
45
46volumes:
47  postgres-data:
48  redis-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 (dotnet-app, postgres, redis)
  • Exposed ports
  • Build configurations (Dockerfiles)
  • Volumes
  • Environment variables

It converts everything into a bunnyshell.yaml environment definition.

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

Add dynamic URLs and replace connection strings using Bunnyshell interpolation:

YAML
ConnectionStrings__DefaultConnection: 'Host=postgres;Port=5432;Database=myapp;Username=myapp;Password={{ env.vars.DB_PASSWORD }}'
App__BaseUrl: 'https://{{ components.dotnet-app.ingress.hosts[0] }}'

Set production environment variables:

YAML
ASPNETCORE_ENVIRONMENT: Production
ASPNETCORE_FORWARDEDHEADERS_ENABLED: 'true'

Add an ingress host to expose the app:

YAML
1hosts:
2  - hostname: 'app-{{ env.base_domain }}'
3    path: /
4    servicePort: 8080

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

  • Set ASPNETCORE_ENVIRONMENT=Production in the Bunnyshell config, not Development. This disables the developer exception page and enables production-grade logging.
  • Use Bunnyshell interpolation for connection strings:
YAML
1# Local docker-compose.yml
2ConnectionStrings__DefaultConnection: Host=postgres;Port=5432;Database=myapp;Username=myapp;Password=secret
3
4# Bunnyshell environment config (after import)
5ConnectionStrings__DefaultConnection: 'Host=postgres;Port=5432;Database=myapp;Username=myapp;Password={{ env.vars.DB_PASSWORD }}'
  • Design for startup resilience — Kubernetes doesn't guarantee depends_on ordering. Use Polly retry policies in your EF Core configuration to handle transient DB startup failures gracefully.

Approach C: Helm Charts

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

Step 1: Create a Helm Chart

Structure your ASP.NET Core Helm chart in your repo:

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

A minimal values.yaml:

YAML
1replicaCount: 1
2image:
3  repository: ""
4  tag: latest
5service:
6  port: 8080
7ingress:
8  enabled: true
9  className: bns-nginx
10  host: ""
11env:
12  ASPNETCORE_ENVIRONMENT: Production
13  ASPNETCORE_URLS: "http://+:8080"
14  ASPNETCORE_FORWARDEDHEADERS_ENABLED: "true"
15  ConnectionStrings__DefaultConnection: ""
16  ConnectionStrings__Redis: ""
17  Jwt__SecretKey: ""
18  App__BaseUrl: ""
19resources:
20  requests:
21    memory: "256Mi"
22    cpu: "100m"
23  limits:
24    memory: "512Mi"
25    cpu: "500m"

Step 2: Define the Bunnyshell Configuration

Create a bunnyshell.yaml using Helm components:

YAML
1kind: Environment
2name: dotnet-helm
3type: primary
4
5environmentVariables:
6  DB_PASSWORD: SECRET["your-db-password"]
7  JWT_SECRET_KEY: SECRET["your-jwt-secret"]
8
9components:
10  # ── Docker Image Build ──
11  - kind: DockerImage
12    name: dotnet-image
13    context: /
14    dockerfile: Dockerfile
15    gitRepo: 'https://github.com/your-org/your-dotnet-repo.git'
16    gitBranch: main
17    gitApplicationPath: /
18
19  # ── PostgreSQL via Helm (Bitnami) ──
20  - kind: Helm
21    name: postgres
22    runnerImage: 'dtzar/helm-kubectl:3.8.2'
23    deploy:
24      - |
25        cat << EOF > postgres_values.yaml
26          global:
27            storageClass: bns-network-sc
28          auth:
29            postgresPassword: {{ env.vars.DB_PASSWORD }}
30            username: myapp
31            password: {{ env.vars.DB_PASSWORD }}
32            database: myapp
33        EOF
34      - 'helm repo add bitnami https://charts.bitnami.com/bitnami'
35      - 'helm upgrade --install --namespace {{ env.k8s.namespace }}
36        --post-renderer /bns/helpers/helm/bns_post_renderer
37        -f postgres_values.yaml postgres bitnami/postgresql --version 12.12.10'
38      - |
39        POSTGRES_HOST="postgres-postgresql.{{ env.k8s.namespace }}.svc.cluster.local"
40    destroy:
41      - 'helm uninstall postgres --namespace {{ env.k8s.namespace }}'
42    start:
43      - 'kubectl scale --replicas=1 --namespace {{ env.k8s.namespace }} statefulset/postgres-postgresql'
44    stop:
45      - 'kubectl scale --replicas=0 --namespace {{ env.k8s.namespace }} statefulset/postgres-postgresql'
46    exportVariables:
47      - POSTGRES_HOST
48
49  # ── ASP.NET Core App via Helm ──
50  - kind: Helm
51    name: dotnet-app
52    runnerImage: 'dtzar/helm-kubectl:3.8.2'
53    deploy:
54      - |
55        cat << EOF > dotnet_values.yaml
56          replicaCount: 1
57          image:
58            repository: {{ components.dotnet-image.image }}
59          service:
60            port: 8080
61          ingress:
62            enabled: true
63            className: bns-nginx
64            host: app-{{ env.base_domain }}
65          env:
66            ASPNETCORE_ENVIRONMENT: Production
67            ASPNETCORE_URLS: "http://+:8080"
68            ASPNETCORE_FORWARDEDHEADERS_ENABLED: "true"
69            ConnectionStrings__DefaultConnection: 'Host={{ components.postgres.exported.POSTGRES_HOST }};Port=5432;Database=myapp;Username=myapp;Password={{ env.vars.DB_PASSWORD }}'
70            ConnectionStrings__Redis: 'redis:6379'
71            Jwt__SecretKey: '{{ env.vars.JWT_SECRET_KEY }}'
72            App__BaseUrl: 'https://app-{{ env.base_domain }}'
73        EOF
74      - 'helm upgrade --install --namespace {{ env.k8s.namespace }}
75        --post-renderer /bns/helpers/helm/bns_post_renderer
76        -f dotnet_values.yaml dotnet-{{ env.unique }} ./helm/dotnet'
77    destroy:
78      - 'helm uninstall dotnet-{{ env.unique }} --namespace {{ env.k8s.namespace }}'
79    start:
80      - 'helm upgrade --namespace {{ env.k8s.namespace }}
81        --post-renderer /bns/helpers/helm/bns_post_renderer
82        --reuse-values --set replicaCount=1 dotnet-{{ env.unique }} ./helm/dotnet'
83    stop:
84      - 'helm upgrade --namespace {{ env.k8s.namespace }}
85        --post-renderer /bns/helpers/helm/bns_post_renderer
86        --reuse-values --set replicaCount=0 dotnet-{{ env.unique }} ./helm/dotnet'
87    gitRepo: 'https://github.com/your-org/your-dotnet-repo.git'
88    gitBranch: main
89    gitApplicationPath: /helm/dotnet
90
91  # ── Redis ──
92  - kind: Service
93    name: redis
94    dockerCompose:
95      image: 'redis:7-alpine'
96      command: ['redis-server', '--appendonly', 'yes']
97      ports:
98        - '6379:6379'

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 Azure DevOps 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 integration tests or custom 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 verify 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
10
11# Check health
12curl https://app-${ENV_DOMAIN}/health
13
14# Run a custom .NET script for seeding
15bns exec COMPONENT_ID -- dotnet run --project src/Seeder

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 PostgreSQL to local port 15432
2bns port-forward 15432:5432 --component POSTGRES_COMPONENT_ID
3
4# Connect with psql, Azure Data Studio, or Rider's DB tools
5psql -h 127.0.0.1 -p 15432 -U myapp myapp
6
7# Forward Redis to local port 16379
8bns port-forward 16379:6379 --component REDIS_COMPONENT_ID
9redis-cli -p 16379
10
11# Forward the app to local port 8081 (useful for debugging with local frontend)
12bns port-forward 8081:8080 --component DOTNET_COMPONENT_ID

With PostgreSQL forwarded to localhost:15432, you can run EF Core CLI commands locally against the remote database:

Bash
1# Run pending migrations against the remote DB
2CONNECTION_STRING="Host=localhost;Port=15432;Database=myapp;Username=myapp;Password=your-password"
3dotnet ef database update \
4    --project src/MyApp \
5    --connection "$CONNECTION_STRING"
6
7# Check migration status
8dotnet ef migrations list \
9    --project src/MyApp \
10    --connection "$CONNECTION_STRING"

Execute Commands

Bash
1# Open an interactive shell in the container
2bns exec COMPONENT_ID -- sh
3
4# Check running ASP.NET Core process
5bns exec COMPONENT_ID -- ps aux
6
7# View environment variables (useful for debugging config issues)
8bns exec COMPONENT_ID -- env | grep -i aspnetcore
9
10# Run a .NET migration or seeding tool
11bns exec COMPONENT_ID -- dotnet MyApp.Migrations.dll

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

The structured JSON logs from ASP.NET Core are readable in the Bunnyshell UI. Look for EF Core migration output at startup to confirm migrations applied.

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 to the running container
3# When done:
4bns remote-development down

For .NET hot reload in a container, your Dockerfile's ENTRYPOINT must use dotnet watch run instead of running the published DLL. Keep a separate Dockerfile.dev for this pattern and use the production Dockerfile for Bunnyshell deployments.


Troubleshooting

IssueSolution
App starts but health check returns 503EF Core migration failed at startup and db.Database.Migrate() threw an exception. Check startup logs for the EF Core error. Verify ConnectionStrings__DefaultConnection is correct.
Connection refused to PostgreSQLApp is starting before PostgreSQL is ready. EF Core has built-in retry logic via EnableRetryOnFailure(). Add .EnableRetryOnFailure(5, TimeSpan.FromSeconds(10), null) to your Npgsql options.
Mixed content / HTTPS redirect loopUseForwardedHeaders() is missing or called after UseHttpsRedirection(). Move it to be the first middleware. Also set ASPNETCORE_FORWARDEDHEADERS_ENABLED=true.
HTTP 400: Bad Request on HTTPS URLsHTTPS_REDIRECTION is active but the ingress doesn't send HTTPS. Disable UseHttpsRedirection() in K8s deployments — the ingress handles TLS.
EF Core migration: relation already existsA previous migration partially applied. Check __EFMigrationsHistory table. You may need to manually resolve via bns port-forward + psql.
Jwt__SecretKey config not foundASP.NET Core configuration hierarchy: environment variables override appsettings.json. Use double underscore __ for nested keys. Verify with bns exec COMPONENT_ID -- env | grep Jwt.
Port 80 connection refused.NET 8 defaults to port 8080. Set ASPNETCORE_URLS=http://+:8080 and expose port 8080 in your bunnyshell.yaml, not 80.
CERTIFICATE_VERIFY_FAILED connecting to DBNpgsql SSL verification failing. Set Trust Server Certificate=true in the connection string for preview environments, or configure proper CA certificates.
System.OutOfMemoryException on buildMulti-stage Docker build requires enough memory for the SDK stage. Increase the builder's memory limit or split into smaller projects.
Redis StackExchange.Redis timeoutConnectionStrings__Redis is wrong or Redis isn't ready. Verify the Redis component is Running and the hostname is redis (the component name).
CORS errors in browserThe App__BaseUrl or allowed origins list doesn't include the preview environment's URL. Use {{ components.dotnet-app.ingress.hosts[0] }} in your CORS policy.
522 Connection timed outCluster may be behind a firewall. Verify Cloudflare IPs are whitelisted on the ingress controller.

What's Next?

  • Add background workers — Use IHostedService or BackgroundService for long-running tasks, or add a dedicated worker component with a separate service image
  • Add Hangfire — Schedule and monitor background jobs with a Hangfire dashboard. Add PostgreSQL storage: services.AddHangfireServer() with UsePostgreSqlStorage()
  • Add Mailpit — Test transactional emails with a local SMTP catcher (axllent/mailpit as a Service component, point SmtpHost to mailpit)
  • Add MinIO — S3-compatible object storage for file uploads (minio/minio as a Service component, configure with AWSSDK.S3 or MinIO .NET SDK)
  • Add SignalR scaling — For real-time features, add Redis backplane: services.AddSignalR().AddStackExchangeRedis(connectionString)
  • Seed per-PR data — Run bns exec <ID> -- dotnet MyApp.Seeder.dll after ephemeral environment creation for consistent demo data

Ship faster starting today.

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