How Cron Works: The Daemon, the Spool, and the Schedule

The cron daemon reads two sets of files: per-user crontabs stored in /var/spool/cron/crontabs/ and system crontabs in /etc/cron.d/. On systemd-based distros, crond itself is managed as a service. Check it with:

systemctl status cron # Debian/Ubuntu systemctl status crond # RHEL/CentOS

The daemon wakes up every minute, compares the current time against every active job, and forks a shell for each match. Jobs run in a minimal environment - PATH is typically /usr/bin:/bin, HOME is the user's home directory, and SHELL defaults to /bin/sh, not bash. This minimal environment is the source of roughly 60% of the "my script works manually but fails in cron" tickets we see in production.

Understanding the spool directory matters when you are debugging. If you edit /var/spool/cron/crontabs/root directly instead of using crontab -e, you bypass the syntax checker and can corrupt the file silently. Always use crontab -e. The -e flag opens the file in your $EDITOR, validates it on save, and only installs it if the syntax passes.

# Check which crontabs are installed
ls -la /var/spool/cron/crontabs/

# List crontab for current user
crontab -l

# List crontab for specific user (as root)
crontab -l -u deploy

# Edit crontab for current user
crontab -e

Crontab Syntax: Every Field Explained

A crontab entry has five time fields followed by the command. The fields are minute, hour, day-of-month, month, and day-of-week, in that order. Each field accepts a specific number, an asterisk for 'every', a comma-separated list, a hyphen for a range, or a slash for step values.

Day-of-week runs 0 through 7 where both 0 and 7 represent Sunday. Some versions of cron also accept three-letter abbreviations: sun, mon, tue, wed, thu, fri, sat. Mixing numeric and text forms in the same field causes undefined behavior on some implementations, so pick one and stick with it.

Step values are the feature most sysadmins underuse. */5 in the minute field means 'every 5 minutes'. 1-59/2 means 'every odd minute from 1 to 59'. This matters when you want to stagger jobs across a cluster to avoid thundering herd on a shared database or NFS mount.

# ┌───────────── minute (0-59)
# │ ┌───────────── hour (0-23)
# │ │ ┌───────────── day of month (1-31)
# │ │ │ ┌───────────── month (1-12)
# │ │ │ │ ┌───────────── day of week (0-7, 0 and 7 are Sunday)
# │ │ │ │ │
# * * * * *  command to execute

# Run at 2:30 AM every day
30 2 * * * /opt/scripts/backup.sh

# Run every 15 minutes
*/15 * * * * /opt/scripts/health-check.sh

# Run at midnight on the first of every month
0 0 1 * * /opt/scripts/monthly-report.sh

# Run at 9 AM Monday through Friday
0 9 * * 1-5 /opt/scripts/weekday-job.sh

# Run every 6 hours, staggered to :07 past the hour
7 */6 * * * /opt/scripts/sync.sh

# Run on the 15th and last day of the month (approximate)
0 0 15,28-31 * * [ $(date +\%d) -ge 28 ] && /opt/scripts/end-of-month.sh || /opt/scripts/mid-month.sh

Special Strings and @reboot

Vixie cron and its derivatives (which ship with every major distro in 2026) support shorthand strings that replace the five time fields. These are more readable for common patterns and less error-prone than manual field entry.

@reboot deserves special attention. It runs the command once when the cron daemon starts, which on a systemd system means once at boot after cron.service reaches the active state. This is useful for starting user-space daemons or running initialization scripts without adding a full systemd unit. We have used @reboot on dozens of production servers to start tmux sessions with monitoring dashboards for on-call engineers.

One important limitation: @reboot does not guarantee that networking, mounts, or other services are available. If your @reboot job depends on a mounted NFS share or a running database, you need a systemd unit with proper After= and Requires= directives instead.

@reboot     # Run once at startup
@yearly     # = 0 0 1 1 *
@annually   # = 0 0 1 1 *
@monthly    # = 0 0 1 * *
@weekly     # = 0 0 * * 0
@daily      # = 0 0 * * *
@midnight   # = 0 0 * * *
@hourly     # = 0 * * * *

# Practical @reboot example: start a monitoring session
@reboot /usr/bin/tmux new-session -d -s monitor '/opt/scripts/live-dashboard.sh'

# Run a database warmup script at boot
@reboot sleep 30 && /opt/scripts/db-warmup.sh >> /var/log/db-warmup.log 2>&1
// advertisement

Environment Variables: The Silent Job Killer

The single biggest source of cron failures in production is environment mismatch. When cron runs your job, it does not source ~/.bashrc, ~/.bash_profile, or /etc/profile. The PATH is minimal, DISPLAY is not set, and any variables you export in your shell profile are absent.

The most reliable fix is to set PATH explicitly at the top of your crontab file. Variables set before the first job definition apply to all jobs in that crontab. You can also set SHELL if your scripts require bash-specific features.

For scripts that rely on many environment variables - database credentials, API keys, S3 bucket names - we recommend sourcing a dedicated environment file at the start of the script rather than setting everything in crontab. This keeps secrets out of crontab -l output and makes the script testable in isolation with the same environment cron will use.

Mail output is another environment concern. By default, cron mails any stdout or stderr from a job to the local user's mailbox using sendmail or a compatible MTA. If you do not have a working MTA, this causes silent failures or log spam. Set MAILTO= (empty) in your crontab to disable mail, and redirect output explicitly to a log file.

# At the top of your crontab file:
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
MAILTO=""

# For scripts needing many env vars, source a file:
*/5 * * * * source /etc/app-env && /opt/scripts/check.sh >> /var/log/check.log 2>&1

# The /etc/app-env file:
# export DB_HOST=db1.internal
# export DB_PASS=secretvalue
# export S3_BUCKET=prod-backups

# Test your script with the same environment cron will use:
env -i HOME=/root SHELL=/bin/bash PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin /opt/scripts/your-script.sh

System-Wide Crontabs: /etc/crontab and /etc/cron.d/

/etc/crontab and files dropped into /etc/cron.d/ have a sixth field between the time fields and the command: the username to run the command as. This is what distinguishes system crontabs from per-user ones. The root user reads and executes these, but the job itself runs as the specified user.

Packages commonly use /etc/cron.d/ to install their own scheduled jobs. Run ls /etc/cron.d/ on any production server and you will find entries from logrotate, sysstat, update-notifier, and whatever application packages are installed. These files must be owned by root and not group-writable, or cron will ignore them with a security check failure.

The /etc/cron.daily/, /etc/cron.hourly/, /etc/cron.weekly/, and /etc/cron.monthly/ directories work differently - they contain executable scripts, not crontab-format files. On Debian/Ubuntu, run-parts executes everything in those directories on the schedule defined in /etc/crontab. On RHEL, anacron handles these. Drop a script into /etc/cron.daily/ and it runs once per day. The scripts must be executable and must not have a file extension on Debian-based systems (run-parts by default ignores files with dots in the name).

# System crontab format - note the extra username field
# /etc/crontab
SHELL=/bin/sh
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin

# m  h  dom  mon  dow  user    command
17   *  *    *    *    root    cd / && run-parts --report /etc/cron.hourly
25   6  *    *    *    root    test -x /usr/sbin/anacron || ( cd / && run-parts --report /etc/cron.daily )

# /etc/cron.d/myapp - same format as /etc/crontab
*/10 * * * * appuser /opt/myapp/scripts/queue-worker.sh >> /var/log/myapp/queue.log 2>&1

# Check file permissions on cron.d entries
ls -la /etc/cron.d/
# Files must be: -rw-r--r-- root root
chmod 644 /etc/cron.d/myapp
chown root:root /etc/cron.d/myapp

Preventing Overlapping Runs with flock

If a cron job takes longer than its interval to complete, the next scheduled run starts before the first finishes. For backup scripts, database maintenance, or any job that writes to shared resources, overlapping runs cause corruption, deadlocks, or cascading failures.

flock from util-linux is the right tool for this. It acquires an exclusive lock on a file before executing your command and releases it when the command exits. If another instance is already running and holds the lock, flock either waits or exits immediately depending on flags.

We use flock -n for most production jobs. The -n flag makes it non-blocking: if the lock cannot be acquired, flock exits with code 1 and the job is skipped for this interval. This prevents queue buildup on slow jobs. Use -w seconds to wait up to a timeout before giving up, which is better for jobs that occasionally run long but should not be skipped entirely.

For more complex workflow orchestration, tools like taskbotshub.ai offer managed job scheduling with dependency resolution, retry logic, and alerting built in - useful when cron's fire-and-forget model starts creating gaps in your observability.

# Non-blocking lock: skip this run if previous is still running
*/5 * * * * flock -n /var/lock/myapp-worker.lock /opt/myapp/worker.sh >> /var/log/myapp/worker.log 2>&1

# Wait up to 5 minutes before giving up
0 2 * * * flock -w 300 /var/lock/backup.lock /opt/scripts/backup.sh

# With explicit lock file and verbose logging
*/10 * * * * flock -n /var/lock/queue.lock -c '/opt/scripts/queue.sh >> /var/log/queue.log 2>&1' || echo "$(date): queue job already running, skipped" >> /var/log/queue-skip.log

# Verify flock is available
which flock
flock --version
// advertisement

Logging Cron Jobs: What Actually Helps at 3 AM

The default cron log on Ubuntu 22.04 is /var/log/syslog. On RHEL 9, it is /var/log/cron. These logs tell you when cron ran a job and as which user, but they contain nothing about the job's output or exit code unless you redirect it yourself.

Every production cron job should redirect both stdout and stderr to a log file. Use 2>&1 to merge stderr into stdout, or log them separately if you need to distinguish them. Prepend timestamps using the date command or the ts utility from moreutils.

Log rotation is not optional for frequently running jobs. Without it, /var/log/myapp/worker.log hits gigabytes within days. Write a logrotate config in /etc/logrotate.d/ for any custom log file your cron jobs create.

For structured logging that feeds into your existing log aggregation stack (Loki, Elasticsearch, Splunk), write JSON from your scripts rather than plain text. A small wrapper function handles this cleanly.

# Basic redirect with timestamp
*/5 * * * * /opt/scripts/worker.sh >> /var/log/myapp/worker.log 2>&1

# Timestamped output using ts (apt install moreutils)
*/5 * * * * /opt/scripts/worker.sh 2>&1 | ts '[%Y-%m-%d %H:%M:%S]' >> /var/log/myapp/worker.log

# Separate stdout and stderr
*/5 * * * * /opt/scripts/worker.sh >> /var/log/myapp/worker-out.log 2>> /var/log/myapp/worker-err.log

# Logrotate config: /etc/logrotate.d/myapp
# /var/log/myapp/*.log {
#     daily
#     rotate 14
#     compress
#     delaycompress
#     missingok
#     notifempty
#     create 0640 appuser appuser
# }

# JSON logging wrapper in bash
log_json() {
    local level=$1 msg=$2
    printf '{"ts":"%s","level":"%s","job":"worker","msg":"%s"}\n' \
        "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$level" "$msg"
}

# View only cron entries in syslog
grep CRON /var/log/syslog | tail -50
grep cron /var/log/cron | tail -50

Cron Access Control: cron.allow and cron.deny

Cron respects two files for access control: /etc/cron.allow and /etc/cron.deny. The logic follows the same pattern as hosts.allow and hosts.deny.

If /etc/cron.allow exists, only users listed in it can use crontab. If it does not exist but /etc/cron.deny exists, everyone except listed users can use crontab. If neither file exists, behavior is implementation-dependent - on most modern Linux systems, only root can use crontab in that case, though some distros allow all users.

For a multi-user server where only specific service accounts should be able to schedule jobs, create /etc/cron.allow with exactly those usernames, one per line. This prevents developers who have SSH access from accidentally scheduling resource-intensive jobs during business hours.

# Allow only root and deploy user to use crontab
cat /etc/cron.allow
# root
# deploy
# appuser

# Deny specific users
cat /etc/cron.deny
# contractor1
# temp-user

# Verify a user's crontab access by attempting to list it as root
crontab -l -u contractor1
# Should return: crontabs for contractor1: permission denied

# Check current allow/deny files
ls -la /etc/cron.allow /etc/cron.deny 2>/dev/null

Debugging Cron Jobs: A Systematic Approach

When a cron job fails silently, work through these checks in order. First, verify the job actually ran by checking the cron log. Second, run the exact command manually as the same user cron uses, with the same minimal environment. Third, check for missing dependencies in PATH. Fourth, verify file permissions.

The env -i trick is the fastest way to reproduce the cron environment locally. Strip your shell's environment and set only what cron provides, then run your command. If it fails here but not in your normal shell, you have found an environment dependency.

For scripts that fail intermittently, add set -x at the top of the script during debugging and capture the full trace to a file. Remove it before returning to production - the output volume from set -x on a job running every minute will fill your disk.

On RHEL 9, SELinux occasionally blocks cron jobs from accessing files or network sockets. Check /var/log/audit/audit.log for AVC denials if a job fails with permission errors despite correct file permissions. The audit2allow tool can generate the necessary policy module if the denial is legitimate.

# Step 1: Verify the job ran
grep CRON /var/log/syslog | grep worker | tail -20

# Step 2: Reproduce the cron environment
sudo -u appuser env -i \
    HOME=/home/appuser \
    SHELL=/bin/bash \
    PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin \
    /opt/scripts/worker.sh

# Step 3: Check which binaries are missing from cron's PATH
which psql         # Is it in /usr/local/bin?
which python3      # Is it in /usr/bin?

# Step 4: Verify permissions
ls -la /opt/scripts/worker.sh
stat /opt/scripts/worker.sh

# Step 5: Check SELinux denials (RHEL)
ausearch -m AVC -ts recent | grep cron

# Add debug output to a temporary wrapper
cat > /tmp/debug-wrapper.sh << 'EOF'
#!/bin/bash
set -x
exec 2>> /tmp/cron-debug.log
date
env
/opt/scripts/worker.sh
EOF
chmod +x /tmp/debug-wrapper.sh
// advertisement

Cron Alternatives: When to Reach Past Cron

Cron has real limitations in 2026. It has no built-in retry logic. It cannot express dependencies between jobs. It provides no visibility into job duration or exit codes without custom wrapper scripts. It does not scale across multiple hosts.

For jobs that need retry on failure, use a wrapper script with exponential backoff, or move to systemd timers which have OnFailure= directives and full journal integration. Systemd timers also survive missed runs via Persistent=true, which anacron provides for /etc/cron.daily but cron itself does not.

For distributed task scheduling across a fleet, tools like Celery with Redis, HashiCorp Nomad's periodic jobs, or Kubernetes CronJobs handle multi-host scheduling. When you need AI-assisted automation pipelines or complex conditional triggers, platforms like taskbotshub.ai provide a higher-level abstraction over the raw scheduler.

For naming cron job scripts and automation projects consistently across teams - especially when they end up with public-facing names or internal tooling registries - using a clean, memorable naming convention matters. If a project grows into an external service, a quick check on nicename.me helps find available domain names aligned with the project name early, before the name is locked in.

For single-server use cases with complex schedules, systemd timers are the right upgrade path from cron. They give you journalctl integration, precise timing, and dependency management without adding external dependencies.

# Systemd timer equivalent of a cron job
# /etc/systemd/system/myapp-worker.service
[Unit]
Description=MyApp Queue Worker
After=network.target postgresql.service

[Service]
Type=oneshot
User=appuser
ExecStart=/opt/scripts/worker.sh
StandardOutput=journal
StandardError=journal

# /etc/systemd/system/myapp-worker.timer
[Unit]
Description=Run MyApp Worker every 5 minutes

[Timer]
OnCalendar=*:0/5
Persistent=true

[Install]
WantedBy=timers.target

# Enable and start
systemctl daemon-reload
systemctl enable --now myapp-worker.timer

# Check timer status
systemctl list-timers myapp-worker.timer
journalctl -u myapp-worker.service -n 50

Production Crontab Template

After a decade of managing production cron jobs, we have converged on a standard template that prevents the most common failures. Every production crontab on our servers follows this structure: explicit environment at the top, MAILTO disabled with output redirected to log files, flock on anything that writes to shared state, and a comment block above each job explaining what it does and who owns it.

Keep crontabs in version control. We store them in /etc/cron.d/ for system jobs and deploy them via configuration management (Ansible, Puppet, or Chef). For per-user crontabs, use crontab - < /path/to/crontab to install from a file rather than editing by hand on each server. This makes crontab changes auditable and reproducible.

# /etc/cron.d/myapp-production
# Owner: platform-team@company.com
# Last reviewed: 2026-06-01
# Deployed by: ansible role 'myapp'

SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin:/opt/myapp/bin
MAILTO=""

# Queue worker - processes background jobs
# Lock prevents overlap; logs to /var/log/myapp/
*/5 * * * * appuser flock -n /var/lock/myapp-worker.lock /opt/myapp/bin/worker.sh >> /var/log/myapp/worker.log 2>&1

# Database cleanup - removes records older than 90 days
# Runs at 3 AM to avoid peak hours
0 3 * * * appuser flock -w 3600 /var/lock/myapp-cleanup.lock /opt/myapp/bin/db-cleanup.sh >> /var/log/myapp/cleanup.log 2>&1

# Health check - pings monitoring endpoint
# No lock needed; idempotent and fast
* * * * * appuser /opt/myapp/bin/health-ping.sh > /dev/null 2>&1

# Monthly usage report - first day of month at 6 AM
0 6 1 * * appuser /opt/myapp/bin/usage-report.sh >> /var/log/myapp/reports.log 2>&1

# Install from file (in Ansible or manually)
# crontab -u appuser /etc/cron.d/myapp-production