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.
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 runs under an init that is PID 1 —
tini, which the published image carries.
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.
cronaute /etc/crontab --include=/etc/cron.d --run-parts-daily=/etc/cron.dailyThe published image already runs it under an init, and needs nothing added:
ENTRYPOINT ["/sbin/tini", "-w", "-g", "--", "cronaute"]-w so tini waits for its children before exiting, -g so a docker stop
reaches the jobs in flight and not only the daemon. Building your own image from
another base, keep that shape — cronaute reaps its own children and nothing
else, so an orphan a job leaves behind needs a PID 1 that will collect it.
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".
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 --fullBlank 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.
SHELL is read rather than merely passed along: the command runs under
$SHELL -c, and under /bin/sh -c when the crontab names none. A SHELL that
does not exist reports 127, like any command that cannot be launched.
A sixth field may be given, in which case the leading one is seconds:
*/30 * * * * * /usr/bin/tickThe 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.
@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/sweepDurations 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.
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.
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/slowThe 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.
--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)
--tls-cert=FILE Serve TLS with this certificate chain (needs --tls-key)
--tls-key=FILE Private key of --tls-cert
--tls-client-ca=FILE Require a client certificate signed by this CA
--max-concurrent-jobs=N How many jobs may run at once (default 16)
--job-timeout=TIMESPEC Terminate a job still going after this (default 24h, 0s to never)
--healthcheck Probe a running daemon and exit 0 or 1
--healthcheck-path=PATH What --healthcheck asks for (default /live)
--healthcheck-host=NAME Name --healthcheck connects to and checks the certificate against
--verbose Log scheduling decisions, not only executions
--licenses Print the bundled third-party notices
--version Print the version banner
--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=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.
At most --max-concurrent-jobs run at once. An occurrence that comes due with
every slot busy is dropped, not queued, and logged as such — queuing it
would pile one waiting fibre per occurrence behind a job that may never return
its slot, and release them all in a burst if it ever did.
For the same reason a job does not run forever: --job-timeout (24 h by
default) sends it SIGTERM, then SIGKILL ten seconds later, and the run is
recorded as 143 or 137 like any other signalled process. Vixie and go-crond
never kill anything, and neither has a pool where one stuck job can starve the
rest; use --job-timeout=0s for the restore-sized exception.
The signal reaches the shell the cronline runs in, and a background child it started outlives it, still holding the job's output pipe. Five seconds after the kill, cronaute stops waiting for that pipe to close and takes the slot back — the run is still recorded with the signal that really ended the job, and the orphan is left to the init process, exactly as a job outliving the shutdown grace is. What such a child costs is the tail of the output, never the slot.
--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.
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.
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? |
--tls-cert and --tls-key turn the listener into a TLS one. --tls-client-ca
adds the other half: a client must then present a certificate that CA signed,
which is what the Prometheus exporter-toolkit calls
RequireAndVerifyClientCert.
cronaute /etc/crontab \
--tls-cert=/vault/certificates/cron/server.crt \
--tls-key=/vault/certificates/cron/server.key \
--tls-client-ca=/vault/certificates/root_ca/root.crtIt is the whole surface or none of it. There is one listener, so the
dashboard, /api/jobs, /metrics and both probes are either all encrypted or
all in the clear; there is no flag to keep a plaintext port beside it. That is
not an omission. A cronline is a command line — paths, flags, sometimes an
argument nobody meant to publish — and the dashboard and the JSON API exist to
show them. A "metrics-only" TLS option would encrypt the counters and serve the
commands in the clear, which is the wrong half.
Half a pair is refused rather than ignored: --tls-cert without --tls-key, or
--tls-client-ca without either, exits 2. Left to itself that combination
starts a plaintext listener that looks exactly like a clean boot. For the same
reason the startup line carries tls and client_auth, so a deployment that
lost its flags says so on every boot:
{"ts":"…","level":"info","source":"cronaute.daemon","message":"listening",
"host":"0.0.0.0","port":8080,"tls":true,"client_auth":"required"}Under TLS the in-container probe needs one more thing: a name. HTTP::Client
verifies the certificate against the host it was given, and a certificate issued
to a service names the service, not 127.0.0.1 — hence --healthcheck-host,
which points the probe at a name the certificate carries. Under Docker the
service name resolves to the container itself, so the probe stays local. Nothing
is switched off by it: both the chain and the name go on being verified, and a
name absent from the SAN is reported unreachable, which is what a
misconfiguration deserves.
The probe presents --tls-cert/--tls-key — the daemon's own certificate — as
its client certificate, and trusts --tls-client-ca. That is what step-ca
issues (one leaf carrying both serverAuth and clientAuth) and what every
other probe in such a deployment already does.
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: 3Docker 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
alpine, with a shell because jobs need one, and nothing else: 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"]The line in the published image is plaintext because the image carries no
certificate and TLS is opt-in. Serving TLS means overriding it with the same
--tls-cert, --tls-key, --tls-client-ca the daemon got, plus
--healthcheck-host — see TLS. The Dockerfile carries that form in a
comment above the line.
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.
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.
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. A run that is still going when the crontab is
edited also survives: it goes on counting against the job it belongs to, and
--overlap=skip still sees it as running.
The next occurrence is the one thing not carried over — the schedule may have changed under an unchanged identity — so a reload costs a job at most one occurrence, recomputed on the tick that follows.
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.
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 dev:smoke # build the runtime image and run a job inside it
mise release:static # static Linux binaries, amd64 and arm64, via Dockermise 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.