VibeHost

Monorepo deploys

One repo, many apps. Per-package CI matrix, only deploy what changed.

The pattern: monorepo with multiple deployable apps (web, docs, marketing, admin). Each is its own VibeHost app. CI deploys only the apps that changed in a given commit / PR.

Layout assumption

my-monorepo/
├── apps/
│   ├── web/          → VibeHost app "my-web"
│   ├── docs/         → VibeHost app "my-docs"
│   └── marketing/    → VibeHost app "my-marketing"
├── packages/
│   └── ui/           (shared, not deployed)
└── package.json      (pnpm / npm / yarn workspaces)

One PAT per workspace, restricted to app IDs

Issue a single PAT for CI, restricted to the three app IDs:

  1. vibehost.com/account/tokens → Create token.
  2. Scopes: apps:read, apps:deploy.
  3. Resources: pick my-web, my-docs, my-marketing.
  4. Store as VIBEHOST_TOKEN.

The PAT can only deploy these three apps; if a compromised CI tries to deploy elsewhere, it gets PAT_RESOURCE_NOT_ALLOWED.

Workflow — main branch

.github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

jobs:
  changes:
    runs-on: ubuntu-latest
    outputs:
      web: ${{ steps.filter.outputs.web }}
      docs: ${{ steps.filter.outputs.docs }}
      marketing: ${{ steps.filter.outputs.marketing }}
    steps:
      - uses: actions/checkout@v4
      - uses: dorny/paths-filter@v3
        id: filter
        with:
          filters: |
            web:
              - 'apps/web/**'
              - 'packages/**'
            docs:
              - 'apps/docs/**'
              - 'packages/**'
            marketing:
              - 'apps/marketing/**'

  deploy:
    needs: changes
    if: needs.changes.outputs.web == 'true' || needs.changes.outputs.docs == 'true' || needs.changes.outputs.marketing == 'true'
    runs-on: ubuntu-latest
    strategy:
      matrix:
        include:
          - app: my-web
            dir: apps/web
            out: dist
            changed: ${{ needs.changes.outputs.web }}
          - app: my-docs
            dir: apps/docs
            out: out
            changed: ${{ needs.changes.outputs.docs }}
          - app: my-marketing
            dir: apps/marketing
            out: dist
            changed: ${{ needs.changes.outputs.marketing }}

    steps:
      - if: matrix.changed != 'true'
        run: |
          echo "Skipping ${{ matrix.app }} — no changes"
          exit 0
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22, cache: pnpm }
      - run: pnpm install --frozen-lockfile
      - run: pnpm --filter ./${{ matrix.dir }} build
      - run: curl -fsSL https://vibehost.com/install.sh | sh
      - env: { VIBEHOST_TOKEN: ${{ secrets.VIBEHOST_TOKEN }} }
        run: |
          ~/.vibehost/bin/vibehost deploy ./${{ matrix.dir }}/${{ matrix.out }} \
            --app ${{ matrix.app }} --json

What this does:

  1. changes jobpaths-filter returns true/false per app based on which files changed.
  2. deploy job — fans out per app in a matrix. Each leg checks its own changed flag and exits early if untouched.
  3. packages/** triggers everything that depends on shared packages. If you want finer-grained dependency awareness, integrate turbo run or nx affected instead of paths-filter.

Workflow — PR previews

Per-PR channel per affected app:

      - if: matrix.changed == 'true'
        env:
          VIBEHOST_TOKEN: ${{ secrets.VIBEHOST_TOKEN }}
          PR: ${{ github.event.pull_request.number }}
        run: |
          URL=$(~/.vibehost/bin/vibehost deploy \
            ./${{ matrix.dir }}/${{ matrix.out }} \
            --app ${{ matrix.app }} \
            --channel "pr-$PR" \
            --json | jq -r '.data.url')
          echo "PREVIEW_${{ matrix.app }}=$URL" >> "$GITHUB_ENV"

Then collect URLs and post a single comment listing all changed apps:

  comment:
    needs: deploy
    runs-on: ubuntu-latest
    steps:
      - uses: thollander/actions-comment-pull-request@v3
        with:
          comment-tag: vibehost-previews
          message: |
            Previews for this PR:
            - my-web: ${{ env.PREVIEW_my-web }}
            - my-docs: ${{ env.PREVIEW_my-docs }}
            - my-marketing: ${{ env.PREVIEW_my-marketing }}

(Note: env carry-over between jobs requires actions/upload-artifact or job outputs in practice — simplify per your tooling.)

With Turborepo

Turbo's --filter + remote cache makes this much faster on cold runners:

      - run: pnpm dlx turbo run build --filter=...[origin/main]

That builds only packages affected by the diff. After build, you still deploy per-app:

      - run: |
          for app in my-web my-docs my-marketing; do
            dir="apps/${app#my-}"
            test -d "$dir/dist" || continue   # was it built?
            ~/.vibehost/bin/vibehost deploy "$dir/dist" --app "$app"
          done

Pair with turbo-cache.vibehost.cc if you want a hosted Turbo remote cache — see the Turborepo docs for setup.

Channel naming for monorepo previews

The naming convention we use internally:

  • production — main branch.
  • pr-NN — per PR.
  • feature-foo — long-lived feature branches.
  • staging — pre-promote slot (deploy here, soak, promote to production).

Channel names live in URLs, so DNS-safe ([a-z][a-z0-9-]*, 1–32 chars, no underscores).

Pitfalls

  • packages/** change triggers everything — that's correct in most cases (shared lib touched → every consumer rebuilds). For very large monorepos, prefer turbo affected or nx affected over coarse paths-filter.
  • Don't share PATs across workspaces — if your monorepo deploys to multiple workspaces (e.g. dev-ws + prod-ws), use separate PATs and GitHub environments.
  • One linked app per dirvibehost link writes .vibehost/project.json. In CI you usually want to pass --app explicitly instead of linking (the link file isn't committed).

See also

On this page