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.

Which failures each kind of check catches A matrix of five failure types against three check styles. A homepage status check catches only a full outage. A per-template status check also catches a section returning 404. A per-template content check additionally catches an empty or broken template, a missing stylesheet and a broken redirect. A TLS check catches certificate problems before they become outages. Coverage grows with what the check looks at home status per-template status content + TLS full outage caught caught caught section returns 404 missed caught caught template renders empty (200) missed missed caught stylesheet empty or missing missed missed caught certificate expiring at expiry at expiry 14 days early The same six URLs, checked three different ways
Moving from status codes to markers is what turns a synthetic check from an outage detector into a correctness check.

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.

False alarms with one region versus two Over three months, single-region checks raised 23 alerts, of which 21 were probe-side network problems and 2 were real outages. Requiring two regions to fail raised 2 alerts, both real, with detection delayed by about one minute. Alerts over three months, same checks Any 1 region fails 21 false (probe network) +2 2 regions agree 2 real, 0 false false alarm real outage (detection delay +1 min) Checks every minute from US East, EU West and AP Southeast
Two-region agreement removed every false alarm in the period and cost about a minute of detection time.

Step 4: Route by Severity

Not every failure deserves the same response. Route them:

ConditionDestination
Home or a key template unreachable from 2+ regions, 2 runspage on-call
Certificate under 7 dayspage on-call
Content marker missing, redirect wrong, CSS undersizedteam channel
Certificate under 14 daysteam channel
Response time over ceilingweekly report
Routing check failures by severity Check results flow into three destinations. Unreachable key pages from two regions and certificates under seven days page the on-call person. Missing markers, wrong redirects, undersized CSS and certificates under fourteen days go to the team channel. Slow responses go to the weekly report. Three destinations, chosen by urgency check results every run page: unreachable (2 regions) · TLS < 7 days channel: marker · redirect · CSS size · TLS < 14 days weekly report: response time over ceiling
Only two conditions wake anyone up; both mean readers cannot use the site or soon will not be able to.

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.

MeasureHomepage status onlyContent-aware set
Reader-affecting incidents in period1114
Detected by synthetic checks210
Median time to detection2.6 days11 minutes
False-alarm pages70
Monthly cost0~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.