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:
| Approach | Best for | Complexity | CI/CD maintenance |
|---|---|---|---|
| Approach A: Bunnyshell UI | Teams that want the fastest setup with zero pipeline maintenance | Easiest | None — Bunnyshell manages webhooks automatically |
| Approach B: Docker Compose Import | Teams already using docker-compose.yml for local development | Easy | None — import converts to Bunnyshell config automatically |
| Approach C: Helm Charts | Teams with existing Helm infrastructure or complex K8s needs | Advanced | Optional — 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:
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 /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_URLSenvironment 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:
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:
1dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL
2dotnet add package Microsoft.EntityFrameworkCore.Design
3dotnet add package AspNetCore.HealthChecks.Npgsql
4dotnet add package AspNetCore.HealthChecks.RedisCreate your AppDbContext:
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:
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 thisdb.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:
1{
2 "Logging": {
3 "LogLevel": {
4 "Default": "Information",
5 "Microsoft.AspNetCore": "Warning"
6 }
7 },
8 "AllowedHosts": "*"
9}Key environment variables for Kubernetes deployment:
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 callingapp.UseForwardedHeaders()inProgram.cs. Using both is safe and redundant.
ASP.NET Core Deployment Checklist
- Multi-stage Dockerfile:
dotnet/sdk:8.0builder +dotnet/aspnet:8.0runtime -
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
/healthviaAddHealthChecks() -
ConnectionStrings__DefaultConnectionset via environment variable (overridesappsettings.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
- Log into Bunnyshell
- Click Create project and name it (e.g., "ASP.NET Core App")
- 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:
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: 512MiKey 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 theConnectionStrings:DefaultConnectionvalue inappsettings.json.- Port 8080 —
http://+:8080tells Kestrel to listen on all network interfaces on port 8080. The+is equivalent to0.0.0.0. - EF Core migrations — Run automatically at startup via
db.Database.Migrate()inProgram.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:
- Build your ASP.NET Core Docker image from the Dockerfile
- Pull PostgreSQL and Redis images
- Deploy everything into an isolated Kubernetes namespace
- 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:
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 updateIf 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:
- In your environment, go to Settings
- Find the Ephemeral environments section
- Toggle "Create ephemeral environments on pull request" to ON
- Toggle "Destroy environment after merge or close pull request" to ON
- 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:
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
- Create a Project and Environment in Bunnyshell (same as Approach A, Step 1)
- Click Define environment
- Select your Git account and repository
- Set the branch (e.g.,
main) and the path todocker-compose.yml(use/if it's in the root) - 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:
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:
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:
ASPNETCORE_ENVIRONMENT: Production
ASPNETCORE_FORWARDEDHEADERS_ENABLED: 'true'Add an ingress host to expose the app:
1hosts:
2 - hostname: 'app-{{ env.base_domain }}'
3 path: /
4 servicePort: 8080Step 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=Productionin the Bunnyshell config, notDevelopment. This disables the developer exception page and enables production-grade logging. - Use Bunnyshell interpolation for connection strings:
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_onordering. 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:
1helm/dotnet/
2├── Chart.yaml
3├── values.yaml
4└── templates/
5 ├── deployment.yaml
6 ├── service.yaml
7 ├── ingress.yaml
8 ├── configmap.yaml
9 └── secret.yamlA minimal values.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:
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:
- Ensure your primary environment has been deployed at least once (Running or Stopped status)
- Go to Settings in your environment
- Toggle "Create ephemeral environments on pull request" → ON
- Toggle "Destroy environment after merge or close pull request" → ON
- 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:
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/SeederRemote 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:
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_IDWith PostgreSQL forwarded to localhost:15432, you can run EF Core CLI commands locally against the remote database:
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
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.dllLive Logs
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 5mThe 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:
1bns remote-development up --component COMPONENT_ID
2# Edit files locally — changes sync automatically to the running container
3# When done:
4bns remote-development downFor .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
| Issue | Solution |
|---|---|
| App starts but health check returns 503 | EF 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 PostgreSQL | App 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 loop | UseForwardedHeaders() 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 URLs | HTTPS_REDIRECTION is active but the ingress doesn't send HTTPS. Disable UseHttpsRedirection() in K8s deployments — the ingress handles TLS. |
EF Core migration: relation already exists | A previous migration partially applied. Check __EFMigrationsHistory table. You may need to manually resolve via bns port-forward + psql. |
Jwt__SecretKey config not found | ASP.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 DB | Npgsql SSL verification failing. Set Trust Server Certificate=true in the connection string for preview environments, or configure proper CA certificates. |
System.OutOfMemoryException on build | Multi-stage Docker build requires enough memory for the SDK stage. Increase the builder's memory limit or split into smaller projects. |
Redis StackExchange.Redis timeout | ConnectionStrings__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 browser | The 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 out | Cluster may be behind a firewall. Verify Cloudflare IPs are whitelisted on the ingress controller. |
What's Next?
- Add background workers — Use
IHostedServiceorBackgroundServicefor 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()withUsePostgreSqlStorage() - Add Mailpit — Test transactional emails with a local SMTP catcher (
axllent/mailpitas a Service component, pointSmtpHosttomailpit) - Add MinIO — S3-compatible object storage for file uploads (
minio/minioas a Service component, configure withAWSSDK.S3or 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.dllafter ephemeral environment creation for consistent demo data
Related Resources
- Bunnyshell Quickstart Guide
- Docker Compose with Bunnyshell
- Helm with Bunnyshell
- Bunnyshell CLI Reference
- Preview Environments for Django — Same pattern for Python/Django
- Ephemeral Environments — Learn more about the concept
- All Guides — More technical guides
Ship faster starting today.
14-day full-feature trial. No credit card required. Pay-as-you-go from $0.007/min per environment.