Skip to content

Uptime monitoring — what to point at, and what not to

There are three layers of downtime detection, and they answer different questions. Only the third can wake you up.

LayerWhere it runsDetectsTells you
Liveness beatInside the APIA gap in its own heartbeatAfter the outage, as an alert card
HTTP monitorOutsideThe API not answeringDuring, if you configure it
Dead-man’s-switchOutsideOur pings stoppingDuring, even when we’re unreachable

The first ships and runs already (apps/api/alerts/livenessBeat.ts). The other two need a (free) third-party account, which is the only part nobody has done for you.

Do not point an uptime monitor at /api/health.

That endpoint governs Traefik routing, so it returns 200 even when the database is down — deliberately. On 2026-06-29 a DB node outage hung it, the container was pulled from the load balancer, and the entire API 503’d instead of degrading. Keeping it 200 is what makes a DB blip a degradation rather than a blackout.

The cost is that a monitor asking “is it 200?” reports green through a total database outage — a false negative on the failure you most want caught.

Same reading, expressed as a status code:

  • 200 — database reachable.
  • 503 — database unreachable.

Nothing routes on it, so it is free to be strict. systemSchedulesHealthy deliberately does not affect the code: recurring billing failing to register is real and is in the body to keyword-match on, but every request still serves, and paging for it at 03:00 teaches you to silence the pager.

Terminal window
curl -i https://platform-api.viite.ai/api/health/strict
SettingValue
URLhttps://platform-api.viite.ai/api/health/strict
MethodGET
Interval1–5 min
Up whenHTTP 200 and body contains "db":"up"
Down after2 consecutive failures (avoids paging on one dropped packet)

Keep the keyword check even though the endpoint is strict. It costs nothing and it survives someone later “fixing” the status code back to 200 — belt and braces on the exact mistake this page exists to prevent.

Free tiers that cover this: UptimeRobot (50 monitors, 5-min), Better Stack (10 monitors, 3-min), Healthchecks.io (20 checks).

It looks free and is not, on a private repo: Actions bill per job rounded up to the minute, so a 5-minute cron is ~8,640 minutes/month against a 2,000-minute free allowance — roughly $50/month, and it competes with CI for the same allowance. A public repo avoids the cost but makes your operational status public. Actions cron is also best-effort and drifts under load. A free SaaS tier is better on every axis.

The dead-man’s-switch — the only one that beats an unreachable host

Section titled “The dead-man’s-switch — the only one that beats an unreachable host”

An HTTP monitor has to reach us. If the host is up but unreachable — a firewall change, a DNS mistake, an expired certificate — it sees an outage and cannot tell you which. Inverting the direction fixes that: we ping out; the service alerts when the pings stop. Nothing of ours has to be alive for its alarm to fire.

This is already wired into the liveness beat and is dormant until you set one env var.

Create a heartbeat and copy its ping URL. It takes HEAD, GET or POST; the beat sends a plain GET.

https://uptime.betterstack.com/api/v1/heartbeat/<token>
SettingValueWhy
Expected every10 minThe beat runs every 5, so one missed beat is not an incident — two are.
Grace period5 minAbsorbs a deploy restart without paging. Total 15 min to alarm, matching the in-process downtime threshold.

Treat the ping URL as a credential. Anyone holding it can send heartbeats — masking a real outage — or append /fail to trigger a false page. It lives in Coolify env only: never in git, and never in this repo, because this site is public.

And never put it in a local or worktree .env. This one is not about disclosure, it is a false negative, which is worse: a dev API running on your laptop pings the same heartbeat, so Better Stack stays green while production is down and the alarm never fires. The value belongs in exactly two places — the main checkout’s apps/api/.env, which is the source of truth the sync reads, and Coolify. Worktrees deliberately do not get it; the beat simply stays dormant there, which is the correct local behaviour.

HEARTBEAT_PING_URL is declared in three places so a Coolify row actually reaches the container (see adding an env var). Set the value on the platform app in Coolify.

Prefer setting the single row in the Coolify UI over bun run env:coolify:sync --apply, which pushes your whole local env file and can revert prod flags — if you do use the sync, dry-run it first and read the diff.

An env var only works in production when it is declared in all of these. The compose entry is the one that silently breaks things if forgotten: a Coolify row with no environment: line never reaches the container, so the feature stays dormant with no error anywhere.

FileEntry
apps/api/core/env/parseEnvSchema.tsHEARTBEAT_PING_URL: z.string().default('')
.env.exampledocumented, empty
docker-compose.coolify.yml- HEARTBEAT_PING_URL=${HEARTBEAT_PING_URL:-}
operations/env-vars.mdinventory row + class

Two properties worth knowing, both deliberate and both tested:

  • A database outage does NOT stop the ping. The ping happens before, and outside, the database work. A dead-man’s-switch says the process is alive; a DB outage is a degraded service, not a dead one, and pinging through it stops the loudest alarm we have from crying wolf. The HTTP monitor above is what catches that case.
  • A failing ping never breaks the beat. A monitoring provider being down must not take us with it.

Better Stack accepts <url>/fail (and <url>/<exit-code>) to report a failure explicitly, rather than waiting for pings to stop. That would let the heartbeat alone cover database outages too, collapsing both monitors into one signup.

It is deliberately not wired, because it changes what the alarm means: today the heartbeat says the process is alive and nothing else, and the HTTP monitor owns degradation. Merging them makes one page mean two things. Worth revisiting if the HTTP monitor never gets set up — one imperfect alarm beats a perfect one that does not exist.

  1. HEARTBEAT_PING_URL → a dead-man’s-switch. Catches the API being dead or unreachable, and pages.
  2. An HTTP monitor on /api/health/strict. Catches degradation the process itself cannot see — most importantly a database outage while the API is otherwise happily serving.
  3. The liveness beat, already running. Catches the gap after the fact and leaves a card explaining what did not run, which is the part you read in the morning.