CronLite runs reliable scheduled webhooks from your own infrastructure.
A self-hosted cron-as-a-service for backend teams that need retries, HMAC signing, failover, metrics, and a built-in Admin UI without another SaaS dependency.
Create jobs through REST, manage them in Admin, or let your MCP agent handle the schedule. Now with Tailscale: call private services and run with no public port.
Plain English or a five-field cron expression.
At 9:00 AM on weekdays
Next runs
- —
- —
- —
- —
- —
Create it with one request
$ curl -X POST http://localhost:8080/jobs \
-H "Authorization: Bearer $CRONLITE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "every-weekday-at-9am",
"cron_expression": "0 9 * * 1-5",
"timezone": "UTC",
"webhook_url": "https://api.example.com/hooks",
"webhook_secret": "your-signing-secret"
}'→ 201 Created · enabled · HMAC-signed deliveries
Self-hosted
Runs inside your own infrastructure, or only on your Tailscale tailnet, instead of another scheduler SaaS.
Go + PostgreSQL
One deployable service, backed by the database your team already knows.
HMAC-signed webhooks
Every delivery can be verified by your endpoint before it does real work.
REST · Admin · MCP
Manage the same jobs through an API, the built-in Admin UI, or your agent.
From one API call to a signed delivery.
Move recurring backend work out of hidden server crontabs and into an API-driven scheduler with visible executions. POST a schedule; CronLite evaluates it, records an execution, and posts an HMAC-signed event to your endpoint.
01
Create the job
POST a cron expression, an IANA timezone, a webhook URL and a signing secret.
POST /jobs → 201 created
02
CronLite schedules it
The leader evaluates the schedule in the job's timezone and records an execution before anything is sent.
0 9 * * 1-5 → execution persisted
03
Your endpoint gets a signed event
The dispatcher posts the event with an HMAC-SHA256 signature and a stable execution ID for deduplication.
X-CronLite-Signature
04
Failures are retried
Server errors, 429s and network failures retry with backoff. A per-URL circuit breaker stops hammering an endpoint that is down.
retry after 30s → 2m → 10m
- Simple REST API
- POST a job with a cron expression and webhook URL. That's the entire integration.
- POST /jobs
- Timezone-aware scheduling
- Full IANA timezone support. “Every weekday at 9am New York” actually means 9am New York.
- timezone: "America/New_York"
- Job tags
- Tag jobs with key-value pairs and filter with AND semantics, by team, environment, or service.
- ?tag=env:prod&tag=team:billing
- Execution ACKs
- Downstream services acknowledge delivery. Query unacknowledged executions to see what hasn't been processed.
- POST /executions/{id}/ack
- Dual dispatch modes
- Channel mode for lowest latency on one instance. DB mode with SKIP LOCKED for multi-instance HA.
- DISPATCH_MODE=channel | db
- Per-job analytics
- Optional Redis execution-count timeseries with per-job retention (0–7 days). Best-effort; never blocks the scheduler.
- analytics.retention_seconds: 86400
Your jobs, in a clear view.
New in v1.6. Search your fleet, read schedules in plain language, and follow every delivery. The Job Control Center brings everyday operations into the same service you already run.
4
Total jobs
3
Active
1
Paused
| Job | State | Schedule | Timezone |
|---|---|---|---|
| invoice-syncteam=billingTailnet · allowed | Active | Every 5 minutes | UTC |
| daily-digestteam=product | Active | At 09:00 on Monday–Friday | America/New_York |
| session-cleanupteam=platform | Paused | At 04:00 on Sunday | UTC |
| inventory-refreshteam=commerce | Active | Every 15 minutes | UTC |
4 of 4 example jobs match · explore the views or search by name
- Find your focus
- Live name search, tag and state filters, numbered pages, and saved views for your API key and namespace.
- Review, then act
- Preview bulk pause and resume before confirming. Changed jobs are skipped, and per-job results stay in the audit history.
- Follow the evidence
- Inspect persisted execution states and delivery attempts. Acknowledge terminal executions from the Runs and history screens.
- Manage your workspace
- Create and revoke API keys, preview schedules, and inspect effective settings. Existing namespace data appears when you sign in.
- See your tailnet
- Workspace → Tailscale shows the node, its key expiry, and which hosts this namespace may call. Jobs are marked Tailnet · allowed or blocked.
- Light, dark, or auto
- A redesigned admin with one-line rows that open into details, next-run times, and a saved appearance setting.
Optional. Embedded in Go. Core workflows work without JavaScript.
Enable the Admin UIReach private services. Keep CronLite off the internet.
New: CronLite can join your Tailscale tailnet with an embedded node. No Tailscale app, no sidecar, no relay. Scheduled jobs call internal services directly, and the API, Admin and MCP can run with no public port at all. Hosted schedulers need a public endpoint to call; CronLite doesn't.
https://invoices.example-tailnet.ts.net/hookTailnet · allowedMagicDNS → 100.x node address, checked, then dialledhttps://api.example.com/syncPublic · allowedNormal public path, existing SSRF guardhttps://100.101.2.3/hookBlockedIP literals never route to the tailnethttps://100.100.100.100/BlockedQuad100, 4via6 and CronLite's own addresshttps://ledger.example-tailnet.ts.net/hookBlockedNot in WEBHOOK_TAILNET_HOSTS for this namespace
Call services with no public address
Jobs can target https://my-service.<tailnet>.ts.net. Only your own tailnet's names take the tailnet path; every answer must be a Tailscale node address, and only a checked IP is dialled. Each namespace reaches only the hosts you list for it.
- Every redirect hop is routed and checked again
- Refused hosts fail at job creation with 422
- Admin marks each job Tailnet · allowed or blocked
WEBHOOK_ALLOW_TAILNET=true
WEBHOOK_TAILNET_HOSTS=billing=invoices;ops=*
TS_AUTHKEY=tskey-auth-...
TS_HOSTNAME=cronliteOpt-in. Compiled only with -tags tailscale; the default binary contains no Tailscale code and behaves exactly as before. Tested against a real tailnet.
Let your agent handle the recurring work.
If you're building with Claude, custom agents, or any MCP client, CronLite ships 10 native tools. Your agent can create, pause, trigger, and manage cron jobs through conversation. Use the Admin UI when you want a visual view.
I need a nightly job that syncs our Stripe invoices to the data warehouse, and a weekly cleanup that purges stale sessions every Sunday at 4am
resolve-schedule · tool call
"every day at 1am"
0 1 * * * → "Every day at 1:00 AM"
create-job · tool call
"stripe-invoice-sync", cron_expression: "0 1 * * *", timezone: "UTC"
Job created · id: 7f3a.. · next: tonight 1:00 AM
create-job · tool call
"session-cleanup", cron_expression: "0 4 * * 0", timezone: "UTC"
Job created · id: a2c9.. · next: Sunday 4:00 AM
Both jobs are live with HMAC signing enabled. stripe-invoice-sync runs nightly at 0 1 * * *, session-cleanup fires every Sunday at 0 4 * * 0.
Pause the stripe sync — we're migrating the warehouse this week
pause-job · tool call
id: "7f3a.."
Job paused · won't fire until resumed
Done. The cleanup job is unaffected and will still run Sunday. Say “resume stripe sync” when the migration is done.
10 MCP tools
Full CRUD plus scheduling operations. No wrapper SDK needed.
create-jobCreate a new scheduled cron joblist-jobsList all jobs with optional filtersget-jobGet job details + recent executionsupdate-jobPartially update job configdelete-jobPermanently delete a jobpause-jobPause scheduling without deletingresume-jobResume a paused jobtrigger-jobFire a job immediately, off-schedulenext-runPreview the next 5 scheduled runsresolve-scheduleNatural language → cron expression
Wire it up in 30 seconds
Stdio transport via MCP proxy
{
"mcpServers": {
"cronlite": {
"command": "./cronlite-mcp",
"env": {
"CRONLITE_URL": "http://localhost:8080",
"CRONLITE_API_KEY": "ec_..."
}
}
}
}Just HTTP.
No SDK to install and no queue to configure. Create, inspect, pause, and trigger jobs over HTTP with a namespace-scoped key; your receiver gets a structured, signed event.
Request
$ curl -X POST http://localhost:8080/jobs \
-H "Authorization: Bearer $CRONLITE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "daily-sync",
"cron_expression": "0 9 * * *",
"timezone": "UTC",
"webhook_url": "https://api.example.com/sync",
"webhook_secret": "replace-with-your-signing-secret",
"tags": {"team": "billing"}
}'Response
{
"id": "a1b2c3d4-e5f6-4890-abcd-ef1234567890",
"namespace": "default",
"name": "daily-sync",
"enabled": true,
"cron_expression": "0 9 * * *",
"timezone": "UTC",
"webhook_url": "https://api.example.com/sync",
"tags": {"team": "billing"},
"created_at": "2026-10-12T14:30:00Z",
"updated_at": "2026-10-12T14:30:00Z"
}Built to keep going, and to recover.
Not a wrapper around crontab. CronLite is a distributed scheduler with leader election, crash recovery, circuit breaking, and parallel dispatch with row locking, all backed by Postgres.
50,000 executions in a sustained-load test.
internal/dispatcher/sustained_test.go
Recurring work needs more than a happy path. CronLite's dispatcher was exercised through wave after wave of executions, repeated failures, and retries, with concurrency kept in check.
- Repeated failures & retries
- Bounded concurrency
- Cancellation tested
Leader election
Postgres advisory locks. One leader schedules; if it dies, another takes over once Postgres releases the lock. Timing depends on connection detection and retry settings. No ZooKeeper, no etcd.
pg_try_advisory_lock
Orphan reconciler
If a worker crashes mid-delivery, the execution doesn't vanish. The reconciler detects stalled executions and re-queues them automatically.
created → claimed → crashed → re-queued → delivered
Circuit breaker
Per-URL breaker. If your endpoint is down, CronLite stops hammering it; when it recovers, traffic resumes.
closed → open → half-open → closed
Parallel dispatch
Workers claim bounded batches with SELECT … FOR UPDATE SKIP LOCKED. Locked rows are skipped; concurrent delivery slots keep slow receivers separate from polling.
skip locked rows · horizontal scale
At-least-once delivery.
A stable ID for deduplication.
Restart catch-up and orphan recovery help scheduled work survive interruptions. Your receiver should deduplicate with X-CronLite-Execution-ID; retries and recovery can still deliver more than once.
On current main: renewable PostgreSQL leases and generation fencing strengthen execution ownership in both dispatch modes. Upgrading requires draining old writers and applying migration 011.
Read the ownership and upgrade notesHardened by default, observable from day one.
Every layer, from token storage to webhook delivery, is hardened by default. Prometheus metrics cover scheduler activity, delivery attempts, retries, in-flight executions, and leader election.
- HMAC-SHA256 webhook signing
- Every delivery is signed with your secret. Constant-time comparison via hmac.Equal() prevents timing attacks.
- X-CronLite-Signature: <hmac-sha256-hex>
- SSRF blocked at creation and delivery
- Webhook URLs targeting private networks (RFC 1918), loopback, link-local, carrier-grade NAT, and cloud metadata endpoints are rejected when you create the job, then every connection is checked again after DNS resolution and on each redirect. Internal hosts are opt-in.
- 10.0.0.0/8, 172.16.0.0/12, 169.254.x, 100.64.0.0/10 · WEBHOOK_ALLOWED_PRIVATE_SUFFIXES
- Scoped tailnet delivery
- Opt-in. Only your own tailnet's MagicDNS names take the tailnet path, each namespace reaches only its allowlisted hosts, and every redirect hop is checked again. IP literals, including 100.x, stay blocked.
- WEBHOOK_TAILNET_HOSTS=billing=invoices;ops=*
- Tailscale identity sign-in
- Admin sign-in from the tailnet uses WhoIs on the connection, never headers. Grants come from your tailnet policy file and are re-checked on every request; tagged devices are refused.
- github.com/djlord-it/cronlite/cap/admin
- Two-layer rate limiting
- Per-IP token bucket before auth (10 req/s default). Per-namespace token bucket after auth (100 req/s default).
- IP → Auth → Namespace → Handler
- SHA-256 token hashing
- API keys are stored as SHA-256 hashes; plaintext is shown once at creation and never persisted. Constant-time comparison via crypto/subtle.
- ec_ + 64 hex chars → sha256 → stored
- Slow loris protection
- 10s TLS handshake timeout, 30s response header timeout, and per-request context deadlines. Connection pool limits prevent exhaustion.
- ResponseHeaderTimeout: 30s, MaxIdleConns: 100
- Error sanitization
- Database errors, constraint names, and query fragments are never exposed in API responses. Unknown errors return a generic 500.
- internal_error: "internal server error"
- Namespace isolation
- Every API key is scoped to a namespace. Jobs, executions, and tags are isolated; one tenant cannot see or affect another's data.
- namespace injected at auth → enforced at query
- Credential masking & SSL warnings
- DATABASE_URL passwords and REDIS_ADDR credentials are masked in config output. Startup warns loudly if sslmode=disable.
- postgres://*** · WARNING: sslmode=disable
Prometheus metrics
Grafana, Datadog, or any Prometheus stack| Metric | Type |
|---|---|
cronlite_Scheduled executions created | counter |
cronlite_Delivery attempts, by attempt number and status class | counter |
cronlite_Webhook request latency, excluding retry backoff | histogram |
cronlite_Retry attempts, excluding the initial attempt | counter |
cronlite_Executions currently being processed | gauge |
cronlite_Difference between expected and actual scheduler ticks | histogram |
cronlite_Orphaned executions observed by the reconciler | gauge |
cronlite_Whether this instance holds the leader lock | gauge |
cronlite_Whether the embedded Tailscale node is connected | gauge |
Suggested alerts
Example rule expressions. Tune thresholds for your workload.
- DeliveryFailureswarning
rate(cronlite_ dispatcher_ delivery_ outcomes_ total{ outcome="failed"}[ 5m]) > 0.1 - SchedulerErrorswarning
increase(cronlite_ scheduler_ tick_ errors_ total[ 5m]) > 0 - LeaderLostcritical
sum(cronlite_ leader_ is_ leader) == 0 - OrphansDetectedwarning
cronlite_orphaned_ executions > 0 - TailnetDownwarning
cronlite_tailnet_ up == 0
Frequently asked questions.
Straight answers about what CronLite does, how delivery behaves when things fail, and what it takes to run it yourself.
What is CronLite?
CronLite is a free, open-source, self-hosted cron-as-a-service. A single Go service backed by PostgreSQL stores your recurring jobs and, on each cron schedule, sends an HMAC-signed HTTP POST to the webhook URL you chose, with automatic retries, Prometheus metrics, an optional Admin UI, and native MCP tools for AI agents.
What is a good self-hosted alternative to hosted cron and scheduled-webhook services?
CronLite is an open-source option you run on your own infrastructure with Docker Compose or a single Go binary plus PostgreSQL. It provides timezone-aware schedules, signed webhook delivery, retries, and execution history, without sending your job definitions or secrets to a third party, and it can call private services that a public SaaS cannot reach.
Read the guideHow is CronLite different from crontab or Linux cron?
Crontab runs shell commands on one machine and keeps no shared history; CronLite schedules HTTP webhooks from a service. Jobs live in PostgreSQL and are managed over a REST API, Admin UI, or MCP; every run is recorded with its delivery attempts; failed deliveries are retried; and several instances can share one database with automatic leader failover, so a single dead server does not silently stop your jobs.
Read the guideHow do I schedule an HTTP request or webhook on a cron schedule?
Send POST /jobs to CronLite with a name, a five-field cron expression, an IANA timezone, a webhook_url, and optionally a webhook_secret. CronLite then sends a POST to that URL on every matching minute and records each execution, which you can list with GET /jobs/{id}/executions. The same job can be created from the Admin UI or by an AI agent through the create-job MCP tool.
Read the guideDoes CronLite support timezones and per-minute schedules?
Yes. Each job has its own IANA timezone, such as America/New_York or Europe/Paris, and standard five-field cron expressions down to every minute. Schedules can also be described in plain language, for example "every weekday at 9am", and resolved to cron with POST /schedules/resolve, which also previews upcoming run times.
What happens when a webhook delivery fails?
CronLite retries up to four attempts with backoff: immediately, then after 30 seconds, 2 minutes, and 10 minutes. 5xx responses, 429 responses, and network errors are retried; other 4xx responses are not. A per-URL circuit breaker stops retry storms against an endpoint that keeps failing, and every attempt is stored for inspection.
Read the guideCan a CronLite webhook be delivered more than once?
Yes, delivery is at-least-once. Retries, restart catch-up, and orphan recovery can deliver the same execution again, so receivers should deduplicate with the X-CronLite-Execution-ID header, which stays the same across every attempt for one scheduled run.
How do I verify that a webhook came from CronLite?
Each delivery carries an X-CronLite-Signature header containing the hex HMAC-SHA256 of the raw request body, keyed with the job's webhook secret. Compute the same HMAC in your receiver and compare the two values in constant time before trusting the payload.
Read the guideCan CronLite run with high availability across multiple instances?
Yes. With DISPATCH_MODE=db, any number of instances share one PostgreSQL database. A PostgreSQL advisory lock elects one leader to run the scheduler and reconciler, all instances serve the API and dispatch webhooks using SKIP LOCKED row claims, and a follower takes over within seconds if the leader dies.
Can AI agents create and manage cron jobs with CronLite?
Yes. Every CronLite instance serves the Model Context Protocol at /mcp over Streamable HTTP, and a stdio proxy is available for clients such as Claude Desktop. Ten tools let an agent create, list, update, pause, resume, trigger, and delete jobs, check next run times, and turn natural-language schedules into cron, all scoped to the API key's namespace.
Read the guideCan CronLite call private or internal services that are not on the public internet?
Yes, explicitly. By default CronLite blocks webhooks to private, loopback, and link-local addresses to prevent SSRF. You can allow named internal hostnames with WEBHOOK_ALLOWED_PRIVATE_SUFFIXES, or build CronLite with Tailscale support so it joins your tailnet as an embedded node and calls services by their MagicDNS names without opening any port.
Read the guideDoes CronLite have a web dashboard?
Yes. Set ADMIN_ENABLED=true to serve an optional, server-rendered Admin UI at /admin. It lets you search and filter jobs, edit schedules, pause, resume, or trigger runs, review executions and delivery attempts, build schedules with a preview of the next runs, and manage API keys. It works without JavaScript and needs no separate frontend service.
Read the guideWhat do I need to run CronLite, and is it free?
You need Docker and a PostgreSQL database. Clone the repository and run ./scripts/dev.sh to start PostgreSQL, apply migrations, create an API key, and launch CronLite with the Admin UI in one step (it needs Go 1.25+); without Go, docker compose up -d starts the same stack. CronLite is free and open source under the AGPL-3.0 license and has no paid tier; you run it on your own servers or cloud.
Read the guideStart everything
with one script.
Your team has better things to build than a cron system.
CronLite is free, open-source, and runs anywhere Docker does. One binary and one Postgres give you a scheduler that handles retries, failover, and crash recovery.
./scripts/dev.sh needs Docker and Go 1.25+. It starts PostgreSQL, applies migrations, saves an API key to .cronlite.local.env, and serves the API and Admin UI on localhost:8080. Re-run it any time; --reset wipes the local database.
No Go toolchain? Use Docker Compose and copy the printed key:
$ docker compose up -d$ docker compose exec cronlite cronlite create-key default local-dev# Clone and start everything: PostgreSQL, migrations,# an API key, and CronLite with the Admin UI$ git clone https://github.com/djlord-it/cronlite.git$ cd cronlite$ ./scripts/dev.sh # In a second terminal, load the saved key and create a job$ source .cronlite.local.env$ curl -X POST http://localhost:8080/jobs \ -H "Authorization: Bearer $CRONLITE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name":"ping","cron_expression":"*/5 * * * *", "timezone":"UTC","webhook_url":"https://httpbin.org/post", "webhook_secret":"replace-with-your-signing-secret"}'