On-Demand Preview Environments: Ephemeral Infrastructure for Pull Request Workflows
A practical guide to building on-demand preview environments that spin up automatically for every pull request and tear down on merge. Covers architecture patterns, DNS routing, database isolation, CI/CD integration, cost management, and security.
Your staging environment is a shared lie. Five engineers are pushing to it simultaneously. The QA team is testing the payment flow while a database migration is running. Nobody agrees on which branch staging actually reflects. When a bug surfaces in staging, the first question is always “which PR broke it?” and the answer takes 20 minutes to determine.
Preview environments solve this by giving every pull request its own isolated, fully deployed environment. A PR opens, infrastructure spins up automatically, a unique URL appears in the PR comment, and QA reviews against that exact code. PR merges, environment tears down. The feedback loop shrinks from hours to minutes.
This guide covers the infrastructure mechanics, not the marketing. You will see exactly how to build this.
The Architecture Decision
Three patterns dominate how teams implement preview environments.
Container-based (Docker Compose / Fly.io model): Build a Docker image from the branch, deploy it to an isolated compute slot, assign a unique hostname. Works well for monolithic apps. Startup time is 30-90 seconds depending on image size. Cost is predictable: one machine per environment.
Kubernetes namespace per PR: Create a namespace named pr-{number}, apply your Helm chart or raw manifests, expose via Ingress. Works for multi-service apps where you need sidecars, init containers, and service-to-service communication. Teardown is a single kubectl delete namespace. Cost scales with the cluster, and you share the control plane overhead across all preview environments.
Serverless (edge functions + managed services): Deploy function code to edge workers, point a unique subdomain at it. Zero cold-start cost for environments nobody is actively using. Works extremely well for API layers but requires your entire application to be deployable as stateless functions.
For most teams building SaaS applications, the Kubernetes namespace approach gives the best balance of isolation, flexibility, and operational familiarity. The rest of this guide focuses there, with notes on where the other patterns diverge.
DNS and Routing
Every preview environment needs a unique URL. The standard approach is wildcard DNS.
Point *.preview.yourdomain.com at your ingress controller’s external IP. Then for PR 417, the environment lives at pr-417.preview.yourdomain.com. Your ingress controller routes by hostname.
// infrastructure/preview/ingress.ts
import { Ingress } from "@pulumi/kubernetes/networking/v1";
export function createPreviewIngress(
prNumber: number,
namespaceName: string,
serviceName: string,
servicePort: number
): Ingress {
const host = `pr-${prNumber}.preview.yourdomain.com`;
return new Ingress(
`preview-ingress-pr-${prNumber}`,
{
metadata: {
name: `preview-ingress`,
namespace: namespaceName,
annotations: {
"kubernetes.io/ingress.class": "nginx",
"cert-manager.io/cluster-issuer": "letsencrypt-prod",
"nginx.ingress.kubernetes.io/proxy-read-timeout": "60",
},
},
spec: {
tls: [{ hosts: [host], secretName: `preview-tls-pr-${prNumber}` }],
rules: [
{
host,
http: {
paths: [
{
path: "/",
pathType: "Prefix",
backend: {
service: {
name: serviceName,
port: { number: servicePort },
},
},
},
],
},
},
],
},
},
{ parent: namespaceName as unknown as Ingress }
);
}
cert-manager with the ACME wildcard challenge handles TLS automatically. The certificate is issued once for *.preview.yourdomain.com and reused across all preview environments. You do not want per-environment certificate issuance: it is slow and burns your ACME rate limit.
For path-based routing (if wildcard DNS is not available), you can prefix all routes with /pr-417/ and configure your ingress rules accordingly. It works but breaks applications that generate absolute URLs without the path prefix, which is most applications.
Database Strategy
Database isolation is the hardest part of preview environments. Three approaches, in order of isolation strength:
Shared staging database with schema namespacing: Every preview environment connects to the same database but uses a schema prefix (pr_417_users instead of users). Cheap and fast to provision. Breaks down when your application uses hardcoded table names in raw SQL queries, views, or stored procedures. Also creates a cleanup problem: dropped PRs do not always clean up their schemas.
Snapshot and restore: Take a sanitized snapshot of staging data, restore it into an isolated database per PR. Full data isolation, realistic data volume. Expensive: a 10GB database takes 3-5 minutes to restore and costs real money at scale.
Seed-only databases: Start with a fresh empty database and run your seed script against it. Fast (15-30 seconds), free (tiny database), and deterministic. The tradeoff: if your seed data does not cover the case being tested, the environment is useless.
For most teams, seed-only with a well-maintained seed script is the right default. Here is a typed seeder that works across environments:
// scripts/seed-preview.ts
import { db } from "../src/db";
interface SeedOptions {
prNumber: number;
organizationCount?: number;
usersPerOrg?: number;
}
async function seedPreviewEnvironment(options: SeedOptions): Promise<void> {
const { prNumber, organizationCount = 3, usersPerOrg = 5 } = options;
console.log(`Seeding preview environment for PR #${prNumber}`);
// Use deterministic IDs so reruns are idempotent
for (let orgIndex = 0; orgIndex < organizationCount; orgIndex++) {
const orgId = `pr-${prNumber}-org-${orgIndex}`;
await db
.insertInto("organizations")
.values({
id: orgId,
name: `Preview Org ${orgIndex + 1}`,
plan: orgIndex === 0 ? "enterprise" : "starter",
created_at: new Date(),
})
.onConflict((oc) => oc.column("id").doNothing())
.execute();
for (let userIndex = 0; userIndex < usersPerOrg; userIndex++) {
await db
.insertInto("users")
.values({
id: `pr-${prNumber}-user-${orgIndex}-${userIndex}`,
organization_id: orgId,
email: `user-${userIndex}@pr-${prNumber}.seed.example.com`,
role: userIndex === 0 ? "admin" : "member",
created_at: new Date(),
})
.onConflict((oc) => oc.column("id").doNothing())
.execute();
}
}
console.log(
`Seeded ${organizationCount} organizations, ${organizationCount * usersPerOrg} users`
);
}
const prNumber = parseInt(process.env.PR_NUMBER ?? "0", 10);
if (!prNumber) {
console.error("PR_NUMBER environment variable is required");
process.exit(1);
}
seedPreviewEnvironment({ prNumber }).catch((err) => {
console.error("Seed failed:", err);
process.exit(1);
});
The @seed.example.com email domain is important: it ensures seed users never collide with real accounts if production data ever leaks into a preview database.
GitHub Actions Integration
The CI/CD integration handles three events: PR opened (create), PR updated (update), and PR closed (destroy).
# .github/workflows/preview.yml
name: Preview Environment
on:
pull_request:
types: [opened, synchronize, reopened, closed]
env:
PREVIEW_DOMAIN: preview.yourdomain.com
KUBECONFIG_SECRET: PREVIEW_KUBECONFIG
jobs:
deploy-preview:
if: github.event.action != 'closed'
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v4
- name: Set environment variables
run: |
echo "PR_NUMBER=${{ github.event.pull_request.number }}" >> $GITHUB_ENV
echo "NAMESPACE=pr-${{ github.event.pull_request.number }}" >> $GITHUB_ENV
echo "IMAGE_TAG=pr-${{ github.event.pull_request.number }}-${{ github.sha }}" >> $GITHUB_ENV
echo "PREVIEW_URL=https://pr-${{ github.event.pull_request.number }}.${{ env.PREVIEW_DOMAIN }}" >> $GITHUB_ENV
- name: Build and push image
run: |
docker build -t ghcr.io/${{ github.repository }}:${{ env.IMAGE_TAG }} .
docker push ghcr.io/${{ github.repository }}:${{ env.IMAGE_TAG }}
- name: Deploy to preview namespace
env:
KUBECONFIG: ${{ secrets[env.KUBECONFIG_SECRET] }}
run: |
kubectl create namespace ${{ env.NAMESPACE }} --dry-run=client -o yaml | kubectl apply -f -
helm upgrade --install \
preview-${{ env.PR_NUMBER }} ./helm/app \
--namespace ${{ env.NAMESPACE }} \
--set image.tag=${{ env.IMAGE_TAG }} \
--set ingress.host=pr-${{ env.PR_NUMBER }}.${{ env.PREVIEW_DOMAIN }} \
--set env.PR_NUMBER=${{ env.PR_NUMBER }} \
--set env.DATABASE_URL=${{ secrets.PREVIEW_DATABASE_URL }} \
--wait --timeout 5m
- name: Run database migrations and seed
env:
KUBECONFIG: ${{ secrets[env.KUBECONFIG_SECRET] }}
run: |
kubectl run seed-${{ env.PR_NUMBER }} \
--namespace ${{ env.NAMESPACE }} \
--image=ghcr.io/${{ github.repository }}:${{ env.IMAGE_TAG }} \
--restart=Never \
--env="DATABASE_URL=${{ secrets.PREVIEW_DATABASE_URL }}" \
--env="PR_NUMBER=${{ env.PR_NUMBER }}" \
--command -- npx tsx scripts/seed-preview.ts
kubectl wait --for=condition=complete \
job/seed-${{ env.PR_NUMBER }} \
--namespace ${{ env.NAMESPACE }} \
--timeout=120s
- name: Post preview URL comment
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\n\n**URL:** ${{ env.PREVIEW_URL }}\n**Commit:** ${{ github.sha }}\n\nDeployment completed.`;
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: Destroy preview namespace
env:
KUBECONFIG: ${{ secrets[env.KUBECONFIG_SECRET] }}
run: |
kubectl delete namespace pr-${{ github.event.pull_request.number }} \
--ignore-not-found=true \
--wait=false
The comment update-or-create logic is important: on synchronize events (new commits pushed to the PR), you want to update the existing comment rather than post a new one. A PR with 20 commits should not have 20 separate preview URL comments.
Cost Management
Unrestricted preview environments will surprise your cloud bill. Four controls matter:
Idle shutdown: Most preview environments are used for 20 minutes then forgotten. Deploy a controller that monitors last-request timestamps and scales deployments to zero after 30 minutes of inactivity. Scale back up on the next request (with a loading page during warmup).
Resource limits: Set tight resource requests and limits in your Helm chart. For most web applications, 256Mi memory and 0.1 CPU per container is sufficient for review purposes.
Maximum age TTL: Any preview environment older than 7 days gets torn down automatically, regardless of PR state. This catches stale PRs that nobody closed and long-running feature branches.
Namespace resource quotas: Apply a ResourceQuota to each preview namespace that caps total CPU and memory. This prevents a single broken PR from consuming the entire cluster’s resources.
// infrastructure/preview/quota.ts
import { ResourceQuota } from "@pulumi/kubernetes/core/v1";
export function createNamespaceQuota(namespaceName: string): ResourceQuota {
return new ResourceQuota(`quota-${namespaceName}`, {
metadata: {
name: "preview-quota",
namespace: namespaceName,
},
spec: {
hard: {
"requests.cpu": "500m",
"requests.memory": "512Mi",
"limits.cpu": "1000m",
"limits.memory": "1Gi",
pods: "10",
},
},
});
}
Security Considerations
Preview environments introduce real attack surface. Three non-obvious concerns:
Secrets exposure: Preview environments should have access to preview-tier secrets only. Never pass production API keys, production database credentials, or production OAuth client secrets to preview environments. Create separate credential sets scoped to preview use. The workflow above pulls from PREVIEW_DATABASE_URL, not PRODUCTION_DATABASE_URL. This is deliberate.
Public access: Wildcard subdomains are crawlable. If your preview environments contain unreleased features, competitor or attacker access is real. Add HTTP basic auth at the ingress level using an nginx annotation and a shared preview password stored as a Kubernetes secret. Alternatively, gate access via OAuth using the PR author’s GitHub identity.
SSRF via environment variables: If your application reads URLs from environment variables and makes requests to them, a malicious PR could set UPSTREAM_SERVICE_URL to an internal cluster endpoint. Validate and allowlist URLs that come from configuration.
Tradeoffs
| Dimension | Kubernetes namespace per PR | Serverless per PR | Shared staging only |
|---|---|---|---|
| Isolation | Full (network, compute, storage) | Full compute, shared storage | None |
| Startup time | 60-120s | 5-15s | Instant (already running) |
| Cost per environment | $0.05-0.20/hour | Near-zero (idle) | Zero (amortized) |
| Multi-service support | Yes | Difficult | Yes |
| Database isolation | Configurable | Requires external DB | None |
| Operational complexity | High | Medium | Low |
| Works without Kubernetes | No | Yes | Yes |
Production Considerations
Preview environments are not staging. Staging exists to catch integration issues before production. Preview environments exist to review a specific change in isolation. Keep them separate. Merging to main should still go through staging before production.
Mirror your production deploy process. If production uses helm upgrade with a specific values file, preview environments should use the same chart with preview-specific overrides. Building a separate Docker Compose setup for previews and a Helm chart for production means your preview environments are not actually testing the same artifact.
Observability parity. Ship the same logging and tracing configuration to preview environments that you ship to production. When a reviewer finds a bug in a preview environment, you want structured logs with trace IDs, not console.log output.
Database migration testing. Run your full migration set against the preview database before seeding. This catches migration errors before they reach staging or production. A failed migration in a preview environment is cheap. A failed migration during a production deploy is an incident.
Flaky creation. Preview environment creation will occasionally fail: image build timeouts, DNS propagation delays, migration errors. Build retry logic into your workflow, or at minimum post the failure as a PR comment with enough detail to debug. A silent failure looks identical to a slow deployment, which causes confusion.
Platform Comparison
Managed platforms handle the infrastructure mechanics for you. The tradeoffs are real:
Platforms that offer built-in preview deployments (for static sites and edge functions) provision environments in under 30 seconds but are limited to what their runtime supports. If your application requires background workers, WebSocket servers, or long-running processes, you are outside their model.
Container-native platforms can deploy arbitrary Docker images and give you per-PR environments with minimal configuration. They abstract away the Kubernetes complexity at the cost of less control over networking and secrets management.
Building on raw Kubernetes gives you full control: namespace isolation, custom resource quotas, network policies between preview environments, and the ability to spin up the full stack including Redis, queues, and auxiliary services. The cost is the operational burden: someone on your team needs to own the preview infrastructure.
The right choice depends on your application’s runtime requirements. If your app is a Next.js frontend calling a stateless API, a managed platform is the right default. If your app has 12 services with complex inter-service communication, build it on Kubernetes.
Closing
Preview environments are a forcing function for deployment discipline. If spinning up an environment on every PR is painful, it reveals that your deployment process is not yet repeatable. The friction is informative.
The teams that get the most value from preview environments are the ones who treat them as the primary review surface. Code review catches logic errors. Preview environments catch the things code review cannot: UI regressions, integration failures, migration side effects, and the interactions between features that look fine in isolation but break together. Build the automation once, keep the seed data realistic, and let the environments do the work.
More in DevOps
How Terraform Works Internally: HCL Parsing, Dependency Graph Execution, and the Provider Protocol Behind Infrastructure as Code
A deep dive into Terraform's internal architecture covering HCL parsing, the expression evaluation engine, DAG-based dependency resolution, the gRPC provider plugin protocol, state mechanics, and the plan/apply lifecycle.
How Docker Works Internally: Namespaces, Cgroups, Union Filesystems, and the OCI Runtime Behind Every Container
A deep dive into what happens when you run docker run: Linux namespaces for process isolation, cgroups v2 for resource enforcement, overlay2 union filesystem for layered images, the OCI runtime spec and how runc spawns containers, content-addressable storage, the containerd shim architecture, and networking with veth pairs.
How Kubernetes Works: Pods, Scheduling, and the Reconciliation Loop
A deep dive into Kubernetes internals covering the control plane architecture, etcd watch mechanics, the scheduler's filter and score phases, controller manager reconciliation loops, kubelet pod assignment, the CRI and CNI plugin models, and production considerations for resource requests, pod disruption budgets, and cluster autoscaler behavior.
How Docker Works: Namespaces, Cgroups, Union Filesystems, and the Container Runtime from Build to Execution
A deep dive into Docker internals covering Linux namespace isolation, cgroup resource constraints, OverlayFS copy-on-write layer mechanics, Dockerfile build cache invalidation rules, the containerd and runc OCI runtime stack, and production considerations for image hardening and startup latency.