Tutorial

Automated Screenshots in CI/CD Pipelines

|12 min read

Unit tests catch logic bugs. Type checking catches type errors. But neither catches the broken layout that ships when someone updates a CSS file. Automated screenshots in your CI/CD pipeline catch visual regressions before they reach production.

What you will learn

1.Screenshot API basics
2.GitHub Actions workflow
3.GitLab CI integration
4.Multi-viewport captures
5.Pixel-diff comparison
6.Build artifact storage
7.Preview URL screenshots
8.Visual regression gates

1. The basic API call

SnapRender takes a URL and returns a screenshot. No browser installation, no Puppeteer, no Playwright. A single curl command:

terminal
# Take a screenshot #E8A0BF">with a single #E8A0BF">curl command
#E8A0BF">curl -X POST #A8D4A0">"https://api.snaprender.dev/v1/screenshot" \
  -H #A8D4A0">"x-api-key: $SNAPRENDER_API_KEY" \
  -H #A8D4A0">"Content-Type: application/json" \
  -d #A8D4A0">'{
    #A8D4A0">"url": #A8D4A0">"https://your-preview-url.vercel.app",
    #A8D4A0">"format": #A8D4A0">"png",
    #A8D4A0">"viewport": { #A8D4A0">"width": 1280, #A8D4A0">"height": 720 },
    #A8D4A0">"full_page": true
  }' \
  --output screenshot.png

This works in any CI environment that has curl. No Node.js, no Chrome, no 500MB Docker images.

2. GitHub Actions workflow

Here is a complete GitHub Actions workflow that captures desktop and mobile screenshots on every pull request:

.github/workflows/screenshots.yml
#E8A0BF">name: Visual Regression Check

#E8A0BF">on:
  #E8A0BF">pull_request:
    branches: [main]

#E8A0BF">jobs:
  screenshots:
    runs-#E8A0BF">on: ubuntu-latest
    #E8A0BF">steps:
      - #E8A0BF">uses: actions/checkout@v4

      - #E8A0BF">name: Wait for preview deploy
        #E8A0BF">uses: patrickedqvist/wait-for-vercel-preview@v1.3.2
        id: preview
        #E8A0BF">with:
          token: ${{ secrets.GITHUB_TOKEN }}

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

          # Homepage
          #E8A0BF">curl -s -X POST #A8D4A0">"https://api.snaprender.dev/v1/screenshot" \
            -H #A8D4A0">"x-api-key: $SNAPRENDER_KEY" \
            -H #A8D4A0">"Content-Type: application/json" \
            -d #A8D4A0">"{
              \"url\#A8D4A0">": \"$PREVIEW_URL\#A8D4A0">",
              \"format\#A8D4A0">": \"png\#A8D4A0">",
              \"viewport\#A8D4A0">": { \"width\#A8D4A0">": 1280, \"height\#A8D4A0">": 720 },
              \"full_page\#A8D4A0">": true
            }" --output screenshots/home-desktop.png

          # Mobile viewport
          #E8A0BF">curl -s -X POST #A8D4A0">"https://api.snaprender.dev/v1/screenshot" \
            -H #A8D4A0">"x-api-key: $SNAPRENDER_KEY" \
            -H #A8D4A0">"Content-Type: application/json" \
            -d #A8D4A0">"{
              \"url\#A8D4A0">": \"$PREVIEW_URL\#A8D4A0">",
              \"format\#A8D4A0">": \"png\#A8D4A0">",
              \"viewport\#A8D4A0">": { \"width\#A8D4A0">": 375, \"height\#A8D4A0">": 812 },
              \"full_page\#A8D4A0">": true
            }" --output screenshots/home-mobile.png

          #E8A0BF">echo #A8D4A0">"Screenshots captured successfully"

      - #E8A0BF">name: Upload #E8A0BF">artifacts
        #E8A0BF">uses: actions/upload-artifact@v4
        #E8A0BF">with:
          #E8A0BF">name: screenshots
          path: screenshots/

Pro tip

Store your SnapRender API key as a repository secret (SNAPRENDER_API_KEY). Never commit API keys to your repository.

3. Multi-page screenshot script

For projects with many pages, use a Node.js script that loops through pages and viewports:

capture-screenshots.js
#E8A0BF">const fs = #E8A0BF">require(#A8D4A0">'fs');

#E8A0BF">const SNAPRENDER_KEY = process.#E8A0BF">env.SNAPRENDER_API_KEY;
#E8A0BF">const BASE_URL = process.#E8A0BF">env.PREVIEW_URL || #A8D4A0">'https://localhost:3000';

#E8A0BF">const pages = [
  { path: #A8D4A0">'/', #E8A0BF">name: #A8D4A0">'home' },
  { path: #A8D4A0">'/pricing', #E8A0BF">name: #A8D4A0">'pricing' },
  { path: #A8D4A0">'/docs', #E8A0BF">name: #A8D4A0">'docs' },
  { path: #A8D4A0">'/login', #E8A0BF">name: #A8D4A0">'login' },
];

#E8A0BF">const viewports = [
  { width: 1280, height: 720, label: #A8D4A0">'desktop' },
  { width: 375, height: 812, label: #A8D4A0">'mobile' },
];

#E8A0BF">async #E8A0BF">function captureScreenshots() {
  fs.mkdirSync(#A8D4A0">'screenshots', { recursive: true });

  for (#E8A0BF">const page of pages) {
    for (#E8A0BF">const vp of viewports) {
      #E8A0BF">const resp = #E8A0BF">await #E8A0BF">fetch(
        #A8D4A0">'https://api.snaprender.dev/v1/screenshot',
        {
          method: #A8D4A0">'POST',
          headers: {
            #A8D4A0">'x-api-key': SNAPRENDER_KEY,
            #A8D4A0">'Content-Type': #A8D4A0">'application/json',
          },
          body: JSON.stringify({
            url: BASE_URL + page.path,
            format: #A8D4A0">'png',
            viewport: { width: vp.width, height: vp.height },
            full_page: true,
          }),
        }
      );

      #E8A0BF">const buffer = Buffer.#E8A0BF">from(#E8A0BF">await resp.arrayBuffer());
      #E8A0BF">const filename = `screenshots/${page.#E8A0BF">name}-${vp.label}.png`;
      fs.writeFileSync(filename, buffer);
      console.log(`Saved: ${filename}`);
    }
  }

  console.log(#A8D4A0">'All screenshots captured');
}

captureScreenshots();

4. GitLab CI integration

GitLab CI works the same way. Define a job that captures screenshots and stores them as artifacts:

.gitlab-ci.yml
screenshot-check:
  #E8A0BF">stage: test
  #E8A0BF">image: node:20-alpine
  #E8A0BF">variables:
    SNAPRENDER_KEY: $SNAPRENDER_API_KEY
  #E8A0BF">script:
    - apk add --no-cache #E8A0BF">curl
    - #E8A0BF">mkdir -p screenshots

    # Capture key pages
    - |
      for page in #A8D4A0">"/" #A8D4A0">"/pricing" #A8D4A0">"/docs"; do
        #E8A0BF">name=$(#E8A0BF">echo $page | tr #A8D4A0">'/' #A8D4A0">'-' | sed #A8D4A0">'s/^-/home/')
        #E8A0BF">curl -s -X POST #A8D4A0">"https://api.snaprender.dev/v1/screenshot" \
          -H #A8D4A0">"x-api-key: $SNAPRENDER_KEY" \
          -H #A8D4A0">"Content-Type: application/json" \
          -d #A8D4A0">"{
            \"url\#A8D4A0">": \"$CI_ENVIRONMENT_URL$page\#A8D4A0">",
            \"format\#A8D4A0">": \"png\#A8D4A0">",
            \"viewport\#A8D4A0">": { \"width\#A8D4A0">": 1280, \"height\#A8D4A0">": 720 },
            \"full_page\#A8D4A0">": true
          }" --output #A8D4A0">"screenshots/$#E8A0BF">name.png"
        #E8A0BF">echo #A8D4A0">"Captured: $#E8A0BF">name"
      done

  #E8A0BF">artifacts:
    #E8A0BF">paths:
      - screenshots/
    expire_in: 30 days
  #E8A0BF">only:
    - merge_requests

5. Visual regression detection

Compare screenshots between builds using pixel-diff to catch visual regressions automatically:

compare.js
#E8A0BF">const { createCanvas, loadImage } = #E8A0BF">require(#A8D4A0">'canvas');
#E8A0BF">const pixelmatch = #E8A0BF">require(#A8D4A0">'pixelmatch');
#E8A0BF">const fs = #E8A0BF">require(#A8D4A0">'fs');

#E8A0BF">async #E8A0BF">function compareScreenshots(baselinePath, currentPath) {
  #E8A0BF">const baseline = #E8A0BF">await loadImage(baselinePath);
  #E8A0BF">const current = #E8A0BF">await loadImage(currentPath);

  #E8A0BF">const { width, height } = baseline;
  #E8A0BF">const canvas = createCanvas(width, height);

  #E8A0BF">const baselineData = getImageData(baseline);
  #E8A0BF">const currentData = getImageData(current);
  #E8A0BF">const diffData = new Uint8ClampedArray(width * height * 4);

  #E8A0BF">const mismatchedPixels = pixelmatch(
    baselineData, currentData, diffData,
    width, height, { threshold: 0.1 }
  );

  #E8A0BF">const totalPixels = width * height;
  #E8A0BF">const diffPercent = (mismatchedPixels / totalPixels) * 100;

  console.log(`Diff: ${diffPercent.toFixed(2)}% (${mismatchedPixels} pixels)`);

  #E8A0BF">if (diffPercent > 0.5) {
    console.error(#A8D4A0">'Visual regression detected!');
    process.exit(1);
  }

  console.log(#A8D4A0">'No significant visual changes detected');
}

compareScreenshots(
  #A8D4A0">'screenshots/baseline/home-desktop.png',
  #A8D4A0">'screenshots/current/home-desktop.png'
);

Set the threshold based on your tolerance. 0.5% catches major layout shifts while ignoring anti-aliasing differences across environments.

Why SnapRender vs local browsers

FactorPuppeteer/PlaywrightSnapRender API
Setup300-500MB Chrome installZero dependencies
CI time+30-60s browser startup2-5s per screenshot
Docker imageNeeds Chromium + depsAny base image with curl
Anti-botManual handlingBuilt-in bypass
MaintenanceVersion pinning, crashesManaged by SnapRender

Catch visual bugs before production

Add automated screenshots to your pipeline in under 5 minutes. No browser installs, no Docker headaches. Just a single API call.

Get Your API Key — Free

Frequently asked questions

Screenshots catch visual regressions that unit tests miss: broken layouts, CSS conflicts, z-index stacking issues, font loading failures, and responsive breakpoint bugs. They serve as visual documentation of every deployment, making it easy to see exactly what changed.

Playwright requires installing a full browser runtime in CI, which adds 300-500MB to your Docker image and 30-60s to pipeline startup. SnapRender is a single HTTP call with no browser dependencies. It also handles anti-bot protection and renders pages exactly as real users see them.

Yes. Save screenshots as build artifacts, then use pixel-diff tools (pixelmatch, resemble.js) to compare the current build against the previous one. Set a threshold (e.g., 0.1% pixel difference) and fail the build if exceeded.

Absolutely. Most CI platforms expose the preview URL as an environment variable. Pass it to SnapRender instead of the production URL. This is the most common pattern: screenshot the preview deploy before promoting to production.

SnapRender's free tier includes 100 screenshots/month. A typical project running 5 builds/day with 3 screenshots each uses ~450/month, which fits in the Starter plan. The API call takes 2-5 seconds, adding minimal time to your pipeline.