You can capture a list of 100 web pages every day without running a server: GitHub Actions provides the schedule, a screenshot API does the rendering, and the images land in workflow artifacts or straight in your repository. The whole setup is one workflow file, one Python script and a text file of URLs.
Teams want this for different reasons. Agencies keep a dated record of every client site. Product teams watch their own key pages for broken layouts after deploys. Marketers track competitor landing pages. The mechanics are the same: a list, a schedule, a folder of dated images.
Running Chromium on the Actions runner is possible, but it is the slow and fragile part. Installing the browser and its dependencies adds a minute to every run, a single heavy page can exhaust memory, and consent dialogs end up in half the shots. Moving the rendering to an API turns the runner into a thin client that only sends requests and stores files.
The plan
- Keep the URLs in
urls.txt, one per line.
- A Python script sends them to the SnapRender batch endpoint in chunks of 50, waits for each job, and downloads every capture.
- A scheduled workflow runs the script daily and keeps the results.
Batch jobs return immediately with a job id. Pages render on SnapRender's side with ads and cookie banners removed, always fresh, and each finished capture comes back as a download link valid for 24 hours. Failed URLs are listed with their error and refunded to your quota.
The script
# capture.py
import datetime, os, pathlib, time, requests
from urllib.parse import urlparse
API = "https://app.snap-render.com"
HEADERS = {"X-API-Key": os.environ["SNAPRENDER_API_KEY"]}
urls = [u.strip() for u in open("urls.txt") if u.strip() and not u.startswith("#")]
day = datetime.date.today().isoformat()
out = pathlib.Path("screenshots") / day
out.mkdir(parents=True, exist_ok=True)
failed = []
for i in range(0, len(urls), 50):
body = {"urls": urls[i:i + 50], "format": "jpeg", "quality": 80, "full_page": True}
job = requests.post(f"{API}/v1/screenshot/batch", headers=HEADERS, json=body, timeout=30).json()
while True:
status = requests.get(f"{API}/v1/screenshot/batch/{job['jobId']}", headers=HEADERS, timeout=30).json()
if status["status"] in ("completed", "failed"):
break
time.sleep(10)
for item in status["items"]:
if item["status"] != "completed":
failed.append(f"{item['url']}: {item.get('error')}")
continue
u = urlparse(item["url"])
name = (u.hostname + u.path).strip("/").replace("/", "_") or u.hostname
(out / f"{name}.jpg").write_bytes(requests.get(item["downloadUrl"], timeout=60).content)
print(f"{len(urls) - len(failed)} captured, {len(failed)} failed")
for line in failed:
print("FAILED", line)
JPEG at quality 80 is a fraction of the size of a PNG of the same page, which matters once a year of daily images piles up. Switch to "format": "png" if you need exact pixels for visual diffing.
The workflow
# .github/workflows/screenshots.yml
name: daily-screenshots
on:
schedule:
- cron: "17 6 * * *" # 06:17 UTC daily; off the top of the hour, when runs start late
workflow_dispatch: # run it by hand from the Actions tab
jobs:
capture:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install requests
- run: python capture.py
env:
SNAPRENDER_API_KEY: ${{ secrets.SNAPRENDER_API_KEY }}
- uses: actions/upload-artifact@v4
with:
name: screenshots-${{ github.run_id }}
path: screenshots/
retention-days: 30
Add the key under Settings, Secrets and variables, Actions, as SNAPRENDER_API_KEY, then trigger the workflow once from the Actions tab to check it before waiting for the schedule.
Artifacts or commits?
Artifacts are the simplest option. Each run's screenshots are a downloadable zip on the run page. Retention defaults to 90 days and can be set lower per upload. They do not bloat the repository, and they expire on their own.
Committing the images builds a permanent, browsable history and keeps the schedule alive (more on that below). Replace the upload step with:
- name: Commit screenshots
run: |
git config user.name "screenshot-bot"
git config user.email "[email protected]"
git add screenshots
git commit -m "screenshots $(date -u +%F)" || echo "nothing to commit"
git push
and give the job permissions: contents: write. A year of 100 daily full-page images adds up to many gigabytes, so committing suits short lists or weekly runs. For larger archives, upload to S3 or Cloudflare R2 from the same step instead.
Limits of scheduled workflows
Three behaviors of GitHub's scheduler are worth knowing before you rely on it.
- Five-minute floor. Cron expressions that fire more often than every five minutes are accepted but not honored.
- Late starts. Scheduled runs are queued and can start several minutes late, more at the top of the hour. That is why the example uses minute 17.
- Sixty-day pause. In public repositories, scheduled workflows are disabled after 60 days without repository activity. Committing the screenshots counts as activity. If you use artifacts only, push something at least every two months or re-enable the workflow when GitHub emails you.
For timing that must be exact, use cron on a server or a cloud scheduler; the script does not change.
What it costs
One URL is one render, and failed renders are refunded.
| URLs |
Frequency |
Renders a month |
SnapRender plan |
| 6 |
daily |
about 180 |
Free |
| 100 |
weekly |
about 430 |
Starter, $9 |
| 100 |
daily |
about 3,000 |
Growth, $29 |
| 500 |
daily |
about 15,000 |
Business, $79 |
Every row above is one GET request away. 200 free renders a month, no card.
Start free
GitHub Actions minutes are free for public repositories, and this job spends most of its time waiting on the batch, typically a few minutes a run.
Next steps
- Compare each day's capture with yesterday's to flag changes; the change monitoring guide has a pixel-diff script.
- Register a webhook for
screenshot.completed instead of polling if the job runs on your own server.
- Agencies capturing client sites on desktop and mobile can follow the client reports guide.
To see what the captures look like before writing anything, paste a few URLs into the bulk screenshot tool. It runs five a day without an account.