VibeHost

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: write in the workflow.

The workflow

.github/workflows/preview.yml
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 --force

Three 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 each synchronize invalidates 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 == false

Resumes 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 runtime

Caveat: 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:

  1. Cleanup more aggressively (delete channel on pull_request.review_requested failure, etc.).
  2. Upgrade to Business (200 channels per app).
  3. 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

On this page