Per-PR preview deploys with GitHub Actions
Deploy every PR to its own channel, comment the URL on the PR, and clean up when it closes.
Every pull request gets its own preview URL on a channel named pr-NN, and the workflow comments the link on the PR. When the PR closes, merged or not, the workflow deletes the channel.
Prerequisites
- A PAT with
apps:deployandapps:read, restricted to the target app ID. See Personal access tokens. - The PAT stored as a repo secret named
VIBEHOST_TOKEN. - Optionally, 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@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- uses: pnpm/action-setup@v6
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: curl -fsSL -o vibehost-install.sh https://vibehost.com/install.sh && sh vibehost-install.sh && rm vibehost-install.sh
- name: Deploy
id: deploy
env:
VIBEHOST_TOKEN: ${{ secrets.VIBEHOST_TOKEN }}
PR: ${{ github.event.pull_request.number }}
run: |
~/.vibehost/cli/vibehost deploy ./dist \
--app my-site \
--channel "pr-$PR" \
--json > deploy.json
# Fail unless the platform reports this deployment healthy.
jq -e '.data.status == "healthy"' deploy.json > /dev/null || { cat deploy.json; exit 1; }
echo "url=$(jq -r '.data.url' deploy.json)" >> "$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 -o vibehost-install.sh https://vibehost.com/install.sh && sh vibehost-install.sh && rm vibehost-install.sh
- env:
VIBEHOST_TOKEN: ${{ secrets.VIBEHOST_TOKEN }}
PR: ${{ github.event.pull_request.number }}
run: |
~/.vibehost/cli/vibehost channel delete "pr-$PR" \
--app my-site --forceThe workflow handles three events:
openedruns the first deploy and posts the comment.synchronizedeploys again on each push to the PR. The comment tag (vibehost-preview) makes the action update the same comment instead of posting a new one each time.closeddeletes the channel, whether the PR was merged or just closed.
The concurrency block cancels a preview that's still running when a new push comes in, which saves runner time when you push often.
Variations
To keep a preview private to internal reviewers, add a password gate and share the password somewhere other than the PR.
Don't set the password on your production app. App passwords are stored on the app, not on the channel, so 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 merge a PR that ran this recipe, your production site ends up behind the password. Never run app password set against your real production app from a PR workflow.
Instead, deploy PR previews to a dedicated app that nothing else publishes to. Its channels are still independent, and the password gate sits on an app no end user is supposed to open anyway. Name that app explicitly at deploy time:
- name: Deploy preview (separate app)
env:
VIBEHOST_TOKEN: ${{ secrets.VIBEHOST_TOKEN }}
PR: ${{ github.event.pull_request.number }}
run: |
~/.vibehost/cli/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/cli/vibehost app password set "$PW" --app my-site-previews
echo "Password (DM'd, not in logs): $PW"Two things to note:
if: github.event.action == 'opened'sets the password once, when the PR opens, not on every push. Changing the password on eachsynchronizeinvalidates everyone's cookie and makes them enter it again.- Don't clear the password on close if the preview app is shared across PRs, because another PR may still need the gate.
vibehost channel deletein the closed job is the cleanup you need. The password stays on the dedicated preview app, which is all it gates.
If you need a different password for each PR, give each PR its own preview app with --app my-site-pr-$PR. That costs one app per PR, but the gate then covers only that PR, and vibehost app delete my-site-pr-$PR --force in the close hook removes the password along with everything else.
In a monorepo with several deployable apps, deploy each one 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/cli/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 == falsePreviews start deploying again when the author marks the PR ready for review.
An app that reads a runtime config endpoint might need a different API_URL for each preview:
- name: Set preview env
env:
VIBEHOST_TOKEN: ${{ secrets.VIBEHOST_TOKEN }}
run: |
~/.vibehost/cli/vibehost env set \
API_URL="https://staging-api.example.com" \
--app my-site \
--target runtimeEnv vars belong to the app, not the 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. Full per-PR isolation takes a separate app per PR, which is more than almost any team needs.
Costs
Per-PR previews are cheap on VibeHost. A channel costs nothing at runtime beyond the deployment it points to. What adds up is storage: every preview deploy counts toward the workspace storage cap.
If a busy repo gets close to that cap, you can:
- Run
vibehost gcto prune old deployments. It keeps the last 5 per channel by default. - Stop deploying drafts.
- Upgrade to Business for more storage.
What this isn't
- It doesn't deploy on merge. This recipe deploys preview channels. To deploy to production when a PR merges to main, see CI/CD with GitHub Actions.
- It doesn't know about branches. VibeHost knows nothing about git. The channel name (
pr-NN) is just a string the workflow picks. - It isn't a sandboxed env. PR previews share the runtime env vars of the rest of the app. If you need per-PR env, see the "ephemeral env vars" variation above.
See also
- Channels explains how channels work and how to name them.
- CI/CD with GitHub Actions deploys when you merge to main.
- Personal access tokens has the scope matrix.
CI/CD with GitHub Actions
Deploy on every push to main, confirm the live URL serves the new build, and fail the job when it doesn't.
Deploy HTML reports on a schedule
Deploy cron-driven HTML reports to a permanent URL that always shows the latest data, instead of sharing them through Notion, Google Sheets, or Tableau.