# Crontab Version Control: Deploy Cron Jobs as Code, Detect Drift

By Dan Ochoa · Published 2026-10-25 · Updated 2026-10-25
Canonical: https://cronradar.com/blog/crontab-version-control

> Move hand-edited crontabs into git: deploy /etc/cron.d files or user crontabs from the repo, manage them with Ansible, lint them in CI, and catch drift.

**To put crontabs under version control, keep each one as a file in a git repository, have your deploy install the whole file on every server, and stop editing crontabs on the servers.** For jobs a deploy owns, the cleanest target is one file per application in `/etc/cron.d/`, owned by root and replaced as a unit; cron rereads it within a minute. Then lint the files in CI and compare each server with the repository on a schedule, so a hand edit shows up as drift instead of surprising the next deploy.

This guide is for servers where jobs were added with `crontab -e`. Cron behavior was checked against cronie 1.7.2 (Fedora, RHEL, Arch) and Debian's cron 3.0pl1-197 (Debian 13; Ubuntu packages the same cron). Tool versions were checked October 25, 2026.

## Why Hand-Edited Crontabs Fail

- **No history or review.** `crontab -e` puts a change live when the editor closes, unreviewed, over the previous version. cronie 1.7 and later keep one backup; Debian's cron keeps none ([crontab command guide](https://cronradar.com/blog/crontab-guide)).
- **Lost on rebuild.** User crontabs live in the host's spool directory, `/var/spool/cron` on cronie and `/var/spool/cron/crontabs` on Debian. Rebuilding the server doesn't bring them back.
- **One key from deletion.** `crontab -r`, next to `-e` on the keyboard, deletes the whole crontab without asking.
- **Divergence.** Servers that should run the same jobs drift apart one quick fix at a time.

A repository fixes these only if it is the one way changes reach a server.

## Capture What Is Running Now

Before changing anything, copy each server's crontabs into a repository exactly as they are. Run this as root on every host:

```bash
#!/bin/sh
# capture-crontabs.sh: copy this host's crontabs into ./cron-inventory/<host>. Changes nothing.
set -eu
dest="cron-inventory/$(hostname -s)"
mkdir -p "$dest/users" "$dest/cron.d"
getent passwd | cut -d: -f1 | while read -r user; do
  crontab -l -u "$user" > "$dest/users/$user" 2>/dev/null || rm -f "$dest/users/$user"
done
if [ -f /etc/crontab ]; then cp -p /etc/crontab "$dest/"; fi
for f in /etc/cron.d/*; do
  if [ -f "$f" ]; then cp -p "$f" "$dest/cron.d/"; fi
done
```

Read the result for credentials before committing it, and rotate any you find rather than just moving them. `diff -r cron-inventory/web01 cron-inventory/web02` then shows where servers have diverged. The crontab guide's inventory script also lists `/etc/cron.daily` scripts and systemd timers, which this copy skips.

## Choose Where Each Schedule Lives

| Target                    | How the repository reaches the server          | Fits                                              |
| ------------------------- | ---------------------------------------------- | ------------------------------------------------- |
| `/etc/cron.d/<app>`       | The deploy copies the whole file as root       | Jobs your deploy owns, on cronie or Debian's cron |
| A user's crontab          | `crontab -u user file`                         | Deploys without root; Alpine                      |
| Config management entries | Ansible, Puppet, Chef, or Salt                 | Fleets one of them already manages                |
| systemd timer units       | Unit files plus `systemctl daemon-reload`      | systemd hosts that want journal logs              |
| Kubernetes CronJob        | Manifests applied by CI or a GitOps controller | Jobs that already run in containers               |

Alpine's BusyBox crond reads only its crontabs directory, not `/etc/cron.d` ([crond.c](https://sources.debian.org/src/busybox/1:1.37.0-6/miscutils/crond.c/)).

## Deploy /etc/cron.d Files From the Repository

An `/etc/cron.d` file is a crontab with one extra field, the user each job runs as. Keep it in the repository with a header that tells the next person where it came from:

```text
# /etc/cron.d/billing
# Source: billing repository, ops/cron/cron.d/billing. Deployed by CI.
# Edits made on a server are overwritten by the next deploy and reported as drift.
# Fields: minute hour day-of-month month day-of-week user command. Hosts run on UTC.
SHELL=/bin/sh
PATH=/usr/local/bin:/usr/bin:/bin
MAILTO=billing-alerts@example.com

# Nightly invoice run.
30 2 * * * billing /opt/billing/bin/run-invoices
# Retry failed card payments at five past every hour.
5 * * * * billing /opt/billing/bin/retry-payments
# Monthly statements on the 1st.
0 6 1 * * billing /opt/billing/bin/send-statements
# Compare this host's cron files with the deployed copies.
17 * * * * root /usr/local/sbin/check-cron-drift /var/lib/cron-managed/billing
```

Both daemons pick up changes without a restart: Debian's cron checks the modification times of `/etc/cron.d` and each file in it every minute ([database.c](https://sources.debian.org/src/cron/3.0pl1-197/database.c/)), and cronie uses inotify or the same minute-by-minute check ([cron(8)](https://man7.org/linux/man-pages/man8/cron.8.html)). The file also has to pass checks that differ by daemon; the crontab guide has the full table:

- Debian's cron silently skips a name containing anything but letters, digits, `_`, and `-`, so `billing.cron` never runs.
- Both require root ownership and no group or other write permission; cronie also rejects an executable file ([database.c](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/database.c)).
- One invalid line makes Debian ignore the whole file; cronie skips just that line ([Debian user.c](https://sources.debian.org/src/cron/3.0pl1-197/user.c/), [cronie user.c](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/user.c)).
- Copy, don't link. With inotify, cronie misses changes behind a symlinked crontab until it gets a SIGHUP, and Debian rejects a file with more than one hard link.

Because cron reads files as they change, write each one under a name starting with a dot, which both daemons skip, then rename it into place; [rename(2)](https://man7.org/linux/man-pages/man2/rename.2.html) replaces the target atomically:

```bash
#!/bin/sh
# deploy-cron.sh: install ops/cron/cron.d/* on this host. Run as root from the repository root.
set -eu
managed=/var/lib/cron-managed/billing   # what the last deploy installed

# Remove files an earlier deploy installed that the repository no longer has.
for old in "$managed"/cron.d/*; do
  if [ -f "$old" ] && [ ! -f "ops/cron/cron.d/${old##*/}" ]; then
    rm -f "/etc/cron.d/${old##*/}"
  fi
done

for f in ops/cron/cron.d/*; do
  [ -f "$f" ] || continue
  name=${f##*/}
  install -o root -g root -m 0644 "$f" "/etc/cron.d/.$name.new"
  mv -f "/etc/cron.d/.$name.new" "/etc/cron.d/$name"
done

install -o root -g root -m 0755 ops/bin/check-cron-drift /usr/local/sbin/check-cron-drift
rm -rf "$managed"
mkdir -p "$managed"
cp -R ops/cron/cron.d "$managed/cron.d"
```

The removal loop matters: without it, a file deleted from the repository keeps running on every server.

## Install a User Crontab From a File

When the deploy has no root access, or on Alpine, keep the user crontab (no user field) in the repository and install it as the job's user:

```bash
crontab -l > "$HOME/crontab-$(date +%F).bak" 2>/dev/null || true   # keep the old one
crontab -T ops/cron/users/billing   # check: cronie 1.5.6+; Debian's cron 3.0pl1-160+ uses -n
crontab ops/cron/users/billing      # replaces the entire crontab
```

`crontab file` and `crontab -` (standard input) replace rather than merge, which is what you want when the repository holds the complete list. Root can install for another user with `crontab -u billing file`, but cronie refuses `-T` together with `-u` ([crontab.c](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/crontab.c)). BusyBox's crontab has no check option and installs files unchecked ([crontab.c](https://sources.debian.org/src/busybox/1:1.37.0-6/miscutils/crontab.c/)). To check for drift:

```bash
diff <(crontab -l -u billing) ops/cron/users/billing && echo "no drift"
```

The output matches the installed file on both daemons: cronie adds no header, and Debian's `crontab -l` omits the three header lines its install adds ([crontab.c](https://sources.debian.org/src/cron/3.0pl1-197/crontab.c/)).

## Manage Cron Jobs With Ansible

The `ansible.builtin.cron` module (ansible-core 2.21.5) manages one entry per task:

```yaml
- name: Nightly invoice run
  ansible.builtin.cron:
    name: billing invoice run
    cron_file: billing-invoices
    user: billing
    minute: '30'
    hour: '2'
    job: /opt/billing/bin/run-invoices
```

That writes `/etc/cron.d/billing-invoices`:

```text
#Ansible: billing invoice run
30 2 * * * billing /opt/billing/bin/run-invoices
```

From the [module documentation](https://docs.ansible.com/ansible/latest/collections/ansible/builtin/cron_module.html) and [source](https://github.com/ansible/ansible/blob/v2.21.5/lib/ansible/modules/cron.py):

- Later runs find the entry by its `#Ansible:` line, so changing `name` creates a new entry instead of renaming the old one.
- `cron_file` requires `user`, warns about a file name outside letters, digits, `_`, and `-`, and refuses `/etc/crontab`. The docs assume the module owns that file outright.
- Deleting a task leaves its entry on the host. Retire it with `state: absent`; removing the last entry deletes the file.
- On an existing crontab, the module adopts an unmarked line only if it's identical to the line it would write. Otherwise it appends a second line, and the job runs twice.

For a file with several jobs, deploy the whole file from the role's `files/` directory:

```yaml
- name: Install /etc/cron.d/billing from the repository
  ansible.builtin.copy:
    src: cron.d/billing
    dest: /etc/cron.d/billing
    owner: root
    group: root
    mode: '0644'
    validate: crontab -T %s # cronie; on Debian and Ubuntu use crontab -n %s

- name: Remove a retired cron file
  ansible.builtin.file:
    path: /etc/cron.d/billing-legacy
    state: absent
```

`validate` checks the temporary copy before `copy` renames it into place ([basic.py](https://github.com/ansible/ansible/blob/v2.21.5/lib/ansible/module_utils/basic.py)). Use `template` with the same options for per-host values. For drift, `ansible-playbook site.yml --check --diff` reports what each task would change without changing anything ([check mode](https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_checkmode.html)); run it on a schedule and treat any changed task as drift. It sees only what tasks manage, so a job added with `crontab -e` stays invisible to it.

## Puppet, Chef, and Salt

| Tool, version checked     | Per-entry resource                  | Marker it writes              | Whole file                                |
| ------------------------- | ----------------------------------- | ----------------------------- | ----------------------------------------- |
| Puppet, cron_core 2.1.0   | `cron`, user crontabs via `crontab` | `# Puppet Name: <title>`      | A `file` resource                         |
| Chef Infra Client 19.4.48 | `cron`, user crontabs               | `# Chef Name: <name>`         | `cron_d`, one job per file                |
| Salt 3008.4               | `cron.present`, user crontabs       | `# SALT_CRON_IDENTIFIER:<id>` | `file.managed`, or `cron.file` for a user |

Puppet's `cron` type lives in [puppetlabs-cron_core](https://github.com/puppetlabs/puppetlabs-cron_core/blob/main/REFERENCE.md), bundled with the [Puppet](https://github.com/puppetlabs/puppet-agent/blob/main/configs/components/module-puppetlabs-cron_core.json) and [OpenVox](https://github.com/OpenVoxProject/openvox-agent/blob/main/configs/components/module-puppetlabs-cron_core.json) agents; removing a parameter from a manifest doesn't reset it, so set it to `absent`. Chef's [`cron_d`](https://github.com/chef/chef/blob/v19.4.48/lib/chef/resource/cron/cron_d.rb) (Chef 14.4+) defaults to mode `0600` and, on Linux, refuses names with characters other than letters, digits, `_`, and `-`. Salt's [state docs](https://github.com/saltstack/salt/blob/v3008.4/salt/states/cron.py) recommend setting `identifier`, because the default identity is the state name, often the command itself, so changing the command installs a second job.

None of the four removes a line it didn't write unless told to, so adopting one doesn't clear hand-written jobs. Whole-file management does: the file in the repository is the complete list.

## systemd Timers as Unit Files

Units are already files, so they version like any other config:

```bash
install -o root -g root -m 0644 ops/systemd/billing-invoices.service \
  ops/systemd/billing-invoices.timer /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now billing-invoices.timer
```

`daemon-reload` reloads all unit files ([systemctl(1)](https://www.freedesktop.org/software/systemd/man/latest/systemctl.html)). In CI, `systemd-analyze verify` catches unknown directives, though it also flags `ExecStart=` commands missing on the build runner ([systemd-analyze(1)](https://www.freedesktop.org/software/systemd/man/latest/systemd-analyze.html)). systemd's `crontab -e` is `systemctl edit`, which writes a drop-in that overrides your unit without touching it; `systemd-delta --type=extended /etc/systemd/system` lists them ([systemd-delta(1)](https://www.freedesktop.org/software/systemd/man/latest/systemd-delta.html)). [systemd timers vs cron](https://cronradar.com/blog/systemd-timers-vs-cron) covers converting a crontab line without a double run.

## Kubernetes CronJobs in Git

`kubectl diff -f cronjobs/` exits 0 when the cluster matches your manifests and 1 when it differs ([kubectl diff](https://kubernetes.io/docs/reference/kubectl/generated/kubectl_diff/), Kubernetes 1.37), a ready-made drift check. Argo CD 3.5 doesn't undo live changes unless `syncPolicy.automated.selfHeal` is `true` ([automated sync](https://argo-cd.readthedocs.io/en/stable/user-guide/auto_sync/)); Flux 2.9's kustomize-controller corrects drift every `.spec.interval` ([Kustomization](https://fluxcd.io/flux/components/kustomize/kustomizations/)). Watching the runs themselves is covered in [Kubernetes CronJob monitoring](https://cronradar.com/blog/kubernetes-cronjob-monitoring).

## Lint Crontabs in CI

`crontab -T` and `crontab -n` parse the user-crontab format, where the user field is just the first word of the command, so they pass an `/etc/cron.d` line that's missing its user. Debian then rejects that line and the rest of the file. This script adds the missing checks:

```bash
#!/usr/bin/env bash
# lint-cron-d: check /etc/cron.d files before they ship. Run on your servers' distribution.
# Usage: CRON_USERS="root billing" lint-cron-d ops/cron/cron.d/*
set -u
status=0
problem() { printf '%s: %s\n' "$1" "$2" >&2; status=1; }

for f in "$@"; do
  [[ ${f##*/} =~ ^[A-Za-z0-9_-]+$ ]] || problem "$f" "Debian's cron skips this name"
  [ -z "$(tail -c 1 "$f")" ] || problem "$f" "no newline at the end of the file"

  # Job lines: five time fields (or an @ keyword), a user from CRON_USERS, a command.
  awk -v users=" ${CRON_USERS:-root} " '
    /^[ \t]*(#|$)/ { next }          # comments and blank lines
    /^[ \t]*[A-Za-z_]/ { next }      # NAME=value lines
    {
      if ($1 ~ /^@/) { user = $2; need = 3 } else { user = $6; need = 7 }
      if (NF < need) { printf "%s:%d: missing user or command\n", FILENAME, FNR; bad = 1; next }
      if (index(users, " " user " ") == 0) { printf "%s:%d: unexpected user %s\n", FILENAME, FNR, user; bad = 1 }
      if ($0 ~ /(^|[^\\])%/) { printf "%s:%d: unescaped %% becomes a newline\n", FILENAME, FNR; bad = 1 }
    }
    END { exit bad }' "$f" >&2 || status=1

  # Time fields and variable lines, parsed by the cron package itself.
  if [ -f /etc/debian_version ]; then crontab -n "$f" || status=1; else crontab -T "$f" || status=1; fi
done
exit "$status"
```

For Debian 13 hosts, in GitHub Actions:

```yaml
# .github/workflows/crontabs.yml
name: crontabs
on:
  pull_request:
    paths: ['ops/cron/**', 'ops/bin/**']
jobs:
  lint:
    runs-on: ubuntu-24.04
    container: debian:trixie
    steps:
      - uses: actions/checkout@v7
      - run: apt-get update && apt-get install -y --no-install-recommends cron
      - run: CRON_USERS="root billing" bash ops/bin/lint-cron-d ops/cron/cron.d/*
```

For cronie hosts, use a Fedora or RHEL-family image with `cronie` installed. Lint proves a file is well formed, not that its schedule is right; [testing cron jobs](https://cronradar.com/blog/testing-cron-jobs) covers previewing run times.

## Detect Drift on the Servers

Drift happens on the server, so check there. This script compares the `/etc/cron.d` files with the copies the last deploy left, and treats any user crontab as a hand edit, since every job should arrive through `/etc/cron.d`. It ignores `/etc/cron.d` files the repository doesn't manage, because packages install their own:

```bash
#!/usr/bin/env bash
# check-cron-drift: compare this host's cron files with the copies the last deploy installed.
# Usage: check-cron-drift /var/lib/cron-managed/billing   (as root; changes nothing; exits 1 on drift)
set -u
managed=${1:?usage: check-cron-drift DIR}
findings=()
found() { findings+=("$1"); printf 'drift: %s\n' "$1"; }

for want in "$managed"/cron.d/*; do
  [ -f "$want" ] || continue
  live=/etc/cron.d/${want##*/}
  if [ ! -f "$live" ]; then found "$live is missing"; continue; fi
  cmp -s "$want" "$live" || found "$live differs from the deployed copy"
  perms=$(stat -c '%U:%G %a' "$live")
  [ "$perms" = "root:root 644" ] || found "$live is $perms, not root:root 644"
done

spool=/var/spool/cron/crontabs             # Debian, Ubuntu
[ -d "$spool" ] || spool=/var/spool/cron   # cronie
for tab in "$spool"/*; do
  [ -f "$tab" ] || continue
  case ${tab##*/} in tmp.* | \#*) continue ;; esac   # crontab's temporary files
  found "unmanaged crontab for ${tab##*/}"
done

# Report to CronRadar: a heartbeat when clean, a failure naming the first finding.
# /etc/cronradar/curl.conf is root-owned, mode 0400, one line: user = "ck_app_12345_abcdef:"
conf=/etc/cronradar/curl.conf
if [ -r "$conf" ]; then
  url="https://cron.life/api/v1/ping/cron-drift-$(hostname -s)"
  report() {
    curl --config "$conf" --fail --silent --show-error --max-time 5 --output /dev/null \
      --request POST --get \
      --data-urlencode "schedule=17 * * * *" \
      --data-urlencode "gracePeriod=900" \
      "$@" || true
  }
  if [ ${#findings[@]} -eq 0 ]; then
    report "$url"
  else
    report --data-urlencode "message=${findings[0]} (${#findings[@]} findings)" "$url/fail"
  fi
fi

[ ${#findings[@]} -eq 0 ]
```

The hourly line in `/etc/cron.d/billing` runs it, so it doubles as a canary: if that file is deleted or gets a bad mode, if Debian ignores it over one bad line, or if cron stops, the heartbeat stops and the monitor alerts on the missed run. A drift finding sends a `fail` check-in, which alerts immediately, with a message naming file paths, never their contents. Each host gets its own monitor, created by its first check-in because the report carries the schedule.

CronRadar sees only those check-ins. It doesn't read crontabs or compare them with your repository, and there's no CronRadar SDK or auto-discovery for plain cron, so each job reports with its own HTTP check-ins from a wrapper script.

## Review Schedule Changes Like Code

In the pull request, have the description name the expression, the host's time zone, and the next few run times, and check for daylight saving surprises, overlapping runs, and both day fields restricted at once; [reviewing AI-generated cron jobs](https://cronradar.com/blog/reviewing-ai-generated-cron-jobs) has a checklist that fits any author.

A job that reports to CronRadar needs one more step. A check-in's `schedule` and `gracePeriod` apply only when that check-in creates the monitor, so changing them in the wrapper doesn't update an existing monitor; change the schedule in the monitor's settings when the change deploys. CronRadar evaluates schedules in UTC. A monitor created by its first check-in can't flag a job whose line never reached the server, so for a new job, create the monitor in the dashboard first: it stays `pending` and sends a never-executed alert if the first expected run plus the grace period passes without a report.

## Keep Secrets Out of the Repository and the Crontab

A committed crontab is readable by everyone with repository access, and an `/etc/cron.d` file at `0644` by every account on the host, even when Ansible Vault kept the secret out of git. On cronie and Debian, crontab variables also travel in an `X-Cron-Env` header of every mail cron sends ([cronie](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/do_command.c#L539-L540), [Debian](https://sources.debian.org/src/cron/3.0pl1-197/do_command.c/#L597)). Keep credentials in a file only the job's account can read, as the drift check does with its curl config; curl's manual says credentials belong in a file, not on a command line other users can see ([curl --user](https://curl.se/docs/manpage.html#-u)). [Cron job security](https://cronradar.com/blog/cron-security) goes further.

## Frequently Asked Questions

### How do I put my crontab in version control?

Copy it into a repository with `crontab -l > file`, along with any `/etc/cron.d` files, and commit it once credentials are removed. Then make the deploy the only way it changes, installing the whole file to `/etc/cron.d` or with `crontab -u user file`, and compare servers with the repository on a schedule.

### How do I manage cron jobs with Ansible?

Use `ansible.builtin.cron` for single entries; it tags each with an `#Ansible: <name>` comment and can write to `/etc/cron.d` with `cron_file`. For a file with several jobs, deploy it whole with `ansible.builtin.copy` or `template`. Retire jobs with `state: absent`, because deleting a task leaves its entry behind.

### Does cron reload /etc/cron.d automatically?

Yes. cronie and Debian's cron notice changed files within a minute, with no restart. The exceptions: cronie with inotify misses changes behind a symlink until it receives a SIGHUP, and Alpine's BusyBox crond doesn't read `/etc/cron.d` at all.

### How do I detect changes to crontabs?

Compare live files with what you deployed: `cmp` for `/etc/cron.d` files, `diff <(crontab -l -u app) repo-file` for user crontabs, and a check for crontabs nobody deployed. Run it hourly and report a failure when anything differs.

### Should I use /etc/cron.d or user crontabs?

Use `/etc/cron.d` for anything a deploy installs: one file per application, the user named on each line, writable only by root, and replaced whole without touching personal crontabs. Use a user crontab when the deploy has no root access, or on Alpine.
