Preview deployments automatically build and host a temporary, isolated version of your application for every pull request — letting reviewers see and test actual changes in a live environment before merging, without waiting for a full staging deployment.
Why Preview Deployments Are Valuable
- Reviewers can interact with actual running changes, not just read code diffs
- Catches visual/UX issues that code review alone misses
- Stakeholders (design, product) can review without needing to run the code locally
Basic Architecture
Pull request opened → CI/CD builds the PR's branch → Deploys to a unique subdomain/path → Comment posted on the PR with the preview link
Prerequisites
- A VPS with enough resources to host multiple preview environments simultaneously (or a cleanup strategy limiting active previews)
- Nginx configured for dynamic subdomain routing
Step 1 — Set Up Wildcard DNS
Type: A
Name: *.preview
Value: YOUR_SERVER_IP
This routes any subdomain under *.preview.yourdomain.com to your VPS.
Step 2 — Configure Nginx for Dynamic Preview Routing
server {
listen 80;
server_name ~^pr-(?<pr_number>\d+)\.preview\.yourdomain\.com$;
root /var/www/previews/pr-$pr_number;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
This single server block dynamically serves content based on the PR number extracted from the subdomain.
Step 3 — Create the CI/CD Workflow
name: Preview Deployment
on:
pull_request:
types: [opened, synchronize]
jobs:
preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm install
- run: npm run build
- name: Deploy preview
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H ${{ secrets.SERVER_IP }} >> ~/.ssh/known_hosts
rsync -avz --delete -e "ssh -i ~/.ssh/deploy_key" \
./dist/ ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_IP }}:/var/www/previews/pr-${{ github.event.pull_request.number }}/
- name: Comment preview link
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `Preview: https://pr-${context.issue.number}.preview.yourdomain.com`
})
Step 4 — Clean Up Previews When PRs Close
on:
pull_request:
types: [closed]
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Remove preview
run: |
ssh -i ~/.ssh/deploy_key user@server \
"rm -rf /var/www/previews/pr-${{ github.event.pull_request.number }}"
Restricting Access to Previews (Recommended)
location / {
auth_basic "Preview - Authorized Only";
auth_basic_user_file /etc/nginx/.htpasswd-previews;
}
Preview environments shouldn't be publicly discoverable/indexed, especially for private projects or those handling any sensitive content.
Adding HTTPS for Wildcard Previews
Use a wildcard certificate covering *.preview.yourdomain.com — see How to Install a Wildcard SSL Certificate with Certbot DNS Challenge.
Preventing Resource Exhaustion from Too Many Active Previews
Implement a maximum active preview count or automatic expiration (e.g. delete previews inactive for 7+ days) to prevent unbounded disk usage as PR volume grows.
Common Errors
Preview subdomain doesn't resolve — verify the wildcard DNS record was correctly configured and has propagated.
Preview shows old content — confirm the synchronize event trigger is included, so pushes to the PR branch (not just the initial open) trigger a fresh deployment.
Best Practices
- Always restrict preview access, even for open-source projects, unless intentionally public
- Clean up previews automatically when PRs close
- Set a reasonable limit on total active previews to prevent resource exhaustion
Related Articles
- How to Set Up Automatic Static Site Deployment from Git
- How to Install a Wildcard SSL Certificate with Certbot DNS Challenge
- How to Build a Simple CI/CD Pipeline with GitHub Actions
