A free uptime check with GitHub Actions that opens (and closes) an issue for you
You don't need a monitoring stack to know your side project is down. If the code already lives on GitHub, a scheduled workflow can check your URLs and use issues as incidents: open one when a check fails, comment while it stays down, and close it when the site recovers. Issue notifications go to your email and phone, so alerting comes for free.
This post builds that in about 70 lines, and then covers the limits honestly, because there are some.
What you'll get
- A check every 10 minutes (more on why not every minute below).
- One open issue per down URL, not a new issue every run.
- Automatic close with a "recovered after N minutes" comment.
- No secrets beyond the built-in
GITHUB_TOKEN.
The URL list
Create .github/uptime/urls.txt:
https://example.com https://example.com/login https://api.example.com/healthz The check script
.github/uptime/check.sh checks each URL with sensible timeouts and writes the failures to a file:
#!/usr/bin/env bash set -uo pipefail : > failures.txt while read -r url; do [ -z "$url" ] && continue code=$(curl -s -o /dev/null -w '%{http_code}' \ --connect-timeout 5 --max-time 15 --retry 2 --retry-delay 3 \ -A "uptime-check (github-actions)" "$url") if [ "$code" -lt 200 ] || [ "$code" -ge 400 ]; then echo "$url $code" >> failures.txt echo "DOWN $code $url" else echo "UP $code $url" fi done < .github/uptime/urls.txt --retry 2 matters: one dropped packet from a GitHub runner shouldn't page you. curl's %{http_code} prints 000 when the connection fails entirely, which the numeric check also treats as down.
The workflow
.github/workflows/uptime.yml:
name: uptime on: schedule: - cron: "*/10 * * * *" workflow_dispatch: permissions: issues: write contents: read jobs: check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: bash .github/uptime/check.sh - name: Sync issues uses: actions/github-script@v7 with: script: | const fs = require('fs'); const down = new Map(fs.readFileSync('failures.txt','utf8') .split('\n').filter(Boolean) .map(l => { const [u, c] = l.split(' '); return [u, c]; })); const urls = fs.readFileSync('.github/uptime/urls.txt','utf8') .split('\n').map(s => s.trim()).filter(Boolean); const { data: open } = await github.rest.issues.listForRepo({ ...context.repo, state: 'open', labels: 'uptime', per_page: 100 }); const byUrl = new Map(open.map(i => [i.title.replace(/^DOWN: /,''), i])); for (const url of urls) { const issue = byUrl.get(url); if (down.has(url) && !issue) { await github.rest.issues.create({ ...context.repo, title: `DOWN: ${url}`, labels: ['uptime'], body: `Status \`${down.get(url)}\` at ${new Date().toISOString()}` }); } else if (down.has(url) && issue) { await github.rest.issues.createComment({ ...context.repo, issue_number: issue.number, body: `Still down (\`${down.get(url)}\`) at ${new Date().toISOString()}` }); } else if (!down.has(url) && issue) { const mins = Math.round((Date.now() - new Date(issue.created_at)) / 60000); await github.rest.issues.createComment({ ...context.repo, issue_number: issue.number, body: `Recovered after ~${mins} min.` }); await github.rest.issues.update({ ...context.repo, issue_number: issue.number, state: 'closed' }); } } Create the uptime label once (Issues → Labels), push, and trigger the workflow manually from the Actions tab to test it. To see the alert path, temporarily add a URL that's guaranteed to fail, such as http://127.0.0.1:9/ (nothing listens on that port on a runner).
Why every 10 minutes and not every minute
GitHub's own docs say scheduled workflows can be delayed during periods of high load, especially around the top of the hour, and the shortest interval allowed is every 5 minutes. In practice, treat the schedule as "roughly every N minutes". For a side project, a 10-15 minute detection window is usually fine. If you need minute-level detection, this isn't the tool.
Also: on public repos, scheduled workflows are disabled automatically after 60 days without repository activity. Put a reminder in your calendar, or push something occasionally.
The limits, honestly
| Limitation | Why it matters |
|---|---|
| Single vantage point (GitHub's runners) | Can't tell "down for everyone" apart from "unreachable from Azure" |
| Schedule delays | Detection time varies |
| Status-code only | A blank 200 page counts as up |
| Uses Actions minutes | Free and generous on public repos, metered on private ones |
The first limit is the important one. When an issue opens, your first question should be "is it really down?". Check from a second location before you start digging. A quick check on upordown.online or a curl from your own machine tells you whether it's the site or the runner.
Fewer false alarms: re-check failures before opening an issue
--retry 2 covers a dropped connection within a few seconds. It doesn't help when a deploy restarts your app for 30 seconds, or a runner has a bad network path for a minute. A cheap improvement: re-check only the failed URLs after a pause, and count a URL as down only if it fails both passes. Append this to the end of check.sh:
if [ -s failures.txt ]; then echo "re-checking $(wc -l < failures.txt) URL(s) in 60s" sleep 60 cut -d' ' -f1 failures.txt > recheck.txt : > failures.txt while read -r url; do code=$(curl -s -o /dev/null -w '%{http_code}' \ --connect-timeout 5 --max-time 15 \ -A "uptime-check (github-actions)" "$url") if [ "$code" -lt 200 ] || [ "$code" -ge 400 ]; then echo "$url $code" >> failures.txt echo "STILL DOWN $code $url" else echo "RECOVERED $code $url" fi done < recheck.txt fi The workflow step that syncs issues doesn't change, because it still reads failures.txt. The cost is at most one extra minute of runner time, and only on runs where something failed. In exchange, you get far fewer issues that open and close within a single cycle.
If issues still flap, deploys are the usual suspect. Either point the check at a URL that stays up during deploys, or confirm across runs instead: open the issue only after two consecutive scheduled runs have failed. Keep in mind that the re-check runs from the same runner, so it filters blips, not regional problems. For those you still need the second location.
Easy upgrades
- Body check:
curl ... | grep -q "Sign in"for pages where content matters. - TLS expiry warning: parse
openssl s_clientoutput and open a separate issue below 14 days. - Latency: add
-w '%{time_total}'and comment when a page is slow but still up. - Status badge: the workflow badge in your README turns red on failure, which works as a crude public status page.
Why issues work surprisingly well as incidents
Each outage becomes a permanent, linkable record with timestamps, and you get comments for free ("deployed a fix in #123"). Six months later you can filter label:uptime is:closed and see your real incident history, which is more than many paid setups give small teams.
Related reading
- Our monitor said 100% uptime while the CDN served a 6-hour-old homepage
- Alert after 1 failed check or 3? Tuning uptime alerts so on-call stops ignoring them
- Your HTTP client's default timeout is probably infinite: fix it in Python, Node, Go and curl
- How to check if a website is down for everyone or just you
Do you run any monitoring from CI or GitHub Actions? What's the hackiest setup you've relied on that actually caught a real outage?