# Cron setup: a practical walkthrough

LLMS index: [llms.txt](/en/llms.txt)

---

Cron is the standard job scheduler in Linux, found in every infrastructure. Jobs pile up, logs accumulate, and the environment breaks things. Let's walk through how to configure cron reliably and where it falls short.

## When to Use Cron and When to Avoid It

Cron fits simple recurring tasks: backups, log rotation, temp file cleanup, periodic notifications. It's a daemon that sleeps between runs — no resource consumption.

Avoid cron for:

- tasks requiring millisecond precision (cron fires at the minute level)
- tasks with hard dependencies on other services (use systemd units with `After=`)
- long-running processes that could overlap (you need locking)

## Anatomy of a Crontab Entry

Format:

```
* * * * * command
- - - - -
| | | | |
| | | | └── day of week (0-7, 0 and 7 = Sunday)
| | | └──── month (1-12)
| | └────── day of month (1-31)
| └──────── hour (0-23)
└────────── minute (0-59)
```

Special values:

```
@reboot   — at boot
@yearly   — once a year (0 0 1 1 *)
@monthly  — once a month (0 0 1 * *)
@weekly   — once a week (0 0 * * 0)
@daily    — once a day (0 0 * * *)
@hourly   — once an hour (0 * * * *)
```

Examples:

```
0 3 * * *          /opt/scripts/backup.sh       # daily at 03:00
15,45 * * * *      /usr/local/bin/check.sh      # every 15 and 45 minutes
0 */4 * * *        /opt/metrics/collect.sh       # every 4 hours
0 9-17 * * 1-5     /opt/reports/daily.sh         # hourly during business hours on weekdays
```

## crontab -e and crontab -l: Common Commands

```
crontab -l              # show current user crontab
crontab -e              # edit crontab (opens in EDITOR)
crontab -r              # remove crontab (no confirmation!)
crontab -l -u username  # view another user's crontab (from root)
crontab filename        # load jobs from file
```

> [!NOTE]
> Default editor is `vi`. Change it: `export EDITOR=nano`.

## Where System Schedules Live

Beyond user crontabs, there are system files:

```
/etc/crontab                # system crontab (format differs — includes username field)
/etc/cron.d/                # drop-in directory
/etc/cron.daily/            # daily jobs (run-parts)
/etc/cron.hourly/           # hourly jobs
/etc/cron.monthly/          # monthly jobs
/etc/cron.weekly/           # weekly jobs
/var/spool/cron/crontabs/   # user crontab files
```

Lines in `/etc/crontab` and `/etc/cron.d/*` include a `username` field:

```
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
MAILTO=root

* * * * * root /opt/scripts/check.sh
```

> [!WARNING]
> Don't add jobs directly to `/etc/crontab`. Use `/etc/cron.d/` instead. It's safer across package updates.

## Environment and PATH: Why Jobs Break in Cron

Cron runs commands with a minimal environment. Common failure:

```
# works in terminal
/opt/scripts/backup.sh

# in cron — "command not found"
```

Reason: cron sets `PATH` to only `/usr/bin:/bin`. Solutions:

**Specify full paths explicitly:**

```
0 3 * * * /usr/bin/python3 /opt/scripts/backup.py
```

**Set PATH in crontab:**

```
PATH=/usr/local/bin:/usr/bin:/bin:/opt/scripts
0 3 * * * backup.sh
```

**Use a wrapper script:**

```bash
#!/bin/bash
# /opt/scripts/run_backup.sh
source /etc/profile
cd /opt/project || exit 1
./backup.sh
```

> [!TIP]
> Always verify variables: `env | sort` in terminal vs. job `* * * * * env | sort > /tmp/cron_env.txt`.

## Output Redirection and Log Rotation

By default, cron emails stdout and stderr to the user. Set `MAILTO=""` to disable.

```
# send output to file
0 3 * * * /opt/scripts/backup.sh >> /var/log/backup.log 2>&1

# add timestamp to log (readability)
0 3 * * * /opt/scripts/backup.sh >> /var/log/backup.log 2>&1

# rotate old logs
0 3 * * * /opt/scripts/backup.sh >> /var/log/backup.log 2>&1 && \
  find /var/log -name "backup.log*" -mtime +7 -delete
```

Or use `logger` for syslog:

```
0 3 * * * /opt/scripts/backup.sh 2>&1 | logger -t backup
```

## Timezones and TZ

Cron uses the system timezone. To override:

```
# option 1: variable in crontab
TZ=Europe/Moscow
0 9 * * * /opt/scripts/report.sh

# option 2: wrapper
0 9 * * * TZ=Europe/Moscow /opt/scripts/report.sh
```

> [!WARNING]
> TZ only affects the schedule. Inside the script, use `$TZ` explicitly: `date +%Z` shows system timezone.

Check schedule in UTC:

```
crontab -l | while read line; do
  if [[ ! "$line" =~ ^# ]] && [[ ! -z "$line" ]]; then
    echo "$line" | awk '{print $1":"$2" UTC  |  "$5" "$6" "$7" "$8" "$9" "$10}'
  fi
done
```

## Common Pitfalls and Pre-Deployment Checks

**1. Percent sign in commands**

`%` in crontab means newline. Escape it:

```
# Wrong:
0 3 * * * /opt/scripts/report.sh "Report for $(date +%Y-%m-%d)"

# Right:
0 3 * * * /opt/scripts/report.sh "Report for $(date +\%Y-\%m-\%d)"
```

**2. Overlapping jobs**

If a script may run longer than the interval, use locking:

```bash
# /opt/scripts/long-task.sh
LOCKFILE=/var/run/long-task.lock

if [ -f "$LOCKFILE" ]; then
  echo "Already running" >&2
  exit 1
fi

trap "rm -f $LOCKFILE" EXIT
touch "$LOCKFILE"

# main logic
sleep 30
```

**3. Pre-deployment checks**

```bash
# show next run time for each job
for f in /etc/cron.d/*; do
  if [ -f "$f" ]; then
    echo "=== $f ==="
    head -1 "$f"
    # nearest run time
    next=$(echo "0 3 * * *" | sed 's/\*/0/g' | xargs -I{} date -d "{}" '+%Y-%m-%d %H:%M')
    echo "Next: $next"
  fi
done

# dry run
cat /etc/cron.d/my-task | grep -v "^#" | grep -v "^$" | while read schedule cmd; do
  echo "Would run: $cmd"
done
```

**4. Syntax and validation**

```bash
# check crontab format
crontab -l | grep -v "^#" | grep -v "^$" | awk '{print $1" "$2" "$3" "$4" "$5}' | \
  while read min hour dom mon dow; do
    # basic check
    echo "$min $hour $dom $mon $dow"
  done
```

## Ansible and Cron: Idempotent Job Setup

```yaml
- name: Add backup cron job
  community.general.cron:
    name: "backup database"
    minute: "0"
    hour: "3"
    job: "/opt/scripts/backup.sh >> /var/log/backup.log 2>&1"
    user: "root"
    state: present
    cron_file: "backup"
```

```yaml
# Remove job
- name: Remove old cron job
  community.general.cron:
    name: "obsolete task"
    state: absent
    user: "root"
```

```yaml
# Drop a file into /etc/cron.d/
- name: Deploy cron file
  ansible.builtin.copy:
    src: files/my-cron-job
    dest: /etc/cron.d/my-cron-job
    owner: root
    group: root
    mode: "0644"
  notify: restart cron
```

## systemd Timers as an Alternative to Cron

Timers are more precise, support service dependencies, randomiseddelaysec, and calendar specs.

```ini
# /etc/systemd/system/backup.service
[Unit]
Description=Backup database

[Service]
Type=oneshot
ExecStart=/opt/scripts/backup.sh

[Install]
WantedBy=multi-user.target
```

```ini
# /etc/systemd/system/backup.timer
[Unit]
Description=Run backup daily at 3am

[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true

[Install]
WantedBy=timers.target
```

```bash
systemctl daemon-reload
systemctl enable --now backup.timer
systemctl list-timers --all | grep backup
```

Timer benefits:

- dependencies (`After=network.target`)
- logging via journal (`journalctl -u backup.service`)
- randomiseddelaysec for staggering (avoid thundering herd)
- one-shot and monotonic timers

Cron stays simpler for basic tasks. systemd timers are the right choice for services with dependencies and monitoring through journald.
