A tiny cron daemon for low-memory Linux boxes (VPS, NAS, single-board computers, PRoot jails) that runs jobs on a schedule and pushes an alert to Telegram, Discord, ntfy.sh, or any generic webhook when a job fails.
Go 1.24, standard library only (plus robfig/cron/v3 and gopkg.in/yaml.v3).
Static binary, ~8 MB, designed to run at a handful of MB of RAM.
- Cron-style scheduling via
robfig/cron/v3(see its standard 5-field syntax plus descriptors like@every 10s,@daily,@hourly). - Per-job timeout with process-group cleanup: on expiry the whole process group gets SIGTERM, then SIGKILL shortly after.
- Overlap protection: a job never runs twice concurrently; a skipped fire is logged.
- stdout/stderr captured with a 4096-byte tail that ships with every alert.
- Alerts on non-zero exit or failed spawn (exit code 1,
started=false). - Channel support: Telegram, Discord webhooks, ntfy, and generic HTTP (custom method, headers, optional Go text/template body).
- Durable alert queue: deliveries that fail are persisted to
queue.jsonland retried with exponential backoff until they succeed or age out (max_age). Survives restarts. - History log: every run (success or failure) is appended to
history.jsonl, rotated on boot to stay within 1 MB. - Three commands:
serve(daemon),run(one-shot),status(history table). - Graceful shutdown: on SIGTERM/SIGINT in-flight jobs are cancelled and the alert queue is drained (up to 5s) before exit 0.
make build
./bin/cronping serve -config testdata/example.yamlWrite your own config (cronping.yaml) and test it:
./bin/cronping run -config cronping.yaml -job mybackup
echo $? # job's exit code
./bin/cronping status -config cronping.yaml -n 5YAML or JSON. All fields optional unless marked required.
state_dir: /var/lib/cronping # where history.jsonl + queue.jsonl live (default: data)
jobs: # required: at least one job
- name: nightly-backup # required, must be unique
schedule: "0 3 * * *" # required, robfig/cron syntax
command: /usr/local/bin/backup.sh # required
args: ["--quiet"]
timeout: 300s # default 10m; "0" disables nothing, resets to default
alert: # required: at least one channel below
telegram: # optional
bot_token: "123:ABC"
chat_id: "12345"
discord: # optional
webhook_url: "https://discord.com/api/webhooks/..."
ntfy: # optional
url: "https://ntfy.sh" # default https://ntfy.sh
topic: "server-alerts"
generic: # optional list
- url: "http://host:9090/hook" # required
method: POST # default POST
headers: { "X-Api-Key": "secret" }
body_template: '{"job":"{{.Job}}","exit":{{.ExitCode}}}' # Go text/template
queue:
retry_initial: 5s # default 5s
retry_max: 5m # default 5m
backoff_factor: 2.0 # default 2.0
max_age: 24h # default 24h; drop undelivered after thisbody_template is evaluated against these fields: Job, Host, Command,
ExitCode, Started, Duration, Timestamp, Stderr, Stdout. If the
template is absent or fails to render, the default JSON body below is sent.
{
"job": "nightly-backup",
"host": "myhost",
"command": "/usr/local/bin/backup.sh --quiet",
"exit_code": 7,
"started": true,
"duration_sec": 12.34,
"timestamp": "2026-08-12T14:00:00Z",
"stderr": "last 4096 bytes of stderr"
}Telegram, Discord, and ntfy each get their own native body format; stderr
tail and exit code are always included.
cronping <serve|status|run> [flags]
| Command | Flags | Behavior |
|---|---|---|
serve |
-config PATH (default cronping.yaml) |
Run the daemon; register jobs, run on schedule, queue alerts, drain on SIGTERM/SIGINT. |
run |
-config PATH, -job NAME |
Execute one job once and exit with its exit code. Alerts are sent synchronously. |
status |
-config PATH, -n N (default 20) |
Print the N most recent history records as a table (JOB EXIT STARTED DURATION TIME ALERT). |
make build # bin/cronping (host)
make build-arm64 # bin/cronping-linux-arm64
make build-amd64 # bin/cronping-linux-amd64
make cross # both static binaries
make test # unit + integration tests
make vet
make lint # golangci-lint if presentA working unit file is included at deploy/cronping.service. Install it:
sudo cp deploy/cronping.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now cronpingLogs:
journalctl -u cronping -fRun it manually once to validate your config first:
/usr/local/bin/cronping run -config /etc/cronping.yaml -job nightly-backupserve is a plain process with no extra daemonization — run it directly inside
a proot jail (no systemd required):
proot -R <rootfs> ./cronping serve -config /etc/cronping.yamlKeep state_dir inside the jail (e.g. /var/lib/cronping) so history and the
retry queue survive restarts. The jail needs outbound network to reach your
webhook hosts; no listening ports are required.
For environments where a long-running process is awkward, use the one-shot mode from cron or a wrapper script. The exit code is the job's:
./cronping run -config /etc/cronping.yaml -job nightly-backupSet GOMAXPROCS=1 if you want to cap scheduler overhead to a single thread:
GOMAXPROCS=1 ./cronping serve -config /etc/cronping.yaml- RAM: the daemon itself is a few MB.
servelogs resident RSS at boot (msg="cronping serving" ... rss_kb=5760) so you can see the baseline. - History:
history.jsonlis truncated on start to the newest ~1 MB. Dump it withstatusor read the file directly. - Queue: undelivered alerts sit in
queue.jsonlwith the retry schedule above. It is append-only and safe totail -f. - Logs: structured text (
slog) on stderr; grep forlevel=ERRORor a job name to follow one job.