From lehnert-skills
Use when user wants to schedule a task with cron, write a crontab entry, create a /etc/cron.d/ file, set up a cron job with locking and logging, convert a cron job to a systemd timer, debug a cron job that isn't running, or understand cron syntax and environment behavior.
How this skill is triggered — by the user, by Claude, or both
Slash command
/lehnert-skills:linux-cron-managerThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Generates production-ready cron entries and /etc/cron.d/ files with proper environment setup, flock-based locking to prevent overlapping runs, structured logging, and failure alerting. Includes a full cron syntax reference and a conversion path to systemd timers for complex scheduling needs.
Generates production-ready cron entries and /etc/cron.d/ files with proper environment setup, flock-based locking to prevent overlapping runs, structured logging, and failure alerting. Includes a full cron syntax reference and a conversion path to systemd timers for complex scheduling needs.
Language: Respond in the user's language. Cron files and scripts in English.
linux-systemd-managerlinux-shell-scriptor| User says | Output |
|---|---|
| "run script X every Y" | /etc/cron.d/ entry + wrapper with locking + logging |
| "crontab entry for..." | Direct crontab line with explanation |
| "my cron job isn't running" | Debugging checklist |
| "convert cron to systemd timer" | Equivalent .service + .timer pair |
| "cron syntax for..." | Explanation + validated expression |
⚠️ Timezone: Cron uses the system timezone (from
/etc/timezone). To override per-job, addTZ=America/New_Yorkat the top of the/etc/cron.d/file — this affects all entries in that file only.
┌──────────── minute (0–59)
│ ┌────────── hour (0–23)
│ │ ┌──────── day of month (1–31)
│ │ │ ┌────── month (1–12 or JAN–DEC)
│ │ │ │ ┌──── day of week (0–7, 0 and 7 = Sunday, or SUN–SAT)
│ │ │ │ │
* * * * * user command
Common expressions:
| Schedule | Expression |
|---|---|
| Every minute | * * * * * |
| Every 5 minutes | */5 * * * * |
| Every hour at :00 | 0 * * * * |
| Every hour at :30 | 30 * * * * |
| Daily at 2 AM | 0 2 * * * |
| Daily at 2:30 AM | 30 2 * * * |
| Every Monday at 3 AM | 0 3 * * 1 |
| Weekdays at 9 AM | 0 9 * * 1-5 |
| 1st of every month | 0 0 1 * * |
| Every 15 minutes | */15 * * * * |
| Twice daily (6 AM + 6 PM) | 0 6,18 * * * |
| Every Sunday at midnight | 0 0 * * 0 |
Validate any expression at: https://crontab.guru
Never write a bare cron entry. Always wrap with locking, logging, and error handling.
/etc/cron.d/myapp-task:
# myapp-task — describe what this does
# Runs: daily at 2 AM
# Logs: /var/log/cron/myapp-task.log
# Owner: appuser
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
MAILTO=""
HOME=/home/appuser # cron doesn't source profile — set HOME explicitly to avoid "~" surprises
0 2 * * * appuser /usr/bin/flock -n /var/lock/myapp-task.lock /opt/myapp/tasks/run-task.sh
/opt/myapp/tasks/run-task.sh):Always generate the wrapper script alongside the cron entry:
#!/usr/bin/env bash
set -euo pipefail
# ── Config ──────────────────────────────────────────────────
SCRIPT_NAME="myapp-task"
LOG_DIR="/var/log/cron"
LOG_FILE="${LOG_DIR}/${SCRIPT_NAME}.log"
LOCK_FILE="/var/lock/${SCRIPT_NAME}.lock"
MAX_LOG_SIZE_MB=50
# ── Logging ──────────────────────────────────────────────────
mkdir -p "$LOG_DIR"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [INFO] $*" | tee -a "$LOG_FILE"; }
warn() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [WARN] $*" | tee -a "$LOG_FILE" >&2; }
error() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR] $*" | tee -a "$LOG_FILE" >&2; }
# ── Log rotation ─────────────────────────────────────────────
if [[ -f "$LOG_FILE" ]]; then
LOG_SIZE_MB=$(du -m "$LOG_FILE" | cut -f1)
if [[ "$LOG_SIZE_MB" -ge "$MAX_LOG_SIZE_MB" ]]; then
ARCHIVED_LOG="${LOG_FILE}.$(date +%Y%m%d_%H%M%S)"
mv "$LOG_FILE" "$ARCHIVED_LOG"
gzip "$ARCHIVED_LOG"
fi
fi
# ── Lock — prevent concurrent runs ───────────────────────────
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
warn "Another instance is already running. Exiting."
exit 0
fi
# ── Trap — cleanup on exit ───────────────────────────────────
cleanup() {
local exit_code=$?
if [[ $exit_code -ne 0 ]]; then
error "Task failed with exit code $exit_code"
# Optional: send alert
# echo "Cron job $SCRIPT_NAME failed" | mail -s "CRON FAILURE: $SCRIPT_NAME" [email protected]
fi
flock -u 9
}
trap cleanup EXIT
# ── Main ─────────────────────────────────────────────────────
log "=== Starting $SCRIPT_NAME ==="
# --- YOUR TASK CODE HERE ---
# Example:
# log "Running backup..."
# /opt/backup/backup.sh
# log "Backup complete"
# ---------------------------
log "=== $SCRIPT_NAME complete ==="
Add to the cron file to capture output and email on non-zero exit:
# Option 1: Send all output to email (requires working MTA)
MAILTO="[email protected]"
0 2 * * * appuser /opt/myapp/tasks/run-task.sh
# Option 2: Only email on failure using a wrapper
0 2 * * * appuser /opt/myapp/tasks/run-task.sh || echo "myapp-task failed" | mail -s "CRON FAILURE" [email protected]
# Option 3: Log to file and send to a webhook (Slack/Discord) on failure
0 2 * * * appuser /opt/myapp/tasks/run-task.sh 2>&1 | tee -a /var/log/cron/myapp.log | \
grep -i "error\|fail" | while read line; do \
curl -s -X POST -H 'Content-type: application/json' \
--data "{\"text\":\"Cron failure: $line\"}" "${SLACK_WEBHOOK_URL}"; done
Cron runs in a minimal environment — this is the #1 cause of cron jobs that "work manually but not in cron."
Always include in /etc/cron.d/ files:
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
Common environment pitfalls:
| Problem | Cause | Fix |
|---|---|---|
| Command not found | PATH is minimal in cron | Use full path: /usr/bin/python3 |
| Script works manually, not in cron | Missing env vars | Add source /etc/environment or export each var at top of wrapper script |
| App-specific env vars missing | .env file not loaded | Add set -a; source /opt/myapp/.env; set +a at top of wrapper script |
| Python/Node virtualenv not active | No shell init | Source virtualenv explicitly: source /opt/venv/bin/activate |
| HOME not set | Cron doesn't source profile | Add HOME=/home/appuser to cron file |
| No display/dbus | X11/dbus not available | Not suitable for GUI apps; use headless mode |
When a cron job isn't running or is failing silently:
# 1. Verify cron daemon is running
systemctl status cron # Debian/Ubuntu
systemctl status crond # RHEL/Rocky
# 2. Check cron logs
grep CRON /var/log/syslog | tail -30 # Debian/Ubuntu
journalctl -u cron --since "1 hour ago" # systemd systems
grep cron /var/log/messages | tail -30 # RHEL/Rocky
# 3. Check the cron entry is valid
crontab -l -u appuser # list user crontab
ls -la /etc/cron.d/ # list system cron jobs
# 4. Test the command manually as the cron user
sudo -u appuser /path/to/script.sh
# 5. Test with same minimal environment as cron
sudo -u appuser env -i HOME=/home/appuser \
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin \
/path/to/script.sh
# 6. Verify file permissions
ls -la /path/to/script.sh # must be executable
stat /etc/cron.d/myfile # must be owned by root, mode 644
# 7. Check for syntax errors in /etc/cron.d/ files
# File must NOT have a .sh extension — cron ignores them
# File must NOT be world-writable
The #1 reason cron jobs "silently do nothing": output (stdout + stderr) is discarded unless captured. Always write logs inside the wrapper script using the log() function above, or add >> /var/log/cron/myapp.log 2>&1 to the cron entry.
Common reasons cron jobs silently do nothing:
| Issue | Check |
|---|---|
| Script not executable | chmod +x script.sh |
/etc/cron.d/ file has .sh extension | Rename to no extension |
/etc/cron.d/ file is world-writable | chmod 644 |
| Wrong user in cron entry | Verify user exists: id appuser |
| Script path wrong | Use absolute paths only in cron |
| Cron daemon not reloaded | systemctl reload cron after adding new file |
| Lock file stuck from previous crash | Delete /var/lock/scriptname.lock |
When to switch to systemd timers instead of cron:
| Use cron when | Use systemd timer when | Use Anacron when |
|---|---|---|
| Simple schedule, no dependencies | Need After= dependencies | System isn't always on (laptop, desktop) |
| Script already works standalone | Need resource limits (MemoryMax, CPUQuota) | Daily/weekly tasks that must run even after downtime |
| Quick setup needed | Need detailed logging via journalctl | No systemd available and Persistent=true is needed |
| No systemd available (Alpine, containers) | Need Persistent=true for missed runs | — |
Anacron (/etc/anacrontab) — runs jobs that were missed while the system was off:
# period delay job-id command
1 5 daily-backup /opt/backup/backup.sh
7 10 weekly-report /opt/reports/weekly.sh
Install: apt install anacron / dnf install cronie-anacron
Conversion example:
Cron entry:
0 2 * * * appuser /opt/myapp/backup.sh
Equivalent systemd timer — see /linux-systemd-manager for full output.
Write files to ./cron/ in the current working directory:
cron/
myapp-task ← drop into /etc/cron.d/ (no extension!)
run-task.sh ← wrapper script with locking + logging
Then print ONLY:
✅ Cron job written to ./cron/
▶ Install:
# ⚠️ /etc/cron.d files MUST be owned by root with mode 644.
# Cron silently ignores files with wrong ownership or permissions.
sudo cp cron/myapp-task /etc/cron.d/myapp-task
sudo chmod 644 /etc/cron.d/myapp-task
sudo chown root:root /etc/cron.d/myapp-task
sudo cp cron/run-task.sh /opt/myapp/tasks/run-task.sh
sudo chmod 755 /opt/myapp/tasks/run-task.sh
sudo chown appuser:appuser /opt/myapp/tasks/run-task.sh
sudo systemctl reload cron # tell cron to pick up the new /etc/cron.d/ file
▶ Verify cron picked it up:
grep myapp-task /var/log/syslog
# or: journalctl -u cron | grep myapp-task
▶ Test manually as cron user:
sudo -u appuser /opt/myapp/tasks/run-task.sh
💡 For complex scheduling with dependencies or resource limits → /linux-systemd-manager
npx claudepluginhub chfle/lehnert-claude-skills --plugin linux-vuln-scannerBuilds production-grade scheduled jobs with cron syntax, overlap prevention, monitoring, and structured logging. Activates when users mention cron jobs, scheduled tasks, or recurring jobs.
Schedules Claude Code tasks via native OS schedulers (launchd, crontab, Task Scheduler) from natural language. Handles one-time/recurring jobs with git worktree isolation.