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.

Schedule console

Plain English or a five-field cron expression.

0
minute
9
hour
*
day
*
month
1-5
weekday

At 9:00 AM on weekdays

Next runs

  1. —
  2. —
  3. —
  4. —
  5. —

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.

  1. 01

    Create the job

    POST a cron expression, an IANA timezone, a webhook URL and a signing secret.

    POST /jobs → 201 created

  2. 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

  3. 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

  4. 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.

Job Control Centerworkspace / default
Interactive preview · example data

4

Total jobs

3

Active

1

Paused

Example jobs in the Admin Job Control Center
JobStateScheduleTimezone
invoice-syncteam=billingTailnet · allowedActiveEvery 5 minutesUTC
daily-digestteam=productActiveAt 09:00 on Monday–FridayAmerica/New_York
session-cleanupteam=platformPausedAt 04:00 on SundayUTC
inventory-refreshteam=commerceActiveEvery 15 minutesUTC

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 UI

Reach 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.

Delivery routingnamespace / billing
Example decisions
  • https://invoices.example-tailnet.ts.net/hookTailnet · allowedMagicDNS → 100.x node address, checked, then dialled
  • https://api.example.com/syncPublic · allowedNormal public path, existing SSRF guard
  • https://100.101.2.3/hookBlockedIP literals never route to the tailnet
  • https://100.100.100.100/BlockedQuad100, 4via6 and CronLite's own address
  • https://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
.env
WEBHOOK_ALLOW_TAILNET=true
WEBHOOK_TAILNET_HOSTS=billing=invoices;ops=*
TS_AUTHKEY=tskey-auth-...
TS_HOSTNAME=cronlite

Opt-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.

Claude · CronLite MCPExample conversation

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 job
  • list-jobsList all jobs with optional filters
  • get-jobGet job details + recent executions
  • update-jobPartially update job config
  • delete-jobPermanently delete a job
  • pause-jobPause scheduling without deleting
  • resume-jobResume a paused job
  • trigger-jobFire a job immediately, off-schedule
  • next-runPreview the next 5 scheduled runs
  • resolve-scheduleNatural language → cron expression

Wire it up in 30 seconds

Stdio transport via MCP proxy

claude_desktop_config.json
{
  "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
See the sustained-load test

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 notes

Hardened 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
Selected CronLite Prometheus metrics
MetricType
cronlite_scheduler_jobs_triggered_totalScheduled executions createdcounter
cronlite_dispatcher_delivery_attempts_totalDelivery attempts, by attempt number and status classcounter
cronlite_dispatcher_webhook_duration_secondsWebhook request latency, excluding retry backoffhistogram
cronlite_dispatcher_retry_attempts_totalRetry attempts, excluding the initial attemptcounter
cronlite_dispatcher_events_in_flightExecutions currently being processedgauge
cronlite_scheduler_tick_drift_secondsDifference between expected and actual scheduler tickshistogram
cronlite_orphaned_executionsOrphaned executions observed by the reconcilergauge
cronlite_leader_is_leaderWhether this instance holds the leader lockgauge
cronlite_tailnet_upWhether the embedded Tailscale node is connectedgauge

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 guide

How 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 guide

How 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 guide

Does 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 guide

Can 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 guide

Can 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 guide

Can 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 guide

Does 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 guide

What 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 guide

Start 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
Start CronLite, then register a webhook
# 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"}'