Blog 13 min read

How to Capture Screenshots in CI/CD Pipelines with GitHub Actions

Add screenshot capture to your CI/CD pipeline for visual regression testing, deploy verification, and PR preview comments. Covers GitHub Actions, GitLab CI, and Jenkins with working YAML configs.

SnapRender Team
|

How to Capture Screenshots in CI/CD Pipelines with GitHub Actions

Adding screenshot capture to your CI/CD pipeline catches visual regressions, generates preview images, and archives page states on every deploy. A screenshot API is the cleanest way to do this because there's no browser to install on the runner, no Puppeteer dependency to maintain, and no Xvfb display server to configure. You send an HTTP request from any CI environment and get back an image. This tutorial covers GitHub Actions in depth, plus GitLab CI and Jenkins equivalents.

Why Screenshots in CI/CD?

There are five patterns where capturing screenshots during a build actually pays off:

  • Visual regression detection: Compare staging screenshots against production baselines. Catch CSS breakage, missing assets, and layout shifts before users see them.
  • Deploy verification: After shipping, capture key pages and upload as build artifacts. If something breaks Friday at 5pm, you've got screenshots showing exactly when it happened.
  • OG image generation: Generate social preview images (Open Graph / Twitter Cards) at build time from actual page renders. Always accurate, always up to date.
  • Changelog screenshots: Automatically capture before/after screenshots for release notes. Saves product teams from doing it manually.
  • PR preview comments: Capture deploy preview URLs and post screenshots as PR comments so reviewers can see visual changes without checking out the branch.

The common thread is that these all run in an automated environment where you don't want to install and manage a headless browser. A screenshot API keeps your CI config simple and your runner requirements minimal.

Basic GitHub Action: Capture and Upload Artifacts

This workflow captures a list of URLs after deployment and uploads the screenshots as build artifacts. Create .github/workflows/screenshots.yml:

name: Capture Screenshots

on:
  workflow_dispatch:
  deployment_status:

jobs:
  capture:
    if: github.event_name == 'workflow_dispatch' || github.event.deployment_status.state == 'success'
    runs-on: ubuntu-latest

    strategy:
      matrix:
        viewport:
          - { width: 1280, height: 720, label: desktop }
          - { width: 375, height: 812, label: mobile }

    steps:
      - name: Capture screenshots
        env:
          SNAPRENDER_API_KEY: ${{ secrets.SNAPRENDER_API_KEY }}
        run: |
          mkdir -p screenshots

          URLS=(
            "https://yoursite.com"
            "https://yoursite.com/pricing"
            "https://yoursite.com/docs"
            "https://yoursite.com/blog"
          )

          for url in "${URLS[@]}"; do
            slug=$(echo "$url" | sed 's|https\?://||;s|/|_|g;s|[^a-zA-Z0-9_]||g')
            filename="${slug}_${{ matrix.viewport.label }}.png"

            echo "Capturing $url (${{ matrix.viewport.label }})"

            http_code=$(curl -s -o "screenshots/$filename" -w "%{http_code}" \
              -H "X-API-Key: $SNAPRENDER_API_KEY" \
              "https://app.snap-render.com/v1/screenshot?url=${url}&format=png&width=${{ matrix.viewport.width }}&height=${{ matrix.viewport.height }}&cache_ttl=0&block_ads=true&no_cookie_banners=true")

            if [[ "$http_code" -ne 200 ]]; then
              echo "::warning::Failed to capture $url (HTTP $http_code)"
            fi

            sleep 0.3
          done

      - name: Upload screenshots
        uses: actions/upload-artifact@v4
        with:
          name: screenshots-${{ matrix.viewport.label }}
          path: screenshots/
          retention-days: 30

Key details: cache_ttl=0 forces a fresh capture every time (critical in CI where you want the current state, not a cached version). The matrix strategy runs desktop and mobile viewports in parallel as separate jobs. The deployment_status trigger fires after a successful deploy, but workflow_dispatch lets you trigger it manually for testing.

Visual Regression Workflow

This is the high-value pattern. Capture staging and production side by side, diff the images, and fail the build if the visual difference exceeds a threshold. The workflow uses a small Node.js script with pixelmatch for the comparison step.

First, the workflow (.github/workflows/visual-regression.yml):

name: Visual Regression Check

on:
  pull_request:
    types: [opened, synchronize]

jobs:
  visual-diff:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20

      - name: Install pixelmatch dependencies
        run: npm install pixelmatch pngjs

      - name: Capture production screenshots (baseline)
        env:
          SNAPRENDER_API_KEY: ${{ secrets.SNAPRENDER_API_KEY }}
        run: |
          mkdir -p screenshots/production screenshots/staging screenshots/diffs

          PAGES=("/" "/pricing" "/docs" "/blog")
          PROD_BASE="https://yoursite.com"

          for page in "${PAGES[@]}"; do
            slug=$(echo "$page" | sed 's|/|_|g;s|^_||')
            [[ -z "$slug" ]] && slug="home"
            curl -sf -o "screenshots/production/${slug}.png" \
              -H "X-API-Key: $SNAPRENDER_API_KEY" \
              "https://app.snap-render.com/v1/screenshot?url=${PROD_BASE}${page}&format=png&width=1280&height=720&cache_ttl=0&no_cookie_banners=true"
            sleep 0.3
          done

      - name: Capture staging screenshots
        env:
          SNAPRENDER_API_KEY: ${{ secrets.SNAPRENDER_API_KEY }}
          STAGING_URL: ${{ vars.STAGING_URL }}
        run: |
          PAGES=("/" "/pricing" "/docs" "/blog")

          for page in "${PAGES[@]}"; do
            slug=$(echo "$page" | sed 's|/|_|g;s|^_||')
            [[ -z "$slug" ]] && slug="home"
            curl -sf -o "screenshots/staging/${slug}.png" \
              -H "X-API-Key: $SNAPRENDER_API_KEY" \
              "https://app.snap-render.com/v1/screenshot?url=${STAGING_URL}${page}&format=png&width=1280&height=720&cache_ttl=0&no_cookie_banners=true"
            sleep 0.3
          done

      - name: Run visual diff
        id: diff
        run: |
          node << 'SCRIPT'
          const fs = require("fs");
          const { PNG } = require("pngjs");
          const pixelmatch = require("pixelmatch");
          const path = require("path");

          const THRESHOLD = 0.5;  // percentage of pixels allowed to differ
          const prodDir = "screenshots/production";
          const stagingDir = "screenshots/staging";
          const diffDir = "screenshots/diffs";

          const prodFiles = fs.readdirSync(prodDir).filter(f => f.endsWith(".png"));
          let failed = false;
          const results = [];

          for (const file of prodFiles) {
            const prodPath = path.join(prodDir, file);
            const stagingPath = path.join(stagingDir, file);

            if (!fs.existsSync(stagingPath)) {
              console.log(`SKIP: ${file} (no staging screenshot)`);
              continue;
            }

            const prodImg = PNG.sync.read(fs.readFileSync(prodPath));
            const stagingImg = PNG.sync.read(fs.readFileSync(stagingPath));

            if (prodImg.width !== stagingImg.width || prodImg.height !== stagingImg.height) {
              console.log(`WARN: ${file} has different dimensions, skipping diff`);
              continue;
            }

            const { width, height } = prodImg;
            const diff = new PNG({ width, height });

            const mismatchedPixels = pixelmatch(
              prodImg.data, stagingImg.data, diff.data,
              width, height,
              { threshold: 0.1 }
            );

            const totalPixels = width * height;
            const diffPercent = ((mismatchedPixels / totalPixels) * 100).toFixed(2);

            fs.writeFileSync(path.join(diffDir, file), PNG.sync.write(diff));

            const status = parseFloat(diffPercent) > THRESHOLD ? "FAIL" : "PASS";
            if (status === "FAIL") failed = true;

            results.push({ file, diffPercent, status });
            console.log(`${status}: ${file} - ${diffPercent}% different (${mismatchedPixels} pixels)`);
          }

          // Write summary for later steps
          fs.writeFileSync("screenshots/summary.json", JSON.stringify(results, null, 2));

          if (failed) {
            console.log("\nVisual regression detected. Check diff images in artifacts.");
            process.exit(1);
          }
          SCRIPT

      - name: Upload diff artifacts
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: visual-regression-diffs
          path: screenshots/
          retention-days: 14

The pixelmatch threshold of 0.1 is for per-pixel color sensitivity (0 = exact match, 1 = anything counts as matching). The THRESHOLD variable at the top (0.5%) controls how many pixels can differ before the build fails. I've found 0.5% works well for catching real regressions while ignoring sub-pixel rendering differences between captures.

Deploy Preview Screenshots in PR Comments

This pattern captures Vercel or Netlify preview URLs and posts the screenshots as a comment on the pull request. Reviewers see visual changes without leaving GitHub.

name: Preview Screenshots

on:
  pull_request:
    types: [opened, synchronize]

jobs:
  preview-screenshots:
    runs-on: ubuntu-latest
    steps:
      - name: Wait for preview deploy
        run: sleep 60  # Adjust based on typical deploy time

      - name: Determine preview URL
        id: preview
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          PR_NUMBER=${{ github.event.pull_request.number }}
          REPO_NAME=$(echo "${{ github.repository }}" | cut -d/ -f2)

          # Option A: Vercel pattern
          PREVIEW_URL="https://${REPO_NAME}-git-pr-${PR_NUMBER}-your-team.vercel.app"

          echo "url=$PREVIEW_URL" >> $GITHUB_OUTPUT

      - name: Capture preview screenshots
        env:
          SNAPRENDER_API_KEY: ${{ secrets.SNAPRENDER_API_KEY }}
          PREVIEW_URL: ${{ steps.preview.outputs.url }}
        run: |
          mkdir -p screenshots

          PAGES=("/" "/pricing" "/docs")
          VIEWPORTS=("1280:720:desktop" "375:812:mobile")

          for page in "${PAGES[@]}"; do
            for vp in "${VIEWPORTS[@]}"; do
              IFS=: read -r width height label <<< "$vp"
              slug=$(echo "$page" | sed 's|/|_|g;s|^_||')
              [[ -z "$slug" ]] && slug="home"
              filename="${slug}_${label}.png"

              curl -sf -o "screenshots/$filename" \
                -H "X-API-Key: $SNAPRENDER_API_KEY" \
                "https://app.snap-render.com/v1/screenshot?url=${PREVIEW_URL}${page}&format=png&width=${width}&height=${height}&cache_ttl=0&no_cookie_banners=true"

              sleep 0.3
            done
          done

      - name: Upload screenshots
        uses: actions/upload-artifact@v4
        with:
          name: preview-screenshots
          path: screenshots/
          retention-days: 7

      - name: Comment on PR
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          PREVIEW_URL: ${{ steps.preview.outputs.url }}
        run: |
          ARTIFACT_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"

          BODY="## Preview Screenshots

          **Preview URL:** $PREVIEW_URL

          Screenshots captured for: homepage, pricing, docs (desktop + mobile).

          [Download screenshot artifacts]($ARTIFACT_URL)

          *Captured at $(date -u '+%Y-%m-%d %H:%M UTC')*"

          gh pr comment ${{ github.event.pull_request.number }} \
            --repo ${{ github.repository }} \
            --body "$BODY"

GitHub Actions doesn't let you embed uploaded artifact images directly in PR comments (artifacts aren't publicly accessible URLs). The comment links to the artifact download instead. If you need inline images, upload the screenshots to an S3 bucket or Cloudflare R2 with public read access and reference those URLs in the comment body.

Scheduled Monitoring Workflow

Capture key pages on a weekly schedule and commit them to a screenshots/ directory in the repo. This builds a visual history you can browse through Git.

name: Weekly Screenshot Archive

on:
  schedule:
    - cron: '0 9 * * 1'  # Every Monday at 9:00 UTC
  workflow_dispatch:

permissions:
  contents: write

jobs:
  archive:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Capture screenshots
        env:
          SNAPRENDER_API_KEY: ${{ secrets.SNAPRENDER_API_KEY }}
        run: |
          DATE=$(date +%Y-%m-%d)
          DIR="screenshots/$DATE"
          mkdir -p "$DIR"

          URLS=(
            "https://yoursite.com"
            "https://yoursite.com/pricing"
            "https://yoursite.com/docs"
          )

          for url in "${URLS[@]}"; do
            slug=$(echo "$url" | sed 's|https\?://||;s|/|_|g;s|[^a-zA-Z0-9_]||g')
            curl -sf -o "$DIR/${slug}.png" \
              -H "X-API-Key: $SNAPRENDER_API_KEY" \
              "https://app.snap-render.com/v1/screenshot?url=${url}&format=png&width=1280&height=720&cache_ttl=0"
            sleep 0.3
          done

      - name: Commit screenshots
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git add screenshots/
          git commit -m "Add weekly screenshots $(date +%Y-%m-%d)" || echo "No changes to commit"
          git push

After a few months, you can browse screenshots/ and see exactly how your site looked on any given Monday. Git handles the storage, and you can diff screenshots between dates using ImageMagick locally.

GitLab CI Equivalent

The same basic capture workflow in .gitlab-ci.yml:

stages:
  - screenshots

capture-screenshots:
  stage: screenshots
  image: alpine/curl:latest
  variables:
    SNAPRENDER_API_KEY: $SNAPRENDER_API_KEY
  script:
    - mkdir -p screenshots

    - |
      URLS="https://yoursite.com https://yoursite.com/pricing https://yoursite.com/docs"
      for url in $URLS; do
        slug=$(echo "$url" | sed 's|https\?://||;s|/|_|g;s|[^a-zA-Z0-9_]||g')
        echo "Capturing $url"
        curl -sf -o "screenshots/${slug}.png" \
          -H "X-API-Key: $SNAPRENDER_API_KEY" \
          "https://app.snap-render.com/v1/screenshot?url=${url}&format=png&width=1280&height=720&cache_ttl=0"
        sleep 0.3
      done

  artifacts:
    paths:
      - screenshots/
    expire_in: 30 days
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule"
    - if: $CI_PIPELINE_SOURCE == "web"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: manual

GitLab's alpine/curl image is about 8MB, so the job starts fast. The rules section lets it run on schedules, manual triggers, and optionally on main branch commits.

Jenkins Pipeline

For Jenkins users, here's the equivalent Jenkinsfile stage:

pipeline {
    agent any

    environment {
        SNAPRENDER_API_KEY = credentials('snaprender-api-key')
    }

    stages {
        stage('Capture Screenshots') {
            steps {
                sh '''
                    mkdir -p screenshots

                    URLS="https://yoursite.com https://yoursite.com/pricing https://yoursite.com/docs"
                    for url in $URLS; do
                        slug=$(echo "$url" | sed 's|https\\?://||;s|/|_|g;s|[^a-zA-Z0-9_]||g')
                        echo "Capturing $url"
                        curl -sf -o "screenshots/${slug}.png" \
                            -H "X-API-Key: $SNAPRENDER_API_KEY" \
                            "https://app.snap-render.com/v1/screenshot?url=${url}&format=png&width=1280&height=720&cache_ttl=0"
                        sleep 0.3
                    done
                '''
            }
            post {
                always {
                    archiveArtifacts artifacts: 'screenshots/*.png', fingerprint: true
                }
            }
        }
    }
}

Add your API key as a Jenkins secret text credential with the ID snaprender-api-key. The credentials() helper injects it securely without exposing it in build logs.

Comparison: API vs Puppeteer vs Percy in CI

How the three main approaches to CI screenshots compare:

Screenshot API (SnapRender)

  • Runner setup: None. Just cURL.
  • Build time added: 5-15 seconds for 10 pages
  • Memory on runner: Minimal (HTTP requests only)
  • Works on any CI: Yes. Anything with cURL.
  • Visual diff built in: No (add pixelmatch yourself)
  • Cost for 1,000 screenshots/mo: $9/mo (SnapRender Starter)
  • Maintenance: API key rotation

Puppeteer in CI

  • Runner setup: Install Chrome + Node.js + Puppeteer. ~500MB.
  • Build time added: 30-90 seconds (browser startup + captures)
  • Memory on runner: 500MB-2GB (headless Chrome)
  • Works on any CI: Need a runner that supports Chrome.
  • Visual diff built in: No (add yourself)
  • Cost for 1,000 screenshots/mo: Free (your compute)
  • Maintenance: Chrome version updates, Puppeteer compat, memory tuning

Percy / Chromatic

  • Runner setup: Install SDK + configure.
  • Build time added: 20-60 seconds (upload + processing)
  • Memory on runner: Minimal (SDK upload)
  • Works on any CI: GitHub/GitLab/CircleCI mainly.
  • Visual diff built in: Yes (core feature)
  • Cost for 1,000 screenshots/mo: $399/mo+ (Percy), $149/mo+ (Chromatic)
  • Maintenance: SDK updates, config changes

The tradeoff is clear: Percy and Chromatic give you a managed visual regression platform with dashboards, approval workflows, and baseline management. A screenshot API gives you the raw captures, and you build the comparison logic yourself. For most teams, the API approach with pixelmatch covers 90% of the use case at a fraction of the cost.

Cost Calculation

Typical CI usage for a mid-sized project:

  • Pull requests per month: 50
  • Pages captured per PR: 10
  • Viewports per page: 2 (desktop + mobile)
  • Environments: 2 (staging + production)
  • Total per month: 2,000 screenshots

That fits exactly in SnapRender's Starter plan at $9/month. If you add weekly monitoring (3 pages x 4 weeks = 12 more), you're still well under the limit.

For comparison, Percy charges $399/month for their Team plan, and Chromatic starts at $149/month. If visual regression testing is your primary goal and your budget allows it, those platforms offer better UX for reviewing and approving visual changes. But if you're a small team watching costs, an API plus 40 lines of pixelmatch code gets you there.

All SnapRender plans include every feature (ad blocking, device emulation, dark mode, PDF output). There's no feature gating, so your CI screenshots get the same capabilities whether you're on the free tier or the Scale plan.

Tips for CI Screenshot Workflows

Always use cache_ttl=0 in CI. The default cache returns previously captured images for the same URL. In CI, you always want a fresh render of the current deployment. Set cache_ttl=0 on every request.

Store the API key as a CI secret. In GitHub Actions, go to Settings > Secrets and variables > Actions > New repository secret. Name it SNAPRENDER_API_KEY. Reference it as ${{ secrets.SNAPRENDER_API_KEY }} in workflow YAML. Never hardcode it.

Use artifacts for debugging failed visual tests. When a visual diff fails the build, the diff images show exactly which pixels changed. Upload both the baseline, the new capture, and the diff as build artifacts so developers can inspect them without re-running the pipeline.

Add block_ads=true and no_cookie_banners=true to CI captures. Ads and cookie banners are non-deterministic. They change across requests, inject different content, and create false positives in visual diffs. Strip them out for consistent comparisons.

Set appropriate timeouts. The default GitHub Actions step timeout is 6 hours. For a screenshot step, set timeout-minutes: 5 so a hung request doesn't burn your CI minutes.

Use matrix builds for viewports. Instead of looping through viewports in a single job, use GitHub Actions' strategy.matrix to run each viewport in parallel. This cuts total wall time roughly in half.

Which Pattern Fits Your Team

Solo developer or small team (1-5 people): Start with the basic capture workflow. Upload as artifacts and review manually when builds fail. This costs $0-9/month and takes 15 minutes to set up.

Mid-size team (5-20 people): Add the visual regression workflow with pixelmatch. The automated diff catches regressions before they reach production. Add PR preview screenshots so code reviewers see visual changes in context. Budget around $9-29/month.

Large team (20+ people): Evaluate whether the pixelmatch approach scales for your needs. If you're running 100+ PRs/month and need approval workflows, baseline management, and a dashboard, Percy or Chromatic might be worth the premium. If you don't need those features, the API approach continues to scale fine at $29-79/month depending on volume.

In all cases, the API approach keeps your CI runners lean. No headless Chrome eating 2GB of RAM per job. No browser version mismatches between local and CI. Just HTTP requests and image files.

Related posts:

Try SnapRender Free

500 free screenshots/month, no credit card required.

Sign up free