Self-hosted Docker-native cron scheduler with a web UI. One tool that both runs recurrent jobs reliably AND makes their state observable through a browser.
Read this section before running Cronduit.
Cronduit is a single-operator tool for homelab environments. It makes three explicit security trade-offs you must understand before deploying:
- Cronduit mounts the Docker socket. That socket is root-equivalent on the host. Anything that can talk to
/var/run/docker.sockcan spawn containers, read secrets from other containers, and access the host filesystem. Only run Cronduit on a host where you already accept Docker-as-root. - The web UI ships unauthenticated in v1. There is no login screen. Cronduit defaults
[server].bindto127.0.0.1:8080for this reason. If you bind it to any non-loopback address, Cronduit emits a loudWARNlog line at startup and setsbind_warning: truein the structured startup event. Put Cronduit behind a reverse proxy (Traefik, Caddy, nginx) with auth if you want to expose it beyond localhost. - Secrets live in environment variables, not in the config file. The TOML config uses
${ENV_VAR}references that are interpolated at parse time. TheSecretStringwrapper from thesecrecycrate ensures credentials never appear inDebugoutput or in log lines.
For non-loopback deployments, always place Cronduit behind a reverse proxy with authentication (Traefik, Caddy, nginx basic auth, etc.).
See THREAT_MODEL.md for the full threat model covering Docker socket access, untrusted clients, config tampering, and malicious images.
Get from git clone to a running scheduled job in under 5 minutes:
# 1. Clone and enter the directory
git clone https://github.com/SimplicityGuy/cronduit
cd cronduit
# 2. Start Cronduit — pick the variant that matches your host.
#
# On Linux: derive DOCKER_GID from the host socket and start the default compose.
# export DOCKER_GID=$(stat -c %g /var/run/docker.sock)
# docker compose -f examples/docker-compose.yml up -d
#
# On macOS + Rancher Desktop: the docker daemon socket lives inside the lima
# VM at /var/run/docker.sock (not ~/.rd/docker.sock — that is the host-side
# client relay). The VM's socket is root:102, so DOCKER_GID must be 102:
# export DOCKER_GID=102
# docker compose -f examples/docker-compose.yml up -d
#
# On macOS + Docker Desktop, or when you want defense-in-depth (socket-proxy
# sidecar, narrow allowlist, no direct socket mount in cronduit):
# docker compose -f examples/docker-compose.secure.yml up -d
#
# See examples/docker-compose.yml and .secure.yml headers for the full
# rationale and threat model notes.
# 3. Open the web UI
open http://localhost:8080Pinning a specific image tag in production? See Docker image tags for which tag matches different operator needs (:X.Y, :latest, :rc, :main).
You should see four example jobs in the dashboard:
- echo-timestamp (command) -- every minute, prints
dateoutput. Instant heartbeat so you know Cronduit is alive. - http-healthcheck (command) -- every 5 minutes,
wget --spideragainsthttps://www.google.com. Realistic uptime canary demonstrating DNS + TLS + egress. - disk-usage (script) -- every 15 minutes,
du -sh /data && df -h /data. Shows off the script-job path and the/datanamed volume. - hello-world (Docker) -- every 5 minutes, pulls
hello-world:latestin an ephemeral container withdelete = true. Exercises the Docker executor end-to-end (requires the socket mount from the default compose file or the docker-socket-proxy sidecar from the secure compose file).
The echo job fires within 60 seconds, giving you instant feedback that Cronduit is working. The other three demonstrate every execution type Cronduit supports (command, script, and Docker) so you can pattern-match on them when writing your own.
Cronduit publishes a small, explicit set of floating and versioned tags to ghcr.io/simplicityguy/cronduit. Pick the one that matches your risk tolerance.
| Tag | What it points at | When it moves | Who should pin this |
|---|---|---|---|
:X.Y.Z (e.g. :1.0.1, :1.1.0) |
An immutable tagged release | Never -- once published, a specific version tag points at one digest forever | Production deployments that want reproducibility |
:X.Y (e.g. :1.0, :1.1) |
The most recent :X.Y.Z patch release for that minor line |
Every time a new patch of that minor ships | Production deployments that want to auto-pick up patch fixes |
:X (e.g. :1) |
The most recent :X.Y.Z release for that major line |
Every time a new minor or patch of that major ships | Production deployments willing to take minor-version upgrades |
:latest |
The most recent stable (non-rc) release -- currently :1.1.0 |
Only on stable release tags (vX.Y.Z with no -rc.N suffix) |
Operators who always want the newest stable. Never bleeds rc or main-branch builds in |
:rc |
The most recent release candidate -- currently :1.1.0-rc.6 (last rc before v1.1.0 shipped; won't move again until the next milestone begins rc cycling) |
Every rc push (vX.Y.Z-rc.N). Never moves on stable releases |
Early adopters who want to exercise the next milestone before it ships |
:main |
A CI-built image of whatever commit is currently at the tip of main |
Every push to main, multi-arch, built with the same toolchain as release images |
Homelab operators who want the bleeding edge and can accept that main may be unstable |
flowchart LR
PUSH_MAIN["push to main"] --> MAINBUILD["main-build.yml"]
MAINBUILD --> MAIN[":main"]
PUSH_RC["push v1.1.0-rc.N tag"] --> RELEASE["release.yml"]
RELEASE --> RC[":1.1.0-rc.N<br/>:rc"]
PUSH_STABLE["push v1.1.0 tag"] --> RELEASE
RELEASE --> STABLE[":1.1.0<br/>:1.1<br/>:1<br/>:latest"]
classDef trigger fill:#0a1f2d,stroke:#00ff7f,color:#e0ffe0
classDef workflow fill:#1a1a1a,stroke:#666,color:#ccc
classDef tag fill:#0a3d0a,stroke:#00ff7f,color:#e0ffe0
class PUSH_MAIN,PUSH_RC,PUSH_STABLE trigger
class MAINBUILD,RELEASE workflow
class MAIN,RC,STABLE tag
- Most operators should pin
:1.1(or whatever the current minor line is). It picks up patch fixes automatically but never surprises you with a major upgrade. :latestis fine for "just try it out" quickstart flows -- which is whyexamples/docker-compose.ymlpins it -- but is not recommended for long-running deployments where you want reproducibility.:rclets you validate the next milestone early. If an rc breaks in your environment, file an issue before the final cut.:mainis for operators who WANT the bleeding edge. It pulls unreviewed code from the tip ofmain; treat it the same way you would treat pulling from a branch of any open-source project. It is not recommended for any environment that values uptime.
The following tags are intentionally NOT published -- they are not footguns we plan to add later, they are footguns we have deliberately declined:
- No
:edge,:nightly, or:devtags. If you want bleeding-edge, pin:main. - No per-branch tags (e.g. no
:feature-foofor branches other thanmain). - No per-commit tags (e.g. no
:sha-abc1234). Cronduit previously published these and they cluttered the package page without a clear use case.
If you need to pin to a specific commit, use an :X.Y.Z release tag; if no release tag exists for your target commit, that commit is not a supported deployment target.
flowchart TD
subgraph "Single Binary"
CLI["CLI (clap)"] --> CFG["Config Parser (TOML)"]
CFG --> SCH["Scheduler Loop (tokio + croner)"]
SCH --> CMD["Command Executor"]
SCH --> SCR["Script Executor"]
SCH --> DOC["Docker Executor (bollard)"]
SCH --> DB["Database (sqlx)"]
SCH --> MET["Metrics (metrics facade)"]
DB --> SQL["SQLite (default)"]
DB --> PG["PostgreSQL (optional)"]
WEB["Web UI (axum + askama + HTMX)"] --> DB
WEB --> SSE["SSE Log Streaming"]
WEB --> MET
end
DOC --> SOCK["/var/run/docker.sock"]
SOCK --> CONTAINERS["Ephemeral Job Containers"]
PROM["Prometheus"] -.->|scrape /metrics| MET
classDef core fill:#0a3d0a,stroke:#00ff7f,color:#e0ffe0
classDef external fill:#1a1a1a,stroke:#666,color:#888
class CLI,CFG,SCH,CMD,SCR,DOC,DB,WEB,SSE,MET core
class SQL,PG,SOCK,CONTAINERS,PROM external
Cronduit is a single Rust binary that:
- Runs recurrent jobs on a cron schedule (command, inline script, or ephemeral Docker container)
- Shows every run's status, timing, and logs in a terminal-green web UI (no SPA -- server-rendered HTML with HTMX live updates)
- Supports every Docker network mode, including
network = "container:<name>"(the marquee feature -- route traffic through a VPN sidecar) - Stores everything in SQLite by default, or PostgreSQL if you prefer
- Ships as a single binary and a multi-arch Docker image (
linux/amd64,linux/arm64)
Cronduit is configured via a single TOML file. The config file is the source of truth -- jobs not in the file are disabled on reload.
New to Cronduit? Start with docs/QUICKSTART.md for a zero-to-first-scheduled-job walkthrough. Looking up a specific field? The complete reference is in docs/CONFIG.md. The section below is a cheat sheet.
[server]
bind = "127.0.0.1:8080" # Default: loopback only. Loud WARN at startup if non-loopback.
timezone = "UTC" # REQUIRED -- no implicit host-timezone fallback (D-19).
log_retention = "90d" # Default 90d. How long to keep run logs before the daily pruner reclaims them.
shutdown_grace = "30s" # Default 30s. Grace period for running jobs on SIGINT/SIGTERM before SIGKILL.
watch_config = true # Default true. Set false to disable the debounced file-watch reload path.
# database_url = "sqlite:///data/cronduit.db" # Optional. Falls back to env DATABASE_URL,
# then to "sqlite://./cronduit.db?mode=rwc" for local dev.[defaults]
image = "alpine:latest" # Default Docker image for container jobs
network = "bridge" # Default Docker network mode
delete = true # When true, cronduit removes the container after wait_container drains.
# NOT bollard auto_remove -- cronduit always sets auto_remove=false to
# avoid the moby#8441 race that loses exit codes; the explicit remove
# happens after the run is fully recorded.
timeout = "5m" # Default job timeout
random_min_gap = "90m" # Minimum gap between @random-scheduled jobs on the same day.
# Optional -- omit to allow @random jobs to land back-to-back.Cronduit attaches arbitrary Docker labels to spawned containers. Operators use this to integrate cronduit with reverse proxies (Traefik, Caddy), update tooling (Watchtower), backup tooling, and any other Docker ecosystem tool that filters or routes by container label.
Labels are configured in two places — [defaults].labels (inherited by every docker job) and per-job [[jobs]].labels (merges with or replaces the defaults). The merge precedence is:
flowchart LR
A["[defaults].labels"] -->|"per-job present + use_defaults != false"| B["merge: defaults ∪ per-job<br/>(per-job wins on collision)"]
A -->|"per-job present + use_defaults = false"| C["replace: per-job only<br/>(defaults discarded)"]
A -->|"per-job absent"| D["inherit: defaults verbatim"]
B --> E["operator label set"]
C --> E
D --> E
E -->|"cronduit-internal labels<br/>(cronduit.run_id, cronduit.job_name)<br/>added — internal wins on impossible-collision"| F["bollard Config::labels<br/>(reaches Docker daemon)"]
classDef step fill:#0a3d0a,stroke:#00ff7f,color:#e0ffe0
classDef internal fill:#2a1a3d,stroke:#bf7fff,color:#f0e0ff
class A,B,C,D,E step
class F internal
The merge semantics in tabular form:
per-job labels field |
per-job use_defaults |
Resulting label set on container |
|---|---|---|
| absent | unset / true |
[defaults].labels verbatim (inherit) |
| present | unset / true |
[defaults].labels ∪ per-job; per-job key wins on collision |
| present | false |
per-job ONLY; defaults entirely discarded |
In all cases, cronduit's two internal labels — cronduit.run_id and cronduit.job_name — are added to every spawned container after the operator-defined merge resolves. These power orphan reconciliation; they are not operator-configurable.
Reserved namespace. Keys under the cronduit.* prefix are rejected at config-LOAD with a clear error. The prefix is reserved for cronduit-internal use:
# REJECTED at load:
[[jobs]]
labels = { "cronduit.foo" = "bar" } # error: cronduit.* is reservedType-gate (docker-only). Labels are valid only on docker jobs (jobs with image = "..." set, either directly or via [defaults].image). Setting labels on a command or script job is rejected at config-LOAD; there is no container to attach labels to:
# REJECTED at load -- labels on a command job:
[[jobs]]
name = "no-good"
command = "echo hi"
labels = { "team" = "ops" } # error: labels are docker-onlySize limits. Each label value must be ≤ 4 KB (4096 bytes); the total byte length of all keys + values for a single job must be ≤ 32 KB. Both limits are enforced at config-LOAD. The 32 KB ceiling sits well below dockerd's informal label-size threshold so cronduit's error fires first with a clear message.
Env-var interpolation. Cronduit applies a whole-file textual pre-parse pass over the entire TOML source BEFORE TOML parses it; this pass replaces every ${VAR} reference with the resolved value of the named environment variable (reference: src/config/interpolate.rs). Interpolation runs over keys and values uniformly — it does NOT distinguish TOML key positions from value positions. Cronduit then enforces a strict character regex on every resolved label key: ^[a-zA-Z0-9_][a-zA-Z0-9._-]*$ (alphanumeric or underscore start; alphanumeric, dot, hyphen, or underscore body). Any character outside this set — including the literal $, {, } left behind when an env var is UNSET — is rejected at config-LOAD with a clear error.
Concretely, this means:
labels = { "deployment.id" = "${DEPLOYMENT_ID}" }withDEPLOYMENT_ID=12345exported → resolves tolabels = { "deployment.id" = "12345" }and is accepted.labels = { "deployment.id" = "${DEPLOYMENT_ID}" }withDEPLOYMENT_IDunset → leaves the literal${DEPLOYMENT_ID}in the value, the validator's value-side checks pass, but interpolation itself emits amissing environment variableerror and the load fails. (See the${VAR:-default}rule below — there is no default-value syntax in v1.)labels = { "${TEAM}" = "v" }withTEAM=opsexported → resolves tolabels = { "ops" = "v" }and is accepted (the resolved keyopsmatches the strict pattern).labels = { "${TEAM}" = "v" }withTEAMunset → the literal${TEAM}survives, fails the strict-char regex on$,{, and}, and is rejected at config-LOAD.
Recommended pattern. Use ${VAR} interpolation for label VALUES, not keys. Stable label keys should be written as literal strings; if you write ${VAR} inside a label key the resolved value must match the strict pattern above, which is fragile against env-var typos and reduces the visible diff in code review. The supported and tested pattern is labels = { "deployment.id" = "${DEPLOYMENT_ID}" }.
# RECOMMENDED -- env-var interpolation in label VALUE only:
[[jobs]]
labels = { "deployment.id" = "${DEPLOYMENT_ID}" }
# SUPPORTED BUT DISCOURAGED -- env-var interpolation in label KEY position.
# The resolved key must match `^[a-zA-Z0-9_][a-zA-Z0-9._-]*$`. If TEAM is set
# to "ops" the line below is accepted as `labels = { "ops" = "v" }`. If TEAM
# is unset the literal `${TEAM}` survives and is rejected by the strict char
# regex.
[[jobs]]
labels = { "${TEAM}" = "v" }Label values are NOT secrets. Anyone with access to the Docker daemon can read them via docker inspect. Use env vars (the env = field on docker jobs) for anything sensitive — env values are redacted in cronduit logs and never surface in docker inspect for the spawned container.
See examples/cronduit.toml for three integration patterns: Watchtower exclusion in [defaults], Traefik routing labels merged onto a per-job block, and a use_defaults = false job that establishes its own clean label set.
Command job -- runs a local shell command:
[[jobs]]
name = "health-probe"
schedule = "*/15 * * * *"
command = "curl -sf https://example.com/health"
timeout = "30s"Script job -- runs an inline script:
[[jobs]]
name = "backup-index"
schedule = "0 * * * *"
script = """
#!/bin/sh
set -eu
echo "building backup index at $(date -u +%FT%TZ)"
find /data -type f -mtime -1 | wc -l
"""
timeout = "2m"Docker container job -- spawns an ephemeral container:
[[jobs]]
name = "nightly-backup"
schedule = "15 3 * * *"
image = "restic/restic:latest"
network = "container:vpn" # Route through VPN sidecar
volumes = ["/data:/data:ro", "/backup:/backup"]
timeout = "30m"
delete = true
[jobs.env]
RESTIC_PASSWORD = "${RESTIC_PASSWORD}" # Interpolated from host environmentSecrets use ${ENV_VAR} syntax -- Cronduit interpolates at parse time and wraps values in SecretString. If a referenced variable is unset, cronduit check fails with a clear error.
For the full configuration reference, see docs/SPEC.md.
Cronduit exposes a Prometheus-compatible /metrics endpoint for integration with your existing monitoring stack.
Cronduit exposes six metric families, all eagerly described at boot so /metrics returns full HELP/TYPE lines even before the first observation:
| Metric | Type | Labels | Description |
|---|---|---|---|
cronduit_scheduler_up |
Gauge | -- | 1 once the scheduler loop is running. Liveness sentinel. |
cronduit_jobs_total |
Gauge | -- | Number of currently configured jobs |
cronduit_runs_total |
Counter | job, status |
Total runs by job and status (success, failed, timeout, cancelled) |
cronduit_run_duration_seconds |
Histogram | job |
Run duration with homelab-tuned buckets (1s to 1h) |
cronduit_run_failures_total |
Counter | job, reason |
Failures by reason (image_pull_failed, network_target_unavailable, timeout, exit_nonzero, abandoned, unknown) |
cronduit_docker_reachable |
Gauge | -- | Docker daemon preflight result: 1 reachable, 0 unreachable. See Troubleshooting below. |
Cardinality is bounded: job labels scale with your job count (typically 5-50), status is a 4-value closed enum, and reason is a 6-value closed enum.
Copy the provided scrape config into your prometheus.yml:
scrape_configs:
- job_name: 'cronduit'
scrape_interval: 15s
static_configs:
- targets: ['localhost:8080']A ready-to-use scrape configuration is also available at examples/prometheus.yml.
The /metrics endpoint is unauthenticated, consistent with standard Prometheus target conventions. Protect it via network controls if needed.
- Rust 1.94+ (pinned via
rust-toolchain.toml) - just task runner
- Docker (for container job tests and image builds)
Every build/test/lint/image command goes through just:
just --list # Show every recipe
just build # cargo build --all-targets
just test # cargo test --all-features
just fmt-check # Formatter gate
just clippy # Linter gate
just openssl-check # Rustls-only dependency guard
just schema-diff # SQLite vs Postgres schema parity test
just image # Multi-arch Docker image via cargo-zigbuild
just ci # Full ordered CI chainjust tailwind # Build CSS once
just tailwind-watch # Watch mode for live developmentThe rust-embed crate reads assets from disk in debug builds, so template and CSS changes are visible on browser refresh without recompiling.
just check-config examples/cronduit.tomlCronduit pings the Docker daemon once at startup and exposes the result as a Prometheus gauge:
curl -sS http://localhost:8080/metrics | grep cronduit_docker_reachable
# cronduit_docker_reachable 1 <- daemon reachable, docker jobs will work
# cronduit_docker_reachable 0 <- preflight failed, docker jobs will errorIf the gauge is 0 and you see a cronduit.docker WARN log line at startup, the cause is almost always a mismatch between cronduit's supplementary group and the host Docker group.
Derive the right DOCKER_GID:
| Host | Command | Typical value |
|---|---|---|
| Linux | stat -c %g /var/run/docker.sock |
999 (default) |
| macOS + Rancher Desktop | fixed value (VM-side docker group) | 102 |
| macOS + Docker Desktop | unstable, varies by release | use docker-compose.secure.yml instead |
Export the value and restart the stack:
export DOCKER_GID=102 # for Rancher Desktop on macOS
docker compose -f examples/docker-compose.yml down -v
docker compose -f examples/docker-compose.yml up -d
curl -sS http://localhost:8080/metrics | grep cronduit_docker_reachableThe Cronduit runtime image is based on alpine:3 and ships busybox date, wget, du, df, and /bin/sh. If your command references a binary that isn't in busybox (e.g. curl, jq, bash), either install it via a custom Dockerfile that extends ghcr.io/simplicityguy/cronduit:latest, or rewrite the job as a script = that invokes the busybox equivalent.
Check that the compose stack is actually up (docker compose ps) and that nothing else is bound to port 8080. The default bind is 0.0.0.0:8080 inside the example compose files; cronduit emits a loud WARN at startup if you bind to a non-loopback address without a reverse proxy (see the Security section above).
Use cronduit check to parse your config and surface errors without starting the scheduler:
docker run --rm \
-v $PWD/examples/cronduit.toml:/etc/cronduit/config.toml:ro \
ghcr.io/simplicityguy/cronduit:latest \
cronduit check /etc/cronduit/config.toml- Create a feature branch (
gsd/...orfeat/...) - Make changes and run
just cilocally - Open a PR -- direct commits to
mainare blocked by policy - All diagrams in PR descriptions, commits, and docs must be mermaid code blocks (no ASCII art)
See CLAUDE.md for the full project constraints.
MIT. See LICENSE.