Per-PR previews
Auto-deploy every PR to its own channel, comment the URL on the PR, clean up on close.
The pattern: every pull request gets its own preview URL on a channel named pr-NN. The PR description gets a comment with the link. When the PR closes (merged or not), the channel is deleted to keep things tidy.
Prerequisites
- A PAT with
apps:deploy+apps:read, restricted to the target app ID. See Personal access tokens. - Stored as a repo secret named
VIBEHOST_TOKEN. - (Optional) Permission for the workflow to comment on PRs —
pull-requests: writein the workflow.
The workflow
name: PR preview
on:
pull_request:
types: [opened, synchronize, closed]
permissions:
contents: read
pull-requests: write # so we can comment on the PR
concurrency:
group: preview-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
preview:
if: github.event.action != 'closed'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- uses: pnpm/action-setup@v4
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: curl -fsSL https://vibehost.com/install.sh | sh
- name: Deploy
id: deploy
env:
VIBEHOST_TOKEN: ${{ secrets.VIBEHOST_TOKEN }}
PR: ${{ github.event.pull_request.number }}
run: |
~/.vibehost/bin/vibehost deploy ./dist \
--app my-site \
--channel "pr-$PR" \
--json | tee deploy.json
URL=$(jq -r '.data.url' deploy.json)
echo "url=$URL" >> "$GITHUB_OUTPUT"
- name: Comment on PR
uses: thollander/actions-comment-pull-request@v3
with:
comment-tag: vibehost-preview
message: |
🚀 Preview deployed: ${{ steps.deploy.outputs.url }}
<sub>Updated automatically on every push. Cleaned up when this PR closes.</sub>
cleanup:
if: github.event.action == 'closed'
runs-on: ubuntu-latest
steps:
- run: curl -fsSL https://vibehost.com/install.sh | sh
- env:
VIBEHOST_TOKEN: ${{ secrets.VIBEHOST_TOKEN }}
PR: ${{ github.event.pull_request.number }}
run: |
~/.vibehost/bin/vibehost channel delete "pr-$PR" \
--app my-site --forceThree event handlers in one workflow:
- opened — first deploy + comment.
- synchronize — re-deploy on each push to the PR. The comment-tag (
vibehost-preview) makes the action update the same comment instead of spamming new ones. - closed — delete the channel. Cleanup runs whether the PR was merged or just closed.
The concurrency block cancels in-flight previews if a new push comes in — saves runner time on rapid PR iteration.
Variations
Want the preview private to internal reviewers? Add a password gate per preview, share it in a sidechannel.
The trap to avoid first. App passwords are stored on the app, not on the channel — vibehost app password set from a PR workflow gates every channel of that app, including production. If you then forget to clear the password when the PR closes (or just merge a PR that ran this recipe), your production site is now password-gated. Never run app password set against your real production app from a PR workflow.
The fix: deploy PR previews to a dedicated preview-only app that nothing else publishes to. Channels of that app are still independent, but the password gate now sits on an app no end-user is supposed to hit anyway. Make this explicit at deploy time:
- name: Deploy preview (separate app)
env:
VIBEHOST_TOKEN: ${{ secrets.VIBEHOST_TOKEN }}
PR: ${{ github.event.pull_request.number }}
run: |
~/.vibehost/bin/vibehost deploy ./dist \
--app my-site-previews \
--channel "pr-$PR" --json
- name: Set preview-app password (once, gates ALL preview channels — OK)
if: github.event.action == 'opened'
env:
VIBEHOST_TOKEN: ${{ secrets.VIBEHOST_TOKEN }}
PR: ${{ github.event.pull_request.number }}
run: |
PW=$(echo "${{ secrets.PREVIEW_PASSWORD_SALT }}-pr-$PR" | sha256sum | cut -c1-12)
echo "::add-mask::$PW"
~/.vibehost/bin/vibehost app password set "$PW" --app my-site-previews
echo "Password (DM'd, not in logs): $PW"Two notes on this shape:
if: github.event.action == 'opened'— set the password once when the PR opens, not on every push. Rotating the password on eachsynchronizeinvalidates everyone's cookie and forces a re-prompt.- Don't try to "clear the password on close" if the preview app is shared across PRs — another PR may still need the gate. The right cleanup is
vibehost channel delete(already in the closed job); the password stays on the dedicated preview app, where it's the only thing it gates.
If you genuinely need per-PR password rotation, give each PR its own preview-app: --app my-site-pr-$PR. Heavier (one app row per PR), but the gate is then truly scoped to that PR — and vibehost app delete my-site-pr-$PR --force in the close hook removes the password along with everything else.
Monorepo with multiple deployable apps. Deploy each to its own channel:
strategy:
matrix:
include:
- app: docs-site
dir: apps/docs/out
- app: marketing
dir: apps/marketing/dist
steps:
# ... checkout + build all apps ...
- run: |
~/.vibehost/bin/vibehost deploy ${{ matrix.dir }} \
--app ${{ matrix.app }} \
--channel "pr-${{ github.event.pull_request.number }}"Use paths-filter to skip apps that didn't change in the PR. See the monorepo recipe.
jobs:
preview:
if: github.event.action != 'closed' && github.event.pull_request.draft == falseResumes auto-deploy when the author marks the PR ready for review.
You might want a different API_URL per preview (for any app that reads a runtime config endpoint):
- name: Set preview env
env:
VIBEHOST_TOKEN: ${{ secrets.VIBEHOST_TOKEN }}
run: |
~/.vibehost/bin/vibehost env set \
API_URL="https://staging-api.example.com" \
--app my-site \
--target runtimeCaveat: env vars are per-app, not per-channel. To get per-PR env, use a dedicated "preview" app and deploy all PRs to it (each on its own channel, but sharing the env). For full per-PR isolation, separate apps per PR — but that's overkill in 99% of cases.
Costs
Per-PR previews are cheap on VibeHost — there's no per-channel runtime cost beyond the deployment they currently point to. Free tier supports 50 channels per app. Active PRs rarely exceed that; the cleanup job ensures merged/closed PRs free their slot.
If you're hitting the channel cap on a busy repo, three options:
- Cleanup more aggressively (delete channel on
pull_request.review_requestedfailure, etc.). - Upgrade to Business (200 channels per app).
- Stop deploying drafts.
What this isn't
- Not a deploy-on-merge replacement. This recipe deploys preview channels. For "deploy to production on merge to main", see CI/CD with GitHub Actions.
- Not branch-aware. VibeHost knows nothing about git. The channel name (
pr-NN) is just a string we pick. - Not a sandboxed env. PR previews share the same runtime env vars as the rest of the app. If you need per-PR env, see the "ephemeral env vars" variation above.
See also
- Channels — model + naming rules
- CI/CD with GitHub Actions — for the merge-to-main flow
- Personal access tokens — scope matrix