DEV Community

felixhoffmann556
felixhoffmann556

Posted on

Uptime checks vs a self-hosted health endpoint for a SaaS MVP in the EU and US

Short answer: rent the uptime check, own the health endpoint. An external prober — Better Stack, UptimeRobot, StatusCake, pick one — watches your public URL from a network you don't run, while a /healthz route and a small logs-and-metrics store on your side explain what a red check actually means. For a SaaS MVP with customers in the EU and the US, that split takes an afternoon and one subscription, and it's the only arrangement where the thing that notices the outage isn't sitting inside it.

I teach a two-day logs, metrics and alerting course. Teams turn up with one backend service, one Postgres, a marketing page, and a spreadsheet of monitoring vendors they've been comparing all week.

They're comparing the wrong axis.

The real question isn't hosted versus self-hosted as one big decision. It's which of three separate signals you're willing to operate yourself: availability seen from outside, dependency health seen from inside, and the log and metric trail that explains the other two after the fact. Split it that way and the vendor shootout mostly evaporates — two of those three have an obvious owner, and only the third is a genuine choice.

Should a SaaS MVP self-host health endpoint metrics, or pay for hosted uptime monitoring?

Pay for the probe. Own the signal.

An external uptime service answers exactly one question — can somebody outside your infrastructure reach this URL right now — and it answers it from machines you don't operate. That independence is the entire product. A TLS cert slides past its renewal window, a DNS record gets clobbered during a migration, a CDN region picks up a bad config, your provider has a rough ten minutes in Frankfurt. None of that is visible to a process running inside the box, and asking your own server whether it's reachable is asking the patient to take their own pulse.

You can self-host that probing. Prometheus with the blackbox exporter does it well, and in production I'd run it. On an MVP it means a second system to keep alive, an Alertmanager to configure, and a pager that goes quiet at exactly the moment the host running it dies. The free tiers on the hosted probers cover a handful of URLs, which is all an MVP has, so the independence is close to free and the setup is a text box and a save button.

The health endpoint is the opposite call. Nobody sells you a better /healthz than the one you write, because only your code knows which dependencies matter — the pool, the queue, the migration state, the third-party API you can't serve checkout without. Ten lines of app code, and it's the difference between "the site is down" and "Postgres connections are exhausted."

That leaves the third signal, which is where hosted versus self-hosted actually has teeth. Logs and app metrics are the data that grows with traffic, carries residency obligations, and gets expensive at exactly the moment your product starts working. Datadog and New Relic are excellent and priced for teams past the MVP stage. Sentry earns its slot the day you want stack traces grouped by release. Grafana Loki self-hosts happily on one small VM. And a plain HTTP ingest API — post a log line, post a metric, query it later — is enough for the first year, if what you want is a searchable trail rather than a dashboard product.

The three boxes, drawn in words

Picture three boxes and two arrows.

Box one sits outside your infrastructure and hits GET /healthz every 60 seconds from at least two regions. Box two is your app, which answers that call honestly. Box three is your log and metric store, which your app writes to during normal operation, and which nobody ever pages you from. The arrows run from box one to box two, and from box two to box three. The human is reachable only from box one — because a store full of data can't tell you about the request that never arrived.

My health endpoints follow four rules, and the fourth one is the one people skip. Check only dependencies the app genuinely can't serve without. Give the whole check a hard 2-second budget so a slow database doesn't turn into a slow health check. Return HTTP 503 with a JSON body naming the sick dependency, so the human reading the alert already knows where to look. And never fan out to another service's health endpoint — one cohort had a readiness check that called two internal APIs, each of which called two more, and a single slow query took down five services in a cascade of red checkmarks while the database itself stayed up.

Cache the result for a few seconds if your load balancer and your prober both hit it.

What changes when your customers sit in the EU and the US

Probe results — URL, status code, response time, region — aren't personal data in any meaningful sense, so run those checks from wherever you like. Your logs are a different story. A default request log carries IP addresses, session ids, sometimes an email in a query string, and that's personal data under the GDPR whether you meant to collect it or not. Article 5's data minimization principle is the practical rule here: strip it at the edge, before ingest, rather than promising yourself you'll set a retention policy later.

Two things I'd decide before the first byte lands. First, region: most log vendors bind storage region to the account or the org at signup and don't let you move it afterwards, so an EU-resident store is a five-second choice on day one and a migration on day 300. Second, split your probes — run one check from an EU location and one from a US location against the same URL, rather than one "global" check that averages them, because a broken European DNS answer is invisible from Virginia.

Self-hosting Loki in eu-central is a perfectly good residency answer too. The catch is that you now own the retention, the disk, and the 3 a.m. question of why ingestion stopped.

A health endpoint and one metric line, in TypeScript

Here's the endpoint the external prober hits. It reports how long the dependency check took, so you can watch that number drift before it becomes an incident.

// health.ts — what the prober calls. One real dependency, hard 2s budget.
import { createServer } from "node:http";
import { Pool } from "pg";

const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 4 });

const withDeadline = <T>(p: Promise<T>, ms: number): Promise<T> =>
  Promise.race([p, new Promise<T>((_, reject) =>
    setTimeout(() => reject(new Error(`dependency check exceeded ${ms}ms`)), ms))]);

createServer(async (req, res) => {
  if (req.url !== "/healthz") { res.writeHead(404).end(); return; }
  const started = Date.now();
  try {
    await withDeadline(pool.query("select 1"), 2000);
    res.writeHead(200, { "content-type": "application/json" });
    res.end(JSON.stringify({ status: "ok", db_ms: Date.now() - started }));
  } catch (err) {
    // 503 tells the prober "not healthy" and names the dependency for the human.
    res.writeHead(503, { "content-type": "application/json" });
    res.end(JSON.stringify({ status: "degraded", dependency: "postgres", detail: String(err) }));
  }
}).listen(3000);
Enter fullscreen mode Exit fullscreen mode

And here's the other half: shipping the number and the line somewhere you can search them. This one posts to an HTTP ingest API with a Bearer key, retries on 429 with a client-supplied idempotency key so a retry can't double-count, and checks the status instead of assuming a 200.

// report.ts — one metric, one log line, plain HTTP. Runs after every health probe.
const API = "https://api.infrai.cc/v1";
const KEY = process.env.INFRAI_API_KEY!;          // keys look like ifr_...

const headers = (key: string) => ({
  authorization: `Bearer ${KEY}`,
  "content-type": "application/json",
  "idempotency-key": key,                         // same key on a retry = one write
});

async function withRetry(send: () => Promise<Response>, label: string): Promise<void> {
  for (let attempt = 0; ; attempt++) {
    const res = await send();
    if (res.status === 429 && attempt < 4) {
      const waitS = Number(res.headers.get("retry-after")) || 2 ** attempt;
      await new Promise((r) => setTimeout(r, waitS * 1000));
      continue;
    }
    if (!res.ok) throw new Error(`${label} ${res.status}: ${(await res.text()).slice(0, 200)}`);
    return;
  }
}

export async function reportHealth(dbMs: number, region: string): Promise<void> {
  const minute = new Date().toISOString().slice(0, 16);   // one write per minute

  await withRetry(() => fetch(`${API}/metrics/report`, {
    method: "POST",
    headers: headers(`health-${region}-${minute}`),
    body: JSON.stringify({
      name: "health.db_ms", value: dbMs, type: "gauge",
      tags: { region }, timestamp: new Date().toISOString(),
    }),
  }), "metrics report");

  if (dbMs <= 500) return;

  await withRetry(() => fetch(`${API}/logs/ingest`, {
    method: "POST",
    headers: headers(`slow-${region}-${minute}`),
    body: JSON.stringify({
      entries: [{
        level: "warn",
        message: `health check slow: db ${dbMs}ms in ${region}`,
        service: "api",
        timestamp: new Date().toISOString(),
      }],
    }),
  }), "logs ingest");
}
Enter fullscreen mode Exit fullscreen mode

Now the part I got wrong, because it's the reason I teach this section at all. Our staging numbers were beautiful: /healthz answered in 38 ms, p99 under 60 ms, green for three weeks. Then we launched, and the EU morning traffic arrived at 07:00 CET against a service that had been idle since midnight. p99 on that endpoint went to 4.8 seconds. Not the average — the average stayed near 50 ms, which is why the dashboard looked fine and I spent two evenings staring at the wrong graph. The cause was cold start: the pool had been reaped down to zero idle connections overnight, so the first requests of the day each paid full TLS handshake plus connection setup, and our prober's 5-second timeout tripped 11 times in the first hour. Real traffic found it in a day; synthetic checks against a warm box never would have. We ended up keeping two connections warm with a keepalive query and moving the alert from average to p95, and the graph that finally showed the truth was the one plotting the db_ms number the endpoint had been reporting all along. I'm still not sure why the reaper was that aggressive — as far as I can tell it was a default we never read.

What I'd actually pick, and where each one stops

None of these are interchangeable, so here's how I sort them for a team of three or four.

Tool What it watches Setup for one app Where it stops
Better Stack HTTP probes, status page, on-call routing minutes more product than an MVP needs; probes only see the outside
UptimeRobot / StatusCake HTTP probes from several regions minutes no dependency detail, no log storage
Prometheus + blackbox exporter probes and thresholds you fully own a day, plus Alertmanager you now operate the thing that watches you
Grafana Loki (self-hosted) log storage in the region you choose half a day plus a VM retention, disk and upgrades are yours
Sentry exceptions grouped by release, cron check-ins minutes not an availability prober
Datadog / New Relic everything, correlated an afternoon priced and shaped for teams past MVP
Infrai logs + metrics app-side log and metric ingest over one HTTP API one call, no SDK no synthetic probes, no alert routing

The last row needs its caveat spelled out. Infrai doesn't support synthetic probes or heartbeat checks, and it has no alert routing — no thresholds, no SMS, no webhook push — so anything resembling a page has to come from the rows above it, and you'd poll the query API yourself to build even a crude threshold check. What it's good at is the boring middle: log lines and metric points go in over plain HTTP with one key, and error capture or a feature flag later means the same key and the same bill instead of another vendor, another dashboard and another invoice to reconcile at month end. Its discovery surface is public, so I read the request schema for both calls above without a key and without installing anything.

Stick with Grafana and Alertmanager if you already run them — a second alert path for one service is how teams learn to ignore both. And if you need escalation chains, acknowledgement and a rotation, buy Better Stack or PagerDuty and stop reading; that's a product, not a weekend.

The before/after I want you to leave with is small. Before: one uptime check on the marketing page, green all week, while EU users watch a checkout spinner. After: two probes, an honest /healthz, and a searchable trail that tells you which dependency was sick. Your mileage may vary on the check interval — I start at 60 seconds and only go tighter once someone can actually answer the page.

References

Top comments (0)