cronaute
cronaute
A cron daemon for containers: one static binary, one user, no spool, and a read-only dashboard.
It is a reimplementation of webdevops/go-crond in Crystal, cut down to what a container actually needs. The crontab is the only source of truth — it is edited out of band, in a mounted volume or by gitops, and reloaded on change. Nothing in the HTTP surface writes to it.
What it does not do
Stated first, because the omissions are the design:
- No multi-user. No user column in the crontab, no per-user spool, no
fork/setuid, no privilege drop. Every job runs under the account the daemon runs as. - No reaping. The daemon is expected to run under an init that is PID 1 — tini, typically. Orphans a job leaves behind are reparented there and reaped there.
Process#waiton a direct child is all the child handling this does, and that is where the exit code and the duration come from. - No editing from the UI. No
POST, no form, no database. Every HTTP method other thanGETis answered405. - No persistence. Execution state lives in memory. On restart, occurrences are recomputed from the crontab; the full history is in the JSON log.
Running it
cronaute /etc/crontab --include=/etc/cron.d --run-parts-daily=/etc/cron.daily
In an image, as a child of an init process:
ENTRYPOINT ["tini", "-w", "--", "entrypoint"]
where entrypoint ends with exec cronaute "$@".
Exit codes: 0 on a clean shutdown, 2 on a usage mistake, 1 on anything else — so a wrapper can tell "I invoked it wrong" from "it broke".
Crontab format
Standard five fields, single-user, so no user column:
SHELL=/bin/sh
PATH=/usr/local/bin:/usr/bin:/bin
*/15 * * * * /usr/bin/backup --incremental
0 3 * * 0 /usr/bin/backup --full
Blank lines and # comments are skipped. KEY=VALUE assignments apply to every job declared after them and are passed to the command's environment; one layer of matching quotes is stripped.
Seconds
A sixth field may be given, in which case the leading one is seconds:
*/30 * * * * * /usr/bin/tick
The split between schedule and command is resolved by what actually parses as a cron field, so 0 0 * * * echo hi reads as five fields and echo hi.
Shorthands
@yearly, @annually, @monthly, @weekly, @daily, @midnight and @hourly behave as they do in Vixie cron. @reboot is rejected: its next run cannot be predicted.
@every takes a Go-style duration and is anchored on when the crontab was loaded, which is what it means in go-crond:
@every 5m /usr/bin/poll
@every 1h30m /usr/bin/sweep
Durations are a run of <value><unit> with units ms, s, m and h. Anything not consumed in full is rejected rather than truncated: 1h30 and 5ns raise instead of quietly meaning something else.
Day of month and day of week
When both are restricted, a day matches if it satisfies either — the standard cron union, not an intersection. A field counts as unrestricted when it begins with *, so 0 0 */2 * 5 fires on Fridays only.
run-parts
Runs every executable of a directory, in alphabetical order, skipping names that are not run-parts names — alphanumerics, underscores and hyphens, and notably no dot, which is what keeps backup.dpkg-old from running beside backup.
| Flag | Schedule |
|---|---|
--run-parts-1min=DIR |
* * * * * |
--run-parts-15min=DIR |
*/15 * * * * |
--run-parts-hourly=DIR |
0 * * * * |
--run-parts-daily=DIR |
0 0 * * * |
--run-parts-weekly=DIR |
0 0 * * 0 |
--run-parts-monthly=DIR |
0 0 1 * * |
The named periods map to cron lines rather than to durations, because "weekly" and "monthly" are calendar notions: seven days after the last run drifts off the day of the week, and a month has no fixed length.
For anything else, --run-parts=TIMESPEC:PATH sweeps a directory on a fixed interval aligned on the wall clock, counted from local midnight — 1h fires on the hour, 15m on the quarters:
cronaute --run-parts=10s:/etc/periodic/fast --run-parts=1h30m:/etc/periodic/slow
The argument splits on the first colon, so a path may contain more. An interval longer than a day is rejected: alignment is counted from midnight, so it would have no grid to sit on and would silently become daily. Use --run-parts-weekly, --run-parts-monthly or a cron line instead.
Note the difference from @every, which reads alike and does not behave alike: @every 1h fires one hour after the last run, wherever that falls; --run-parts=1h:DIR fires on the hour.
Options
--include=DIR Load every file of DIR as a crontab (repeatable)
--run-parts-<period>=DIR See above (repeatable)
--run-parts=TIMESPEC:DIR Sweep DIR every TIMESPEC (repeatable)
--overlap=allow|skip What to do when a run is still going (default allow)
--host=ADDR HTTP bind address (default 0.0.0.0)
--port=PORT HTTP port (default 8080)
--max-concurrent-jobs=N How many jobs may run at once (default 16)
--healthcheck Probe a running daemon and exit 0 or 1
--healthcheck-path=PATH What --healthcheck asks for (default /live)
--verbose Log scheduling decisions, not only executions
--licenses Print the bundled third-party notices
--version Print the version banner
--dumpversion Print the bare version number
--help Print the usage
Crontabs are positional arguments. --include applies the same naming rule as Debian's /etc/cron.d — no dot — but does not require the executable bit: those are crontabs, not scripts.
Overlap
--overlap=allow is the default because it is what Vixie cron and go-crond do, and a drop-in replacement that quietly skipped executions would be a trap. Use --overlap=skip for a job that can outrun its own period, where the default piles up processes until something gives; skipped occurrences are logged.
Not carried over from go-crond
--allow-unprivileged has no meaning here — there is nothing to drop. --log.json is not a flag because the log is always JSON. --server.bind=ADDR:PORT is split into --host and --port.
Time zones
Schedules are computed in the zone TZ names, on the wall clock, the way a system cron does. TZ absent, empty or unknown falls back to UTC — not the host zone, so that the same image schedules identically wherever it runs.
Daylight saving is handled by the pinned cron_parser fork:
- An occurrence whose wall clock a zone skipped runs at the instant the clock jumped to, where Vixie runs it — a
02:30job runs at03:00on the spring-forward day inEurope/Paris. - An occurrence inside a repeated hour runs once, not twice.
#nextis always strictly after the time it is given, in every zone, so the scheduler cannot loop on a stationary occurrence.
The scheduler carries its own guard for that last point anyway, and logs an error if a schedule ever stops advancing.
HTTP
Five GET routes, on port 8080 by default. Anything else is 404; any other method is 405.
| Route | What it serves |
|---|---|
/ |
Read-only dashboard: schedule, command, source, last run with its exit code and duration, next run, counters |
/api/jobs |
The same as JSON |
/metrics |
Prometheus text format |
/health |
Readiness — is the configuration good? |
/live |
Liveness — is the tick loop turning? |
Readiness and liveness are not the same question
They trigger opposite reactions, which is the whole reason there are two:
-
/healthanswers200once a crontab has been loaded without error and503while the last attempt failed, with a body carryingjobs_loaded,last_reload_ok,last_reload_atandlast_reload_error. A failing readiness probe takes an instance out of service. That is the right reaction to a crontab pushed broken into the volume: restarting would not fix the file, and it would destroy the previously loaded, working schedule that is still running jobs — turning a configuration mistake into an outage. -
/liveanswers200while the scheduler has completed a pass within the last 30 seconds,503otherwise, withlast_tick_atandgrace_seconds. A failing liveness probe restarts the container, and that is the right reaction to the failure it catches: a wedged or dead tick fiber leaves a process that answers every request, a crontab that parses, a dashboard that renders — and not one job ever starting again. From outside, that is indistinguishable from a daemon with nothing to do.It says nothing about the crontab. A daemon whose configuration is broken is not stuck.
In Kubernetes, where the two are separate settings:
readinessProbe:
httpGet: { path: /health, port: 8080 }
livenessProbe:
httpGet: { path: /live, port: 8080 }
periodSeconds: 30
failureThreshold: 3
Docker HEALTHCHECK
Docker has a single health state, so it cannot express both. The image already carries one:
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["cronaute", "--healthcheck"]
Two things about that line:
- The binary probes itself. The released image is
gcr.io/distroless/static-debian12— no shell, nocurl, nowget. AHEALTHCHECK CMD curl …cannot work there, so--healthcheckperforms the request and exits0or1. It reads--hostand--port, substituting the loopback when the daemon is bound to0.0.0.0. - It asks
/live, not/health. Docker's health state is commonly wired to something that restarts the container: Swarm reschedules an unhealthy task, and the various autoheal sidecars restart it. Restarting on a broken crontab would destroy the working schedule still in force./liveonly goes red on a stopped tick loop, which a restart does fix.
If nothing in your chain restarts on unhealthy — plain docker compose, where the state is a flag and a depends_on: service_healthy gate — then /health is the more informative one:
CMD ["cronaute", "--healthcheck", "--healthcheck-path=/health"]
Either way, the crontab's state is best watched through cronaute_last_reload_ok in the metrics, which is where a broken configuration belongs: it is an alert, not a restart.
Metrics exposed: cronaute_build_info, cronaute_last_reload_ok, cronaute_jobs_loaded, cronaute_last_reload_timestamp_seconds, and per job cronaute_job_next_run_timestamp_seconds, cronaute_job_last_run_timestamp_seconds, cronaute_job_last_duration_seconds, cronaute_job_last_exit_code, cronaute_job_running, cronaute_job_runs_total and cronaute_job_failures_total.
Logging
One JSON object per line on stdout — the container is the rotation, the shipper and the retention policy. Every execution leaves a record, and nothing else persists it:
{"ts":"2026-08-09T14:38:39.015Z","level":"info","source":"cronaute.executor",
"message":"job finished","job":"/etc/crontab#0\t*/15 * * * *\t/usr/bin/backup",
"label":"/usr/bin/backup","schedule":"*/15 * * * *","origin":"/etc/crontab:4",
"start":"2026-08-09T14:38:39.004Z","end":"2026-08-09T14:38:39.015Z",
"exit_code":0,"duration_ms":10.9}
source is the component that emitted the line and origin the crontab line the job came from — deliberately two names, since a key written twice in one JSON object is not an error and a parser simply keeps the last one.
A job's stdout and stderr are logged too, bounded so that a command writing without end cannot take the daemon with it. A process killed by a signal reports 128 + signal; a command that could not be launched reports 127.
Reloading
The crontab files and --include directories are watched, and a change reloads them. Reloading is atomic: everything is read and parsed before the current set is replaced, so a broken crontab leaves the daemon running exactly what it was running, and only flips /health to 503.
Execution history survives a reload for every job whose identity is unchanged — identity being its file, schedule and command, deliberately not its line number, so that inserting a line at the top of a crontab does not blank the dashboard for every job below it.
Watching is done by polling rather than inotify. inotify is Linux-only, and it handles the container case worst: a bind mount replaced wholesale, or a file swapped by rename, produces events a watch on the old inode never sees.
Building
mise dev:deps # install dependencies
mise dev:build # build bin/cronaute
mise dev:spec # run the suite
mise dev:spec-mt # run it multi-threaded
mise dev:format-check # formatting
mise dev:ameba # static analysis
mise dev:docs # crystal doc
mise release:static # static Linux binaries, amd64 and arm64, via Docker
mise pins the compiler; the CI runs these same tasks.
The binary embeds the third-party notices it owes, assembled at build time from licenses.manifest and licenses-spdx/ — cronaute --licenses prints them. Every task that compiles depends on that assembly, crystal doc included, since doc runs the macros too.
cronaute
- 0
- 0
- 0
- 0
- 5
- about 3 hours ago
- August 9, 2026
MIT License
Sun, 09 Aug 2026 15:15:55 GMT