Browse the docs

Preview deployments

Ready-to-copy workflows for Vercel, Netlify, Render, Cloudflare Pages, your own pipeline, or no preview host at all.

QualityGate scans a URL, so every integration comes down to one question: what is the address of this pull request’s preview? This page answers it for common hosts. Each example is a complete GitHub Actions workflow; the same idea applies to GitLab and Bitbucket.

All examples use the keyless GitHub App setup. For an API key, add api_key: ${{ secrets.QUALITYGATE_API_KEY }} and see Setup with an API key.

Before you start

The preview must be public to the runner. QualityGate waits for a 2xx response and loads the page like a visitor. Password protection, Vercel Deployment Protection, Netlify password protection, or an IP allowlist will make the run time out waiting for the preview. Turn protection off for previews, or use Build and serve in the same job.

Add the preview host to the project’s allowed domains. Use the narrowest pattern your host allows. A *. prefix matches any subdomain:

HostAllowed domain to add
Your own preview domain*.preview.example.com
Cloudflare Pages*.your-project.pages.dev
Vercel*.vercel.app
Netlify*.netlify.app
Render*.onrender.com
Scanning in the joblocalhost

Shared platform domains such as *.vercel.app are accepted. The allowed list decides which hosts a run may target, so the narrower it is, the less a leaked key could spend.

Scan the right commit. Predictable preview addresses keep serving the previous build until the new one is live, so a scan that starts too early can look at the last commit. Where the host tells you the exact deployment URL, the examples below use it. Otherwise, run the scan after the host has finished deploying.

Vercel

Deploy the preview from the workflow with the Vercel CLI, which prints the exact deployment URL. Create a Vercel token and add the secrets VERCEL_TOKEN, VERCEL_ORG_ID, and VERCEL_PROJECT_ID (the last two are in .vercel/project.json after vercel link).

name: Preview and QualityGate

on:
  pull_request:

permissions:
  contents: read
  id-token: write

env:
  VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
  VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}

jobs:
  preview:
    runs-on: ubuntu-latest
    outputs:
      url: ${{ steps.deploy.outputs.url }}
    steps:
      - uses: actions/checkout@v5
      - run: npm install --global vercel@latest
      - run: vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_TOKEN }}
      - run: vercel build --token=${{ secrets.VERCEL_TOKEN }}
      - id: deploy
        run: echo "url=$(vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }})" >> "$GITHUB_OUTPUT"

  quality:
    needs: preview
    runs-on: ubuntu-latest
    steps:
      - run: npx -y playwright@1.61.1 install --with-deps chromium
      - uses: qualitygate/scan-action@v1
        with:
          url: ${{ needs.preview.outputs.url }}

Vercel protects preview deployments by default on many plans. Under Project Settings → Deployment Protection, disable Vercel Authentication for preview deployments, or the scan will wait until it times out.

Netlify

Netlify deploy previews have a predictable address: https://deploy-preview-<pull request number>--<site name>.netlify.app. The simplest setup scans it and gives Netlify time to finish building:

name: QualityGate

on:
  pull_request:

permissions:
  contents: read
  id-token: write

jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - run: npx -y playwright@1.61.1 install --with-deps chromium
      - uses: qualitygate/scan-action@v1
        with:
          url: https://deploy-preview-${{ github.event.pull_request.number }}--your-site.netlify.app
          wait_timeout: '600'

On a pull request’s first commit the address returns 404 until the build finishes, and the action keeps waiting. On later commits the address already serves the previous build, so the scan can run against it. For an exact match per commit, deploy from the workflow with the Netlify CLI and scan the URL it returns. Add the secrets NETLIFY_AUTH_TOKEN and NETLIFY_SITE_ID:

jobs:
  preview:
    runs-on: ubuntu-latest
    outputs:
      url: ${{ steps.deploy.outputs.url }}
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v5
        with:
          node-version: 22
      - run: npm ci
      - id: deploy
        env:
          NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
          NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
        run: |
          url=$(npx -y netlify-cli deploy --build --json | jq -r '.deploy_url')
          echo "url=$url" >> "$GITHUB_OUTPUT"

  quality:
    needs: preview
    runs-on: ubuntu-latest
    steps:
      - run: npx -y playwright@1.61.1 install --with-deps chromium
      - uses: qualitygate/scan-action@v1
        with:
          url: ${{ needs.preview.outputs.url }}

Render

With pull request previews enabled, Render serves each pull request at https://<service>-pr-<pull request number>.onrender.com, where <service> is the subdomain of your service’s normal onrender.com address:

- run: npx -y playwright@1.61.1 install --with-deps chromium
- uses: qualitygate/scan-action@v1
  with:
    url: https://your-service-pr-${{ github.event.pull_request.number }}.onrender.com
    wait_timeout: '900'

Render builds can take several minutes, hence the longer wait. Check the exact preview address on your first pull request and adjust the pattern if needed.

Cloudflare Pages

Deploy with the official Wrangler action and scan the deployment URL it returns. The preview lives under your project’s pages.dev subdomain, so you can allow just *.your-project.pages.dev:

jobs:
  preview:
    runs-on: ubuntu-latest
    outputs:
      url: ${{ steps.deploy.outputs.deployment-url }}
    steps:
      - uses: actions/checkout@v5
      - run: npm ci && npm run build
      - id: deploy
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          command: pages deploy dist --project-name=your-project --branch=${{ github.head_ref }}

  quality:
    needs: preview
    runs-on: ubuntu-latest
    steps:
      - run: npx -y playwright@1.61.1 install --with-deps chromium
      - uses: qualitygate/scan-action@v1
        with:
          url: ${{ needs.preview.outputs.url }}

Your own deployment pipeline

If you deploy previews yourself (Kubernetes, Fly.io, a VM, anything), make the deploy job output the URL and point QualityGate at it. A wildcard preview domain you control keeps the allowed list tight:

jobs:
  preview:
    runs-on: ubuntu-latest
    outputs:
      url: ${{ steps.deploy.outputs.url }}
    steps:
      - uses: actions/checkout@v5
      - id: deploy
        run: |
          ./deploy.sh --name "pr-${{ github.event.pull_request.number }}"
          echo "url=https://pr-${{ github.event.pull_request.number }}.preview.example.com" >> "$GITHUB_OUTPUT"

  quality:
    needs: preview
    runs-on: ubuntu-latest
    steps:
      - run: npx -y playwright@1.61.1 install --with-deps chromium
      - uses: qualitygate/scan-action@v1
        with:
          url: ${{ needs.preview.outputs.url }}

Allowed domain: *.preview.example.com.

Build and serve in the same job

No preview host? Build the app in the CI job, start it on the runner, and scan localhost. Add localhost to the project’s allowed domains. This always scans exactly the commit under review, and works well for regression mode.

name: QualityGate

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read
  id-token: write

jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v5
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm run build

      # Start the production server in the background. For a static site,
      # serve the build output instead: npx -y serve -l 3000 dist &
      - run: npm run start -- --port 3000 &

      - run: npx -y playwright@1.61.1 install --with-deps chromium
      - uses: qualitygate/scan-action@v1
        with:
          url: http://localhost:3000/
          fail_on: regression

The action waits for the server to answer before scanning. Keep in mind that a local server does not have your hosting platform’s response headers, so the security bundle is better run against a real deployment.