VibeHost

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.

This GitHub Actions workflow deploys a static site to VibeHost on every push to main, checks that the live URL serves the build it just deployed, and posts the result back to the run.

Prerequisites

  • A repo with a Vite, Astro, Hugo, or any other project that emits a build directory.
  • A PAT scoped to apps:deploy and apps:read, restricted to the target app ID. See Personal access tokens.
  • The PAT stored as a repository / environment secret named VIBEHOST_TOKEN.
  • For the smoke check: an app set to public with no password. Apps are private by default, and a gated app answers an anonymous request with a redirect to sign in, not with your site. See Gated apps.

The workflow

.github/workflows/deploy.yml
name: Deploy
on:
  push:
    branches: [main]
  workflow_dispatch:

concurrency:
  group: deploy-main
  cancel-in-progress: false   # don't cancel a deploy mid-upload

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    timeout-minutes: 10

    steps:
      - uses: actions/checkout@v6

      - uses: actions/setup-node@v6
        with:
          node-version: 24
          cache: pnpm

      - uses: pnpm/action-setup@v6

      - name: Install
        run: pnpm install --frozen-lockfile

      - name: Build
        run: |
          pnpm build
          # Stamp the build so the smoke check can tell it apart from the previous one.
          echo "$GITHUB_SHA" > dist/build-id.txt

      - name: Install VibeHost CLI
        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 }}
        run: |
          ~/.vibehost/cli/vibehost deploy ./dist --app my-site --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"
          echo "id=$(jq -r '.data.id' deploy.json)" >> "$GITHUB_OUTPUT"

      - name: Smoke check
        env:
          URL: ${{ steps.deploy.outputs.url }}
        run: |
          # After a redeploy the URL can keep serving the previous build for up to about a minute.
          DEADLINE=$((SECONDS + 120))
          i=0
          while [ "$SECONDS" -lt "$DEADLINE" ]; do
            i=$((i + 1))
            BUILD=$(curl -sSL --max-time 5 "$URL/build-id.txt?run=$GITHUB_RUN_ID-$i" || true)
            if [ "$BUILD" = "$GITHUB_SHA" ]; then
              echo "✓ $URL serves $GITHUB_SHA"
              exit 0
            fi
            echo "Attempt $i: $URL/build-id.txt → ${BUILD:0:40} (retrying in 5s)"
            sleep 5
          done
          echo "✗ Smoke check failed: $URL never served build $GITHUB_SHA"
          exit 1

      - name: Summary
        if: always()
        env:
          URL: ${{ steps.deploy.outputs.url }}
          DEPLOYMENT: ${{ steps.deploy.outputs.id }}
        run: |
          {
            echo "## Deploy"
            echo ""
            echo "**Live:** $URL"
            echo ""
            echo "**Deployment:** $DEPLOYMENT"
          } >> "$GITHUB_STEP_SUMMARY"

Push to main, and the workflow runs and puts the URL in the GitHub Actions summary.

What each step does

  1. Checkout, setup-node, and setup-pnpm are the standard setup steps.
  2. Install and Build run on the runner, the same as locally. VibeHost doesn't build a static app. The build also writes the commit SHA to build-id.txt, a file that differs on every build, so the smoke check can recognize this one. It is published with the site.
  3. Install VibeHost CLI runs an install script that is idempotent and small, since the CLI is a single static binary.
  4. Deploy uses the PAT from VIBEHOST_TOKEN. A failed deploy exits non-zero and prints its error, which fails the step. Otherwise the step checks that the deployment's status is healthy. It writes the JSON to a file instead of piping it into tee: a run step without shell: bash has no pipefail, so a failed deploy piped into tee would still pass.
  5. Smoke check fetches build-id.txt from the live URL until it returns this commit's SHA. A redeploy is healthy before the URL switches over: the previous build can keep answering for up to about a minute, and it answers 200 too, so a status code alone would pass on the old build. The loop gives up after two minutes. It follows redirects, so an app with a custom domain works too, and the query string keeps any cache from answering for the old file.
  6. Summary writes the URL and the deployment ID to the GitHub run summary, so anyone viewing the run sees them.

The concurrency block keeps two pushes from racing. The later one waits for the earlier deploy to finish.

Single-page apps

Rewrite rules run before VibeHost looks for a file, so a catch-all rewrite to /index.html answers /build-id.txt with your index.html too, and the smoke check never sees the SHA. List an exact rule for build-id.txt ahead of the catch-all in vibehost.json:

vibehost.json
{
  "rewrites": [
    { "source": "/build-id.txt", "destination": "/build-id.txt" },
    { "source": "/(.*)", "destination": "/index.html" }
  ]
}

Gated apps

The smoke check needs an app anyone can open. A private or workspace app, or one with a password, answers the anonymous curl with a redirect to the sign-in or password page. Following that redirect ends on a page that returns 200, so a check on the status code alone passes whether or not your build is live. The build-id.txt comparison fails there instead, because the sign-in page does not contain your SHA.

For a gated app, remove the Smoke check step. The Deploy step's status: "healthy" then confirms that the deployment succeeded, not that the URL already serves it: after a redeploy the previous build can keep answering for up to about a minute. Seeing the new build live takes a request that clears the app's gate, such as opening the URL signed in. Don't roll back because an anonymous request was turned away.

Variations

Deploy each PR to its own channel, comment the URL on the PR, and clean up when it closes. See the full recipe at PR previews.

Separate production and staging apps, each with its own PAT:

jobs:
  deploy-staging:
    if: github.ref == 'refs/heads/develop'
    environment: staging
    steps:
      - # ... build ...
      - env:
          VIBEHOST_TOKEN: ${{ secrets.VIBEHOST_TOKEN_STAGING }}
        run: ~/.vibehost/cli/vibehost deploy ./dist --app my-site-staging

  deploy-production:
    if: github.ref == 'refs/heads/main'
    environment: production
    needs: deploy-staging
    steps:
      - # ... build ...
      - env:
          VIBEHOST_TOKEN: ${{ secrets.VIBEHOST_TOKEN_PROD }}
        run: ~/.vibehost/cli/vibehost deploy ./dist --app my-site

Each environment has its own app, PAT, and GitHub environment, which keeps a push to the develop branch from reaching production by accident.

Build and deploy only the apps that changed:

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

  deploy-docs:
    needs: changes
    if: ${{ needs.changes.outputs.docs == 'true' }}
    # ... build + deploy apps/docs ...

  deploy-web:
    needs: changes
    if: ${{ needs.changes.outputs.web == 'true' }}
    # ... build + deploy apps/web ...

See the full monorepo recipe.

To ship a specific version, trigger on a release tag:

on:
  push:
    tags: ['v*']
  workflow_dispatch:
    inputs:
      ref:
        description: 'Tag or commit to deploy'
        required: true

The workflow_dispatch path lets a maintainer roll back to an older tag without touching git.

Rolling back from CI

name: Rollback
on:
  workflow_dispatch:

jobs:
  rollback:
    runs-on: ubuntu-latest
    environment: production
    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 }}
        run: |
          ~/.vibehost/cli/vibehost rollback --app my-site --json

With this workflow in place, the Run workflow button in the GitHub Actions UI rolls the app back.

Troubleshooting

SymptomCauseFix
UNAUTHENTICATED in deploy stepPAT not set, or from the wrong workspaceCheck the secret name. The PAT must come from the workspace that contains the app
TOKEN_WORKSPACE_MISMATCHPAT minted in workspace A, target app in workspace BRe-mint the PAT in the correct workspace
Smoke check prints the previous SHAThe URL still serves the previous buildThe loop waits up to two minutes for the switch. Raise the 120 in DEADLINE if you see it run out
Smoke check prints your own index.htmlA catch-all rewrite to /index.html also answers /build-id.txtList an exact /build-id.txt rule before it. See Single-page apps
Smoke check prints sign-in page HTMLThe app is private, workspace, or password-protectedMake the app public, or drop the smoke check for gated apps. See Gated apps
Smoke check 404 on build-id.txtThe build step didn't write the file into the deployed directoryWrite build-id.txt into the same directory you pass to vibehost deploy
RATE_LIMITED after many runsWorkspace deploy quota hit (~30/min)Reduce CI fanout, or upgrade plan
Build hangs / OOMs on the runnerDisk / memory limit on free runnerUse runs-on: ubuntu-latest-large or split the build step

See also

On this page