Skip to content

Repository files navigation

cronaute

GitHub License Build Status GitHub Release

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 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#wait on 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 than GET is answered 405.
  • 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

The 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".

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.

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.

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)
--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

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

Concurrency, and what happens when it runs out

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.

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:30 job runs at 03:00 on the spring-forward day in Europe/Paris.
  • An occurrence inside a repeated hour runs once, not twice.
  • #next is 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?

TLS

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

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

Readiness and liveness are not the same question

They trigger opposite reactions, which is the whole reason there are two:

  • /health answers 200 once a crontab has been loaded without error and 503 while the last attempt failed, with a body carrying jobs_loaded, last_reload_ok, last_reload_at and last_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.

  • /live answers 200 while the scheduler has completed a pass within the last 30 seconds, 503 otherwise, with last_tick_at and grace_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 alpine, with a shell because jobs need one, and nothing else: no curl, no wget. A HEALTHCHECK CMD curl … cannot work there, so --healthcheck performs the request and exits 0 or 1. It reads --host and --port, substituting the loopback when the daemon is bound to 0.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. /live only 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.

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

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 dev:smoke          # build the runtime image and run a job inside it
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.

About

A single-user cron daemon for containers: one static binary, no spool, JSON logs, Prometheus metrics and a read-only dashboard.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages