TL;DR / Decision Roll-your-own is fully viable on a single Debian/Docker VM and needs no PaaS. The core loop is ~150 lines of bash: a trigger (GitHub Actions self-hosted runner or adnanh/webhook ⭐ 11.9k [4]) fires
deploy.shonpull_requestopen/sync andteardown.shon close. Traefik with wildcard DNS handles routing without any config-file reload. Pick this if you want full transparency and can spend 1–2 days on wiring. Skip it if you need managed database isolation, out-of-the-box HTTPS from minute one, or a UI — Coolify covers all of that at lower ops cost.
How the Core Loop Works
Every PR preview system answers the same three questions:
- How does the server know a PR was opened/updated/closed? → Trigger layer
- How does a new stack start up with a unique URL? → Deploy script + reverse proxy
- How does it get torn down? → Cleanup layer
A DIY stack gives you explicit control of all three, at the cost of assembling them yourself [1].
Trigger Layer: Two Patterns
Option A — GitHub Actions + Self-Hosted Runner (recommended entry point)
The lowest-friction wiring. No extra webhook server; GitHub orchestrates the lifecycle [14]:
on:
pull_request:
types: [opened, synchronize, reopened, closed]
jobs:
deploy:
if: github.event.action != 'closed'
runs-on: [self-hosted, linux] # your Debian VM runner
steps:
- uses: actions/checkout@v4
- run: bash scripts/deploy.sh $ $
cleanup:
if: github.event.action == 'closed'
runs-on: [self-hosted, linux]
steps:
- run: bash scripts/teardown.sh $
The self-hosted runner (myoung34/docker-github-actions-runner ⭐ 2.3k [13]) runs in a container on the same Debian VM. It mounts /var/run/docker.sock so the deploy script can drive Docker on the host.
Tradeoff: GitHub initiates the connection outward — no open inbound ports needed. But your runner must have internet access and a GitHub token with repo scope.
Option B — Standalone Webhook Server
adnanh/webhook ⭐ 11.9k [4] is a Go binary that listens for HTTP requests and executes shell scripts. Expose it behind Traefik or Cloudflare Tunnel; GitHub POSTs to it on PR events [12].
{
"id": "pr-deploy",
"execute-command": "/opt/preview/deploy.sh",
"pass-arguments-to-command": [
{ "source": "payload", "name": "pull_request.number" },
{ "source": "payload", "name": "pull_request.head.ref" },
{ "source": "payload", "name": "action" }
],
"trigger-rule": {
"match": {
"type": "payload-hmac-sha256",
"secret": "YOUR_WEBHOOK_SECRET",
"parameter": { "source": "header", "name": "X-Hub-Signature-256" }
}
}
}
Always validate HMAC — an open endpoint that runs shell scripts is a remote code execution vulnerability [10]. Lighter alternatives: staticfloat/docker-webhook ⭐ 85 [5] (Python, simpler), webhookd [8] (bare bash path-to-script mapping) [11].
The Deploy Script
deploy.sh $PR_NUMBER $BRANCH:
#!/usr/bin/env bash
set -euo pipefail
PR_NUMBER=$1
BRANCH=$2
PREVIEW_DIR="/opt/previews/pr-${PR_NUMBER}"
PROJECT="pr-${PR_NUMBER}"
# Clone or fast-forward
if [ -d "$PREVIEW_DIR/.git" ]; then
git -C "$PREVIEW_DIR" fetch origin && git -C "$PREVIEW_DIR" checkout "$BRANCH" && git -C "$PREVIEW_DIR" pull
else
git clone --branch "$BRANCH" git@github.com:YOUR_ORG/YOUR_REPO.git "$PREVIEW_DIR"
fi
# Write per-PR env
cat > "$PREVIEW_DIR/.env.preview" <<EOF
COMPOSE_PROJECT_NAME=${PROJECT}
APP_PORT=$((10000 + PR_NUMBER))
DATABASE_NAME=preview_${PR_NUMBER}
PREVIEW_URL=https://pr-${PR_NUMBER}.preview.example.com
EOF
cd "$PREVIEW_DIR"
docker compose -p "$PROJECT" -f docker-compose.yml -f docker-compose.preview.yml \
--env-file .env.preview up -d --build
COMPOSE_PROJECT_NAME=pr-$Nisolates all containers, networks, and volumes for this PR sodocker compose downonly touches that PR’s resources.- Port formula
10000 + PR_NUMBERgives deterministic, non-colliding ports without a port registry [1]. - Override file (
docker-compose.preview.yml) applies resource limits (0.5 CPU, 512 MB RAM) and swaps hostnames without touching the main compose file [15].
teardown.sh $PR_NUMBER:
#!/usr/bin/env bash
set -euo pipefail
PR_NUMBER=$1
PREVIEW_DIR="/opt/previews/pr-${PR_NUMBER}"
docker compose -p "pr-${PR_NUMBER}" down -v --remove-orphans 2>/dev/null || true
rm -rf "$PREVIEW_DIR"
Reverse Proxy: Traefik vs nginx
| Traefik | nginx | |
|---|---|---|
| Config on deploy | Docker label — no file write | Generate .conf + nginx -s reload |
| TLS | Automatic (Let’s Encrypt via ACME) | certbot certonly per subdomain or wildcard |
| Wildcard routing | Native with *.preview. DNS + wildcard cert |
Needs wildcard cert + server_name ~^pr-(\d+)\. regex |
| Learning curve | New mental model (routers/services/middlewares) | Familiar to most engineers |
| Hot-reload | Instant (watches Docker socket) | Requires nginx -s reload after each deploy |
Traefik is the better fit for ephemeral environments because every docker compose up auto-registers the preview without a config-file write [9] [3]. Add these labels to docker-compose.preview.yml:
services:
app:
labels:
- "traefik.enable=true"
- "traefik.http.routers.pr-${PR_NUMBER}.rule=Host(`pr-${PR_NUMBER}.preview.example.com`)"
- "traefik.http.routers.pr-${PR_NUMBER}.tls.certresolver=letsencrypt"
- "traefik.http.services.pr-${PR_NUMBER}.loadbalancer.server.port=3000"
DNS prerequisite: a wildcard A record *.preview.example.com → VM_IP and a wildcard TLS certificate. Without this, every new PR requires a DNS change [3].
nginx is workable if Traefik is unfamiliar — generate a sites-enabled/pr-${PR_NUMBER}.conf and reload. The oneuptime.com example does exactly this [1].
Cleanup: The Hard Part
Three mechanisms, each catching a different failure mode [2] [3]:
| Mechanism | Catches |
|---|---|
teardown.sh on pull_request: closed |
Normal PR lifecycle |
Daily cron: find /opt/previews -maxdepth 1 -mtime +7 -exec teardown.sh {} \; |
Stale PRs nobody closed |
docker system prune -f weekly |
Dangling images from failed builds |
The “destroy on PR close” event fires when PRs are merged or closed without merging. Long-lived branches that never get a PR, or webhook delivery failures, bypass this — the daily cron is the safety net [2].
Critical Pitfalls
| Pitfall | Impact | Mitigation |
|---|---|---|
| No HMAC validation on webhook endpoint | RCE on your VM | Always verify X-Hub-Signature-256 [12] |
| Shared production/staging DB | Data corruption across PRs | Spin a per-PR Postgres container; seed from a sanitized dump [2] |
| No resource limits | One PR starves the host | Set deploy.resources.limits in the override file [1] |
| Port collisions > PR 1000 | Port 11000+ conflicts with nothing, but formula breaks for 5000+ |
Fine for homelab; at scale use a dynamic port registry |
| Missing wildcard DNS/cert | Every new PR needs manual DNS update | Set up *.preview. record before starting |
| Leaked secrets in compose env | Credentials in .env.preview on disk |
Mount secrets via Docker secrets or environment injection at runtime |
| Runner with docker.sock access | Privilege escalation from malicious PR code | Never run CI with host docker.sock on public repos [10] |
Resource Footprint on a Single VM
A homelab Proxmox/Debian VM can comfortably host 10–15 simultaneous small preview environments before CPU/RAM contention becomes noticeable [3]. With --memory=512m --cpus=0.5 limits per stack and a 4-vCPU/8 GB VM, that’s roughly:
- No DB per stack: up to 20 concurrent previews
- With Postgres per stack: 8–10 concurrent previews (Postgres eats 100–200 MB at idle)
- Build time:
--buildon every push is the real bottleneck; layer caching (DOCKER_BUILDKIT=1,cache_from:) keeps incremental builds under 30 s for typical Node/Go apps
Comparison: DIY vs PullPreview vs Coolify
| Dimension | DIY bash/compose | PullPreview [7] | Coolify |
|---|---|---|---|
| Setup time | 1–2 days | 30 min | 1–2 hours |
| Cost (software) | Free | €300/yr commercial | Free (OSS) |
| TLS | Manual cert/Traefik ACME | Caddy auto | Automatic |
| DB isolation | Manual (your script) | Manual | Manual |
| UI | None (logs only) | GitHub comment + SSH | Full web UI |
| Control | Complete | GitHub Action black box | Partial |
| Ops burden | High | Low (no server to manage) | Medium |
| Stale cleanup | Your cron | Platform policy | Automatic |
| Scales to | ~15 concurrent | Per-cloud-instance | ~50+ |
The Homelab Pattern That Works
A practical single-VM setup seen in homelab communities [6] ⭐ 101 [11]:
- Traefik in a permanent compose stack with Docker socket access and wildcard cert
- Self-hosted GitHub Actions runner in a container on the same VM
deploy.sh/teardown.shchecked into the target repo underscripts/- Wildcard DNS
*.preview.yourdomain.com → VM_IP - Cron on the VM:
0 2 * * * /opt/preview/cron-cleanup.sh
Total bash: ~200 lines. Total new infrastructure: one DNS record. Total dependencies: Docker, Traefik, a GitHub runner.
When to pick this over Coolify: you want to understand every layer, you have non-standard deployment needs (custom networking, special compose flags), or you’re already comfortable operating Docker manually.
When to pick Coolify instead: you need a UI, you want automatic PR env creation with zero bash, or you value the managed certificate + reverse proxy + cleanup lifecycle over full control.