VibeHost

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:deploy and apps: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: 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@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 --force

The workflow handles three events:

  • opened runs the first deploy and posts the comment.
  • synchronize deploys 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.
  • closed deletes 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 each synchronize invalidates 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 delete in 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 == false

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

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

  1. Run vibehost gc to prune old deployments. It keeps the last 5 per channel by default.
  2. Stop deploying drafts.
  3. 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

On this page