LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Ephemeral Environments: Preview Deployments, Branch Environments, and Testing in Isolation

devopsci-cdkubernetesdockerpreview-deploymentstestingplatform-engineeringgithub-actions

Every engineering team eventually hits the same wall: staging is a shared environment, it’s broken half the time, and no one is sure whose changes are on it. QA is blocked waiting for a stable build. Developers are afraid to push to staging because it’ll break someone else’s review. The staging database has a year’s worth of unreviewed test data in it. A hotfix needs testing but staging is occupied.

Ephemeral environments solve this by flipping the model: instead of one persistent shared staging environment, every pull request gets its own isolated, complete, disposable environment. It’s deployed automatically when the PR is opened, updated with every push, accessible at a unique URL, and destroyed when the PR is merged or closed.

Done well, this collapses the feedback loop dramatically. A designer can review the actual running UI at a shareable link without setting up a local dev environment. QA can test a feature in complete isolation from every other in-flight change. The author gets a real deployment to validate against — not just a local docker compose up. And everyone stops fighting over staging.

This guide covers the architecture patterns, the implementation options from simple to sophisticated, and the operational trade-offs that determine what approach is right for your team.


What an Ephemeral Environment Actually Is

An ephemeral environment is a complete, isolated deployment of your application stack, created on demand and destroyed when no longer needed.

“Complete” is the key word. A preview deployment that only runs the frontend and proxies the API to production isn’t an ephemeral environment — it’s a partial deployment with shared state. True ephemeral environments include:

  • Your application (all services)
  • A dedicated database (seeded with representative data)
  • Any supporting infrastructure (queues, caches, mock external services)
  • A unique publicly accessible URL

The lifecycle is:

  1. Developer opens a PR → environment automatically created
  2. Every push to the branch → environment automatically updated
  3. PR merged or closed → environment automatically destroyed

Each environment is identified by something stable and human-readable: the PR number, the branch name, or a short hash. https://pr-247.preview.yourapp.com is more useful than https://d84f3a.preview.yourapp.com.


Naming and URL Routing

Before implementation details, the routing architecture matters. You need:

  1. A wildcard DNS record: *.preview.yourapp.com → your ingress IP
  2. An ingress controller that routes by subdomain to the right environment
  3. Automatic TLS certificates for each subdomain

With Traefik or nginx-ingress, each ephemeral environment gets an Ingress resource like:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: preview-pr-247
  namespace: preview-pr-247
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
  ingressClassName: traefik
  rules:
  - host: pr-247.preview.yourapp.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: app
            port:
              number: 3000
  tls:
  - hosts:
    - pr-247.preview.yourapp.com
    secretName: preview-pr-247-tls

The wildcard DNS and cert-manager with a DNS challenge issuer handles the TLS automatically — no per-PR manual certificate work needed.


Pattern 1: Namespace-Per-Environment on Kubernetes

The most common and flexible approach for teams already running Kubernetes. Each PR gets its own Kubernetes namespace containing the full application stack.

Why Namespaces

Kubernetes namespaces provide free isolation:

  • Network policies can restrict cross-namespace traffic
  • Resource quotas prevent a runaway PR from consuming all cluster resources
  • RBAC can scope access per environment
  • Destroying the environment is kubectl delete namespace pr-247 — everything inside goes with it

The GitHub Actions Workflow

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
# .github/workflows/preview.yml
name: Preview Environment

on:
  pull_request:
    types: [opened, synchronize, reopened, closed]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}
  PR_NUMBER: ${{ github.event.pull_request.number }}
  NAMESPACE: preview-pr-${{ github.event.pull_request.number }}
  HOST: pr-${{ github.event.pull_request.number }}.preview.yourapp.com

jobs:
  deploy-preview:
    if: github.event.action != 'closed'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      pull-requests: write   # to post the preview URL as a comment

    steps:
      - uses: actions/checkout@v4

      - name: Build and push image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:pr-${{ env.PR_NUMBER }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Set up kubectl
        uses: azure/setup-kubectl@v3

      - name: Configure kubeconfig
        run: echo "${{ secrets.KUBECONFIG }}" | base64 -d > /tmp/kubeconfig

      - name: Deploy preview environment
        env:
          KUBECONFIG: /tmp/kubeconfig
        run: |
          # Create namespace if it doesn't exist
          kubectl create namespace $NAMESPACE --dry-run=client -o yaml | kubectl apply -f -

          # Label for easy selection and cleanup
          kubectl label namespace $NAMESPACE \
            app.kubernetes.io/managed-by=preview \
            preview/pr=$PR_NUMBER \
            --overwrite

          # Apply resource quota to prevent runaway usage
          kubectl apply -f - <<EOF
          apiVersion: v1
          kind: ResourceQuota
          metadata:
            name: preview-quota
            namespace: $NAMESPACE
          spec:
            hard:
              requests.cpu: "2"
              requests.memory: 2Gi
              limits.cpu: "4"
              limits.memory: 4Gi
              pods: "20"
          EOF

          # Deploy the app via Helm (or kustomize — see below)
          helm upgrade --install app ./charts/app \
            --namespace $NAMESPACE \
            --set image.tag=pr-$PR_NUMBER \
            --set image.repository=$REGISTRY/$IMAGE_NAME \
            --set ingress.host=$HOST \
            --set ingress.tls.enabled=true \
            --set database.seed=preview \
            --set env=preview \
            --set replicaCount=1 \
            --wait --timeout 5m

      - name: Comment preview URL on PR
        uses: actions/github-script@v7
        with:
          script: |
            const { data: comments } = await github.rest.issues.listComments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
            });

            const botComment = comments.find(c =>
              c.user.type === 'Bot' && c.body.includes('Preview Environment'));

            const body = `## 🚀 Preview Environment

            | | |
            |--|--|
            | **URL** | https://${{ env.HOST }} |
            | **Status** | Deployed |
            | **Commit** | \`${{ github.sha }}\` |

            _Updated: ${new Date().toISOString()}_`;

            if (botComment) {
              await github.rest.issues.updateComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                comment_id: botComment.id,
                body,
              });
            } else {
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.issue.number,
                body,
              });
            }

  destroy-preview:
    if: github.event.action == 'closed'
    runs-on: ubuntu-latest
    steps:
      - name: Configure kubeconfig
        run: echo "${{ secrets.KUBECONFIG }}" | base64 -d > /tmp/kubeconfig

      - name: Destroy preview environment
        env:
          KUBECONFIG: /tmp/kubeconfig
        run: kubectl delete namespace $NAMESPACE --ignore-not-found=true

Helm Values for Preview Environments

Your Helm chart needs to handle preview-specific configuration:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# charts/app/values-preview.yaml
replicaCount: 1        # single replica — save resources

image:
  pullPolicy: Always   # always pull the latest PR image

database:
  create: true         # provision a dedicated DB per environment
  seed: preview        # seed with anonymized preview data
  size: 1Gi            # small — this is ephemeral

redis:
  enabled: true
  size: 256Mi

ingress:
  enabled: true
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod

resources:
  requests:
    cpu: 100m
    memory: 128Mi
  limits:
    cpu: 500m
    memory: 512Mi

# Disable features that don't make sense in preview
features:
  emailSending: false    # use a mock/catch-all instead
  payments: false        # use Stripe test mode
  backgroundJobs: true   # keep workers — they affect behaviour

Database Seeding Strategy

A preview database that’s empty is barely more useful than no database. Seed strategies from simplest to most realistic:

Option 1: Fixture data — static SQL/JSON fixtures committed to the repo, loaded on init.

1
2
# In your deployment init container or Helm hook
psql $DATABASE_URL < /seeds/preview-fixtures.sql

Option 2: Anonymized production snapshot — take a recent prod dump, scrub PII, compress, store in S3. Load it on every preview deployment.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Nightly: dump, anonymize, upload
pg_dump $PROD_DB | \
  python anonymize.py | \
  gzip | \
  aws s3 cp - s3://preview-seeds/latest.sql.gz

# On preview deployment init:
aws s3 cp s3://preview-seeds/latest.sql.gz - | \
  gunzip | \
  psql $PREVIEW_DB

Option 3: Schema-only with factory data — run migrations to get the schema, then generate synthetic data with factories. Slower to set up, always in sync with schema.

1
2
3
4
5
# Run migrations
alembic upgrade head   # or rails db:migrate, prisma migrate deploy, etc.

# Generate synthetic data
python generate_preview_data.py --users=100 --orders=500

Pattern 2: Docker Compose with Traefik Labels

For smaller teams or projects without Kubernetes, ephemeral environments can be built with Docker Compose and Traefik’s dynamic configuration. Each environment is a Compose stack running on a shared server, identified by a project name.

The Architecture

GitHub Actions Runner → SSH → Preview Server
                                    │
                            Traefik (port 80/443)
                                    │
                    ┌───────────────┼───────────────┐
                    │               │               │
             pr-247 stack    pr-248 stack    pr-249 stack
             (app + db)      (app + db)      (app + db)

Docker Compose Template

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
# docker-compose.preview.yml
# Parameterized with environment variables
services:
  app:
    image: ghcr.io/yourorg/yourapp:${IMAGE_TAG}
    environment:
      DATABASE_URL: postgresql://app:${DB_PASSWORD}@db:5432/${DB_NAME}
      REDIS_URL: redis://redis:6379
      APP_ENV: preview
      APP_URL: https://${PREVIEW_HOST}
      EMAIL_DRIVER: log    # log emails instead of sending
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.${COMPOSE_PROJECT_NAME}.rule=Host(`${PREVIEW_HOST}`)"
      - "traefik.http.routers.${COMPOSE_PROJECT_NAME}.tls=true"
      - "traefik.http.routers.${COMPOSE_PROJECT_NAME}.tls.certresolver=letsencrypt"
      - "traefik.http.services.${COMPOSE_PROJECT_NAME}.loadbalancer.server.port=3000"
    depends_on:
      db:
        condition: service_healthy
    networks:
      - internal
      - traefik-public

  worker:
    image: ghcr.io/yourorg/yourapp:${IMAGE_TAG}
    command: worker
    environment:
      DATABASE_URL: postgresql://app:${DB_PASSWORD}@db:5432/${DB_NAME}
      REDIS_URL: redis://redis:6379
    depends_on:
      - redis
    networks:
      - internal

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: ${DB_NAME}
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - db-data:/var/lib/postgresql/data
      - ./seeds/preview.sql:/docker-entrypoint-initdb.d/seed.sql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 5s
      timeout: 5s
      retries: 10
    networks:
      - internal

  redis:
    image: redis:7-alpine
    networks:
      - internal

networks:
  internal:
  traefik-public:
    external: true

volumes:
  db-data:

Deployment Script

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#!/usr/bin/env bash
# deploy-preview.sh — runs on the preview server via SSH
set -euo pipefail

PR_NUMBER="${1}"
IMAGE_TAG="${2}"
ACTION="${3:-deploy}"   # deploy | destroy

PROJECT_NAME="preview-pr-${PR_NUMBER}"
PREVIEW_HOST="pr-${PR_NUMBER}.preview.yourapp.com"
DB_NAME="preview_pr_${PR_NUMBER}"
DB_PASSWORD=$(openssl rand -base64 20 | tr -d '=/+')
DEPLOY_DIR="/opt/previews/${PROJECT_NAME}"

if [[ "$ACTION" == "destroy" ]]; then
  echo "Destroying preview for PR ${PR_NUMBER}..."
  cd "$DEPLOY_DIR"
  docker compose -p "$PROJECT_NAME" down -v --remove-orphans
  rm -rf "$DEPLOY_DIR"
  echo "Destroyed."
  exit 0
fi

# Create deploy directory
mkdir -p "$DEPLOY_DIR"
cp /opt/previews/docker-compose.preview.yml "$DEPLOY_DIR/"

# Write environment file (not committed — generated per deployment)
cat > "$DEPLOY_DIR/.env" <<EOF
COMPOSE_PROJECT_NAME=${PROJECT_NAME}
IMAGE_TAG=${IMAGE_TAG}
PREVIEW_HOST=${PREVIEW_HOST}
DB_NAME=${DB_NAME}
DB_PASSWORD=${DB_PASSWORD}
EOF

# Pull the new image
docker pull "ghcr.io/yourorg/yourapp:${IMAGE_TAG}"

# Deploy (or update)
cd "$DEPLOY_DIR"
docker compose -p "$PROJECT_NAME" up -d --remove-orphans --wait

echo "Preview deployed: https://${PREVIEW_HOST}"
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# In GitHub Actions — call the script via SSH
- name: Deploy preview
  uses: appleboy/ssh-action@v1
  with:
    host: ${{ secrets.PREVIEW_SERVER_HOST }}
    username: deploy
    key: ${{ secrets.PREVIEW_SERVER_SSH_KEY }}
    script: |
      /opt/previews/deploy-preview.sh \
        ${{ github.event.pull_request.number }} \
        pr-${{ github.event.pull_request.number }} \
        deploy

Cleanup: Don’t Leak Environments

Environments must be destroyed reliably. Two safety nets beyond the PR closed trigger:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 1. Destroy on PR close (primary)
- name: Destroy preview
  if: github.event.action == 'closed'
  ...

# 2. Scheduled cleanup of environments older than 7 days
# .github/workflows/cleanup-previews.yml
on:
  schedule:
    - cron: '0 3 * * *'   # nightly at 3am
jobs:
  cleanup:
    runs-on: ubuntu-latest
    steps:
      - name: Clean up stale preview namespaces
        env:
          KUBECONFIG: ...
        run: |
          # Delete namespaces with preview label older than 7 days
          kubectl get namespaces -l app.kubernetes.io/managed-by=preview \
            -o json | \
          jq -r '.items[] |
            select(
              (now - (.metadata.creationTimestamp | fromdateiso8601)) > 604800
            ) | .metadata.name' | \
          xargs -r kubectl delete namespace

Pattern 3: Managed Preview Services

If you don’t want to manage the infrastructure yourself, several platforms provide ephemeral environments as a service:

Vercel / Netlify (Frontend)

For frontend applications, Vercel and Netlify provide preview deployments out of the box with zero configuration — every PR gets a unique URL automatically. This works for static sites and serverless functions but doesn’t help with full-stack applications that need a backend and database.

Railway / Render

Railway and Render both support preview environments for full-stack applications. Railway creates a copy of your services and databases from a template; Render creates “preview environments” that spin up all services from a render.yaml configuration:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# render.yaml
services:
  - type: web
    name: app
    env: node
    buildCommand: npm run build
    startCommand: npm start
    envVars:
      - key: DATABASE_URL
        fromDatabase:
          name: app-db
          property: connectionString

databases:
  - name: app-db
    databaseName: app
    plan: starter
    previewPlan: starter

Argo CD ApplicationSets (GitOps)

For teams using GitOps with Argo CD, ApplicationSets with the Pull Request generator automatically create Argo CD Applications for each open PR:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: preview-environments
  namespace: argocd
spec:
  generators:
  - pullRequest:
      github:
        owner: your-org
        repo: your-app
        tokenRef:
          secretName: github-token
          key: token
        labels:
        - preview   # only PRs with this label get a preview
      requeueAfterSeconds: 30

  template:
    metadata:
      name: 'preview-{{branch}}-{{number}}'
    spec:
      project: previews
      source:
        repoURL: https://github.com/your-org/your-app
        targetRevision: '{{head_sha}}'
        path: charts/app
        helm:
          values: |
            image:
              tag: pr-{{number}}
            ingress:
              host: pr-{{number}}.preview.yourapp.com
            database:
              seed: preview
      destination:
        server: https://kubernetes.default.svc
        namespace: 'preview-{{number}}'
      syncPolicy:
        automated:
          prune: true
          selfHeal: true
        syncOptions:
        - CreateNamespace=true

With this setup, labeling a PR with preview triggers Argo CD to deploy it. Removing the label or closing the PR automatically destroys the environment.


Handling Databases in Ephemeral Environments

Databases are the hardest part. Options from simplest to most sophisticated:

Option 1: Shared Schema, Isolated Prefix

Use one database server but prefix all table names or use a separate schema per environment. Simple, low resource overhead, but leaks if your app doesn’t support prefixing.

1
2
3
4
-- Each preview environment gets its own PostgreSQL schema
CREATE SCHEMA preview_pr_247;
SET search_path = preview_pr_247;
-- Now all tables are created in this schema

Option 2: Dedicated Database Container Per Environment

Each Compose stack or Kubernetes namespace gets its own database pod. Fully isolated, straightforward, but resource-intensive at scale.

Option 3: Database Branching

Purpose-built database platforms support copy-on-write database branching — instantaneous copies of a database that diverge independently:

Neon (serverless Postgres) has first-class branch support:

1
2
3
4
5
6
7
8
# Create a branch for PR 247 from the main branch
neon branches create --name pr-247 --parent main

# Get the connection string for this branch
neon connection-string pr-247

# Delete the branch when the PR closes
neon branches delete pr-247

PlanetScale (MySQL) has similar branching semantics. Supabase supports database branching in their managed platform. These services integrate well with preview environments because a branch is instantaneous regardless of database size — it’s copy-on-write at the storage layer, not a physical copy.

Option 4: Read Replica + Write Sandbox

For very large databases where even a partial copy is impractical, use the production database (or a read replica) for reads, and capture writes in a sandbox layer that intercepts write queries. This is complex to implement but some companies with very large datasets use this approach.


Resource Management and Cost Control

Ephemeral environments can get expensive if uncontrolled. Key levers:

Kubernetes Resource Quotas

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
apiVersion: v1
kind: ResourceQuota
metadata:
  name: preview-quota
  namespace: preview-pr-247
spec:
  hard:
    requests.cpu: "1"
    requests.memory: 1Gi
    limits.cpu: "2"
    limits.memory: 2Gi
    pods: "10"
    persistentvolumeclaims: "3"
    requests.storage: 5Gi

Auto-Sleep / Scale-to-Zero

For environments that aren’t actively being used, scaling to zero while keeping the namespace alive saves resources without destroying the environment:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Knative Serving — automatic scale to zero after idle timeout
apiVersion: serving.knative.dev/v1
kind: Service
metadata:
  name: app
  namespace: preview-pr-247
spec:
  template:
    metadata:
      annotations:
        autoscaling.knative.dev/min-scale: "0"   # scale to zero when idle
        autoscaling.knative.dev/max-scale: "1"
        autoscaling.knative.dev/target: "10"

Alternatively, a simple cron job that scales preview Deployments to 0 replicas at midnight and back to 1 at business hours:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# CronJob: sleep preview environments at night
apiVersion: batch/v1
kind: CronJob
metadata:
  name: sleep-previews
  namespace: kube-system
spec:
  schedule: "0 22 * * *"   # 10pm
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - name: kubectl
            image: bitnami/kubectl
            command:
            - sh
            - -c
            - |
              kubectl get namespaces -l app.kubernetes.io/managed-by=preview \
                -o name | \
              xargs -I{} kubectl scale deployment --all --replicas=0 -n {}

Limit the Number of Active Preview Environments

1
2
3
4
5
# In your GitHub Actions workflow — close stale PRs or skip preview
# for draft PRs to avoid runaway environment proliferation
- name: Skip preview for draft PRs
  if: github.event.pull_request.draft == true
  run: echo "Skipping preview for draft PR"

Testing in Ephemeral Environments

The real payoff of ephemeral environments is running integration and end-to-end tests against a real deployment:

Post-Deploy Smoke Tests

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# After deployment step in GitHub Actions
- name: Run smoke tests against preview
  run: |
    # Wait for environment to be healthy
    timeout 120 bash -c \
      'until curl -sf https://${{ env.HOST }}/health; do sleep 5; done'

    # Run smoke test suite
    npx playwright test \
      --project=chromium \
      --base-url=https://${{ env.HOST }} \
      tests/smoke/

Visual Regression Testing

1
2
3
4
5
6
7
8
- name: Visual regression tests
  uses: chromaui/action@v1
  with:
    projectToken: ${{ secrets.CHROMATIC_TOKEN }}
    buildScriptName: build-storybook
    # Or run against the full preview URL:
    autoAcceptChanges: false
    exitOnceUploaded: false

Sharing the Environment URL in PRs

Beyond just the URL, a rich PR comment that includes:

  • Link to the preview
  • Direct links to key flows (login page, the feature being reviewed)
  • Environment status and last deploy time
  • Link to the deployment logs
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// In GitHub Actions script step
const body = `## Preview Environment — Ready ✅

**[→ Open Preview](https://${host})**

| Link | Description |
|------|-------------|
| [Home](https://${host}/) | Landing page |
| [Login](https://${host}/login) | Auth flow |
| [${featurePath}](https://${host}${featurePath}) | This PR's feature |

**Build:** \`${sha.slice(0, 7)}\`
**Deployed:** ${new Date().toLocaleString('en-US', {timeZone: 'UTC'})} UTC
**Logs:** [GitHub Actions](${runUrl})

---
_Environment will be destroyed when this PR is merged or closed._`;

Security Considerations

Ephemeral environments introduce a real attack surface:

Secrets management: Preview environments need database credentials, API keys, and service tokens. Never put real production secrets in preview environments. Use:

  • Separate preview-tier credentials (Stripe test mode, SendGrid sandbox)
  • A dedicated secrets store namespace with limited permissions
  • Sealed Secrets or External Secrets Operator to inject preview-specific secrets per namespace

Network exposure: Preview environments are reachable from the internet. Apply:

  • Firewall rules that limit inbound to HTTPS only
  • Authentication on the preview itself (HTTP basic auth via Traefik middleware)
  • IP allowlisting if your team all works from known IPs or a VPN
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Traefik middleware: protect all preview environments with basic auth
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: preview-auth
  namespace: traefik
spec:
  basicAuth:
    secret: preview-credentials    # Kubernetes secret with htpasswd format
    realm: "Preview Environment"
    removeHeader: true

# Apply to all preview ingresses
annotations:
  traefik.ingress.kubernetes.io/router.middlewares: traefik-preview-auth@kubernetescrd

Image scanning: The PR image being deployed should be scanned before deployment:

1
2
3
4
5
6
7
- name: Scan image for vulnerabilities
  uses: aquasecurity/trivy-action@master
  with:
    image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:pr-${{ env.PR_NUMBER }}
    exit-code: '1'
    severity: 'CRITICAL'
    ignore-unfixed: true

When Ephemeral Environments Are Overkill

Ephemeral environments aren’t always the right investment:

  • Solo developers or very small teams — the overhead of setting up the infrastructure often exceeds the value when you’re the only reviewer
  • Simple static sites — Vercel/Netlify handle this for free with zero setup
  • Very fast local dev cycle — if the full stack runs in docker compose up in 30 seconds and everyone on the team works that way, shared staging may be sufficient
  • Regulated environments — some compliance frameworks complicate spinning up and destroying environments freely

The sweet spot is teams of 3–20 engineers with a reasonably complex stack where the shared staging environment is a regular source of friction. At that point, the investment in ephemeral environments pays back in the first week of use.


Getting Started: The Minimal Viable Preview

The most common mistake is over-engineering the first version. The simplest possible ephemeral environment that provides real value:

  1. Build the image on every PR push (you probably already do this)
  2. Deploy it to a single Kubernetes namespace using helm upgrade --install with the PR number
  3. Post the URL as a PR comment
  4. Delete the namespace on PR close

That’s it. No database branching, no scale-to-zero, no visual regression tests. Add those once the basic loop is working and you’ve seen the value first-hand. The infrastructure to do this is 80 lines of GitHub Actions YAML and a Helm chart you likely already have.

Start simple. The compounding value comes from getting it running fast and getting your team into the habit of reviewing real deployments — not from building a perfect preview environment system that takes six weeks to finish.

Comments