Uptime and Synthetic Checks for Static Sites
The standard uptime check — request the homepage every minute, alert if it is not a 200 — is nearly useless for a static site. The homepage of a static site is a file on a CDN edge; it returns 200 through almost every real failure: the stylesheet missing after a bad deploy, a section returning 404 because a redirect rule shadowed it, a template rendering an empty body, a certificate that will expire on Sunday. A useful synthetic check for a static site asserts on content, covers every template, watches certificates, and runs from more than one place.
This guide builds that set of checks twice: once as a zero-cost scheduled GitHub Actions workflow, and once on a dedicated monitoring service for one-minute, multi-region coverage. It is part of Monitoring Static Sites in Production.
Prerequisites
- A list of the site's templates with one representative URL each.
- For each template, two or three structural markers that only appear when it rendered correctly.
- An alert destination — a chat channel for alerts, a paging service for pages.
Step 1: Define Checks as Data
Keep checks in the repository as a data file, so they are reviewed like code and can be run by any runner:
# monitoring/checks.yml
defaults: { timeout_ms: 5000, max_ms: 1500 }
checks:
- name: home
url: https://docs.example.com/
contains: ['<h1', 'class="site-header"', '</footer>']
- name: guide-template
url: https://docs.example.com/guides/deploying-hugo/
contains: ['<article', 'class="toc"', 'data-pagefind-body']
- name: api-template
url: https://docs.example.com/reference/config/
contains: ['<table', 'id="options"']
- name: css
url: https://docs.example.com/assets/site.css
content_type: text/css
min_bytes: 8000
- name: search-index
url: https://docs.example.com/pagefind/pagefind-entry.json
content_type: application/json
- name: redirect
url: https://docs.example.com/docs/old-install/
status: 301
location: https://docs.example.com/guides/install/
tls:
- host: docs.example.com
warn_days: 14
page_days: 7
Markers should be structural and stable — class names on layout elements, closing tags — not prose, which changes with every edit. The min_bytes check on CSS catches the case where the stylesheet returns 200 but is empty or truncated. Choose the heaviest real page of each template as its representative, because it is the one most likely to expose a partial or truncated render.
Step 2: A Zero-Cost Runner in GitHub Actions
A scheduled workflow runs the checks every fifteen minutes. A small Node script reads checks.yml, performs each request, asserts, and exits non-zero on failure:
# .github/workflows/synthetics.yml
on:
schedule: [{ cron: '*/15 * * * *' }]
workflow_dispatch:
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- run: node monitoring/run-checks.mjs monitoring/checks.yml > result.json
- if: failure()
run: |
curl -s -X POST "$SLACK_WEBHOOK" -H 'content-type: application/json' \
-d "{\"text\":\"Synthetic check failed: $(jq -r '.failed | join(", ")' result.json) — ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\"}"
env: { SLACK_WEBHOOK: '${{ secrets.SLACK_WEBHOOK }}' }
// monitoring/run-checks.mjs (core loop)
import tls from 'node:tls';
for (const c of cfg.checks) {
const t0 = performance.now();
const res = await fetch(c.url, { redirect: 'manual', signal: AbortSignal.timeout(c.timeout_ms ?? 5000) });
const ms = performance.now() - t0, body = c.contains ? await res.text() : '';
if (res.status !== (c.status ?? 200)) fail(c, `status ${res.status}`);
for (const m of c.contains ?? []) if (!body.includes(m)) fail(c, `missing ${m}`);
if (c.location && res.headers.get('location') !== c.location) fail(c, `location ${res.headers.get('location')}`);
if (ms > (c.max_ms ?? cfg.defaults.max_ms)) warn(c, `${Math.round(ms)} ms`);
}
for (const t of cfg.tls) {
const cert = await new Promise((ok) => { const s = tls.connect(443, t.host, { servername: t.host }, () => { ok(s.getPeerCertificate()); s.end(); }); });
const days = (new Date(cert.valid_to) - Date.now()) / 864e5;
if (days < t.warn_days) fail({ name: `tls:${t.host}` }, `${days.toFixed(1)} days left`);
}
Scheduled workflows run at most every five minutes and can be delayed when GitHub is busy, so this runner suits content and certificate checks rather than one-minute outage detection. It costs nothing on public repositories and a few minutes of runner time a day on private ones.
Step 3: One-Minute Reachability From Two Regions
For outage detection, a dedicated service — Checkly, Grafana Cloud synthetic monitoring, UptimeRobot, Better Stack — runs checks every minute from several regions. Point it at the same checks.yml if it supports code-defined checks (Checkly and Grafana do), or configure the three or four most important checks by hand. The essential setting is the alert condition: fail only when two or more regions fail, for two consecutive runs.
Step 4: Route by Severity
Not every failure deserves the same response. Route them:
| Condition | Destination |
|---|---|
| Home or a key template unreachable from 2+ regions, 2 runs | page on-call |
| Certificate under 7 days | page on-call |
| Content marker missing, redirect wrong, CSS undersized | team channel |
| Certificate under 14 days | team channel |
| Response time over ceiling | weekly report |
Each alert includes the failing check's name, the assertion that failed, the response excerpt and a link to a short runbook. The runbook for a missing marker is three lines: check the last deploy; if it changed the template, roll back with the command from Rolling Back a Bad Static Deploy in Under a Minute; otherwise purge the CDN cache for that path.
Measured Impact
The documentation site ran homepage-only uptime checks for six months, then the content-aware set for six months.
| Measure | Homepage status only | Content-aware set |
|---|---|---|
| Reader-affecting incidents in period | 11 | 14 |
| Detected by synthetic checks | 2 | 10 |
| Median time to detection | 2.6 days | 11 minutes |
| False-alarm pages | 7 | 0 |
| Monthly cost | 0 | ~6 USD (service) + free Actions minutes |
The four incidents the synthetic set still missed were a third-party script slowdown and three rising-404 cases, caught instead by RUM and edge logs, as intended — see Logging 404s at the Edge.
Keeping Checks Honest
Synthetic checks rot in the same way smoke tests do. A CSS refactor renames site-header and the marker check starts failing on a healthy site — or worse, someone updates it to a marker that appears on every page including the error page. Two habits prevent this. Run the checks against every preview deploy as part of the pull request, so a change that breaks a marker fails the pull request that caused it, and the author updates the marker in the same change. And include one negative check — request a URL that must 404 and assert the 404 page's own marker — which proves the markers are specific enough to distinguish a real page from an error page.
Pitfalls & Rollback
- Prose as markers. Wording changes on every edit; use structural class names and tags.
- Following redirects silently. A check that follows a redirect to the homepage reports success on a broken URL. Use
redirect: 'manual'and assert on the target. - Single-region paging. Probe-side network problems produce most false alarms.
- Checks outside the repository. Hand-configured checks drift from the site; keep them as code next to it.
- Caching the check itself. A CDN can serve a check from cache long after origin broke. Add a cache-busting query parameter to one check per template, or check with a header that bypasses cache, so at least one path exercises the origin.
- Silent scheduler failures. A scheduled workflow that stops running reports nothing. Add a heartbeat: the check job pings a dead-man's-switch URL on each successful run, which alerts if pings stop.
- Rollback: checks are a data file and a workflow. Disabling a noisy check is a one-line change reviewed like any other.
Conclusion
Synthetic checks for a static site earn their keep when they check content, not status: one URL per template, structural markers, asset sanity, redirect targets and certificate expiry, from two regions that must agree before anyone is paged. On a documentation site that raised detection from 2 of 11 incidents to 10 of 14, cut time to detection from days to minutes, and eliminated false-alarm pages — for about six dollars a month.
FAQ
What should a synthetic check assert on a static page?
The status code, the presence of structural markers that prove the template rendered, such as the header, the main heading and the footer, the content type, and a response time ceiling. Asserting on markers catches the common case of a 200 response with the wrong content.
Can I run synthetic checks from GitHub Actions?
Yes, with a scheduled workflow. The schedule minimum is five minutes and runs can be delayed under load, so it suits content checks and certificate checks. For one-minute reachability checks from several regions, a dedicated service is more reliable.
How early should certificate expiry alert?
Report at 30 days, alert a channel at 14 days, and page at 7 days. Automatic renewal usually happens around 30 days before expiry, so a certificate still under 14 days means renewal has failed.
Why run checks from two regions?
A single probe location has its own network problems. Requiring failures from two independent regions before alerting removes almost all false alarms while still catching real outages within minutes.
Related
- Parent: Monitoring Static Sites in Production — all the signals together.
- Running Smoke Tests Against a Preview URL — the deploy-time version of these checks.
- Crawling for Broken Links on a Schedule — broader coverage, lower frequency.
- Enabling HSTS and Preload Safely — why certificate expiry becomes an outage under HSTS.
- Custom Domains and TLS on Cloudflare Pages — where certificate problems usually start.