Tutorial

How to Capture Screenshots in CI/CD Pipelines

A DevOps guide to screenshot automation in CI/CD. Capture, compare, and deploy screenshots as part of your deployment workflow.

Asad AliDecember 20, 20259 min read

Modern CI/CD pipelines do more than build and deploy code. They can automatically update documentation screenshots, run visual regression tests, and generate preview imagesβ€”all without manual intervention.

This guide covers practical patterns for integrating screenshots into your deployment workflows. The CI platforms referenced throughout: GitHub Actions, GitLab CI/CD, CircleCI, and Buildkite. For visual-diff specifically in CI, pixelmatch and ImageMagick's compare are the open-source primitives most pipelines build on.

Why CI/CD Screenshots?

The Manual Problem

Documentation screenshots get stale because:

  • Updates are easy to forget during releases
  • Manual capture is time-consuming
  • Different people produce inconsistent results
  • Staging screenshots differ from production

The Automated Solution

CI/CD screenshot automation provides:

  • Synchronized updates: Screenshots update with code
  • Consistency: Same capture settings every time
  • No manual work: Runs automatically on deploy
  • Version control: Screenshots tracked with code

GitHub Actions Integration

Basic Screenshot Workflow

# .github/workflows/screenshots.yml
name: Update Documentation Screenshots

on:
  push:
    branches: [main]
  workflow_dispatch:  # Manual trigger

jobs:
  capture-screenshots:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Capture screenshots
        env:
          SCREENSHOTLY_API_KEY: ${{ secrets.SCREENSHOTLY_API_KEY }}
          APP_URL: https://app.example.com
        run: node scripts/capture-screenshots.js
      
      - name: Commit updated screenshots
        run: |
          git config user.name "GitHub Actions Bot"
          git config user.email "actions@github.com"
          git add docs/images/
          git diff --staged --quiet || git commit -m "docs: Update screenshots [skip ci]"
          git push

Screenshot Capture Script

// scripts/capture-screenshots.js
const fs = require('fs');
const path = require('path');

const API_KEY = process.env.SCREENSHOTLY_API_KEY;
const APP_URL = process.env.APP_URL;

const pages = [
  { path: '/dashboard', filename: 'dashboard.png' },
  { path: '/settings', filename: 'settings.png' },
  { path: '/reports', filename: 'reports.png' },
];

async function captureScreenshot(url) {
  const response = await fetch('https://api.screenshotly.app/screenshot', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      url,
      device: 'desktop',
      format: 'png',
      viewport: { width: 1280, height: 800 },
      aiRemoval: {
        enabled: true,
        types: ['cookie-banner', 'chat-widget'],
      },
    }),
  });

  if (!response.ok) {
    throw new Error(`Failed to capture ${url}: ${response.status}`);
  }

  return Buffer.from(await response.arrayBuffer());
}

async function main() {
  const outputDir = path.join(process.cwd(), 'docs/images');
  fs.mkdirSync(outputDir, { recursive: true });

  for (const page of pages) {
    console.log(`Capturing: ${page.path}`);
    const screenshot = await captureScreenshot(`${APP_URL}${page.path}`);
    fs.writeFileSync(path.join(outputDir, page.filename), screenshot);
  }

  console.log('All screenshots captured');
}

main().catch(err => {
  console.error(err);
  process.exit(1);
});

GitLab CI Integration

# .gitlab-ci.yml
stages:
  - deploy
  - screenshots

deploy:
  stage: deploy
  script:
    - echo "Deploy application..."

update-screenshots:
  stage: screenshots
  image: node:20
  dependencies:
    - deploy
  script:
    - npm ci
    - node scripts/capture-screenshots.js
    - |
      if [ -n "$(git status --porcelain docs/images)" ]; then
        git config user.email "bot@example.com"
        git config user.name "GitLab Bot"
        git add docs/images/
        git commit -m "docs: Update screenshots"
        git push origin HEAD:$CI_COMMIT_REF_NAME
      fi
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

Visual Regression Testing

Comparison Workflow

# .github/workflows/visual-tests.yml
name: Visual Regression Tests

on:
  pull_request:
    branches: [main]

jobs:
  visual-test:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Capture current screenshots
        env:
          SCREENSHOTLY_API_KEY: ${{ secrets.SCREENSHOTLY_API_KEY }}
          APP_URL: ${{ secrets.STAGING_URL }}
        run: node scripts/capture-for-comparison.js
      
      - name: Compare with baselines
        run: node scripts/compare-screenshots.js
      
      - name: Upload diff artifacts
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: visual-diffs
          path: test-results/diffs/

Comparison Script

// scripts/compare-screenshots.js
const fs = require('fs');
const path = require('path');
const { PNG } = require('pngjs');
const pixelmatch = require('pixelmatch');

const BASELINE_DIR = 'test-results/baselines';
const CURRENT_DIR = 'test-results/current';
const DIFF_DIR = 'test-results/diffs';
const THRESHOLD = 0.1; // 10% difference threshold

async function compareImages(baselinePath, currentPath, diffPath) {
  const baseline = PNG.sync.read(fs.readFileSync(baselinePath));
  const current = PNG.sync.read(fs.readFileSync(currentPath));
  
  const { width, height } = baseline;
  const diff = new PNG({ width, height });
  
  const mismatchedPixels = pixelmatch(
    baseline.data,
    current.data,
    diff.data,
    width,
    height,
    { threshold: 0.1 }
  );
  
  const diffPercent = (mismatchedPixels / (width * height)) * 100;
  
  if (diffPercent > THRESHOLD) {
    fs.writeFileSync(diffPath, PNG.sync.write(diff));
    return { passed: false, diffPercent };
  }
  
  return { passed: true, diffPercent };
}

async function main() {
  fs.mkdirSync(DIFF_DIR, { recursive: true });
  
  const baselines = fs.readdirSync(BASELINE_DIR);
  const results = [];
  
  for (const file of baselines) {
    const baselinePath = path.join(BASELINE_DIR, file);
    const currentPath = path.join(CURRENT_DIR, file);
    const diffPath = path.join(DIFF_DIR, `diff-${file}`);
    
    if (!fs.existsSync(currentPath)) {
      console.log(`MISSING: ${file}`);
      results.push({ file, status: 'missing' });
      continue;
    }
    
    const result = await compareImages(baselinePath, currentPath, diffPath);
    console.log(`${result.passed ? 'PASS' : 'FAIL'}: ${file} (${result.diffPercent.toFixed(2)}% diff)`);
    results.push({ file, ...result });
  }
  
  const failures = results.filter(r => !r.passed);
  if (failures.length > 0) {
    console.error(`\n${failures.length} visual regression(s) detected`);
    process.exit(1);
  }
}

main();

Deploy Preview Screenshots

Vercel Deployment Comments

# .github/workflows/preview-screenshots.yml
name: Preview Screenshots

on:
  pull_request:
    types: [opened, synchronize]

jobs:
  capture-preview:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Wait for Vercel Preview
        uses: patrickedqvist/wait-for-vercel-preview@v1.3.1
        id: vercel
        with:
          token: ${{ secrets.GITHUB_TOKEN }}
          max_timeout: 300
      
      - name: Capture preview screenshots
        env:
          SCREENSHOTLY_API_KEY: ${{ secrets.SCREENSHOTLY_API_KEY }}
        run: |
          node scripts/capture-preview.js "${{ steps.vercel.outputs.url }}"
      
      - name: Comment on PR
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const images = fs.readdirSync('preview-screenshots');
            
            let body = '## πŸ“Έ Preview Screenshots\n\n';
            for (const img of images) {
              body += `### ${img.replace('.png', '')}\n`;
              body += `![${img}](https://your-cdn.com/previews/${context.payload.pull_request.number}/${img})\n\n`;
            }
            
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body
            });

Scheduled Screenshot Updates

Weekly Documentation Refresh

# .github/workflows/weekly-screenshots.yml
name: Weekly Screenshot Update

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

jobs:
  update:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Capture all documentation screenshots
        env:
          SCREENSHOTLY_API_KEY: ${{ secrets.SCREENSHOTLY_API_KEY }}
        run: node scripts/capture-all-docs.js
      
      - name: Create PR with updates
        uses: peter-evans/create-pull-request@v6
        with:
          title: 'docs: Weekly screenshot update'
          commit-message: 'docs: Update documentation screenshots'
          branch: automated/screenshot-update
          body: |
            ## Automated Screenshot Update
            
            This PR contains the weekly documentation screenshot refresh.
            
            Please review the changes and merge if they look correct.

Multi-Environment Captures

Staging vs Production Comparison

// scripts/compare-environments.js
const environments = [
  { name: 'staging', url: process.env.STAGING_URL },
  { name: 'production', url: process.env.PRODUCTION_URL },
];

const pages = [
  '/dashboard',
  '/settings',
  '/pricing',
];

async function captureAllEnvironments() {
  const results = {};
  
  for (const env of environments) {
    results[env.name] = {};
    
    for (const page of pages) {
      const screenshot = await captureScreenshot(`${env.url}${page}`);
      results[env.name][page] = screenshot;
    }
  }
  
  return results;
}

async function compareEnvironments() {
  const screenshots = await captureAllEnvironments();
  
  for (const page of pages) {
    const staging = screenshots.staging[page];
    const production = screenshots.production[page];
    
    const diff = await compareImages(staging, production);
    
    if (diff.diffPercent > 5) {
      console.log(`DIFFERENCE on ${page}: ${diff.diffPercent.toFixed(2)}%`);
    }
  }
}

Secrets Management

Store sensitive values as repository secrets:

# Required secrets:
# - SCREENSHOTLY_API_KEY: Your API key
# - STAGING_URL: Staging environment URL
# - PRODUCTION_URL: Production URL (if different from public)

Accessing Secrets

env:
  SCREENSHOTLY_API_KEY: ${{ secrets.SCREENSHOTLY_API_KEY }}

Rotating API Keys

Create a workflow to alert on expiring keys:

- name: Check API key validity
  run: |
    response=$(curl -s -o /dev/null -w "%{http_code}" \
      -H "Authorization: Bearer $SCREENSHOTLY_API_KEY" \
      https://api.screenshotly.app/health)
    
    if [ "$response" != "200" ]; then
      echo "::error::API key may be invalid or expired"
      exit 1
    fi

Error Handling

Retry Logic

async function captureWithRetry(url, maxRetries = 3) {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await captureScreenshot(url);
    } catch (error) {
      console.log(`Attempt ${attempt} failed: ${error.message}`);
      
      if (attempt === maxRetries) throw error;
      
      await new Promise(r => setTimeout(r, attempt * 2000));
    }
  }
}

Graceful Failure

- name: Capture screenshots
  continue-on-error: true
  id: capture
  run: node scripts/capture-screenshots.js

- name: Handle capture failure
  if: steps.capture.outcome == 'failure'
  run: |
    echo "Screenshot capture failed, using cached images"
    # Fallback logic here

Performance Optimization

Parallel Capture

const pLimit = require('p-limit');
const limit = pLimit(5); // 5 concurrent captures

async function captureAll(pages) {
  const promises = pages.map(page =>
    limit(() => captureScreenshot(`${APP_URL}${page.path}`))
  );
  
  return Promise.all(promises);
}

Caching

- name: Cache screenshots
  uses: actions/cache@v4
  with:
    path: docs/images
    key: screenshots-${{ hashFiles('scripts/screenshot-manifest.json') }}

Conclusion

CI/CD screenshot automation eliminates manual work while ensuring documentation stays current. Key patterns:

  1. Push to main β†’ Update production screenshots
  2. Pull request β†’ Visual regression tests
  3. Preview deploy β†’ Capture and comment
  4. Schedule β†’ Weekly documentation refresh
  5. Multi-environment β†’ Compare staging vs production

With proper pipeline integration, screenshots become just another automated part of your deployment process.


Ready to automate CI/CD screenshots?

Get your free API key β†’ - 100 free screenshots to get started.

See also: Visual Regression Testing Guide β†’

ci/cd
github actions
devops
automation
testing

About the Author

Asad Ali

Asad Ali

Full-Stack Developer and Founder of ZTabs with 8+ years of experience building scalable web applications and APIs. Specializes in performance optimization, SaaS development, and modern web technologies.

Credentials: Founder & CEO at ZTabs, Full-Stack Developer, Expert in Next.js, React, Node.js, and API optimization

Frequently Asked Questions

Can I run screenshot capture inside a GitHub Actions workflow without installing Chrome?

Yes β€” that's the whole point of using a hosted API. A single cURL call replaces the entire browser container. Store the API key as a repo secret, call the API on push to main, upload the PNG as a workflow artifact. Total runtime: 4–6 seconds per capture, zero Chrome setup.

How do I fail a PR build on a visual regression?

After capture, diff the new image against the baseline stored in main using ImageMagick's compare or pixelmatch. Any pixel-diff above your threshold (typically 2–3% with pHash/SSIM) exits the job non-zero, which blocks the merge. Post the diff image as a PR comment so the reviewer can see what changed.

What's the right visual-regression tolerance?

Start at 2% pHash/SSIM diff. Tighter triggers false positives from anti-aliasing and subpixel rendering; looser misses real regressions. Zone-split long pages (hero, pricing, footer) with separate baselines so a rotated testimonial on one zone doesn't fail the whole build.

Ready to capture your first screenshot?

Get started with 100 free screenshots. No credit card required.

Related Articles