## What it is

Cron is the Unix scheduler — a long-running daemon that wakes once a minute and runs whatever commands match the current time. Crontabs are the per-user files that tell it what to run; every Linux server with scheduled tasks (backups, log rotation, certificate renewals, queue runners, app-specific batch jobs) has a crontab somewhere driving them.

This sheet covers the syntax and idioms you reach for when wiring cron into a deploy pipeline — the per-release crontab installation that avoids stale jobs, the `flock` patterns that prevent two long-running jobs from racing each other, and the systemd-timer alternatives that solve cron's three structural weaknesses (no logging, no overlap protection, no notifications).

## Quick reference

### crontab commands

```bash
crontab -l                                              # list current user's crontab
crontab -e                                              # edit (uses $EDITOR; vi default)
crontab -r                                              # remove (DANGEROUS — no prompt)
crontab -i                                              # interactive remove (prompts)
crontab my-jobs.cron                                    # install crontab from a file
crontab -u www-data -l                                  # list another user's crontab (root only)
crontab -u www-data my-jobs.cron                        # install for another user
```

`crontab -r` has no confirmation prompt — typing it with the wrong user logged in nukes the whole schedule. Always alias `crontab='crontab -i'` in your shell rc, or use the file-based install pattern (workflow 1 below).

### Field syntax

```
┌───── minute        (0–59)
│ ┌─── hour          (0–23)
│ │ ┌─ day of month  (1–31)
│ │ │ ┌─ month       (1–12 or jan-dec)
│ │ │ │ ┌─ day of week (0–6 or sun-sat; 0 = Sunday)
│ │ │ │ │
* * * * * command-to-run
```

| Symbol | Meaning |
|---|---|
| `*` | every value |
| `,` | list (`0,15,30,45`) |
| `-` | range (`9-17`) |
| `/` | step (`*/5` = every 5; `0-30/10` = 0,10,20,30) |
| `~` | random within range (`H/15` in Jenkins-style; not supported in vixie cron) |

### Common schedules

```cron
* * * * *   cmd            # every minute
*/5 * * * * cmd            # every 5 minutes
0 * * * *   cmd            # top of every hour
0 */2 * * * cmd            # every 2 hours
30 3 * * *  cmd            # 03:30 every day
0 9 * * 1-5 cmd            # 09:00 on weekdays
15 0 1 * *  cmd            # 00:15 on the 1st of each month
0 0 * * 0   cmd            # midnight every Sunday
0 0 1 1 *   cmd            # midnight on Jan 1st (annual)

# Day-of-month AND day-of-week — IMPORTANT: this is an OR, not an AND.
0 0 13 * 5  cmd            # midnight on either the 13th OR a Friday (not "Friday the 13th")
```

### Special strings (vixie cron / GNU mcron)

```cron
@reboot     cmd            # once at boot
@hourly     cmd            # = 0 * * * *
@daily      cmd            # = 0 0 * * *  (alias: @midnight)
@weekly     cmd            # = 0 0 * * 0
@monthly    cmd            # = 0 0 1 * *
@yearly     cmd            # = 0 0 1 1 *  (alias: @annually)
```

`@reboot` is the underused one — single line that fires once after boot. Useful for starting a queue worker, mounting a network filesystem, warming a cache.

### Environment variables in crontab

```cron
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
MAILTO=ops@example.com                                  # where stdout/stderr is mailed
HOME=/var/www

# Date in a variable using ${} for command substitution
* * * * * /usr/local/bin/backup.sh > /var/log/backup.log 2>&1
```

Cron's default `PATH` is *minimal* — usually just `/usr/bin:/bin`. Setting `PATH` near the top of the crontab catches the #1 production failure ("works in my shell, not in cron").

### Output redirection patterns

```cron
* * * * * cmd                                  # stdout/stderr mailed to MAILTO
* * * * * cmd > /var/log/cmd.log 2>&1          # log everything
* * * * * cmd > /dev/null 2>&1                 # silence (use sparingly — see workflow 4)
* * * * * cmd >> /var/log/cmd.log 2>&1         # append (won't truncate)
* * * * * cmd 2>&1 | logger -t myjob           # syslog with tag "myjob"
```

The order `> file 2>&1` matters — `2>&1` must come *after* the stdout redirect. Reversed (`2>&1 > file`) sends stdout to the file but leaves stderr on the original stdout, which usually means /dev/null in cron context.

### User crontabs vs system crontabs

| Location | Owner | Format | Notes |
|---|---|---|---|
| `crontab -e` (per-user) | logged-in user | `m h dom mon dow cmd` | Stored under `/var/spool/cron/crontabs/<user>` |
| `/etc/crontab` | root | `m h dom mon dow USER cmd` | Extra field for user |
| `/etc/cron.d/<file>` | root | `m h dom mon dow USER cmd` | Drop-in style |
| `/etc/cron.{hourly,daily,weekly,monthly}/` | root | shell scripts | Run via `run-parts` |
| `anacron` (`/etc/anacrontab`) | root | period delay USER cmd | For laptops; runs missed jobs at boot |

`/etc/cron.d/` is the right place for per-application schedules installed by a package manager or a deploy hook — one file per app, easy to add and remove cleanly.

### Reading cron logs

```bash
journalctl -u cron --since "1 hour ago"                # systemd-managed cron
journalctl -u crond --since "1 hour ago"               # RHEL/Fedora
grep CRON /var/log/syslog | tail -50                   # Debian/Ubuntu (legacy)
grep CRON /var/log/cron   | tail -50                   # RHEL legacy

# Specifically: which user ran what command at what minute
journalctl -u cron --since today | grep '\[CMD\]'
```

---

## Deployment workflows (the moat)

### 1. Per-deploy crontab installation that's atomic

The wrong way to update a crontab is `crontab -e` in production — manual edits drift across servers and are never in version control. The right way is to ship a crontab file with the release, then install it atomically as part of the deploy hook:

```bash
#!/usr/bin/env bash
# Run as the user whose crontab you're updating, NOT as root
set -euo pipefail

RELEASE="/var/www/current"
CRON_FILE="$RELEASE/config/crontab.production"

# 1. Sanity-check the file exists and is non-empty
test -s "$CRON_FILE" || { echo "no crontab file at $CRON_FILE" >&2; exit 1; }

# 2. Validate syntax — `crontab` validates on install but doesn't have a dry-run flag.
#    Best practical check: each non-comment, non-blank line has 6+ fields.
awk '!/^[[:space:]]*#/ && NF > 0 && NF < 6 { print "bad line:", $0; bad=1 }
     END { exit bad ? 1 : 0 }' "$CRON_FILE" || exit 1

# 3. Install — atomic from the user's perspective (crontab swaps the spool file in one syscall)
crontab "$CRON_FILE"

# 4. Verify what's installed matches what we shipped
diff <(crontab -l) "$CRON_FILE" || { echo "crontab mismatch after install" >&2; exit 1; }

echo "crontab installed: $(wc -l < "$CRON_FILE") lines"
```

What the example `config/crontab.production` looks like:

```cron
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
MAILTO=ops@example.com

# Laravel scheduler — runs the in-app schedule defined in App\Console\Kernel
* * * * * cd /var/www/current && /usr/bin/php artisan schedule:run >> /var/log/app/schedule.log 2>&1

# Nightly DB backup at 03:30 with a 1h lockfile guard
30 3 * * * /usr/bin/flock -w 3600 /var/lock/db-backup.lock /var/www/current/bin/db-backup.sh >> /var/log/app/db-backup.log 2>&1

# Hourly metric flush — skip if previous run is still going
0 * * * *  /usr/bin/flock -n /var/lock/metrics.lock /var/www/current/bin/flush-metrics.sh >> /var/log/app/metrics.log 2>&1

# Weekly log rotation
0 4 * * 0  /usr/sbin/logrotate -f /var/www/current/config/logrotate.conf
```

The key safety property is the symlink in every command: `/var/www/current/bin/db-backup.sh`. When the deploy swaps the `current/` symlink, the *next* cron tick automatically uses the new release. No stale jobs, no manual sync.

For a DeployHQ project, install this as a post-deploy SSH command. The deploy succeeds atomically only if the crontab installs successfully — failing here aborts the release.

### 2. `flock` for overlap protection

Cron has zero built-in protection against a job running longer than its interval. A 70-second backup scheduled every minute starts overlapping with itself at minute 2, and by minute 10 you have 10 concurrent backups thrashing the disk.

Two `flock` patterns, depending on what you want on overlap:

```bash
# Pattern A — SKIP if previous run still going (most jobs)
* * * * * /usr/bin/flock -n /var/lock/heartbeat.lock /usr/local/bin/heartbeat.sh

# Pattern B — QUEUE up to N minutes (jobs that MUST run, e.g. nightly batch)
30 3 * * * /usr/bin/flock -w 3600 /var/lock/db-backup.lock /usr/local/bin/db-backup.sh
```

- `-n` (non-blocking): if the lock is held, `flock` exits 1 immediately. The cron run is a no-op.
- `-w N` (wait): block up to N seconds, then give up if still locked.
- Default (no flag): block forever — bad inside cron, jobs pile up.

A lockfile path must be on local disk (or shared filesystem that supports POSIX locks). NFS works *with care*; Lustre and most object-store-backed filesystems don't. Stick `/var/lock` or `/var/run/lock`.

### 3. Capturing output without losing it to the void

`> /dev/null 2>&1` is the most common cron anti-pattern — it hides real errors. The right defaults:

```cron
# Per-job log file, with shared logrotate config
0 3 * * * /usr/local/bin/backup.sh >> /var/log/app/backup.log 2>&1

# Or pipe through `logger` to syslog (centralised collection)
0 3 * * * /usr/local/bin/backup.sh 2>&1 | /usr/bin/logger -t backup -p local0.info
```

Pair with logrotate so log files don't fill the disk:

```conf
# /etc/logrotate.d/app-cron
/var/log/app/*.log {
    daily
    rotate 14
    compress
    missingok
    notifempty
    create 0640 deploy adm
    sharedscripts
}
```

If a job is supposed to be silent on success and verbose on failure, wrap it:

```bash
#!/usr/bin/env bash
set -euo pipefail

OUT=$(mktemp)
trap 'rm -f "$OUT"' EXIT

if ! "$@" > "$OUT" 2>&1; then
  echo "FAIL: $*" >&2
  cat "$OUT" >&2
  exit 1
fi
```

Save as `/usr/local/bin/cron-quiet` and wrap each cron job: `* * * * * cron-quiet /usr/local/bin/heartbeat.sh`. Now cron output is silent unless something actually broke — and the failure mail (`MAILTO`) carries the full output for diagnosis.

### 4. systemd timers as the modern alternative

Cron is 50 years old and has three structural weaknesses systemd timers fix:

| Concern | Cron | systemd timer |
|---|---|---|
| Logging | stdout/stderr → mail or void | journalctl, structured, queryable |
| Overlap protection | None (use flock) | `Type=oneshot` + `Persistent=true` |
| Missed runs after downtime | Lost | Replayed via `Persistent=true` |
| Resource limits | None | `MemoryMax=`, `CPUQuota=`, `LimitNOFILE=` |
| Random jitter | None | `RandomizedDelaySec=300` |
| Conditional run | None | `ConditionPathExists=`, `ConditionACPower=`, etc. |

Equivalent to a `0 3 * * * /usr/local/bin/backup.sh` crontab line:

```ini
# /etc/systemd/system/app-backup.service
[Unit]
Description=Application database backup
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
User=deploy
WorkingDirectory=/var/www/current
ExecStart=/usr/local/bin/backup.sh
StandardOutput=journal
StandardError=journal
MemoryMax=512M
TimeoutStartSec=30min
```

```ini
# /etc/systemd/system/app-backup.timer
[Unit]
Description=Run app-backup nightly
[Timer]
OnCalendar=03:00
RandomizedDelaySec=900           # ±15 min jitter — avoids 100 servers all hammering at 03:00
Persistent=true                  # run on next boot if missed
[Install]
WantedBy=timers.target
```

```bash
sudo systemctl daemon-reload
sudo systemctl enable --now app-backup.timer

systemctl list-timers --all      # what's scheduled and when
journalctl -u app-backup.service # the last run's full log
systemctl status app-backup.timer
```

Use cron when the simplicity outweighs the limitations (one or two jobs, no overlap concern, output you can afford to lose). Use timers for everything that runs longer than 30 seconds, needs structured logs, or has resource constraints.

### 5. The Laravel / framework-scheduler pattern

Modern PHP frameworks all expect a single cron line as the entry point — Laravel's `schedule:run`, Rails' `whenever` gem, Symfony's `messenger:consume`. The pattern is the same:

```cron
# Single entry — framework figures out what's due
* * * * * cd /var/www/current && /usr/bin/php artisan schedule:run >> /dev/null 2>&1
```

Why this is *the* right pattern:

1. **One crontab line, regardless of how many app-level tasks exist.** Adding a new scheduled job doesn't require a deploy hook to update the crontab — it's a code change in `App\Console\Kernel`.
2. **`current/` symlink handles version updates.** New release ⇒ new code ⇒ next minute the new scheduler runs.
3. **Overlap protection is at the application layer** (`withoutOverlapping()` in Laravel), with timeouts and named locks specific to each job.

The trade-off: the single line *must* run every minute, even when there's nothing to do. That wastes maybe 5MB of memory for 100ms once per minute — irrelevant on any production server.

### 6. `@reboot` for service startup that doesn't deserve a systemd unit

For one-off boot tasks that don't justify a full systemd service, `@reboot` is right-sized:

```cron
@reboot sleep 30 && /usr/local/bin/warm-cache.sh >> /var/log/warm-cache.log 2>&1
@reboot /usr/local/bin/mount-shares.sh
@reboot tmux new-session -d -s monitor '/usr/local/bin/watch-queue.sh'
```

The `sleep 30` is a pragmatic delay — gives the network, DNS, and dependent services time to be actually ready before the cache-warm fires. For dependency-aware startup, use a systemd unit with `After=network-online.target`.

---

## Common errors and fixes

| Error / symptom | Cause | Fix |
|---|---|---|
| Cron job works in shell, fails in cron | Cron's minimal `PATH` doesn't include the binary | Set `PATH=` at top of crontab, or use absolute paths |
| Job runs but produces no output | stdout/stderr → MAILTO, but MTA not configured | Redirect to a log file explicitly: `>> /var/log/job.log 2>&1` |
| Mail-spool fills up over weeks | Every silent success still mails | Redirect to log file, or set `MAILTO=""` per-job |
| Two copies running concurrently | Job takes longer than its interval | Wrap with `flock -n /var/lock/job.lock` |
| Job doesn't fire on the 1st-of-month + Sunday | Day-of-month and day-of-week are OR'd, not AND'd | Use `[ $(date +%u) -eq 7 ] && cmd` inside the job script |
| `crontab -r` wiped the crontab | No confirmation prompt on `-r` | Alias `crontab='crontab -i'`; install from file (workflow 1) |
| Job runs as wrong user | Forgot `-u user` or used `/etc/crontab` with no user field | Use `/etc/cron.d/<file>` with explicit USER, or run as the right user with `crontab -u USER -e` |
| Output truncated at 80 chars in MAILTO mail | Some MTAs wrap lines | Pipe output through `mailx -s` directly, set `MAILTO=""` |
| `command not found` for a Python/Ruby/Node binary | Binary lives in user's `~/.local/bin` or rbenv/nvm shim, not in cron's PATH | Use absolute path or `bash -lc 'cmd'` to source the login shell init |
| Job runs at unexpected times after a server move | New server in a different timezone | Set `CRON_TZ=UTC` at top of crontab, or systemd `OnCalendar=` with explicit TZ |
| `flock` lock never releases | Lockfile FD inherited by a child process that didn't close it | `flock -o /lock cmd` (drops FD before exec); or close-on-exec in your wrapper |
| Job runs but framework reports "nothing scheduled" | Wrong CWD — `php artisan schedule:run` ran outside the app | Always `cd /var/www/current && ...` in cron line |
| systemd timer fires but service exits silently | `StandardOutput=journal` not set | Add `StandardOutput=journal` + `StandardError=journal` to service unit |

---

## Companion: full DeployHQ deploy workflow

Cron is rarely the *target* of a deploy — it's the *entry point* for everything a deploy needs to keep running between releases. A modern deploy ships application code AND any crontab changes; both arrive together so the schedule is always in sync with the release that knows how to handle it.

For DeployHQ deploys, ship a `config/crontab.production` file with your repo, then call the install step (workflow 1) as a post-deploy SSH hook in your [build pipeline](https://www.deployhq.com/features/build-pipelines). Pair it with the [deploy from GitHub guide](https://www.deployhq.com/deploy-from-github) for the end-to-end Git → release flow, and the [zero-downtime deployments](https://www.deployhq.com/features/zero-downtime-deployments) symlink swap so the next cron tick automatically uses the new release.

[Start a free DeployHQ trial](https://www.deployhq.com/signup) to wire a versioned crontab into your deploy pipeline in minutes.

---

## Related cheatsheets

- [Bash cheatsheet](https://www.deployhq.com/cheatsheets/bash) — for the `set -euo pipefail` and `trap` patterns that make cron-driven scripts reliable.
- [SSH cheatsheet](https://www.deployhq.com/cheatsheets/ssh) — for the deploy keys used by cron-driven jobs that pull from git.
- [Composer cheatsheet](https://www.deployhq.com/cheatsheets/composer) — for the PHP dependency installs that scheduled deploy jobs depend on.
- [rsync cheatsheet](https://www.deployhq.com/cheatsheets/rsync) — for the file-sync patterns frequently driven by cron jobs.
- [Docker cheatsheet](https://www.deployhq.com/cheatsheets/docker) — for the container patterns when cron lives alongside containerised services.
- [Cheatsheets hub](https://www.deployhq.com/cheatsheets) — every DeployHQ cheatsheet in one place.

---

Need help? Email [support@deployhq.com](mailto:support@deployhq.com) or follow [@deployhq on X](https://x.com/deployhq).