Puppeteer Screenshot on a Schedule with GitHub Actions and Cron
Schedule Puppeteer website screenshots with GitHub Actions cron. Set up the workflow, choose page readiness, save captures, and troubleshoot missed runs.
To take a website screenshot automatically, put a Puppeteer script in your repository and run it from a GitHub Actions workflow with an on.schedule cron expression. Add the workflow to the repository’s default branch, install Node.js and Puppeteer, capture the page, and save the resulting image as an artifact or publish it somewhere your application can use it.
This guide uses UTC cron for predictable timing. GitHub also supports named IANA timezones. Scheduled runs are not guaranteed to start at the exact minute, so use them for periodic captures rather than deadline-critical work. For the current documented behavior, see GitHub’s schedule event documentation.
1. Create the Puppeteer project
Use a Node.js runtime supported by the Puppeteer version you install. Puppeteer 25.12.0 lists Node.js 22.12 or newer in its system requirements. Check the requirements for your installed version when upgrading.
Create package.json in your repository:
{
"name": "scheduled-site-screenshot",
"private": true,
"scripts": {
"capture": "node capture.js"
},
"dependencies": {
"puppeteer": "^25.12.0"
}
}
Install dependencies locally and commit both package.json and the generated lockfile. For repeatable CI installs, use npm ci in the workflow.
Create capture.js:
const puppeteer = require('puppeteer');
async function main() {
const targetUrl = process.env.TARGET_URL || 'https://example.com';
const outputPath = process.env.OUTPUT_PATH || 'screenshot.png';
const browser = await puppeteer.launch({ headless: true });
try {
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 1000, deviceScaleFactor: 1 });
const response = await page.goto(targetUrl, {
waitUntil: 'networkidle2',
timeout: 60_000
});
if (response && !response.ok()) {
throw new Error(`Navigation returned HTTP ${response.status()} for ${targetUrl}`);
}
await page.screenshot({ path: outputPath, fullPage: true });
console.log(`Saved ${outputPath} from ${targetUrl}`);
} finally {
await browser.close();
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
networkidle2 is the condition used in Puppeteer’s screenshot guide example, but it is not right for every site. Pages with analytics, live updates, or persistent connections may never become network-idle. Choose and validate the readiness condition for the target page; options and tradeoffs are covered below. The browser is closed in a finally block so it is also cleaned up after navigation or screenshot errors. See the Puppeteer screenshot guide and installation guide.
2. Add a scheduled GitHub Actions workflow
Create .github/workflows/screenshot.yml:
name: Scheduled website screenshot
on:
schedule:
# Every day at 08:17 UTC. Avoid minute 0, when Actions load is often high.
- cron: '17 8 * * *'
workflow_dispatch:
permissions:
contents: read
jobs:
capture:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22.12'
cache: npm
- name: Install dependencies
run: npm ci
- name: Capture page
run: npm run capture
env:
TARGET_URL: https://example.com
OUTPUT_PATH: screenshot.png
- name: Keep screenshot as an artifact
uses: actions/upload-artifact@v4
with:
name: scheduled-screenshot
path: screenshot.png
if-no-files-found: error
retention-days: 14
Replace https://example.com with the page to capture. Commit and push the workflow to the repository’s default branch. The workflow_dispatch event adds a manual run option in the Actions tab, useful for confirming setup before waiting for the schedule. The scheduled run uses the latest commit on the default branch.
3. Choose a schedule and timezone
The cron expression has five fields: minute, hour, day of month, month, and day of week. For example, 17 8 * * * means every day at 08:17. GitHub documents a shortest schedule interval of five minutes. Scheduled events use UTC by default.
| Expression | Meaning in UTC |
|---|---|
17 8 * * * |
Daily at 08:17 |
23 9 * * 1-5 |
Weekdays at 09:23 |
41 */6 * * * |
Every six hours, at minute 41 |
*/15 * * * * |
Every 15 minutes |
UTC keeps the run time stable across daylight-saving changes. If a local business time is more useful, current GitHub workflow syntax supports an IANA timezone:
on:
schedule:
- cron: '23 9 * * 1-5'
timezone: 'America/New_York'
A named local timezone follows its daylight-saving rules, so the corresponding UTC time changes seasonally. Review GitHub’s schedule documentation for timezone syntax and DST behavior.
4. Select the right page readiness condition
Navigation completion and visual readiness are related but not identical. A page can finish its initial navigation before fonts, images, or client-rendered content are ready. Conversely, a page with continuous requests may never satisfy a network-idle condition.
domcontentloaded: useful when the required content is present as the document is parsed and additional resources are not important to the capture.load: waits for the page load event and its dependent resources, which may suit ordinary static pages.networkidle0ornetworkidle2: can help with pages that finish loading after their requests settle; persistent connections and background requests can make these conditions time out.- A site-specific selector: after navigation, wait for a meaningful element that signals the content you need is rendered.
For selector-based readiness, use a navigation condition that returns promptly, then wait explicitly:
await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 60_000 });
await page.waitForSelector('[data-page-ready="true"]', { timeout: 30_000 });
await page.screenshot({ path: outputPath, fullPage: true });
Replace the selector with one that exists on the target page. If no stable selector is available, a measured delay can allow client-side rendering to finish, but fixed sleeps add time and can still be too short when the site is slow.
5. Capture a full page or a single element
page.screenshot() captures the page. Set fullPage: true to capture the full document instead of just the viewport. Puppeteer’s screenshot guide also shows element screenshots. For example:
const card = await page.waitForSelector('.report-card', { timeout: 30_000 });
if (!card) throw new Error('Report card was not found');
await card.screenshot({ path: 'report-card.png' });
Use an element capture for a chart, report panel, or component when the rest of the page is irrelevant. Selectors are site-specific and can break when the target markup changes.
6. Store and use the screenshot
The workflow above uploads the generated PNG as a downloadable Actions artifact and keeps it for 14 days. Set retention-days to a value appropriate for your review window and repository policy. Each run produces a separate artifact; this does not maintain a single permanently updated image URL.
To publish a stable latest image, add a separate upload step to storage or a deployment target you control. Keep credentials in GitHub Actions secrets, grant only the permissions that upload needs, and avoid printing secret values in logs. The right destination depends on how the image will be consumed; an artifact is simplest for manual review, while application use generally needs storage or hosting that provides the desired access and retention behavior.
7. Troubleshoot missed runs and capture failures
| Symptom | Likely cause | Fix |
|---|---|---|
| No run appears at the expected time | The workflow file is not on the default branch, the schedule is disabled, or Actions has queued or delayed the event. | Confirm the file is committed on the default branch and the workflow is enabled. Check Actions run history. Put the cron minute away from 0; GitHub notes runs can be delayed during high load and some queued jobs may be dropped. |
| Scheduled runs stopped in a public repository | GitHub automatically disables scheduled workflows in public repositories after 60 days without repository activity. | Check the workflow’s status and repository activity, then enable the workflow again if it was disabled. See GitHub’s workflow troubleshooting guide. |
Workflow fails at npm ci |
The lockfile is missing or out of sync with package.json. |
Generate and commit the lockfile using the same package manager, then rerun. |
| Browser executable not found | Puppeteer’s browser download did not happen, often because package-manager policy blocked install scripts. | Allow Puppeteer’s install script or add an install step such as npx puppeteer browsers install. Puppeteer normally downloads a compatible Chrome for Testing during installation; see its installation documentation. |
| Navigation times out | The chosen readiness condition never occurs, the site is slow, or the URL is unreachable from the runner. | Check the URL and logs, set an intentional timeout, and choose a readiness condition suited to the page. Consider waiting for a meaningful selector rather than network idle on a continuously active page. |
| Screenshot is blank or missing content | The screenshot was taken before client-side content or images rendered, or the page returned an error state. | Wait for the relevant selector or resource condition. Log the HTTP status and inspect the artifact from a manual run. |
| Artifact upload says no files were found | The capture failed, or the script wrote to a different path than the upload step expects. | Make OUTPUT_PATH and the artifact path match. Keep if-no-files-found: error so failed captures are visible. |
| Workflow YAML does not parse | Indentation or cron quoting is invalid, or the workflow was authored with a YAML parser that interprets on unexpectedly. |
Use the indentation shown, quote the cron expression, and inspect the Actions workflow file validation message. |
8. Performance, reliability, and cost
Each scheduled run starts a hosted runner, installs or restores dependencies, launches a browser, loads the page, and uploads the output. Keep the run focused on one capture, use dependency caching as in the example, and avoid unnecessarily frequent schedules. Full-page images and pages with many resources take longer and produce larger artifacts than viewport captures.
GitHub’s schedule is a trigger, not an exact-time guarantee. Queueing can delay runs, and scheduled workflows in public repositories can be disabled after 60 days of inactivity. Check Actions history when a capture is missing. If the capture must happen at a precise time or requires a durable published endpoint, consider whether the schedule-and-artifact workflow meets that operational need before building more infrastructure.
Cost depends on the GitHub plan, repository visibility, Actions usage, runtime, and artifact retention. This workflow does not establish a universal price. Check the current GitHub billing and usage details for the account running it.
Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server. One GET request returns an image or PDF, so you do not need to install and launch Puppeteer for a basic capture. See the ScreenshotNeo API documentation for parameters and response details.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com -o shot.webp
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)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://example.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
const bytes = Buffer.from(await res.arrayBuffer());
await import('node:fs/promises').then(({ writeFile }) => writeFile('shot.webp', bytes));
With ScreenshotNeo, cookie banners, newsletter popups, and chat widgets are removed before the shot; bot checks, blank pages, and failed loads are never billed. Its MCP server lets AI agents take screenshots. The free plan includes 1,000 screenshots a month with no card, and paid plans start at $5 for 3,000 screenshots.
Create a free ScreenshotNeo account and get 1,000 screenshots a month with no card.
FAQ
How do I take a screenshot of a website automatically?
Run a Puppeteer script from a scheduled GitHub Actions workflow, then upload the image as an artifact or send it to storage you control.
How can I run Puppeteer on a schedule with GitHub Actions?
Add a five-field cron expression under on.schedule, install Node.js and dependencies in the job, and invoke the capture script. The workflow must exist on the default branch for the schedule event to trigger.
Why didn’t my scheduled GitHub Action run?
Check that the workflow is on the default branch, enabled, and has valid cron syntax. Review the Actions history and account for queue delays. In a public repository, check whether 60 days without activity caused GitHub to disable the schedule.
Can I use Puppeteer’s default headless mode on Actions?
Yes. Puppeteer runs headless by default, which suits a non-interactive workflow runner. The script makes this explicit with headless: true.
Can GitHub Actions guarantee a capture at an exact minute?
No. The schedule defines when a run becomes eligible, but GitHub documents that high load can delay or drop queued scheduled runs.


