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: