Cronhq - Cron jobs that actually run

Cron jobs fail in silence. Two servers run the same crontab and your billing job fires twice. A job dies and nobody notices for weeks. Cronhq is a scheduler built around one guarantee: exactly-once execution, enforced by Postgres locks — not best-effort. Around it: retries with backoff, HMAC-signed webhooks, heartbeat monitors for jobs we don't run, and alerts that fire once on failure and once on recovery. Rust on Postgres. MIT-licensed, self-hostable, same image we run. Free tier: 5 jobs.

Add a comment

Replies

Best
Hi Product Hunt 👋 I'm Michael, and I built Cronhq alone over the past several months. It started with a bug I kept watching people hit at client gigs. A team scales their app from one box to two. Both boxes have the same crontab. The nightly billing job now runs twice, and a customer gets charged twice. Nobody finds out from a dashboard — they find out from an angry support ticket. The other half of it is quieter and worse: a job that just stops. No crash, no page, no alert. Six weeks later someone notices the reports have been empty since March. Cronhq is my attempt to make both of those failures structurally hard instead of merely unlikely. What's under the hood: → Exactly-once execution. Every due run is claimed through a Postgres lock row with an expiry deadline, and next_run_at moves forward BEFORE dispatch. Two workers cannot fire the same run. If a worker dies holding the lock, the expiry lets another one take over rather than the run being lost. → Retries with backoff, per job — max attempts, delay and timeout are config, not a try/except you copy-pasted from Stack Overflow. → Alerts that dedup on transitions, not state. One alert when a job starts failing, one when it recovers. Not 47 pages at 3am. → Signed webhooks. HMAC-SHA256 over timestamp.body, with per-job secrets you can rotate without downtime, so your endpoint can prove the request came from us. → Heartbeat monitors — a dead-man's switch for jobs we DON'T run. Ping a URL from your existing cron; if the ping doesn't land inside the window, you get alerted. Absence is the thing that's hard to detect. → Cron-as-code. Put your schedules in cronhq.yaml, run `npx cronhq sync`, and `npx cronhq tail` to watch executions stream live. The stack is Rust (axum + sqlx + tokio) for the scheduler, worker and API, Postgres as the only coordination primitive, no Redis, no Kafka, no scheduler-of-schedulers — and a Next.js dashboard. It's MIT licensed and self-hostable with one docker-compose. The self-hosted image is the same image the cloud runs; there are no features hidden behind a flag. Free tier is 5 jobs, paid starts at $5. Two things I'd genuinely love your take on: 1. Is "exactly-once" the right thing to lead with, or does it read as too in-the-weeds outside of backend circles? 2. What would you need to see before you'd trust this with a job that actually moves money? I'll be here all day answering everything. Thanks for reading 🙏

 To your second question: before trusting it with a job that moves money, I'd look for a run ID in the webhook headers.


Exactly-once covers your side. If two workers never fire the same slot, fine. Instead retries are a different story though: if my endpoint does the work and the response gets lost or times out, you call me again and the charge happens twice on my side. In the docs I only see X-Cronhq-Timestamp and X-Cronhq-Signature, and I guess the timestamp changes on every attempt, so I have nothing to dedupe on.


I hit this with Celery and Redis: long tasks got redelivered, ran twice, and I had to build the guard myself.

Is there an execution ID that stays the same across the retries of one run? Or is it planned?

Yep, that’s a fair catch. Right now I don’t expose a stable execution ID across retries — the timestamp/signature headers aren’t intended to solve idempotency on the receiving side. Your example makes the gap pretty clear: the endpoint can complete successfully, the response gets lost, and CronHQ retries with no reliable key for the receiver to dedupe against. I’m going to add a stable run/execution ID that stays the same for every attempt belonging to that execution, while each attempt still gets its own metadata. Really appreciate you pointing this out, especially with the Celery example. This is exactly the kind of failure mode I want CronHQ to handle properly.

This is very useful. This has been a bugbear of mine for a long time. Well done.

Appreciate it! 🙌 That exact frustration is pretty much why I built CronHQ. Glad to hear it solves a real pain point for someone else too. If you end up trying it, I’d genuinely love to hear what breaks or feels missing.

the "job runs twice" framing is exactly the failure mode that's easy to ignore until it hits a billing job specifically. Curious how the Postgres lock holds up under a worker crash mid-job - does the lock get released automatically so the next scheduled run can pick it up, or does a dead worker leave a job stuck until someone notices via the heartbeat alert?

Good question. The lock is tied to the DB session/transaction, so if a worker dies and the connection drops, Postgres releases it automatically. We’re not relying on the heartbeat to clear the lock. The part I’m working through more carefully is the crash window where the target endpoint may have completed the work but CronHQ never received/recorded the success. That’s where run IDs + idempotency become important, especially for billing jobs. Definitely something I want to make explicit in the docs.

 that split makes sense, the lock question was really "does it double run" and the honest answer is that's the smaller risk, the ambiguous-completion case is the scary one because nothing looks broken. is the run ID something CronHQ generates and passes to the endpoint so the endpoint itself can dedupe on it, or is it purely internal bookkeeping on your side right now? if it's the former that would let people build idempotency into the receiving job rather than trusting CronHQ's own retry logic alone.

Right now, it’s internal bookkeeping, I don’t yet pass a stable execution ID to the endpoint across retries. I’m changing that so each scheduled run gets an execution ID exposed in the webhook headers, with the same ID preserved across every retry. The receiving endpoint can then store that ID and safely dedupe the actual work, while CronHQ tracks each retry attempt separately. You’re right: without receiver-side idempotency, exactly-once dispatch alone doesn’t fully solve the ambiguous completion case.

 that's the right fix, and exposing it in the headers rather than keeping it internal is what actually lets people opt into real idempotency instead of just trusting the retry logic. one thing worth thinking through before it ships: will the header be additive (new header, old ones untouched) or will you be changing what X-Cronhq-Timestamp means, since anyone who already built a workaround off the existing headers would need a heads up either way. appreciate you thinking out loud about this in public, it's a good sign for how the product gets built.

Definitely additive. I don’t want to change the semantics of the existing timestamp/signature headers and risk breaking integrations that already depend on them. The execution ID will be a new header, stable across all attempts for the same scheduled run. I’m also considering exposing an attempt number separately so retries are distinguishable without changing the execution ID. And I appreciate that , feedback like this is exactly why I wanted to discuss the implementation openly before shipping it 🙌

 additive plus a separate attempt number is the right shape, that gives people the stable key for dedup and the attempt count for observability without overloading one field with two meanings. good luck with the ship - this has been one of the more useful threads I've followed on here, mostly because you kept answering the harder follow-up instead of stopping at the first good-enough answer.