ScreenshotNeo

BlogHow-to

How to Run Scheduled Screenshot Checks for a Website with Docker

Build a repeatable Playwright screenshot check in Docker, schedule it with GitHub Actions or cron, and keep images, reports, and failures useful.

By the ScreenshotNeo team4 October 202611 min read

Run a short-lived Docker job on a schedule. The job should launch a pinned Playwright browser, navigate to a known URL, wait for a meaningful readiness condition, then either save a dated screenshot or compare the page with a reviewed baseline. Store screenshots, diffs, reports, and traces outside the disposable container, and make navigation failures and visual mismatches visible as failed jobs.

This guide builds a runnable Node.js and Playwright example, schedules it with GitHub Actions, and shows how to run the same container with cron. Use capture-only mode for an image archive; use Playwright Test screenshot assertions when you need an automatic visual regression result. Playwright recommends its matching Docker image for consistent CI environments, including screenshot work. Playwright CI documentation

1. Choose what the scheduled check should prove

Before writing the container, decide what a successful run means. A screenshot by itself proves only that some pixels were captured. A useful monitor also checks that navigation succeeded and that the intended page was ready.

Mode What it does Use it when
Capture only Saves a new image per run, usually with a timestamp. You want a visual history for a person to inspect.
Visual regression Compares the current render with a reviewed reference and fails on differences beyond your configured tolerance. You want changes to trigger investigation or block a workflow.

Record the target URL, viewport, browser, locale and timezone, readiness selector, login needs, and dynamic areas. For authenticated pages, supply credentials through your scheduler’s secret store. Do not bake secrets into the image, commit them, or print them in logs.

2. Create a small Playwright project

The following example uses JavaScript and Playwright Test. Keep the exact Playwright package version aligned with the Docker image tag. The image tag below is an example; choose and pin a tag that matches your package version. The project saves a dated viewport shot on every successful run and optionally performs a baseline comparison.

Project files

scheduled-shot/
├── Dockerfile
├── package.json
├── playwright.config.js
├── screenshot.spec.js
└── .dockerignore

package.json

{
  "name": "scheduled-website-screenshot",
  "private": true,
  "scripts": {
    "check": "npx playwright test"
  },
  "devDependencies": {
    "@playwright/test": "1.63.0"
  }
}

Use your chosen pinned version consistently. Create and commit the lockfile with npm install, then use npm ci in the container so installs follow the lockfile.

playwright.config.js

const { defineConfig } = require('@playwright/test');

module.exports = defineConfig({
  testDir: '.',
  testMatch: 'screenshot.spec.js',
  timeout: 60_000,
  retries: 1,
  reporter: [['list'], ['html', { outputFolder: 'playwright-report', open: 'never' }]],
  use: {
    browserName: 'chromium',
    headless: true,
    viewport: { width: 1440, height: 1000 },
    locale: 'en-US',
    timezoneId: 'UTC',
    colorScheme: 'light',
    trace: 'retain-on-failure'
  },
  expect: {
    timeout: 10_000,
    toHaveScreenshot: {
      animations: 'disabled',
      caret: 'hide',
      // Set a deliberate project-specific tolerance if needed.
      // maxDiffPixels: 100
    }
  }
});

screenshot.spec.js

const { test, expect } = require('@playwright/test');
const fs = require('node:fs');
const path = require('node:path');

const target = process.env.TARGET_URL || 'https://example.com';
const readinessSelector = process.env.READY_SELECTOR;
const mode = process.env.CHECK_MODE || 'capture';
const outputDir = process.env.OUTPUT_DIR || 'artifacts';

test('website is reachable and screenshot is captured', async ({ page }) => {
  const response = await page.goto(target, {
    waitUntil: 'domcontentloaded',
    timeout: 45_000
  });

  if (!response) throw new Error(`No main document response for ${target}`);
  if (!response.ok()) {
    throw new Error(`Navigation returned HTTP ${response.status()} for ${target}`);
  }

  if (readinessSelector) {
    await page.locator(readinessSelector).waitFor({ state: 'visible', timeout: 20_000 });
  }

  await page.evaluate(() => document.fonts.ready);
  await page.waitForTimeout(500);
  fs.mkdirSync(outputDir, { recursive: true });

  if (mode === 'regression') {
    await expect(page).toHaveScreenshot('website.png', { fullPage: true });
  } else {
    const stamp = new Date().toISOString().replaceAll(':', '-');
    const file = path.join(outputDir, `website-${stamp}.png`);
    await page.screenshot({ path: file, fullPage: true, animations: 'disabled' });
    console.log(`Saved ${file}`);
  }
});

domcontentloaded avoids waiting indefinitely for long-lived analytics or streaming requests. If the page’s actual content is populated later, set READY_SELECTOR to a stable, meaningful selector. A fixed delay can help with a known short transition, but a selector or application readiness signal is usually more reliable.

Dockerfile

FROM mcr.microsoft.com/playwright:v1.63.0-noble
WORKDIR /work
COPY package.json package-lock.json ./
RUN npm ci
COPY playwright.config.js screenshot.spec.js ./
RUN mkdir -p artifacts test-results playwright-report
CMD ["npm", "run", "check"]

Playwright’s browser image contains browsers and system dependencies. Pin a matching image version instead of using latest; mismatches between the package and image can cause browser executable errors. See the official Playwright Docker guidance and CI examples.

.dockerignore

node_modules
.git
playwright-report
 test-results
artifacts

Remove the leading space before test-results if copying this block literally; the intended ignore entry is test-results. Keep baseline snapshot directories out of the ignore list when running regression mode, since the test needs committed reference images.

3. Build and run the container

From the project directory, generate the lockfile once and build the image. Mount an output directory so capture files survive when the container exits.

npm install
mkdir -p artifacts

docker build -t scheduled-shot:local .
docker run --rm \
  -e TARGET_URL=https://example.com \
  -e READY_SELECTOR='h1' \
  -e CHECK_MODE=capture \
  -v "$PWD/artifacts:/work/artifacts" \
  scheduled-shot:local

For a visual regression check, run CHECK_MODE=regression. On its first run, Playwright creates reference snapshots. Review those images, then commit them with the test code. Future runs compare against those reviewed references. Do not automatically accept every changed image as a new baseline: that would turn a regression into an unreviewed expected result.

Screenshot assertions require the Playwright Test runner. The assertion waits for consecutive screenshots to match before comparing with the expected image, which helps reduce transient capture differences. Playwright visual comparisons

4. Schedule it with GitHub Actions

Put this workflow in .github/workflows/scheduled-shot.yml. The example runs daily at 09:20 UTC and supports manual runs. Scheduled workflow timing can be delayed during high load, so avoid treating GitHub’s schedule as an exact-time monitoring SLA. The workflow uploads output even when the test fails, so the failure can be investigated.

name: Scheduled website screenshot

on:
  schedule:
    - cron: '20 9 * * *'
  workflow_dispatch:

jobs:
  screenshot:
    runs-on: ubuntu-latest
    container:
      image: mcr.microsoft.com/playwright:v1.63.0-noble
    env:
      TARGET_URL: https://example.com
      READY_SELECTOR: h1
      CHECK_MODE: capture
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npx playwright test
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: screenshot-and-report
          path: |
            artifacts/
            playwright-report/
            test-results/
          if-no-files-found: ignore
          retention-days: 14

Update action versions according to your repository policy. Configure secrets for authenticated checks through the repository or environment secret store, and pass them as environment variables. GitHub documents the schedule event, including cron syntax and timing caveats. Its schedule uses UTC.

For visual regression mode, commit the initial snapshots and set CHECK_MODE: regression. Upload test-results/ and the HTML report even on failure; the test output includes expected, actual, and diff images when comparison fails.

5. Run it with server cron instead

A server cron can launch the same Docker image. For example, this entry runs daily at 09:20 in the server’s local timezone and binds a persistent host directory for output:

20 9 * * * docker run --rm \
  -e TARGET_URL=https://example.com \
  -e READY_SELECTOR=h1 \
  -e CHECK_MODE=capture \
  -v /var/lib/scheduled-shot/artifacts:/work/artifacts \
  scheduled-shot:local >> /var/log/scheduled-shot.log 2>&1

Create the output and log directories with permissions that allow the cron user and Docker to write. Check the host’s timezone and Docker daemon availability. For a production monitor, add log rotation, alerting on nonzero exit status, secret handling, and a defined retention policy. Cron starts the job; it does not provide artifact retention or alerting by itself.

6. Capture-only versus visual regression options

Capture a viewport or full page

In the example, fullPage: true captures the full scrollable page. Use fullPage: false or omit the option for a viewport capture. Full-page images can become very tall and consume more time and storage, especially on pages with long feeds or lazy-loaded regions. If relevant images load only after scrolling, scroll through the page or use the application’s own readiness signal before capture.

Capture a selected element

For a focused check, wait for the element and capture its locator:

const card = page.locator('[data-testid="pricing-card"]');
await card.waitFor({ state: 'visible' });
await card.screenshot({ path: 'artifacts/pricing-card.png' });

For a regression assertion on an element, use await expect(card).toHaveScreenshot('pricing-card.png'). Prefer a stable test id or semantic selector over a brittle class name.

Stabilize dynamic content selectively

Animations are disabled in the example, and the caret is hidden for screenshot assertions. For timestamps, rotating banners, or content that is irrelevant to the specific check, use Playwright’s screenshot assertion options such as mask or a stylesheet to hide it. Mask or hide only genuinely irrelevant regions; otherwise the test can conceal a real defect. Keep data, fonts, viewport, timezone, locale, color scheme, browser, and operating system consistent between baseline creation and scheduled runs.

Choose comparison tolerance deliberately

Playwright supports comparison controls such as threshold, maxDiffPixels, and maxDiffPixelRatio. A larger tolerance can reduce noise but can also let meaningful changes pass. Pick it based on the page and reviewed diffs rather than copying a universal number. Configuration details can vary by Playwright version; consult the visual comparison documentation for the version you pin.

7. Docker, browser, and schedule reliability

  • Pin the environment. Keep the package, browser image, Node version, and baseline environment aligned. Browser or OS changes can alter pixels even when the website code is unchanged.
  • Use an intentional readiness check. Check the navigation response, then wait for a selector or app-specific ready state. Do not let a timeout page or error response become a saved reference.
  • Retry carefully. One retry can smooth a transient network failure, but repeated retries can hide an intermittent outage. Preserve failed-run output and consider reporting whether the first attempt failed.
  • Persist outside the container. Containers are disposable. Mount a volume, upload CI artifacts, or copy results to object storage. Set a retention period appropriate to review and storage needs.
  • Account for schedule behavior. Scheduled CI is suitable for periodic checks, but a busy hosted scheduler may start late. For tight timing needs, use a scheduler whose delivery guarantees meet the monitoring requirement.
  • Separate browser baselines. If you test multiple browsers or environments, maintain separate references because their rendering can differ.
  • Make failures actionable. Set a nonzero exit condition for navigation errors, missing selectors, and screenshot mismatches. Connect the scheduler’s failed-job signal to the team’s chosen notification route.

8. Performance and cost

The main costs are browser startup, page loading, screenshots, storage, and the compute time billed by the scheduler or host. One browser and one viewport are the least complex starting point. Full-page capture, multiple browsers, repeated retries, and high frequency increase run time and artifact volume. Use a cadence that matches how quickly you need to detect a problem, and prune artifacts according to a stated retention policy.

Keep the image and dependency installation out of the scheduled run where possible: build the image ahead of time and reuse it. On CI, follow the browser-image guidance for your provider. Playwright notes that caching browser binaries can take comparable time to downloading them, and Linux dependencies are not cacheable in the same way. Playwright CI browser caching notes

Or skip the browser setup

ScreenshotNeo is a website screenshot API and MCP server from Yorker Media. A single request can return a PNG, JPEG, WebP, or PDF; see the ScreenshotNeo API documentation.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com -o shot.webp

python -c 'import requests; r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://example.com"}, timeout=90); open("shot.webp", "wb").write(r.content)'

node --input-type=module -e 'const q = new URLSearchParams({ access_key: process.env.SCREENSHOTNEO_API_KEY, url: "https://example.com" }); const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); await Bun.write("shot.webp", res);'

For Node.js with Node’s built-in fetch and filesystem APIs, use this runnable script instead:

const fs = require('node:fs/promises');
const q = new URLSearchParams({
  access_key: process.env.SCREENSHOTNEO_API_KEY,
  url: 'https://example.com'
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
await fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));

Cookie banners, popups, and chat widgets are removed before the shot. Bot checks, blank pages, and failed loads are never billed; response headers report the page verdict and billing status. Its MCP server lets AI agents use take_screenshot, get_page_info, and capture_pdf. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000. Those features are available on every plan. Sign up free for ScreenshotNeo.

9. Troubleshooting

Symptom Likely cause Fix
Browser executable missing or version mismatch Playwright package and Docker browser image do not match. Pin matching versions in package.json and FROM; rebuild the image.
Navigation times out Slow server, blocked network, or waiting for idle requests that never stop. Use domcontentloaded, set a deliberate timeout, and wait for a page-specific ready selector.
Screenshot is blank or shows an error page Navigation returned an error, redirect/login behavior changed, or page content was not ready. Check response status and final URL; assert a known page element before saving the image.
Image differs on every run Dynamic ads, timestamps, fonts, animations, personalization, or inconsistent browser environment. Stabilize inputs and environment; disable animation; mask only known volatile regions; inspect diffs before changing tolerance.
Artifacts disappear after execution Output was written only inside the short-lived container. Mount a host volume or upload the directory as a CI artifact.
Permission denied writing artifacts Mounted directory ownership does not allow the container or cron user to write. Create the directory with appropriate ownership and permissions; verify the effective runtime user.
Baseline missing or constantly rewritten First run has not been reviewed, or update mode is being used automatically. Generate the initial reference in the pinned environment, review and commit it, then run without automatic snapshot updates.
Scheduled job is late or absent Scheduler delays, disabled workflow, timezone misunderstanding, or repository policy. Check the scheduler run history and schedule syntax; GitHub schedule is UTC and can be delayed under load.
Local run passes but container fails Host browser differs, secrets are unavailable, or container networking/DNS differs. Run the built image locally, pass required environment variables explicitly, and inspect Playwright debug output.

10. FAQ

Can I use Docker without GitHub Actions?

Yes. The image can be started by server cron or another CI scheduler. Keep the same build and test command so local and scheduled behavior stay comparable.

Does a screenshot check detect every website outage?

No. It detects the failures your assertions cover. Add explicit checks for HTTP response, important content, and any application state that matters to your users.

Should I use full-page screenshots?

Only when content below the fold matters. Viewport and element captures are smaller and can make diffs easier to interpret.

When should I update a reference image?

After reviewing that the visual change is expected and belongs in the product. Commit the new reference alongside the relevant test change.

Can I schedule several URLs?

Yes. Add a test per URL or iterate over a configured list, while keeping failures attributable to individual targets. For a larger set, control parallelism to avoid overwhelming the target site or scheduler.