Why Self-Host Your Cron Job Scheduler?
The traditional Unix crontab has been the backbone of scheduled task execution for decades. It is simple, reliable, and available on every Linux system. But as infrastructure grows in complexity, plain crontab reveals critical shortcomings that make it unsuitable for modern operations:
- No centralized visibility — scattered across dozens of servers, crontab entries are invisible until something breaks. There is no single pane of glass to monitor what ran, when, and whether it succeeded.
- Zero built-in logging — crontab writes output to local mail or stdout. You need additional tooling to capture, aggregate, and search execution logs across machines.
- No dependency management — running Task B after Task A finishes requires fragile shell scripting. There is no native DAG (Directed Acyclic Graph) execution model.
- No access control — anyone with shell access can edit any crontab. There is no role-based permissions system to restrict who can modify scheduled jobs.
- No retry logic — if a job fails at 3 AM, crontab will not retry it. You either miss the execution or write custom error-handling wrappers.
- No concurrent execution safeguards — overlapping job runs can corrupt data, exhaust resources, or trigger cascading failures. Crontab offers no built-in locking mechanism.
Self-hosted cron schedulers solve every one of these problems while keeping your infrastructure data, job definitions, and execution history entirely under your control. You avoid the vendor lock-in, rate limits, and per-node licensing fees of cloud scheduling services like AWS EventBridge, GCP Cloud Scheduler, or hosted Rundeck. This guide covers the best open-source options available in 2026.
Comparison: Cronicle vs Rundeck vs Other Options
| Feature | Cronicle | Rundeck (Community) | Go-Cron (lightweight) |
|---|
| License | MIT | Apache 2.0 | MIT |
| Language | Node.js | Java / Groovy | Go |
| Web UI | ✅ Full dashboard | ✅ Full dashboard | ❌ CLI only |
| Distributed | ✅ Multi-worker | ✅ Multi-node | ❌ Single-node |
| DAG dependencies | ✅ Chain jobs | ✅ Job orchestration | ❌ Sequential only |
| RBAC / ACL | ✅ User roles | ✅ Fine-grained ACLs | ❌ None |
| API | ✅ REST | ✅ REST +docker ❌ CLI only | |
| Docker support | ✅ Official images | ✅ Official images | ✅ Community images |
| Database | SQLite / MySQL / PostgreSQL | H2 / MySQL / PostgreSQL | SQLite / PostgreSQL |
| Resource usage | ~200 MB RAM | ~800 MB RAM | ~30 MB RAM |
| Best for | Teams needing distributed scheduling | Enterprise with strict compliance needs | Minimal single-server setups |
Cronicle is the best all-around choice for homelab operators and small-to-medium teams. It is lightweight, has a clean web interface, supports distributed workers across multiple servers, and handles job chaining natively. If you want something that “just works” without a Java dependency, this is it.
Rundeck is the enterprise heavyweight. It has the most mature RBAC system, audit logging for compliance frameworks (SOC 2, HIPAA), and deep integration with LDAP/Active Directory. The trade-off is higher resource consumption and a steeper learning curve for Groovy-based job definitions.
Go-Cron (and similar lightweight tools) serve a narrow niche: a single-server setup where you want better visibility and logging than crontab but do not need a web UI, distributed execution, or team collaboration. Think of it as “crontab with a database and a REST API.”
Deployment Guide: Cronicle
Cronicle is built by Joseph Huckaby (the creator of the classic logrotate alternative logrotate-ng). It runs on Node.js and provides a full-featured web UI with real-time job monitoring, multi-worker support, and a comprehensive REST API.
Prerequisites
- Docker and Docker Compose installed
- At least 512 MB RAM available
- A persistent volume for job data and logs
Docker Compose Setup
Create a docker-compose.yml file:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| version: "3.8"
services:
cronicle:
image: ghcr.io/jhuckaby/cronicle:latest
container_name: cronicle
restart: unless-stopped
ports:
- "3012:3012"
environment:
- CRONICLE_secret_key=change-me-to-a-random-string
- CRONICLE_web_port=3012
- CRONICLE_storage_backend=Filesystem
- CRONICLE_storage_File_base_dir=/opt/cronicle/data
- CRONICLE_log_dir=/opt/cronicle/logs
volumes:
- cronicle_data:/opt/cronicle/data
- cronicle_logs:/opt/cronicle/logs
networks:
- cronicle_net
networks:
cronicle_net:
driver: bridge
volumes:
cronicle_data:
cronicle_logs:
|
Start the service:
Initial Configuration
Access the web UI at http://your-server:3012. The default credentials are:
- Username:
admin - Password:
admin
Immediately change the default password via Settings > User Management.
Adding a Worker Node
To add a second server as a Cronicle worker, install the same Docker image and point it to the master’s API:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| services:
cronicle-worker:
image: ghcr.io/jhuckaby/cronicle:latest
container_name: cronicle-worker
restart: unless-stopped
environment:
- CRONICLE_secret_key=change-me-to-a-random-string
- CRONICLE_server=master-server
- CRONICLE_server_port=3012
- CRONICLE_worker=true
- CRONICLE_web_port=0
volumes:
- worker_data:/opt/cronicle/data
networks:
- cronicle_net
|
The master automatically discovers workers and distributes jobs based on their availability.
Creating Your First Scheduled Job
- Navigate to Schedules > Create New Schedule
- Set the cron expression (e.g.,
0 2 * * * for daily at 2 AM) - Choose Shell Script as the plugin
- Enter your command:
1
2
3
4
5
6
7
8
9
10
11
| #!/bin/bash
# Database backup script
BACKUP_DIR="/opt/backups/$(date +%Y-%m-%d)"
mkdir -p "$BACKUP_DIR"
pg_dump -U postgres mydb | gzip > "$BACKUP_DIR/mydb.sql.gz"
# Retain only the last 7 days
find /opt/backups -type d -mtime +7 -exec rm -rf {} \;
echo "Backup completed: $(ls -lh "$BACKUP_DIR/mydb.sql.gz")"
|
- Set Timeout to 3600 seconds (1 hour)
- Enable Catch Up if the job should run even if a previous instance was missed
- Save and enable the schedule
Job Chaining (DAG Execution)
Cronicle supports chaining jobs so that Job B runs only after Job A completes successfully:
- Create Job A (e.g., “Download data from API”)
- Create Job B (e.g., “Process downloaded data”)
- In Job B’s settings, add Job A as a dependency
- Optionally create Job C (e.g., “Send notification”) depending on Job B
This creates an execution pipeline where each step waits for the previous one to succeed. If any step fails, the chain stops and you receive an alert.
Using PostgreSQL Instead of SQLite
For production workloads with hundreds of jobs, switch from the default SQLite backend to PostgreSQL:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| services:
cronicle:
image: ghcr.io/jhuckaby/cronicle:latest
environment:
- CRONICLE_secret_key=change-me-to-a-random-string
- CRONICLE_storage_backend=SQL
- CRONICLE_storage_SQL_type=postgresql
- CRONICLE_storage_SQL_host=postgres
- CRONICLE_storage_SQL_port=5432
- CRONICLE_storage_SQL_database=cronicle
- CRONICLE_storage_SQL_user=cronicle
- CRONICLE_storage_SQL_password=strong-password-here
depends_on:
- postgres
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: cronicle
POSTGRES_USER: cronicle
POSTGRES_PASSWORD: strong-password-here
volumes:
- pg_data:/var/lib/postgresql/data
volumes:
pg_data:
|
Deployment Guide: Rundeck
Rundeck is the most feature-complete open-source job scheduler. It is used by enterprises worldwide for infrastructure automation, deployment orchestration, and scheduled maintenance windows.
Docker Compose Setup
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| version: "3.8"
services:
rundeck:
image: rundeck/rundeck:5.6.0
container_name: rundeck
restart: unless-stopped
ports:
- "4440:4440"
environment:
- RUNDECK_GRAILS_URL=http://your-server:4440
- RUNDECK_SERVER_ADDRESS=0.0.0.0
- RUNDECK_LOGGING_AUDIT_ENABLED=true
- RUNDECK_DATABASE_DRIVER=org.h2.Driver
- RUNDECK_DATABASE_URL=jdbc:h2:file:/home/rundeck/server/data/grailsdb;MVCC=true
- RUNDECK_PASSWORD=admin
volumes:
- rundeck_data:/home/rundeck/server/data
- rundeck_logs:/home/rundeck/server/logs
- rundeck_projects:/home/rundeck/projects
volumes:
rundeck_data:
rundeck_logs:
rundeck_projects:
|
Start Rundeck:
Wait approximately 60 seconds for the JVM to initialize, then access http://your-server:4440. Log in with admin/admin (or whatever password you set via RUNDECK_PASSWORD).
Defining a Job in YAML
Rundeck stores job definitions as YAML files in the project’s jobs/ directory. Here is a backup job definition:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
| - name: Daily Database Backup
group: maintenance
description: PostgreSQL backup with retention policy
schedule:
month: '*'
weekday: '*'
hour: '2'
minute: '0'
executionEnabled: true
loglevel: INFO
timeout: '3600'
retry: '2'
retryDelay: '300'
nodeFilterEditable: false
nodefilter:
filter: 'name: db-server'
sequence:
keepgoing: false
strategy: node-first
commands:
- exec: |
#!/bin/bash
set -euo pipefail
BACKUP_DIR="/var/backups/postgres/$(date +%Y-%m-%d)"
mkdir -p "$BACKUP_DIR"
echo "Starting backup at $(date)"
pg_dump -h localhost -U postgres --format=custom mydb > "$BACKUP_DIR/mydb.dump"
echo "Backup size: $(du -h "$BACKUP_DIR/mydb.dump" | cut -f1)"
# Compress and verify
gzip "$BACKUP_DIR/mydb.dump"
gzip -t "$BACKUP_DIR/mydb.dump.gz"
# Cleanup old backups
find /var/backups/postgres -name "*.dump.gz" -mtime +14 -delete
echo "Backup completed successfully at $(date)"
- exec: |
# Send notification on failure (only reached if retry exhausted)
curl -X POST https://hooks.slack.com/services/YOUR/WEBHOOK/URL \
-d '{"text": "❌ Daily backup FAILED on db-server"}'
|
Setting Up Access Control (ACL)
Rundeck’s ACL policy files control who can do what. Create a file at /home/rundeck/projects/your-project/etc/acl-policy.yaml:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
| description: Developer team access
context:
project: 'production'
for:
resource:
- allow: ['read']
adhoc:
- allow: ['read', 'run']
job:
- allow: ['read', 'run', 'view']
match:
group: 'maintenance/.*'
node:
- allow: ['read']
by:
group: 'developers'
---
description: Admin full access
context:
project: '.*'
for:
resource:
- allow: ['*']
job:
- allow: ['*']
node:
- allow: ['*']
adhoc:
- allow: ['*']
by:
group: 'admins'
|
Integrating with LDAP / Active Directory
Rundeck supports LDAP authentication out of the box. Configure it via environment variables:
1
2
3
4
5
6
7
8
| environment:
- RUNDECK_SECURITY_AUTHENTICATION_PROVIDER=ldap
- RUNDECK_LDAP_PROVIDER_URL=ldap://ldap-server:389
- RUNDECK_LDAP_PROVIDER_DN=cn=admin,dc=company,dc=com
- RUNDECK_LDAP_PROVIDER_PASSWORD=ldap-password
- RUNDECK_LDAP_USER_BASE_DN=ou=users,dc=company,dc=com
- RUNDECK_LDAP_USER_SEARCH_FILTER=uid={0}
- RUNDECK_LDAP_GROUP_BASE_DN=ou=groups,dc=company,dc=com
|
Reverse Proxy Configuration (Nginx)
Both Cronicle and Rundeck should sit behind a reverse proxy with TLS termination. Here is a production-ready Nginx configuration for Cronicle:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
| server {
listen 80;
server_name cronicle.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name cronicle.example.com;
ssl_certificate /etc/letsencrypt/live/cronicle.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/cronicle.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
location / {
proxy_pass http://127.0.0.1:3012;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
# WebSocket support for real-time job monitoring
location /api/ {
proxy_pass http://127.0.0.1:3012;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
|
Obtain a free TLS certificate with Certbot:
1
| sudo certbot --nginx -d cronicle.example.com
|
Real-World Use Cases
Use Case 1: Automated SSL Certificate Renewal Check
Monitor all your domains and get notified before certificates expire:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| #!/bin/bash
# SSL certificate expiry checker
DOMAINS=("example.com" "api.example.com" "mail.example.com")
THRESHOLD_DAYS=14
for domain in "${DOMAINS[@]}"; do
expiry=$(echo | openssl s_client -servername "$domain" -connect "$domain:443" 2>/dev/null \
| openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
if [ -z "$expiry" ]; then
echo "ERROR: Cannot check $domain"
continue
fi
expiry_epoch=$(date -d "$expiry" +%s)
current_epoch=$(date +%s)
days_left=$(( (expiry_epoch - current_epoch) / 86400 ))
if [ "$days_left" -lt "$THRESHOLD_DAYS" ]; then
echo "WARNING: $domain expires in $days_left days ($expiry)"
fi
done
|
Schedule this to run daily at 9 AM via Cronicle with a webhook notification to your team chat.
Use Case 2: Log Rotation and Cleanup
Automate log management across multiple servers:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| #!/bin/bash
# Find and compress logs older than 1 day, delete compressed logs older than 30 days
LOG_DIRS=("/var/log/app" "/var/log/nginx" "/opt/backups/logs")
for dir in "${LOG_DIRS[@]}"; do
if [ ! -d "$dir" ]; then continue; fi
# Compress uncompressed logs older than 1 day
find "$dir" -name "*.log" -mtime +1 -exec gzip {} \;
# Delete compressed logs older than 30 days
find "$dir" -name "*.gz" -mtime +30 -delete
# Report disk usage
usage=$(du -sh "$dir" 2>/dev/null | cut -f1)
echo "$dir: $usage after cleanup"
done
|
Use Case 3: Database Health Checks
Run periodic health checks on your databases:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
| #!/bin/bash
# PostgreSQL health check
MAX_CONNECTIONS=$(psql -t -c "SHOW max_connections" | tr -d ' ')
ACTIVE_CONNECTIONS=$(psql -t -c "SELECT count(*) FROM pg_stat_activity" | tr -d ' ')
DB_SIZE=$(psql -t -c "SELECT pg_size_pretty(pg_database_size('mydb'))" | tr -d ' ')
UPTIME=$(psql -t -c "SELECT date_trunc('second', current_timestamp - pg_postmaster_start_time())" | tr -d ' ')
echo "=== PostgreSQL Health Report ==="
echo "Connections: $ACTIVE_CONNECTIONS / $MAX_CONNECTIONS"
echo "Database size: $DB_SIZE"
echo "Uptime: $UPTIME"
# Alert if over 80% connection usage
USAGE_PCT=$(( ACTIVE_CONNECTIONS * 100 / MAX_CONNECTIONS ))
if [ "$USAGE_PCT" -gt 80 ]; then
echo "CRITICAL: Connection usage at ${USAGE_PCT}%"
exit 1
fi
# Alert if database grew more than 10% since last check
LAST_SIZE_FILE="/tmp/db_size_last_check"
if [ -f "$LAST_SIZE_FILE" ]; then
LAST_SIZE=$(cat "$LAST_SIZE_FILE")
CURRENT_SIZE=$(psql -t -c "SELECT pg_database_size('mydb')" | tr -d ' ')
GROWTH=$(( (CURRENT_SIZE - LAST_SIZE) * 100 / LAST_SIZE ))
if [ "$GROWTH" -gt 10 ]; then
echo "WARNING: Database grew ${GROWTH}% since last check"
fi
fi
echo "$CURRENT_SIZE" > "$LAST_SIZE_FILE"
|
Monitoring and Alerting Integration
Webhook Notifications
Both Cronicle and Rundeck support webhook notifications. Configure a webhook to send job status updates to your preferred messaging platform:
1
2
3
4
5
6
7
8
9
| # Cronicle webhook payload format
{
"event": "job_complete",
"job_id": "{{job.id}}",
"job_name": "{{job.name}}",
"status": "{{event.code}}",
"duration": "{{event.duration}}",
"output": "{{event.output}}"
}
|
Gotify / Pushover Integration
For self-hosted push notifications, integrate with Gotify:
1
2
3
4
5
6
| #!/bin/bash
# Send notification via Gotify
MESSAGE="Job '$JOB_NAME' completed with status: $STATUS"
curl -s -X POST "https://gotify.example.com/message?token=APP_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"title\": \"Cronicle Alert\", \"m[prometheus](https://prometheus.io/)\"$MESSAGE\", \"priority\": 5}"
|
Prometheus Metrics Export
Expose job execution metrics to Prometheus for dashboarding:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| #!/bin/bash
# cronicle_metrics_exporter.sh
# Run as a cron job every minute
SCHEDULER_URL="http://localhost:3012"
API_KEY="your-api-key"
# Fetch active jobs and export metrics
ACTIVE=$(curl -s "$SCHEDULER_URL/api/app/tick" \
-H "X-Cronicle-Api-Key: $API_KEY" \
| jq '.active_jobs | length')
echo "cronicle_active_jobs $ACTIVE" > /var/lib/node_exporter/cronicle.prom
# Fetch recent job completions
COMPLETED=$(curl -s "$SCHEDULER_URL/api/app/log/events" \
-H "X-Cronicle-Api-Key: $API_KEY" \
-d '{"offset":0,"limit":100}' \
| jq '[.events[] | select(.event == "job_complete")] | length')
echo "cronicle_jobs_completed_last_hour $COMPLETED" >> /var/lib/node_exporter/cronicle.prom
|
Migration from Crontab
If you have existing crontab entries to migrate, use this script to convert them into a structured format:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| #!/bin/bash
# Export all user crontabs to a readable CSV
echo "User,Schedule,Command,Last Modified" > cron_migration.csv
for user in $(cut -d: -f1 /etc/passwd); do
crontab -u "$user" -l 2>/dev/null | grep -v '^#' | grep -v '^$' | while read -r line; do
schedule=$(echo "$line" | awk '{print $1, $2, $3, $4, $5}')
command=$(echo "$line" | awk '{for(i=6;i<=NF;i++) printf "%s ", $i; print ""}')
last_mod=$(stat -c %Y /var/spool/cron/crontabs/$user 2>/dev/null || echo "unknown")
echo "$user,$schedule,$command,$last_mod" >> cron_migration.csv
done
done
echo "Migration inventory saved to cron_migration.csv"
echo "Review this file, then recreate jobs in your chosen scheduler."
|
This gives you a complete inventory of every scheduled task across your servers — often revealing forgotten jobs, duplicate entries, and opportunities for consolidation.
Choosing the Right Scheduler
Your decision should be guided by three factors:
Team size and collaboration needs. If you are a solo homelab operator, Cronicle or even a lightweight Go-based scheduler may be sufficient. If you have a team of 5+ engineers who need role-based access, audit trails, and approval workflows for production jobs, Rundeck’s maturity justifies its resource overhead.
Infrastructure scale. For single-server setups, any tool works. Once you need to schedule jobs across 3+ servers with load balancing and failover, Cronicle’s built-in multi-worker architecture or Rundeck’s node-based execution model become essential.
Compliance requirements. If you operate in a regulated environment (finance, healthcare, government), Rundeck’s audit logging, LDAP integration, and granular ACL system will save you significant effort during compliance audits. Cronicle covers the basics but lacks the depth required for formal compliance frameworks.
For most homelab operators and small teams starting out, Cronicle offers the best balance of features, simplicity, and resource efficiency. Deploy it via Docker, configure your backup and maintenance jobs, and you will have full visibility into every scheduled task across your infrastructure — something that plain crontab simply cannot provide.
Frequently Asked Questions (FAQ)
Which one should I choose in 2026?
The best choice depends on your specific requirements:
- For beginners: Start with the simplest option that covers your core use case
- For production: Choose the solution with the most active community and documentation
- For teams: Look for collaboration features and user management
- For privacy: Prefer fully open-source, self-hosted options with no telemetry
Refer to the comparison table above for detailed feature breakdowns.
Most tools support data import/export. Always:
- Backup your current data
- Test the migration on a staging environment
- Check official migration guides in the documentation
Are there free versions available?
All tools in this guide offer free, open-source editions. Some also provide paid plans with additional features, priority support, or managed hosting.
How do I get started?
- Review the comparison table to identify your requirements
- Visit the official documentation (links provided above)
- Start with a Docker Compose setup for easy testing
- Join the community forums for troubleshooting