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

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

Why Preview Environments for Astro?

Astro's island architecture gives you the best of both worlds — static HTML with interactive components that hydrate only when needed. But that flexibility creates a deployment question: are you shipping a static site served by Nginx, or an SSR app running on a Node server? The answer changes your Docker setup, your environment variables, and how preview environments work.

On a shared staging server, these differences collide. One developer is testing static output mode while another is building an SSR API route. Someone pushes a change to astro.config.mjs that switches the output mode — and every other branch on staging breaks. Content collections that work locally with file-based data suddenly need different paths in a shared environment.

Preview environments solve this. Every pull request gets its own isolated deployment — whether that's an Astro SSR server on Node, or a static build served by Nginx — running in Kubernetes with production-like configuration. Reviewers click a link and see the actual running site, 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 output, same environment variables
  • 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 Astro. 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 Astro App

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

Understanding Astro's Output Modes

Astro can build your site in two ways:

  • Static mode (output: 'static' — the default): Generates plain HTML/CSS/JS files at build time. Served by Nginx or any static file server. No Node runtime needed in production.
  • SSR mode (output: 'server' or output: 'hybrid'): Runs a Node.js server that renders pages on demand. Requires the @astrojs/node adapter. Needed for dynamic routes, API endpoints, and server-side logic.

Astro's island architecture (partial hydration) works identically in both modes. Interactive components like React, Vue, or Svelte islands hydrate on the client regardless of whether the page was statically generated or server-rendered. Preview environments don't change this behavior.

Option 1: Dockerfile for Static Mode (Nginx)

If your Astro site is fully static (blogs, marketing sites, documentation), use a multi-stage build that outputs to 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
11# PUBLIC_ prefixed vars are inlined at build time
12ARG PUBLIC_SITE_URL
13ARG PUBLIC_API_URL
14ENV PUBLIC_SITE_URL=${PUBLIC_SITE_URL}
15ENV PUBLIC_API_URL=${PUBLIC_API_URL}
16
17RUN npm run build
18
19# ── Stage 2: Serve ──
20FROM nginx:1.25-alpine
21
22COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf
23COPY --from=builder /app/dist /usr/share/nginx/html
24
25EXPOSE 8080
26
27CMD ["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    # Astro generates clean URLs — /about/ maps to /about/index.html
8    location / {
9        try_files $uri $uri/index.html $uri.html =404;
10    }
11
12    # Cache static assets aggressively
13    location /_astro/ {
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    error_page 404 /404.html;
23}

Option 2: Dockerfile for SSR Mode (Node)

If your Astro app uses server-side rendering, API routes, or dynamic pages, you need the Node adapter:

First, install the adapter:

Bash
npx astro add node

This updates astro.config.mjs:

JavaScript
1import { defineConfig } from 'astro/config';
2import node from '@astrojs/node';
3
4export default defineConfig({
5  output: 'server', // or 'hybrid' for mixed static + SSR
6  adapter: node({
7    mode: 'standalone', // runs its own HTTP server
8  }),
9});

Then the Dockerfile:

Dockerfile
1FROM node:20-alpine AS builder
2
3WORKDIR /app
4
5COPY package.json package-lock.json ./
6RUN npm ci
7
8COPY . .
9
10# PUBLIC_ prefixed vars are inlined at build time
11ARG PUBLIC_SITE_URL
12ARG PUBLIC_API_URL
13ENV PUBLIC_SITE_URL=${PUBLIC_SITE_URL}
14ENV PUBLIC_API_URL=${PUBLIC_API_URL}
15
16RUN npm run build
17
18# ── Production stage ──
19FROM node:20-alpine
20
21WORKDIR /app
22
23COPY --from=builder /app/dist ./dist
24COPY --from=builder /app/node_modules ./node_modules
25COPY --from=builder /app/package.json ./
26
27ENV HOST=0.0.0.0
28ENV PORT=4321
29
30EXPOSE 4321
31
32CMD ["node", "dist/server/entry.mjs"]

The HOST=0.0.0.0 environment variable is critical. Without it, the Astro Node server binds to 127.0.0.1 and is unreachable from outside the container. Kubernetes health checks and ingress traffic will fail with connection refused errors.

Environment Variables in Astro

Astro has a clear split between client-side and server-side environment variables:

  • PUBLIC_* variables — Inlined into the client bundle at build time. Available in both static and SSR mode. Example: PUBLIC_SITE_URL, PUBLIC_API_URL.
  • Non-prefixed variables — Available only on the server at runtime (SSR mode only). Example: DATABASE_URL, API_SECRET.

For preview environments, PUBLIC_* variables must be passed as build args since they're baked into the HTML/JS at build time. Server-only variables can be set as runtime environment variables.

YAML
1# In bunnyshell.yaml
2dockerCompose:
3  build:
4    context: .
5    dockerfile: Dockerfile
6    args:
7      PUBLIC_SITE_URL: 'https://{{ components.astro-app.ingress.hosts[0] }}'
8      PUBLIC_API_URL: 'https://api-{{ env.base_domain }}'
9  environment:
10    # Runtime vars (SSR only)
11    DATABASE_URL: 'postgresql://...'
12    API_SECRET: '{{ env.vars.API_SECRET }}'

Astro content collections are file-based — they read Markdown/MDX files from your src/content/ directory at build time. No database is required unless your app explicitly connects to one. This makes Astro preview environments simpler than most frameworks.

Astro Deployment Checklist

  • Decided on output mode: static (Nginx) or server/hybrid (Node SSR)
  • @astrojs/node adapter installed if using SSR mode
  • HOST=0.0.0.0 set for SSR mode — required for Kubernetes networking
  • PUBLIC_* variables passed as build args (inlined at build time)
  • Nginx config handles Astro's clean URL structure (/about/index.html)
  • /_astro/ static assets directory cached with immutable headers
  • Multi-stage Docker build to keep image size small

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

Step 2: Define the Environment Configuration (Static Mode)

Click Configuration in your environment view and paste this bunnyshell.yaml for a static Astro site served by Nginx:

YAML
1kind: Environment
2name: astro-static-preview
3type: primary
4
5components:
6  # ── Astro Static Site (Nginx) ──
7  - kind: Application
8    name: astro-app
9    gitRepo: 'https://github.com/your-org/your-astro-repo.git'
10    gitBranch: main
11    gitApplicationPath: /
12    dockerCompose:
13      build:
14        context: .
15        dockerfile: Dockerfile
16        args:
17          PUBLIC_SITE_URL: 'https://{{ components.astro-app.ingress.hosts[0] }}'
18          PUBLIC_API_URL: 'https://api-{{ env.base_domain }}'
19      ports:
20        - '8080:8080'
21    hosts:
22      - hostname: 'app-{{ env.base_domain }}'
23        path: /
24        servicePort: 8080

That's it for a static site — no database, no Redis, no sidecars. Astro builds to plain HTML and Nginx serves it.

Step 2 (Alternative): SSR Mode Configuration

If your Astro app uses SSR with the Node adapter, use this configuration instead:

YAML
1kind: Environment
2name: astro-ssr-preview
3type: primary
4
5environmentVariables:
6  API_SECRET: SECRET["your-api-secret"]
7  DATABASE_URL: SECRET["postgresql://user:pass@postgres:5432/astro"]
8
9components:
10  # ── Astro SSR Application (Node) ──
11  - kind: Application
12    name: astro-app
13    gitRepo: 'https://github.com/your-org/your-astro-repo.git'
14    gitBranch: main
15    gitApplicationPath: /
16    dockerCompose:
17      build:
18        context: .
19        dockerfile: Dockerfile
20        args:
21          PUBLIC_SITE_URL: 'https://{{ components.astro-app.ingress.hosts[0] }}'
22          PUBLIC_API_URL: 'https://{{ components.astro-app.ingress.hosts[0] }}/api'
23      environment:
24        HOST: '0.0.0.0'
25        PORT: '4321'
26        API_SECRET: '{{ env.vars.API_SECRET }}'
27        DATABASE_URL: '{{ env.vars.DATABASE_URL }}'
28      ports:
29        - '4321:4321'
30    dependsOn:
31      - postgres
32    hosts:
33      - hostname: 'app-{{ env.base_domain }}'
34        path: /
35        servicePort: 4321
36
37  # ── PostgreSQL (optional — only if your SSR app needs a database) ──
38  - kind: Database
39    name: postgres
40    dockerCompose:
41      image: 'postgres:16-alpine'
42      environment:
43        POSTGRES_DB: astro
44        POSTGRES_USER: astro
45        POSTGRES_PASSWORD: '{{ env.vars.DATABASE_URL }}'
46      ports:
47        - '5432:5432'
48
49volumes:
50  - name: postgres-data
51    mount:
52      component: postgres
53      containerPath: /var/lib/postgresql/data
54    size: 1Gi

Most Astro sites are static and don't need a database. Content collections, Markdown pages, and file-based data all work without any backend. Only add PostgreSQL (or another database) if your SSR routes explicitly query one.

Step 3: Deploy

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

  1. Build your Astro Docker image (multi-stage: Node build + Nginx serve, or Node SSR)
  2. Pull any database images if configured
  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 Astro site.

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

For static Astro sites, PUBLIC_* variables are inlined at build time. Each preview environment gets its own build with the correct PUBLIC_SITE_URL pointing to that environment's unique URL — so links, API calls, and canonical URLs all work correctly.


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. Here's one for SSR mode (static mode is even simpler):

YAML
1version: '3.8'
2
3services:
4  astro-app:
5    build:
6      context: .
7      dockerfile: Dockerfile
8      args:
9        PUBLIC_SITE_URL: 'http://localhost:4321'
10        PUBLIC_API_URL: 'http://localhost:4321/api'
11    environment:
12      HOST: '0.0.0.0'
13      PORT: '4321'
14      DATABASE_URL: 'postgresql://astro:secret@postgres:5432/astro'
15      API_SECRET: 'dev-secret-change-me'
16    ports:
17      - '4321:4321'
18    depends_on:
19      - postgres
20
21  postgres:
22    image: postgres:16-alpine
23    environment:
24      POSTGRES_DB: astro
25      POSTGRES_USER: astro
26      POSTGRES_PASSWORD: secret
27    volumes:
28      - postgres-data:/var/lib/postgresql/data
29    ports:
30      - '5432:5432'
31
32volumes:
33  postgres-data:

For a static-only site, you only need the astro-app service with the Nginx Dockerfile — no database.

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  API_SECRET: SECRET["your-api-secret"]
3  DATABASE_URL: SECRET["postgresql://astro:yourpassword@postgres:5432/astro"]

Add dynamic URLs using Bunnyshell interpolation — especially important for PUBLIC_* build args:

YAML
1build:
2  args:
3    PUBLIC_SITE_URL: 'https://{{ components.astro-app.ingress.hosts[0] }}'

Add ingress hosts if not auto-detected:

YAML
1hosts:
2  - hostname: 'app-{{ env.base_domain }}'
3    path: /
4    servicePort: 4321  # or 8080 for static/Nginx

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 (live reload). Remove these in Bunnyshell — the Docker image already contains the built site
  • Use Bunnyshell interpolation for dynamic values:
YAML
1# Local docker-compose.yml
2PUBLIC_SITE_URL: http://localhost:4321
3
4# Bunnyshell environment config (after import)
5PUBLIC_SITE_URL: 'https://{{ components.astro-app.ingress.hosts[0] }}'
  • Design for startup resilience — Kubernetes doesn't guarantee depends_on ordering. If your SSR app connects to a database, add retry logic or a health check wait

Approach C: Helm Charts

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

Step 1: Create a Helm Chart

Structure your Astro Helm chart in your repo:

Text
1helm/astro/
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 static, 4321 for SSR
7ingress:
8  enabled: true
9  className: bns-nginx
10  host: ""
11env:
12  HOST: "0.0.0.0"
13  PORT: "4321"
14buildArgs:
15  PUBLIC_SITE_URL: ""
16  PUBLIC_API_URL: ""

Step 2: Define the Bunnyshell Configuration

Create a bunnyshell.yaml using Helm components:

YAML
1kind: Environment
2name: astro-helm
3type: primary
4
5environmentVariables:
6  API_SECRET: SECRET["your-api-secret"]
7
8components:
9  # ── Docker Image Build ──
10  - kind: DockerImage
11    name: astro-image
12    context: /
13    dockerfile: Dockerfile
14    args:
15      PUBLIC_SITE_URL: 'https://app-{{ env.base_domain }}'
16      PUBLIC_API_URL: 'https://app-{{ env.base_domain }}/api'
17    gitRepo: 'https://github.com/your-org/your-astro-repo.git'
18    gitBranch: main
19    gitApplicationPath: /
20
21  # ── Astro App via Helm ──
22  - kind: Helm
23    name: astro-app
24    runnerImage: 'dtzar/helm-kubectl:3.8.2'
25    deploy:
26      - |
27        cat << EOF > astro_values.yaml
28          replicaCount: 1
29          image:
30            repository: {{ components.astro-image.image }}
31          service:
32            port: 8080
33          ingress:
34            enabled: true
35            className: bns-nginx
36            host: app-{{ env.base_domain }}
37          env:
38            HOST: '0.0.0.0'
39            PORT: '4321'
40            API_SECRET: '{{ env.vars.API_SECRET }}'
41        EOF
42      - 'helm upgrade --install --namespace {{ env.k8s.namespace }}
43        --post-renderer /bns/helpers/helm/bns_post_renderer
44        -f astro_values.yaml astro-{{ env.unique }} ./helm/astro'
45    destroy:
46      - 'helm uninstall astro-{{ env.unique }} --namespace {{ env.k8s.namespace }}'
47    start:
48      - 'helm upgrade --namespace {{ env.k8s.namespace }}
49        --post-renderer /bns/helpers/helm/bns_post_renderer
50        --reuse-values --set replicaCount=1 astro-{{ env.unique }} ./helm/astro'
51    stop:
52      - 'helm upgrade --namespace {{ env.k8s.namespace }}
53        --post-renderer /bns/helpers/helm/bns_post_renderer
54        --reuse-values --set replicaCount=0 astro-{{ env.unique }} ./helm/astro'
55    gitRepo: 'https://github.com/your-org/your-astro-repo.git'
56    gitBranch: main
57    gitApplicationPath: /helm/astro

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 astro -d astro

Execute Commands

Bash
1# Check the 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.HOST, process.env.PORT)"
6
7# View Astro build logs
8bns logs --component COMPONENT_ID -f

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# For SSR mode, the Node server restarts on file changes
4# When done:
5bns remote-development down

Live code sync for static mode requires a rebuild (npm run build) to see changes. For faster iteration during development, consider using SSR mode with output: 'hybrid' — static pages are pre-rendered but you get hot reload for server-rendered pages.


Troubleshooting

IssueSolution
404 on all pagesNginx config doesn't handle Astro's clean URLs. Use try_files $uri $uri/index.html $uri.html =404; in your Nginx config.
Connection refused (SSR)Missing HOST=0.0.0.0. The Astro Node server defaults to 127.0.0.1 which is unreachable from outside the container. Set ENV HOST=0.0.0.0 in your Dockerfile.
PUBLIC_* vars showing undefinedPUBLIC_* variables are inlined at build time, not runtime. Pass them as build.args in your bunnyshell.yaml, not as environment variables.
Wrong URLs in HTML outputPUBLIC_SITE_URL was set to localhost during build. Use Bunnyshell interpolation: 'https://{{ components.astro-app.ingress.hosts[0] }}' as a build arg.
Islands not hydratingCheck browser console for JS errors. Usually caused by mismatched PUBLIC_API_URL — the client tries to fetch from the wrong origin. Fix the build arg.
502 Bad GatewayFor SSR: Node process crashed or isn't listening on the expected port. Check logs with bns logs. For static: Nginx config error — check error.log.
Large Docker imageMissing multi-stage build. The node_modules from the build stage shouldn't be in the final Nginx image. Use COPY --from=builder /app/dist only.
Content collections emptyContent files (src/content/) must be included in the Docker build context. Check your .dockerignore isn't excluding them.
Slow buildsAdd a .dockerignore with node_modules, .astro, dist, .git. Use npm ci instead of npm install for deterministic, cached installs.
Mixed content errorsPUBLIC_SITE_URL uses http:// but the site is served over HTTPS via ingress. Always use https:// in your build args for Bunnyshell deployments.

What's Next?

  • Add a CMS backend — Connect Strapi, Directus, or another headless CMS as a separate component for content editors to preview changes
  • Add image optimization — Use @astrojs/image with a Sharp service container for on-the-fly image processing in SSR mode
  • Set up Astro DB — If using Astro's built-in database (@astrojs/db), add a libSQL/Turso component for preview environments
  • Add Pagefind — Static search index that rebuilds with each preview deployment — zero backend required
  • Test View Transitions — Astro's <ViewTransitions /> component works the same in preview environments — verify smooth page transitions per PR

Ship faster starting today.

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