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:
| 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 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'oroutput: 'hybrid'): Runs a Node.js server that renders pages on demand. Requires the@astrojs/nodeadapter. 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:
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 /app/dist /usr/share/nginx/html
24
25EXPOSE 8080
26
27CMD ["nginx", "-g", "daemon off;"]Create docker/nginx/default.conf:
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:
npx astro add nodeThis updates astro.config.mjs:
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:
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 /app/dist ./dist
24COPY /app/node_modules ./node_modules
25COPY /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.
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) orserver/hybrid(Node SSR) -
@astrojs/nodeadapter installed if using SSR mode -
HOST=0.0.0.0set 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
- Log into Bunnyshell
- Click Create project and name it (e.g., "Astro Site")
- 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:
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: 8080That'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:
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: 1GiMost 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:
- Build your Astro Docker image (multi-stage: Node build + Nginx serve, or Node SSR)
- Pull any database images if configured
- 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 Astro site.
Step 4: 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
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):
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
- 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, 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:
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:
1build:
2 args:
3 PUBLIC_SITE_URL: 'https://{{ components.astro-app.ingress.hosts[0] }}'Add ingress hosts if not auto-detected:
1hosts:
2 - hostname: 'app-{{ env.base_domain }}'
3 path: /
4 servicePort: 4321 # or 8080 for static/NginxStep 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 volumes —
volumes: ['.:/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:
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_onordering. 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:
1helm/astro/
2├── Chart.yaml
3├── values.yaml
4└── templates/
5 ├── deployment.yaml
6 ├── service.yaml
7 └── ingress.yamlA minimal values.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:
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/astroAlways 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" to ON
- Toggle "Destroy environment after merge or close pull request" to 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 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:
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 --waitRemote 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:
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 astroExecute Commands
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 -fLive 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
3# For SSR mode, the Node server restarts on file changes
4# When done:
5bns remote-development downLive 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
| Issue | Solution |
|---|---|
| 404 on all pages | Nginx 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 undefined | PUBLIC_* 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 output | PUBLIC_SITE_URL was set to localhost during build. Use Bunnyshell interpolation: 'https://{{ components.astro-app.ingress.hosts[0] }}' as a build arg. |
| Islands not hydrating | Check 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 Gateway | For 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 image | Missing 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 empty | Content files (src/content/) must be included in the Docker build context. Check your .dockerignore isn't excluding them. |
| Slow builds | Add a .dockerignore with node_modules, .astro, dist, .git. Use npm ci instead of npm install for deterministic, cached installs. |
| Mixed content errors | PUBLIC_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/imagewith 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
Related Resources
- Bunnyshell Quickstart Guide
- Docker Compose with Bunnyshell
- Helm with Bunnyshell
- Bunnyshell CLI Reference
- 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.