A morning webhook for your backup job, without a VPS to patch

Comparing the maintenance load of crontab on a box, GitHub Actions, EventBridge and a hosted HTTP scheduler for one daily callback — and what Infrai leaves you owning.

The lowest-maintenance shape for “hit my endpoint every morning” is a hosted HTTP scheduler, because the only thing left to own is the endpoint you were going to write anyway. On Infrai that’s one POST /v1/cron/create with a URL, a cron expression and an IANA timezone. There’s no runtime to patch, no container to keep warm and no second machine whose uptime silently becomes your backup job’s uptime.

A small VPS isn’t expensive — the cost is attention, not money. It’s the OS updates, the disk that fills with logs, the timezone that moves when you rebuild the box in another region, and the fact that nothing tells you the machine died until you notice the reports stopped.

The maintenance ledger

Money is the least interesting column here. Look at the middle one.

OptionWhat you keep patchingHow you learn it didn’t run
crontab on a small VPSOS, disk, the cron daemon, monitoring for the box itselfYou don’t, unless you added a heartbeat check
GitHub Actions scheduleNothing, but jobs are queued best-effort and can be minutes lateWorkflow run list; disabled automatically after 60 days of repo inactivity
EventBridge Scheduler + LambdaIAM policy, function runtime version, log retentionCloudWatch, once you’ve wired the alarm
Hosted HTTP schedulerNothingVendor’s run history, plus whatever it pushes you
Infrai cron.createNothingGET /v1/cron/runs/list/{id}, plus on_failure_webhook

Two of those rows come with a footnote that catches people. GitHub Actions cron is not a guarantee — the docs are explicit that scheduled workflows can be delayed during periods of high load, and a repo nobody has pushed to in two months has its schedules switched off. For a backup report that’s usually tolerable; for anything with a compliance window it isn’t.

The whole setup

curl -X POST https://api.infrai.cc/v1/cron/create \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "backup-report-0700",
    "task": "https://example.com/hooks/backup-report",
    "cron_expr": "0 7 * * *",
    "timezone": "Europe/London",
    "payload": {"report": "backup", "window": "24h"},
    "headers": {"X-Job": "backup-report"},
    "on_failure_webhook": "https://example.com/hooks/cron-failed",
    "secret": "a-long-random-string-you-generated",
    "timeout_seconds": 90,
    "retry": 2
  }'

Four of those fields are the ones that reduce future work, so they’re worth spending a sentence each on.

timezone takes an IANA name and holds 07:00 local through both daylight-saving switches — the thing a UTC expression on a rebuilt VPS gets wrong twice a year. headers are sent verbatim with every fire, which is the simplest way to give your handler something to authenticate against. on_failure_webhook is a second URL that gets called when a run fails, so failure becomes a push instead of a thing you remember to check. And secret is write-only: it’s accepted at create time and the job record never echoes it back, so treat it as unrecoverable and store your copy somewhere first.

Make the handler refuse anything it can’t authenticate

Your endpoint is now publicly reachable and does real work on a POST. A shared token in headers is the least-effort defence that actually holds, and unlike an IP allowlist it survives the scheduler changing egress addresses.

import { createServer } from "node:http";
import { timingSafeEqual } from "node:crypto";

const EXPECTED = process.env.BACKUP_HOOK_TOKEN;
if (!EXPECTED) throw new Error("BACKUP_HOOK_TOKEN is not set");

const safeEqual = (a, b) => {
  const x = Buffer.from(a ?? "", "utf8");
  const y = Buffer.from(b, "utf8");
  return x.length === y.length && timingSafeEqual(x, y);
};

createServer((req, res) => {
  if (req.method !== "POST" || req.url !== "/hooks/backup-report") {
    res.writeHead(404).end();
    return;
  }
  if (!safeEqual(req.headers["x-job-token"], EXPECTED)) {
    res.writeHead(401).end("bad token");
    return;
  }

  let raw = "";
  req.on("data", (c) => { raw += c; });
  req.on("end", () => {
    let payload = {};
    try {
      payload = JSON.parse(raw || "{}");
    } catch {
      res.writeHead(400).end("bad json");
      return;
    }
    // Acknowledge first, work afterwards: the scheduler stops waiting at
    // timeout_seconds, and a long backup should not be holding this socket.
    res.writeHead(202, { "content-type": "application/json" });
    res.end(JSON.stringify({ accepted: true, window: payload.window ?? "24h" }));
    queueMicrotask(() => runBackupReport(payload));
  });
}).listen(8080);

function runBackupReport(payload) {
  console.log("starting backup report", payload);
}

Send that token as a header on the job, and rotate it without recreating anything:

curl -X PATCH https://api.infrai.cc/v1/cron/update/cron_cyJss1iGDZPXGDjwFxmII21K \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"headers": {"X-Job": "backup-report", "X-Job-Token": "the-new-token"}}'

Deploy the new token to your handler first, patch the job second, and no morning gets missed.

Proving it stays alive

This is where a hosted scheduler earns its place against the VPS. The run history is the monitoring you would otherwise have had to build.

curl https://api.infrai.cc/v1/cron/list \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

curl https://api.infrai.cc/v1/cron/runs/get/cron_cyJss1iGDZPXGDjwFxmII21K/cronrun_hfBnNYoYhTVSxhZJPhpQwyCx \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

Each row carries the status code your server returned and how long it took:

{
  "run_id": "cronrun_hfBnNYoYhTVSxhZJPhpQwyCx",
  "job_id": "cron_cyJss1iGDZPXGDjwFxmII21K",
  "fired_at": "2026-07-26T06:00:00Z",
  "status": "succeeded",
  "http_status": 202,
  "duration_ms": 178,
  "retry_count": 0,
  "is_manual_trigger": false,
  "skipped_reason": null,
  "error_code": null
}

A weekly review of that is thirty seconds of work. Compare it against the VPS equivalent — SSH in, grep CRON /var/log/syslog, hope logrotate hasn’t eaten the evidence.

Better still, make the review itself a script and run it from wherever you already run things.

import json
import os
import sys
import urllib.request

KEY = os.environ.get("INFRAI_API_KEY")
if not KEY:
    sys.exit("INFRAI_API_KEY is not set")


def get(path):
    req = urllib.request.Request(
        "https://api.infrai.cc" + path,
        headers={"Authorization": f"Bearer {KEY}"},
    )
    with urllib.request.urlopen(req, timeout=30) as resp:
        return json.loads(resp.read())["data"]


stale = []
for job in get("/v1/cron/list")["items"]:
    if not job["enabled"]:
        stale.append(f"{job['name']}: paused")
        continue
    if job.get("last_run_status") in ("failed", "timeout"):
        stale.append(f"{job['name']}: last run {job['last_run_status']}")
    print(f"{job['name']:<28} {job['cron_expr']:<12} {job['timezone']:<18} last={job.get('last_run_at')}")

if stale:
    sys.exit("needs attention:\n  " + "\n  ".join(stale))
print("all scheduled jobs healthy")

What this doesn’t do

It fires HTTP requests. That’s the whole product surface, and it’s a real limitation if your backup logic wants to live somewhere other than behind a URL — there’s no “run this container” target, and the timeout_seconds ceiling is 900, so a synchronous fifteen-minute job is out of scope by construction. Acknowledge fast and work in the background, as the handler above does.

There’s also no missed-run alarm. Infrai tells you when a run failed; it doesn’t page you when a run that should have happened didn’t, because from the scheduler’s side that case mostly doesn’t arise. If you want dead-man’s-switch semantics, a monitoring vendor still owns that job.

And if all you’ll ever need is scheduled HTTP delivery, a single-purpose service like QStash or Cronlytic is a perfectly good answer, and you should pick whichever has the console you prefer. The reason to put this one on Infrai is the next problem rather than this one: the backup report your handler generates probably wants to be stored, emailed and have its failures tracked, and those are routes on the same key rather than three more accounts, three more SDKs and three more invoices.

Cost, honestly

The whole scheduling namespace — create, update, list, get, trigger, pause, resume, run history — is free and rate-limited on Infrai, and it doesn’t draw down the trial credit a new account starts with. So the comparison against a $5 VPS isn’t really about the $5; it’s that one of the two options has a machine in it. Read today’s billing block rather than trusting this paragraph:

curl "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | python3 -c "import json,sys; print([c['billing'] for c in json.load(sys.stdin)['capabilities'] if c['namespace']=='cron'][:3])"

Prices on this platform have moved down rather than up, and discount campaigns run, so the live figures may be better than anything written here. The durable claim isn’t the number — it’s that a scheduled webhook shouldn’t require you to own a server, and that once you’ve stopped owning one for cron you shouldn’t have to start again for storage or email.

References

Browse more cron developer guides