← all posts

How to add a cron job from a script, without crontab -e

The usual one-liner adds the job twice when it runs twice, and a pipe that fails replaces your whole crontab with nothing. We tested it on four distros, plus the /etc/cron.d mistakes cron skips without telling you.

10 min read

You’re writing a provisioning script, a Dockerfile, an Ansible task, or you’ve asked a coding agent to “schedule this nightly”. All of them need to add a cron job without a person at the keyboard. crontab -e opens an editor, so everyone reaches for the same line:

(crontab -l 2>/dev/null; echo "0 3 * * * /usr/local/bin/backup.sh") | crontab -

It works the first time. Then the script runs again:

$ crontab -l
0 3 * * * /usr/local/bin/backup.sh
0 3 * * * /usr/local/bin/backup.sh

Now the backup runs twice a night, both copies at the same second. We tested that line and the alternatives on Ubuntu 26.04, Debian 13, AlmaLinux 10 (cronie) and Alpine 3.24 (BusyBox), to find a version you can run as often as you like.

exit 0when crontab - replaced a crontab with nothing
3 of 3broken lines Alpine's crontab installed without complaint
4/etc/cron.d mistakes that make Debian skip the whole file, with no error where you'd see it

Short answer

  • Without root: rebuild the crontab through a temp file. Filter out the old copy of the job by a tag comment, append the new one, install it with crontab FILE. The cron_set function below does that in six lines, and handles updates and removal.
  • With root: write each job to its own file in /etc/cron.d. Writing the same file twice is a no-op, and rm removes the job. It needs mode 644, a user field and a final newline, or cron skips it and tells only the journal.
  • Never pipe something that can fail into crontab -. Empty input replaces every job you had.

Then run the command once in cron’s environment, so you find out now and not at 03:00. If you’d rather have a check command and a run-now command that return real exit codes, systemd timers have both, and so does RunWisp, an open-source cron replacement that ships as a single binary and takes each job as its own small TOML file. Both are below.

Safe to re-runRejects a bad scheduleRun it now, get the exit codeSurvives a reboot
(crontab -l; echo) | crontab -✗ duplicates✓ (✗ on Alpine)✗✓
cron_set function✓✓ (✗ on Alpine)✗✓
File in /etc/cron.d✓✗ skipped, logged✗✓
systemd timer✓✓ systemd-analyze verify✓ systemctl start✓
RunWisp 1.7 (ours)✓✓ runwisp validate --json✓ runwisp run --json✓

Why not crontab -e

crontab -e copies the crontab to a temp file and starts $EDITOR on it. With no terminal, that goes nowhere useful. On our minimal Debian and Ubuntu images it found no editor and exited 1. AlmaLinux opened vim on a non-terminal. On Alpine, BusyBox vi printed can't read user input and crontab -e exited 0 with nothing changed, which a script reads as success.

You can point EDITOR at a sed command, but you’d be scripting the same read-modify-write as below, with an extra layer in the way.

Three ways the one-liner goes wrong

It isn’t idempotent. Each run appends the line again, as above. Provisioning scripts, image builds and agents all run more than once.

Under set -euo pipefail, it fails on a fresh box. With no crontab yet, crontab -l prints no crontab for root and exits 1. Most copies of the one-liner don’t add 2>/dev/null, and that doesn’t help here anyway: set -e carries into the ( ... ) subshell, so it stops before the echo, and crontab - gets empty input. The script exits 1 with a message that reads like a harmless warning. Your job was never added; an empty crontab was.

Empty input wipes the crontab. crontab - takes stdin as the complete new crontab, and nothing is a valid crontab:

$ crontab -l
0 1 * * * /usr/local/bin/keep-me.sh
$ printf '' | crontab -; echo "exit=$?"
exit=0
$ crontab -l
$

So crontab -l | sed '/old-job/d' | crontab - is one typo in the sed away from deleting every job, with exit 0. cronie (RHEL, Fedora, Alma, Arch) is the only cron we tested that keeps a copy: it prints Backup of root's previous crontab saved to /root/.cache/crontab/crontab.bak on every change, and crontab /root/.cache/crontab/crontab.bak puts it back. Debian, Ubuntu and Alpine keep nothing.

One more for the road: crontab -r deletes the whole crontab without asking. It’s one key away from -e.

Without root: a crontab edit you can run twice

Tag each job with a comment, then on every run: drop the tagged line, add the current version, install the result with crontab FILE. Cron hands the trailing # cron_set:backup to /bin/sh as part of the command, and sh ignores it as a comment. BusyBox strips it before running.

# cron_set NAME "SCHEDULE COMMAND"   add the job, or replace it if NAME exists
# cron_set NAME                      remove it
cron_set() {
    tmp=$(mktemp)
    crontab -l 2>/dev/null | grep -v " # cron_set:$1\$" > "$tmp" || true
    [ -n "${2:-}" ] && printf '%s # cron_set:%s\n' "$2" "$1" >> "$tmp"
    crontab "$tmp" && rm -f "$tmp"
}

cron_set backup "0 3 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1"

What each line is for:

  • 2>/dev/null and || true let it run on a box with no crontab yet, under set -euo pipefail.
  • The $ anchor keeps cron_set backup from also deleting backup2.
  • printf '%s\n' always ends the file with a newline. Without one, Debian refuses the file (missing newline before EOF) and cronie calls it a premature EOF.
  • crontab "$tmp" validates before it installs. On a bad line it exits 1 and leaves the old crontab as it was:
$ cron_set report "0 25 * * * /usr/local/bin/report.sh"
"/tmp/tmp.sUpw4yqhsa":3: bad hour
errors in crontab file, can't install.

We ran it under set -euo pipefail on Debian, Alma and Alpine: added two jobs, changed one’s schedule twice, removed both. Other lines, like a MAILTO= you added by hand, survive. After the last removal, crontab -l printed nothing and exited 0, an empty crontab, which is what you asked for.

Two limits. If crontab -l fails for a reason other than “no crontab”, like a permissions problem, the || true treats it as empty and you lose the other lines. And Alpine checks nothing: BusyBox crontab installed 0 25 * * *, a four-field line and a file with no final newline, all with exit 0. On cronie, crontab -T FILE checks a file without installing it.

With root: one file per job in /etc/cron.d

A file per job sidesteps the read-modify-write. Writing it is the whole operation, so running it twice is harmless, and nothing else’s jobs are in the file to lose.

cat > /etc/cron.d/backup <<'EOF'
PATH=/usr/local/bin:/usr/bin:/bin
0 3 * * * root /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1
EOF
chmod 644 /etc/cron.d/backup

Remove it with rm /etc/cron.d/backup. Cron rereads the directory within a minute, no restart.

The format is not a user crontab’s: there’s a user field between the schedule and the command. And crontab never sees these files, so nothing checks them when you write them. We dropped five files into /etc/cron.d with a * * * * * job each and waited two minutes:

FileDebian 13, Ubuntu 26.04AlmaLinux 10 (cronie)
Correct✓ ran✓ ran
Mode 664 (group-writable)✗ skipped, INSECURE MODE✗ skipped, BAD FILE MODE
No user field✗ skipped, bad username✗ fails every minute, getpwnam() failed
No newline at the end✗ skipped, Missing newline✗ skipped, missing newline before EOF
One good line, one 0 25 * * * line✗ whole file skipped, bad hour✓ good line ran, bad one skipped

Without a user field, cronie read touch as the user name and tried to run the rest as that user, every minute. Debian also skips a file with a dot in its name; see step 4 of the cron checklist. On Alpine, BusyBox crond doesn’t read /etc/cron.d, so use cron_set there.

Since nothing fails when you write the file, check the journal a minute later:

$ journalctl -t cron -t crond -g 'error|bad|insecure|missing' --since -5min
cron[38]: Error: bad username; while reading /etc/cron.d/nouser
cron[38]: (*system*nouser) ERROR (Syntax error, this crontab file will be ignored)

On Ubuntu with rsyslog, the same lines are in /var/log/syslog; on RHEL-family boxes, in /var/log/cron. More in Where are cron logs?

Check it before cron does

A valid line can still fail at 03:00, because cron runs the command with /bin/sh and almost no environment. These are the PATHs our test jobs got:

DistroPATH cron gave the job
Debian 13/usr/bin:/bin
AlmaLinux 10/usr/bin:/bin:/usr/sbin:/sbin
Ubuntu 26.04from /etc/environment, includes /usr/local/bin
Alpine 3.24/bin:/sbin:/usr/bin:/usr/sbin:/usr/bin:/usr/sbin:/usr/local/bin:/usr/local/sbin

So don’t wait a minute with a * * * * * test line. Run the command now, with the narrowest of those environments, from the home directory like cron does:

cd ~ && env -i HOME="$HOME" LOGNAME="$(id -un)" PATH=/usr/bin:/bin SHELL=/bin/sh \
    /bin/sh -c '/usr/local/bin/backup.sh >> /var/log/backup.log 2>&1'; echo "exit=$?"

Paste the command part of the cron line between the quotes as it is (but turn any \% back into %). If it works there, it works under cron. If it doesn’t, the usual suspects are in step 6 of the checklist.

Or use a scheduler you can check and run

Cron gives a script two blind spots: whether the job was accepted, and whether it works. Two schedulers answer both with an exit code.

systemd timers

A job is two unit files, backup.service and backup.timer, written the same way as an /etc/cron.d file, so re-running is harmless. The full setup for a nightly job is in Where are cron logs? What matters for a script:

$ systemd-analyze verify /etc/systemd/system/backup.timer
/etc/systemd/system/backup.timer:2: Failed to parse calendar specification, ignoring: *-*-* 25:00:00
backup.timer: Timer unit lacks value setting. Refusing.
Unit backup.timer has a bad unit file setting.
$ echo $?
1
$ systemctl start backup.service; echo "exit=$?"
Job for backup.service failed because the control process exited with error code.
See "systemctl status backup.service" and "journalctl -xeu backup.service" for details.
exit=1

systemctl start on a Type=oneshot service waits for the job to finish, so it’s a real test run. Two catches. systemctl enable --now on the broken timer above exited 1, but had already created the timers.target.wants symlink. And skip the one-liner systemd-run --on-calendar=...: it creates a transient timer under /run,, and after a restart ours was gone.

RunWisp

RunWisp (our project, so weigh accordingly) is an open-source cron replacement that ships as a single binary. A job is a few lines of TOML, and one include line lets each job live in its own file, like /etc/cron.d:

[daemon]
include = ["conf.d/*.toml"]
[tasks.backup]
cron = "0 3 * * *"
run  = "/usr/local/bin/backup.sh"

Write the file again and nothing changes. Delete it and the job is gone. Each step a script needs has a command with an exit code, and --json where it helps:

$ sudo runwisp validate --explain
✓ /etc/runwisp/runwisp.toml is valid.
  tasks:    1
  services: 0
  timezone: Etc/UTC (system)

backup  0 3 * * *  (Etc/UTC)
  At 03:00
  Tue 2026-10-20 03:00 UTC
  Wed 2026-10-21 03:00 UTC
  Thu 2026-10-22 03:00 UTC
$ sudo runwisp run backup --json
[INFO] Task triggered name=backup run=01M59MX3247JA4A1D2Y0D9TJKX
pg_dump: dumping database app
pg_dump: error: connection refused
{
  "schemaVersion": 1,
  "task": "backup",
  "runId": "01M59MX3247JA4A1D2Y0D9TJKX",
  "status": "ended",
  "endReason": "failed",
  "exitCode": 3,
  "triggeredBy": "cli",
  "startedAt": "2026-10-19T08:36:59.076867332Z",
  "endedAt": "2026-10-19T08:36:59.087762513Z",
  "durationMs": 10,
  "failed": true
}
$ echo $?
3
$ sudo runwisp reload
  • validate loads the config exactly as the daemon would. A typo such as schedule = comes back as unknown key "schedule" at line 2:1, a bad hour names the field and its range, and a task name used in two files names both files.
  • run runs the job now, streams its output and exits with the job’s exit code. With the daemon up, the run goes through it and is kept with the scheduled ones.
  • reload applies the change. A config that doesn’t validate is rejected, and the running jobs stay exactly as they were.
  • runwisp schema prints the JSON Schema for every key, for an editor or an agent to check against before validate.

Every run after that keeps its output, exit code and duration. The limits: it’s one more service to install with curl -fsSL https://get.runwisp.com | sudo sh and sudo runwisp service install, and keep running, where cron and systemd are already there. Agents and scripts that only ever add one job to one box are fine with cron_set.

The checklist

For any script, playbook or agent that adds a cron job:

  1. Never feed crontab - from a pipe that can fail. Build the file, then crontab FILE.
  2. Tag the job, replace it by tag, so a second run changes nothing.
  3. End every crontab and /etc/cron.d file with a newline.
  4. For /etc/cron.d: mode 644, a user field, no dot in the name, and check the journal after a minute.
  5. Run the command once in cron’s environment before trusting the schedule.

Common questions

How do I add a cron job from a bash script without crontab -e?
Pipe a complete crontab into crontab -, or write it to a file and run crontab FILE. The common one-liner is (crontab -l 2>/dev/null; echo "0 3 * * * /usr/local/bin/backup.sh") | crontab -. It works once, but running it again adds the line a second time. To make it safe to repeat, tag the line with a comment, filter the tag out of crontab -l into a temp file, append the new line and install the file. With root, writing the job to its own file in /etc/cron.d is simpler: writing the same file twice changes nothing.
How do I add a cron job only if it doesn't already exist?
Don't test for the line and append. Remove any line carrying the job's tag and add the current version, every time: crontab -l 2>/dev/null | grep -v ' # job:backup$' > /tmp/cron; echo '0 3 * * * /usr/local/bin/backup.sh # job:backup' >> /tmp/cron; crontab /tmp/cron. That also updates the schedule when it changes, which a check for the exact line doesn't. Cron passes the trailing # job:backup to /bin/sh, which treats it as a comment.
Why did crontab - delete all my cron jobs?
crontab - replaces the whole crontab with whatever arrives on stdin, and empty input is a valid crontab. If the command in front of it fails or prints nothing, crontab - installs an empty crontab and exits 0. On Debian, Ubuntu and Alpine nothing keeps a copy. cronie (RHEL, Fedora, Alma, Arch) saves the previous one to ~/.cache/crontab/crontab.bak before each change, so crontab ~/.cache/crontab/crontab.bak restores it.
Do I need to restart cron after adding a file to /etc/cron.d?
No. Cron rereads /etc/cron.d within a minute. Check it accepted the file, because it skips bad files without any error you'd see: group-writable permissions, a missing user field, a missing newline at the end, or on Debian and Ubuntu a single bad line all make it ignore the file, or on cronie just that line. The reason goes to the journal: journalctl -t cron -t crond -g 'error|bad|insecure|missing' --since -5min. Alpine's BusyBox crond doesn't read /etc/cron.d at all.
How do I check a crontab line for syntax errors?
On Debian, Ubuntu and cronie, crontab FILE refuses an invalid file and exits 1, naming the line, so an install is also a check. cronie also has crontab -T FILE, which checks without installing. BusyBox crontab on Alpine accepted every broken line we gave it with exit 0. Files in /etc/cron.d are never checked up front; cron logs the problem when it reads them. For systemd timers, systemd-analyze calendar checks an OnCalendar= value.
How do I test a cron job right away instead of waiting for it?
Run the exact command from the crontab line in cron's environment: env -i HOME=/root LOGNAME=root PATH=/usr/bin:/bin SHELL=/bin/sh /bin/sh -c 'COMMAND', from the home directory. That catches PATH and shell problems. A systemd timer's job can be run on demand with systemctl start NAME.service, which for Type=oneshot waits for it to finish and exits 1 if it failed.
Is there a cron replacement a script or AI agent can set up and verify?
RunWisp, an open-source cron replacement that ships as a single binary, takes each job as a small TOML file, so a script can write one file per job into an include directory. runwisp validate --json reports errors with the key and line, runwisp run NAME --json runs the job now and exits with its exit code, and runwisp reload refuses a broken config and keeps the running jobs as they were. Its limits: it's one more service to install and keep running, while cron and systemd are already on the box.
  1. Scheduling restic or borg backups with alerts when they fail: systemd timer, cron or borgmatic

    Four ways to run restic or borg on a schedule and hear about it when a backup fails or never runs: a systemd timer with OnFailure=, cron with a Healthchecks ping, resticprofile and borgmatic. Tested with restic 0.18 and 0.19, including exit 3, stale locks and catch-up after downtime.

  2. Django periodic tasks without Celery or Redis: 5 Docker Compose setups

    You need clearsessions every night and a report every 15 minutes, and every tutorial adds Celery, beat and Redis. Five setups without them, each with a Compose file: a management command plus supercronic, APScheduler (and why it runs 3 times under gunicorn), Huey on SQLite, django-q2 on your database, and RunWisp.

Get started
GPL 3.0 · no accounts · free to self-host

Start in 30 seconds. Ship on your terms.

A single Go binary. If it doesn't earn the disk space, rm it. No account, no signup, no catch.

Install in 30 seconds
curl -fsSL https://get.runwisp.com | sh