Skip to content

SimplicityGuy/cronduit

Repository files navigation

cronduit

CI License: MIT Rust just Clippy Docker Claude Code

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.


Security

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:

  1. Cronduit mounts the Docker socket. That socket is root-equivalent on the host. Anything that can talk to /var/run/docker.sock can 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.
  2. The web UI ships unauthenticated in v1. There is no login screen. Cronduit defaults [server].bind to 127.0.0.1:8080 for this reason. If you bind it to any non-loopback address, Cronduit emits a loud WARN log line at startup and sets bind_warning: true in the structured startup event. Put Cronduit behind a reverse proxy (Traefik, Caddy, nginx) with auth if you want to expose it beyond localhost.
  3. Secrets live in environment variables, not in the config file. The TOML config uses ${ENV_VAR} references that are interpolated at parse time. The SecretString wrapper from the secrecy crate ensures credentials never appear in Debug output 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.


Quickstart

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:8080

You should see four example jobs in the dashboard:

  • echo-timestamp (command) -- every minute, prints date output. Instant heartbeat so you know Cronduit is alive.
  • http-healthcheck (command) -- every 5 minutes, wget --spider against https://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 /data named volume.
  • hello-world (Docker) -- every 5 minutes, pulls hello-world:latest in an ephemeral container with delete = 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.


Architecture

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
Loading

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)

Configuration

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 Settings

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

Default Job Settings

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

Job Types

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 environment

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


Monitoring

Cronduit exposes a Prometheus-compatible /metrics endpoint for integration with your existing monitoring stack.

Metric Families

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.

Prometheus Setup

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.


Development

Prerequisites

  • Rust 1.94+ (pinned via rust-toolchain.toml)
  • just task runner
  • Docker (for container job tests and image builds)

Build and Test

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 chain

Tailwind CSS

just tailwind            # Build CSS once
just tailwind-watch      # Watch mode for live development

The rust-embed crate reads assets from disk in debug builds, so template and CSS changes are visible on browser refresh without recompiling.

Validate Config

just check-config examples/cronduit.toml

Troubleshooting

Docker jobs fail with "no Docker client" or "Socket not found"

Cronduit 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 error

If 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_reachable

Command/script jobs fail with "No such file or directory"

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

Web UI unreachable / /health times out

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

I want to validate before I run anything

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

Contributing

  1. Create a feature branch (gsd/... or feat/...)
  2. Make changes and run just ci locally
  3. Open a PR -- direct commits to main are blocked by policy
  4. All diagrams in PR descriptions, commits, and docs must be mermaid code blocks (no ASCII art)

See CLAUDE.md for the full project constraints.


License

MIT. See LICENSE.

About

↻ cron, modernized for the container era

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors