Dynamic preview environments (also known as ephemeral environments) represent an infrastructure isolation paradigm where an independent, functionally complete application stack is automatically provisioned for every open Pull Request. This eliminates the traditional bottleneck of a shared staging server, where concurrent testing of conflicting feature branches blocks release velocity.
The architectural lifecycle of ephemeral preview environments rests on five engineering pillars:
https://pr-412.preview.company.internal or platform-generated preview domains (Vercel, Coolify, Traefik Ingress). Traffic is strictly isolated at the Ingress controller or virtual host level.createDeployment and createDeploymentStatus endpoints integrates previews directly into the GitHub pull request UI—rendering interactive "View deployment" buttons, status badges (pending, in_progress, success, failure, inactive), and permanent timeline audit records.<!-- metadata:pr-preview-env-marker -->. The script queries existing PR comments, searches for this marker, and executes an HTTP PATCH mutation on the existing comment ID to update status in place.pull_request: [closed] event (which triggers on both successful merge and manual closure without merge) must reliably deprovision all associated resources: Kubernetes namespaces, Docker containers, ephemeral DNS records, and database branch clones.The end-to-end lifecycle between GitHub events, CI runners, orchestrators, and the PR interface forms a closed feedback loop:
[ Developer: Push to PR Branch ]
|
v
[ GitHub Event: pull_request (opened / synchronize) ]
|
v
[ Workflow: preview-deploy.yml ]
|
+---> [ GitHub Deployments API: Status = 'pending' ]
|
+---> [ Orchestrator (K8s Namespace / Coolify / Vercel CLI) ]
| |
| v
| [ Build & Deploy Next.js Standalone ]
| [ Provision Isolated DB Branch (Copy-on-Write) ]
| [ Assign URL: pr-104.preview.domain.com ]
|
+---> [ Healthcheck: GET https://pr-104.../api/health -> 200 OK ]
|
+---> [ Generate PNG/SVG QR Code Data URI ]
|
+---> [ Octokit Bot: Search <!-- pr-marker --> in comments ]
| |
| ├── Found? ---> [ PATCH /comments/:id (In-place update) ]
| └── None? ---> [ POST /comments (Create primary) ]
|
v
[ GitHub Deployments API: Status = 'success', Environment URL ]
======================= TEARDOWN LIFECYCLE =======================
[ PR Closed / Merged: pull_request (closed) ]
|
v
[ Workflow: preview-cleanup.yml ]
|
+---> [ Delete K8s Namespace / Terminate Containers ]
|
+---> [ Deprovision Neon / Supabase DB Branch ]
|
+---> [ GitHub Deployments API: Status = 'inactive' ]
|
v
[ Octokit Bot: Update Comment -> "⚪ Preview Environment Deprovisioned" ]pr-${{ github.event.number }}). A Traefik Ingress Controller issues wildcard Let's Encrypt TLS certificates dynamically.A resilient preview system comprises two coordinated workflows: a deployment pipeline and an automated teardown pipeline, driven by an Octokit TypeScript automation script.
The deployment workflow monitors three pull request lifecycle events: opened, synchronize (new commit pushed to the PR branch), and reopened.
name: Preview Deployment
on:
pull_request:
types:
- opened
- synchronize
- reopened
paths-ignore:
- '**/*.md'
- '.github/workflows/docs.yml'
concurrency:
group: preview-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
deployments: writeConfiguring cancel-in-progress: true is essential: if a developer pushes three commits in rapid succession, obsolete intermediate builds are terminated immediately, saving runner minutes and cloud compute costs.
To generate self-contained, CDN-independent QR codes, the bot script utilizes the qrcode library to output a data:image/png;base64,... Data URI.
The script identifies previous comments using the hidden signature <!-- metadata:pr-preview-env-marker -->.
import { Octokit } from '@octokit/rest';
import QRCode from 'qrcode';
interface DeploymentNotificationParams {
githubToken: string;
owner: string;
repo: string;
prNumber: number;
previewUrl: string;
commitSha: string;
status: 'deploying' | 'ready' | 'failed' | 'destroyed';
buildDurationSec?: number;
}
const BOT_MARKER = '<!-- metadata:pr-preview-env-marker -->';
export async function upsertStickyPrComment(params: DeploymentNotificationParams): Promise<void> {
const { githubToken, owner, repo, prNumber, previewUrl, commitSha, status, buildDurationSec } = params;
const octokit = new Octokit({ auth: githubToken });
// 1. Generate QR Code as Data URI (Base64 PNG)
const qrCodeDataUri = await QRCode.toDataURL(previewUrl, {
width: 160,
margin: 1,
color: { dark: '#000000', light: '#ffffff' }
});
// 2. Format comment markdown based on deployment status
const shortSha = commitSha.substring(0, 7);
let statusBadge = '';
let contentBody = '';
if (status === 'ready') {
statusBadge = '🟢 **Preview Environment Ready**';
contentBody = `
| Parameter | Value |
| :--- | :--- |
| **Environment URL** | [${previewUrl}](${previewUrl}) |
| **Commit SHA** | \`${shortSha}\` |
| **Build Duration** | \`${buildDurationSec ?? 0}s\` |
### Mobile On-Device Audit (QR Code):
<img src="${qrCodeDataUri}" alt="Preview QR Code" width="160" height="160" />
> This ephemeral environment will be automatically destroyed when this PR is closed.
`;
} else if (status === 'deploying') {
statusBadge = '🟡 **Deploying Preview Environment...**';
contentBody = `Building and provisioning ephemeral infrastructure for commit \`${shortSha}\`...`;
} else if (status === 'destroyed') {
statusBadge = '⚪ **Preview Environment Deprovisioned**';
contentBody = `Ephemeral resources and domain for commit \`${shortSha}\` have been decommissioned.`;
} else {
statusBadge = '🔴 **Deployment Failed**';
contentBody = `Failed to deploy preview for commit \`${shortSha}\`. Inspect GitHub Actions workflow logs.`;
}
const commentBody = `${BOT_MARKER}\n## ${statusBadge}\n\n${contentBody}`;
// 3. Query existing PR comments for the hidden marker
const { data: comments } = await octokit.rest.issues.listComments({
owner,
repo,
issue_number: prNumber,
});
const existingComment = comments.find((c) => c.body && c.body.includes(BOT_MARKER));
if (existingComment) {
await octokit.rest.issues.updateComment({
owner,
repo,
comment_id: existingComment.id,
body: commentBody,
});
} else {
await octokit.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: commentBody,
});
}
}The teardown pipeline is decoupled from deployment logic and listens strictly to pull_request: [closed]. It deprovisions compute, releases database branches, and sets the GitHub Deployment state to inactive.
name: Preview Teardown
on:
pull_request:
types:
- closed
permissions:
contents: read
pull-requests: write
deployments: write
jobs:
cleanup:
name: Deprovision Ephemeral Resources
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Teardown Infrastructure in Orchestrator
shell: bash
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
COOLIFY_API_KEY: ${{ secrets.COOLIFY_API_KEY }}
run: |
echo "Decommissioning infrastructure for PR #$PR_NUMBER..."
# Call orchestrator API to delete application instance
# curl -X DELETE "https://orchestrator.internal/api/v1/apps/pr-$PR_NUMBER" \
# -H "Authorization: Bearer $COOLIFY_API_KEY"
- name: Deactivate GitHub Deployment
uses: actions/github-script@v7
with:
script: |
const deployments = await github.rest.repos.listDeployments({
owner: context.repo.owner,
repo: context.repo.repo,
environment: `preview-pr-${context.payload.pull_request.number}`
});
for (const deployment of deployments.data) {
await github.rest.repos.createDeploymentStatus({
owner: context.repo.owner,
repo: context.repo.repo,
deployment_id: deployment.id,
state: 'inactive',
description: 'Pull Request closed. Environment deprovisioned.'
});
}
- name: Update Sticky Bot Comment
uses: actions/github-script@v7
with:
script: |
const BOT_MARKER = '<!-- metadata:pr-preview-env-marker -->';
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
});
const targetComment = comments.find(c => c.body && c.body.includes(BOT_MARKER));
if (targetComment) {
const updated = `${BOT_MARKER}\n## ⚪ **Preview Environment Deprovisioned**\n\nResources for PR #${context.payload.pull_request.number} have been cleaned up.`;
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: targetComment.id,
body: updated
});
}GitHub Actions detects pull_request: synchronize, registers a Deployment in 'pending' status, and posts an initial bot comment.
Deploy an isolated Kubernetes namespace or Coolify container instance; fork an isolated database schema via Database Branching API.
Build Next.js Standalone container, boot service, and poll GET /api/health until HTTP 200 OK is returned with a 60s timeout.
Transition GitHub Deployment to 'success', generate inline Base64 QR code, and mutate the Sticky Comment via PATCH.
Upon PR merge or close, trigger teardown workflow: destroy containers, delete DB branch, and set Deployment to 'inactive'.
Below is an enterprise .github/workflows/pr-preview-full.yml workflow managing both the deployment phase and the teardown phase with native Octokit integration.
name: PR Preview Lifecycle
on:
pull_request:
types:
- opened
- synchronize
- reopened
- closed
concurrency:
group: preview-env-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
deployments: write
jobs:
# -------------------------------------------------------------
# Phase 1: Deploy Preview (on open / update)
# -------------------------------------------------------------
deploy:
name: Deploy Preview Environment
if: github.event.action != 'closed'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22.x
- name: Install Bot Script Dependencies
run: npm install @octokit/rest qrcode @types/qrcode
- name: Register Deployment as 'pending'
id: create-deployment
uses: actions/github-script@v7
with:
script: |
const res = await github.rest.repos.createDeployment({
owner: context.repo.owner,
repo: context.repo.repo,
ref: context.payload.pull_request.head.ref,
environment: `preview-pr-${context.payload.pull_request.number}`,
required_contexts: [],
auto_merge: false,
description: `Preview for PR #${context.payload.pull_request.number}`
});
core.setOutput('deployment_id', res.data.id);
- name: Execute Deployment & Healthcheck
id: deploy-step
shell: bash
run: |
START_TIME=$(date +%s)
PR_NUM=${{ github.event.pull_request.number }}
PREVIEW_HOST="https://pr-${PR_NUM}.preview.internal.dev"
echo "Building and launching Next.js Standalone container..."
# Emulate orchestrator build steps and DNS propagation
sleep 5
END_TIME=$(date +%s)
DURATION=$((END_TIME - START_TIME))
echo "url=$PREVIEW_HOST" >> $GITHUB_OUTPUT
echo "duration=$DURATION" >> $GITHUB_OUTPUT
- name: Publish Sticky Comment with QR Code
uses: actions/github-script@v7
env:
PREVIEW_URL: ${{ steps.deploy-step.outputs.url }}
BUILD_DURATION: ${{ steps.deploy-step.outputs.duration }}
DEPLOYMENT_ID: ${{ steps.create-deployment.outputs.deployment_id }}
with:
script: |
const QRCode = require('qrcode');
const previewUrl = process.env.PREVIEW_URL;
const duration = process.env.BUILD_DURATION;
const prNumber = context.payload.pull_request.number;
const sha = context.payload.pull_request.head.sha.substring(0, 7);
const marker = '<!-- metadata:pr-preview-env-marker -->';
const qrDataUri = await QRCode.toDataURL(previewUrl, {
width: 140,
margin: 1
});
const body = `${marker}
## 🚀 Preview Environment Ready
| Parameter | Value |
| :--- | :--- |
| **URL** | [${previewUrl}](${previewUrl}) |
| **Commit** | \`${sha}\` |
| **Build Duration** | \`${duration}s\` |
### Mobile On-Device Audit:
<img src="${qrDataUri}" width="140" height="140" alt="Preview QR" />
---
*This environment will be automatically decommissioned when this PR is closed.*`;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber
});
const existing = comments.find(c => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body
});
}
// Transition deployment status to success
await github.rest.repos.createDeploymentStatus({
owner: context.repo.owner,
repo: context.repo.repo,
deployment_id: process.env.DEPLOYMENT_ID,
state: 'success',
environment_url: previewUrl,
description: 'Preview successfully deployed and verified via healthcheck'
});
# -------------------------------------------------------------
# Phase 2: Teardown Preview (on PR close)
# -------------------------------------------------------------
teardown:
name: Teardown Preview Environment
if: github.event.action == 'closed'
runs-on: ubuntu-latest
steps:
- name: Deprovision Resources and Mark Inactive
uses: actions/github-script@v7
with:
script: |
const prNumber = context.payload.pull_request.number;
const marker = '<!-- metadata:pr-preview-env-marker -->';
console.log(`Releasing ephemeral resources for PR #${prNumber}`);
// 1. Mark Deployments as inactive
const deployments = await github.rest.repos.listDeployments({
owner: context.repo.owner,
repo: context.repo.repo,
environment: `preview-pr-${prNumber}`
});
for (const d of deployments.data) {
await github.rest.repos.createDeploymentStatus({
owner: context.repo.owner,
repo: context.repo.repo,
deployment_id: d.id,
state: 'inactive',
description: 'PR closed. Ephemeral environment decommissioned.'
});
}
// 2. Update Sticky Comment status
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber
});
const existing = comments.find(c => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body: `${marker}\n## ⚪ **Preview Environment Deprovisioned**\n\nEphemeral resources for PR #${prNumber} have been successfully destroyed.`
});
}Creating a new comment via octokit.rest.issues.createComment on every push without searching for existing messages. In an active PR with 20 commits, the timeline becomes an unreadable wall of bot notifications, obscuring code reviews and discussion threads.
// INCORRECT: Unconditionally posting new comments
await octokit.rest.issues.createComment({
owner, repo, issue_number: prNumber, body: `Preview URL: ${url}`
});
// CORRECT: Query by hidden marker and update via PATCH
const marker = '<!-- pr-preview-sticky-bot -->';
const { data: comments } = await octokit.rest.issues.listComments({ owner, repo, issue_number: prNumber });
const target = comments.find(c => c.body?.includes(marker));
if (target) {
await octokit.rest.issues.updateComment({ owner, repo, comment_id: target.id, body: `${marker}\n${body}` });
} else {
await octokit.rest.issues.createComment({ owner, repo, issue_number: prNumber, body: `${marker}\n${body}` });
}Triggering teardown logic only on push to main. If a developer closes an unmerged or abandoned PR, the ephemeral containers and database branches continue running indefinitely in the cloud, consuming cluster memory and incurring runaway hosting expenses.
# INCORRECT: Teardown triggers only on main branch push
on:
push:
branches: [main]
# CORRECT: Explicit trigger on pull_request closed event
on:
pull_request:
types: [closed]Attempting to access cloud secrets for preview deployments on open-source repositories using pull_request_target while checking out the fork's branch (ref: ${{ github.event.pull_request.head.sha }}). This is the critical PwnRequest vulnerability: a malicious actor can edit package.json scripts to execute arbitrary code within the privileged runner, exfiltrating cloud tokens.
# INCORRECT: Checking out untrusted fork code in a privileged context
on: pull_request_target
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: npm install && npm run build # MALICIOUS CODE EXECUTES WITH SECRET ACCESS!
# CORRECT: Isolate build artifacts without secrets or mandate manual maintainer approvalConnecting all ephemeral preview deployments to a shared staging database. When a feature PR runs a destructive migration (such as dropping a column or modifying constraints), all other active preview environments immediately crash with runtime 500 errors. Dedicated database schemas or database branching are mandatory.
Configure wildcard DNS (*.preview.company.com) and reverse proxy routing to map subdomains dynamically by PR number.
Integrate Database Branching APIs (Neon, Supabase) or PostgreSQL isolated schemas to grant each PR dedicated data persistence.
Write deployment workflow tracking GitHub Deployment status (pending/success) and polling the /api/health endpoint.
Implement TypeScript automation script utilizing hidden metadata markers, generating Base64 QR codes for mobile QA testing.
Configure workflow listening to pull_request: closed to guarantee deprovisioning of containers, databases, and deployment states.