How to Capture Scheduled Screenshots of a Website Behind a VPN
Run a scheduled Playwright capture where it can reach your private site, then save timestamped screenshots for review or monitoring.
To capture scheduled screenshots of a website behind a VPN, run a browser automation job on a machine that can reach the site, connect that machine to the approved private network, and let a scheduler start the job on a cadence. Playwright can navigate to the page and save a screenshot. A scheduler such as GitHub Actions handles when the script runs; it does not provide private network access by itself.
The simplest architecture is a self-hosted runner already connected to the VPN or private network. A hosted runner can also work, but you must deliberately configure its network path and access permissions. GitHub-hosted runners have public internet access by default; GitHub documents private networking approaches, and Tailscale documents an Action that connects a workflow to a tailnet. See the Playwright Page API, GitHub self-hosted runner requirements, GitHub private networking, and Tailscale’s GitHub Action documentation.
1. Choose where the browser runs
| Approach | Use it when | Tradeoffs |
|---|---|---|
| Self-hosted runner on a VPN-connected machine | A machine already has authorized access to the site. | You maintain the machine, browser dependencies, and runner. It needs enough resources for the workflow and connectivity to GitHub Actions. |
| Hosted runner plus private networking | You want managed workflow execution and can configure an approved private connection. | Set up and restrict the network path. A hosted runner does not inherit your laptop’s VPN connection. |
Do not expose a private site publicly just to make a screenshot workflow work. Use the organization’s approved VPN or private-network mechanism and grant only the access needed to reach the target. The VPN vendor, site authentication, and network policy determine the exact setup; there is no universal configuration for an unspecified VPN.
2. Create a Playwright screenshot script
This Node.js example opens the page, waits for a site-specific ready condition, saves a full-page PNG with a timestamp, and closes the browser even if navigation fails. Run it on the VPN-connected machine, or after the workflow has joined the private network.
npm init -y
npm install playwright
npx playwright install chromium
Save as capture.mjs:
import { chromium } from 'playwright';
import { mkdir } from 'node:fs/promises';
const target = process.env.TARGET_URL;
if (!target) throw new Error('Set TARGET_URL to the private page URL');
const outputDir = process.env.OUTPUT_DIR ?? 'screenshots';
await mkdir(outputDir, { recursive: true });
const stamp = new Date().toISOString().replaceAll(':', '-');
const output = `${outputDir}/site-${stamp}.png`;
const browser = await chromium.launch({ headless: true });
try {
const context = await browser.newContext({
viewport: { width: 1440, height: 1000 },
timezoneId: 'UTC'
});
const page = await context.newPage();
const response = await page.goto(target, {
waitUntil: 'domcontentloaded',
timeout: 60000
});
if (!response) throw new Error('Navigation returned no main-document response');
if (!response.ok()) throw new Error(`Main document returned HTTP ${response.status()}`);
// Replace this with an element that signals your page is actually ready.
await page.locator('body').waitFor({ state: 'visible', timeout: 30000 });
await page.screenshot({ path: output, fullPage: true, animations: 'disabled' });
console.log(`Saved ${output}`);
} finally {
await browser.close();
}
Set the target URL through an environment variable rather than hard-coding a private hostname into a shared script:
TARGET_URL='https://internal.example.test/dashboard' node capture.mjs
If the site requires login, authenticate through an approved mechanism before capture. For example, use a dedicated least-privilege test account and inject credentials from your CI secret store. Do not print credentials, cookies, authorization headers, or storage state to workflow logs. If authentication requires an interactive SSO flow, ask the site owner for an approved automation approach; don’t weaken the site’s access controls to accommodate screenshots.
Choose a reliable ready condition
domcontentloaded indicates the initial document has been parsed, but many applications fetch data afterward. Prefer a stable, meaningful signal from your site:
await page.getByRole('heading', { name: 'Operations overview' }).waitFor();
// Or wait for an application-specific element:
await page.locator('[data-testid="dashboard-ready"]').waitFor();
Use waitUntil: 'networkidle' only when it fits the application. Polling, analytics, and long-lived connections can prevent network idle from occurring. A specific element or a bounded delay after an element appears is often more dependable.
3. Schedule the capture with GitHub Actions
For a self-hosted runner, label a runner connected to the private network with vpn, then use that label in runs-on. Add a workflow such as .github/workflows/screenshot.yml:
name: Scheduled private-site screenshot
on:
schedule:
- cron: '17 6 * * *' # Daily at 06:17 UTC
workflow_dispatch:
jobs:
capture:
runs-on: [self-hosted, linux, vpn]
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npx playwright install --with-deps chromium
- name: Capture page
env:
TARGET_URL: ${{ secrets.PRIVATE_TARGET_URL }}
run: node capture.mjs
- name: Upload screenshot
uses: actions/upload-artifact@v4
with:
name: private-site-${{ github.run_id }}
path: screenshots/*.png
retention-days: 14
Change the cron expression to your desired cadence. GitHub Actions cron schedules use UTC. The workflow can also be started manually with workflow_dispatch, which is useful for checking network access and capture behavior before relying on a scheduled run. Store the URL in a repository or organization secret if its hostname or path is sensitive. Artifact retention is configurable; choose a period appropriate for your review and storage needs.
4. Connect a hosted runner to the private network
If your organization permits hosted runners to access the target network, establish that connection before running Playwright. For a Tailscale tailnet, the documented Action supports federated identity, OAuth credentials, or an auth key. Prefer workload identity federation when available, follow Tailscale’s current setup instructions, and grant the workflow identity only the required tagged-device access. This abbreviated workflow shows the ordering; configure the credential and permissions according to the official documentation:
jobs:
capture:
runs-on: ubuntu-latest
steps:
- uses: tailscale/github-action@v4
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
audience: ${{ secrets.TS_AUDIENCE }}
tags: tag:ci
ping: internal-web.my-tailnet.ts.net
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npx playwright install --with-deps chromium
- name: Capture page
env:
TARGET_URL: ${{ secrets.PRIVATE_TARGET_URL }}
run: node capture.mjs
The values and identity permissions above are placeholders: configure a federated identity client and audience, tag policy, and target access as Tailscale documents. Its Action can wait for connectivity to named peers with ping. Other supported approaches include GitHub’s documented private networking options such as a WireGuard overlay or an Azure virtual network. Follow the current provider instructions and verify the runner can resolve and reach the target before launching the browser.
5. Pick capture settings that fit the monitoring job
| Need | Playwright choice | Notes |
|---|---|---|
| Visible viewport only | fullPage: false (default) |
Useful for monitoring a fixed dashboard view. |
| Entire scrollable page | fullPage: true |
Long pages create larger images and can trigger lazy-loading or layout changes. |
| Specific region | locator.screenshot() |
Capture a stable component instead of unrelated page content. |
| Smaller high-DPI-independent output | scale: 'css' |
Produces one image pixel per CSS pixel; the default device scale may produce larger files. |
| JPEG or WebP | type: 'jpeg' or 'webp' |
Use a matching file extension; quality applies to lossy formats, not PNG. |
| Hide volatile areas | style or mask |
Mask timestamps, rotating banners, or personalized data when they are not the subject of monitoring. |
Playwright also supports clipping, transparent backgrounds, timeout control, and abort signals in its screenshot API. See the full API reference for current options. If you compare captures over time, keep browser version, operating system, fonts, viewport, scale, and headless mode consistent. Playwright notes that rendering can vary with host OS, browser version, settings, hardware, power source, and headless mode; use the same environment as the baseline. See Playwright visual comparisons.
6. Store and operate the captures safely
- Choose an output destination with deliberate access controls. Workflow artifacts are convenient for short-term review; use your approved object storage or monitoring system if you need longer retention.
- Set retention explicitly. Screenshots may contain internal information, customer data, or account details; limit access and retention accordingly.
- Use deterministic filenames with a timestamp or run identifier so concurrent or repeated captures do not overwrite one another.
- Log the run ID, target label, capture time, and success or failure. Avoid logging page contents, credentials, cookies, or sensitive URLs.
- For recurring alerts, compare images or selected regions against a baseline and route significant changes to a human review. Mask intentionally dynamic content to reduce noisy changes.
- Test the workflow manually first and verify that the runner can resolve the private hostname, connect to its port, authenticate, render the page, and upload the result.
Performance, reliability, and cost
For one or a few pages per run, browser startup and navigation are usually the main work; full-page screenshots and large assets use more memory, CPU, and storage. Reuse one browser process for several pages in a single job when capturing a batch, but create isolated contexts when cookies or session state must not cross between targets. Set navigation and job timeouts, and keep retries bounded so an unreachable VPN endpoint does not consume a runner indefinitely.
Reliability depends on the entire chain: scheduler delivery, runner availability, VPN authentication, DNS and routing, site authentication, page readiness, screenshot writing, and artifact storage. Make failures visible with a nonzero exit status and retain enough run metadata to diagnose which stage failed. A self-hosted runner avoids setting up a hosted private-network path but requires maintenance and capacity; hosted runners trade that maintenance for deliberate networking configuration and whatever runner charges apply under your GitHub plan. Browser execution and screenshot retention also consume compute and storage. Review the current provider pricing for your environment rather than assuming a fixed cost.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| DNS lookup fails or the hostname is unknown | The runner is outside the private DNS path, or VPN connection setup did not finish. | Connect to the approved network before navigation; check DNS from the runner and use the hostname resolvable in that network. |
| Connection refused or navigation times out | Wrong host or port, missing route, firewall rule, unavailable target, or runner not authorized. | Check network reachability from the runner, target port, access policy, and VPN status. Do not treat a browser timeout as proof the site is down. |
| HTTP 401/403 or login page captured | Missing or expired authentication, inadequate account permissions, or an SSO flow that cannot run unattended. | Use the approved automation identity and authentication flow; confirm access in a browser running on the same runner. |
| Screenshot is blank or incomplete | Capture happened before client-rendered data appeared, or a required resource failed. | Wait for an application-specific ready element and inspect browser console/network errors. Use a bounded timeout. |
networkidle never completes |
Polling, analytics, streaming, or persistent connections keep network activity alive. | Wait for a meaningful selector or use a short, bounded delay after the page’s ready signal. |
| Browser executable missing | Playwright package is installed but Chromium was not installed on the runner. | Run npx playwright install --with-deps chromium in the job, or use a maintained image that includes the matching browser. |
| Scheduled run does not appear | Workflow syntax, branch, repository schedule behavior, or UTC expectation is incorrect. | Validate the YAML and cron expression, check the workflow’s default-branch requirements and Actions status, and try workflow_dispatch. |
| Images differ despite no meaningful page change | Different fonts, browser or OS versions, viewport, device scale, animation, or dynamic content. | Pin the environment and settings; disable animations and mask volatile regions. Compare in the same environment as the baseline. |
| Artifact is missing or overwritten | Output path does not match the upload path, capture failed earlier, or filename is reused. | Check the script’s logged output path and artifact step; use unique timestamped names and confirm the file exists before upload. |
Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server by Yorker Media. One GET request can return a screenshot or PDF. It is designed for sites the service can reach; a VPN-only hostname still needs an authorized network path the service can access, so verify private-network reachability before using it for this target. See the ScreenshotNeo website and API documentation.
For an accessible target, the same call can be made from a scheduled job. cURL:
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
Python:
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
Node.js:
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
Cookie banners, popups, and chat widgets are removed before the shot. Bot checks, blank pages, and failed loads are never billed. An MCP server lets AI agents take screenshots. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000.
Sign up free for 1,000 screenshots a month, with no card required.
FAQ
Can I schedule a screenshot if the site is only reachable from my laptop’s VPN?
Only if the scheduled browser runs on that laptop or another machine with equivalent authorized network access, or the scheduled runner is deliberately connected to the private network. Your local VPN session is not automatically available to a cloud runner.
Do I need a dedicated mini PC?
No. An existing always-on computer or server can work if it supports the runner and browser, has sufficient resources, can reach the site, and can communicate with the scheduler.
Can a screenshot service capture a private URL?
Only if it has an authorized network path to that URL. Check the provider’s private-network support before sending a VPN-only target; a public screenshot endpoint cannot automatically join your VPN.
How do I make weekly captures comparable?
Keep the operating system, browser version, viewport, scale, fonts, and capture timing stable, and suppress dynamic content that is not part of the comparison.


