A lightweight Redis-backed task scheduler and HTTP worker written in Rust. Thermite processes distributed tasks with support for both one-time and periodic (cron-scheduled) execution.
Thermite accepts tasks over HTTP or fetches them from another service, stores them in Redis, and executes them when they are due by sending an HTTP POST request to the target task URL. It also supports periodic jobs using cron expressions.
In short: it's a small, self-hosted alternative to things like Celery Beat / delayed-job queues, where "task execution" simply means "POST to a webhook URL at the scheduled time," with cron recurrence, retries, and a dead-letter queue built in.
Thermite runs in one of two modes:
receivermode: exposes an HTTP API for submitting one or more tasks.fetchermode: periodically pulls tasks from a remote endpoint and enqueues them.
Once a task is in Redis, Thermite:
- stores it in a sorted set using
scheduled_atas the score, - polls for due tasks,
- executes each due task by
POSTing to itstaskURL, - re-enqueues periodic tasks with their next cron-based run time,
- retries failed deliveries with exponential backoff and eventually moves exhausted tasks to a Redis dead-letter queue.
Both run modes share the same internal pipeline:
POST /submit-task ─┐
POST /submit-tasks ┼─► validate ─► Redis ZSET "task_queue" ─► dispatcher ─► in-memory channel ─► worker ─► HTTP POST to task URL
GET FETCH_URL ──┘ (fetcher mode, every 10s) (1s poll) (capacity 32) │
failure ◄─────┘
│
requeue with backoff (ZADD) ◄── retries left? ────┘
push to LIST "dead_letter_queue" ◄── retries exhausted
- Intake. Tasks arrive via the HTTP API (
receivermode) or by pollingFETCH_URLevery 10 seconds (fetchermode). Every task is validated before it is accepted (see Validation rules). - Storage. Accepted tasks are serialized to JSON and stored in a Redis sorted set named
task_queue, scored byscheduled_at(Unix seconds). Because the serialized task JSON is the set member, re-submitting the exact same task is idempotent — Redis deduplicates it. - Dispatch. A background loop checks the queue once per second for tasks whose score is
<= now(ZREVRANGEBYSCORE task_queue now -inf). The due task is removed from the set (ZREM) and handed to the worker through an in-memory Tokio channel with capacity 32. - Periodic rescheduling. If the dequeued task is
periodic(and not a retry), its next run time is computed fromcron_scheduled_atand it is immediately re-enqueued — before execution. This means a periodic schedule keeps advancing even if an individual run fails. - Execution. The worker sends
POST {task.task}with a JSON body of{"task_id": "...", "args": {...}}using an HTTP client with a 15-second timeout. - Failure handling. Any non-2xx status, connection error, or timeout counts as a failure. The task's
retry_countis incremented,last_erroris recorded, and it is requeued with exponential backoff. When retries are exhausted, the task is appended to the Redis listdead_letter_queue, inspectable viaGET /dead-letter-tasks.
| Key | Type | Contents |
|---|---|---|
task_queue |
Sorted set | Pending tasks; member = task JSON, score = scheduled_at |
dead_letter_queue |
List | Tasks that exhausted all retries, oldest first |
You can inspect these directly with redis-cli ZRANGE task_queue 0 -1 WITHSCORES and redis-cli LRANGE dead_letter_queue 0 -1.
Each task contains:
| Field | Description |
|---|---|
id |
Unique task identifier |
name |
Human-readable task name |
description |
Task description |
category |
non_periodic or periodic |
priority |
Priority label |
task |
Target URL to call when the task runs |
scheduled_at |
Unix timestamp for the next run |
cron_scheduled_at |
Cron expression used for periodic jobs |
args |
Optional JSON payload passed through to the target URL |
max_retries |
Optional retry limit before the task is moved to the dead-letter queue (defaults to THERMITE_MAX_RETRIES or 3) |
retry_count |
Current retry attempt count tracked by Thermite |
last_error |
Last delivery error recorded for retry/dead-letter inspection |
is_retry |
Set to true by Thermite when the current run is a retry (omit when submitting) |
Note:
cron_scheduled_atis required in the payload even fornon_periodictasks, but it is only evaluated whencategoryisperiodic.
Every submitted (or fetched) task is validated before enqueueing. A task is rejected with a 400 Bad Request if:
- the
taskURL is not a validhttp://orhttps://URL with a host, - the host is
localhost, ends in.localhost, or is a private/loopback/link-local/broadcast/multicast IP literal (always blocked, regardless of configuration — this is SSRF protection), - the host is not in
THERMITE_ALLOWED_HOSTS(when that variable is set; subdomain matches are allowed), - the scheme is
http://whileTHERMITE_REQUIRE_HTTPSis enabled, categoryisperiodicbutcron_scheduled_atis not a parseable cron expression.
Note: because localhost targets are always rejected, point tasks at a real hostname (e.g.
host.docker.internalfrom Docker, or a LAN/DNS name) when developing locally.
When a task runs, Thermite sends a request like:
POST /jobs/reminder HTTP/1.1
Content-Type: application/json
{
"task_id": "123",
"args": {
"email": "user@example.com"
}
}Your endpoint must respond within 15 seconds. Any 2xx status marks the task as successfully executed; any other status (or a timeout/connection error) triggers the retry flow.
All endpoints return JSON. Unknown routes return 404 {"error": "Not Found"}. If THERMITE_API_KEY is set, every endpoint except /healthz requires either an x-api-key: <key> header or Authorization: Bearer <key>, otherwise it returns 401 {"error": "Unauthorized"}.
Submit a single task. The request body is one task object.
200 OK—{"status": "Task submitted"}400 Bad Request—{"error": "..."}(validation failed)401 Unauthorized— missing/invalid API key
curl -X POST http://localhost:8080/submit-task \
-H "Content-Type: application/json" \
-H "x-api-key: $THERMITE_API_KEY" \
-d '{
"id": "task-1",
"name": "Send reminder",
"description": "Send a reminder email",
"category": "non_periodic",
"priority": "high",
"task": "https://jobs.example.com/reminder",
"scheduled_at": 1893456000,
"cron_scheduled_at": "0 0 * * *",
"args": {"user_id": 42, "channel": "email"}
}'Or use the sample payload file included in this repo:
curl -X POST http://localhost:8080/submit-task \
-H "Content-Type: application/json" \
-H "x-api-key: $THERMITE_API_KEY" \
-d @samples/submit-task.sample.jsonSubmit multiple tasks in one request. The request body is an array of task objects. Each task is validated and enqueued independently — one bad task does not reject the whole batch.
200 OK— all tasks accepted:{"status": "Tasks submitted", "submitted": 2}400 Bad Request— some tasks failed validation:{"status": "Some tasks failed validation", "submitted": 1, "failed": [{"id": "bad-task", "error": "..."}]}500 Internal Server Error— a server-side failure (e.g. Redis unavailable) occurred for at least one task
curl -X POST http://localhost:8080/submit-tasks \
-H "Content-Type: application/json" \
-H "x-api-key: $THERMITE_API_KEY" \
-d '[{"id": "task-1", ...}, {"id": "task-2", ...}]'Inspect tasks that exhausted retries and were moved to the dead-letter queue.
200 OK—{"tasks": [...], "count": 2}(each entry includesretry_countandlast_error)
curl -H "x-api-key: $THERMITE_API_KEY" http://localhost:8080/dead-letter-tasksLiveness probe. Always returns 200 {"status": "ok"} and never requires authentication.
{
"id": "task-1",
"name": "Send reminder",
"description": "Send a reminder email",
"category": "non_periodic",
"priority": "high",
"task": "https://jobs.example.com/reminder",
"scheduled_at": 1893456000,
"cron_scheduled_at": "0 0 * * *",
"args": {
"user_id": 42,
"channel": "email"
}
}Failed deliveries are retried with exponential backoff:
delay = THERMITE_RETRY_BASE_DELAY_SECS * 2^(retry_count - 1)
With the defaults (THERMITE_MAX_RETRIES=3, THERMITE_RETRY_BASE_DELAY_SECS=30) a failing task is retried after roughly 30s, then 60s, then 120s, and moved to dead_letter_queue on the next failure after the 3rd retry. Per-task max_retries overrides the global default.
Retries interact with periodic tasks as follows: the next cron occurrence is already re-enqueued when the task is first dequeued, so a retry only re-runs the failed occurrence — it never shifts or duplicates the cron schedule.
Set category to periodic and provide a cron expression in cron_scheduled_at:
- 5-field (
minute hour day-of-month month day-of-week, e.g."0 9 * * 1-5") or 6-field (with a leading seconds field, e.g."0 30 9 * * 1-5") expressions are accepted. A 5-field expression is automatically expanded with a0seconds field. - Schedules are evaluated in UTC.
- After each run, the next occurrence is computed from the current time and the task is re-enqueued — the
scheduled_atyou submit only determines the first run.
In fetcher mode Thermite does not start the HTTP API. Instead it polls FETCH_URL with a GET request every 10 seconds, expecting a 200 response whose body is a JSON array of task objects (same schema as /submit-tasks). Each task is validated and enqueued exactly as if it had been submitted over HTTP, and the shared dispatcher/worker pipeline executes them.
export FETCH_URL=https://scheduler.example.com/api/tasks/periodic
cargo run -- --mode fetcher --redis-url redis://localhost:6379Thermite requires Redis 5.0 or higher. Redis 5.0+ provides support for sorted sets and streams that Thermite depends on for task scheduling and queue management.
docker compose up --buildThis starts:
- the Thermite worker on
http://localhost:8080(bound to0.0.0.0:8080inside the container) - Redis on
localhost:6379
cargo run -- --mode receiver --redis-url redis://localhost:6379To run in fetcher mode:
export FETCH_URL=http://localhost:8000/api/tasks/periodic
cargo run -- --mode fetcher --redis-url redis://localhost:6379Useful CLI flags (see cargo run -- --help): --mode/-m (receiver or fetcher), --redis-url/-r, --tasks-url/-t (bind address in receiver mode).
Thermite uses these environment variables and CLI options:
| Name | Purpose | Default |
|---|---|---|
REDIS_URL |
Redis connection string | redis://localhost:6379 |
TASKS_URL |
Address the HTTP server binds to in receiver mode |
127.0.0.1:8080 |
FETCH_URL |
Endpoint to poll for tasks in fetcher mode |
required for fetcher |
THERMITE_API_KEY |
Optional API key required on POST /submit-task, POST /submit-tasks, and GET /dead-letter-tasks via x-api-key or Authorization: Bearer ... |
unset |
THERMITE_ALLOWED_HOSTS |
Optional comma-separated allowlist of task target hosts/domains such as jobs.example.com,hooks.example.org |
unset |
THERMITE_REQUIRE_HTTPS |
If set to true, 1, yes, or on, only https:// task targets are accepted |
unset |
THERMITE_MAX_RETRIES |
Default retry count before a failed task is moved to the Redis dead-letter queue | 3 |
THERMITE_RETRY_BASE_DELAY_SECS |
Base retry delay in seconds; Thermite applies exponential backoff from this value | 30 |
RUST_LOG |
Log level / filter for structured logs, e.g. info or thermite=debug,actix_web=info |
info |
--mode |
Run mode: receiver or fetcher |
receiver |
Thermite emits structured logs via tracing. Tune verbosity with RUST_LOG, e.g.:
RUST_LOG=thermite=debug,actix_web=info cargo run -- --mode receiverKey lifecycle events (task enqueued, dequeued, executed, retried, dead-lettered, unauthorized requests) are logged with the task_id attached, so you can trace a task end to end.
Thermite implements multiple layers of security to protect task execution and data integrity:
- API Key Protection: Set
THERMITE_API_KEYto require authentication on task submission endpoints (/submit-task,/submit-tasks) and the dead-letter endpoint (/dead-letter-tasks) - Authorization Methods: Supports both
x-api-keyheader andAuthorization: Bearer <token>formats - Impact: Prevents unauthorized task submission and ensures only trusted clients can enqueue tasks
- Built-in SSRF Protection:
localhost,*.localhost, and private/loopback/link-local/broadcast/multicast IP targets are always rejected, with no opt-out - Host Allowlisting: Use
THERMITE_ALLOWED_HOSTSto restrict task execution to a whitelist of approved domains (e.g.,jobs.example.com,hooks.example.org); subdomains of listed hosts are also accepted - HTTPS Enforcement: Enable
THERMITE_REQUIRE_HTTPS=trueto reject any task with non-HTTPS target URLs - Impact: Prevents execution of tasks pointing to untrusted or internal hosts, protects against SSRF attacks
- Secure Connection: Always use
redis://with TLS support orrediss://for production deployments - Redis ACL: Configure Redis username and password in the connection string (e.g.,
redis://user:password@host:6379) - Network Isolation: Keep Redis on a private network, not exposed to the public internet
- Impact: Prevents unauthorized access to task queues and sensitive task data
- Payload Encryption: Encrypt sensitive arguments in the
argsfield at the application level before submission - Audit Logging: Use
RUST_LOGwith appropriate level (e.g.,thermite=info) to monitor task execution - Dead-Letter Queue Access: Protect access to
GET /dead-letter-tasksendpoint with API key authentication - Impact: Protects sensitive task parameters and enables compliance auditing
- Submit or fetch tasks.
- Thermite stores them in Redis.
- When
scheduled_atis due, Thermite executes the target URL. - If the task is
periodic, it computes the next run fromcron_scheduled_atand requeues it. - If execution keeps failing after the configured retries, the task is stored in
dead_letter_queueand can be reviewed viaGET /dead-letter-tasks.
Thermite is deliberately simple; be aware of these properties before relying on it:
- At-most-once per scheduled occurrence, plus retries. A task is removed from
task_queuebefore it is executed. If the process crashes between dequeue and delivery, that occurrence is lost (periodic tasks simply run again at their next cron occurrence). - No in-flight recovery. There is no visibility timeout or heartbeat for executing tasks, so a crash mid-execution does not requeue the task.
- Latest-due first. The dispatcher picks the due task with the highest score (most recently due) first;
priorityis stored as a label but does not influence ordering. - Single in-memory channel. Due tasks flow through one channel with capacity 32 per process; for higher throughput, scale out worker processes against the same Redis.
- Install the Heroku CLI and log in:
heroku login
heroku container:login- Create the app:
heroku create thermite- Build and push the image:
docker build --platform linux/amd64 -t registry.heroku.com/thermite/worker .
docker push registry.heroku.com/thermite/worker- Set the stack and release:
heroku stack:set container -a thermite
heroku container:release worker -a thermite- Open the app or inspect logs:
heroku open -a thermite
heroku logs --tail -a thermite