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.
crontab - replaced a crontab with nothingcrontab installed without complaint/etc/cron.d mistakes that make Debian skip the whole file, with no error where you'd see itShort 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. Thecron_setfunction 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, andrmremoves the job. It needs mode644, 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-run | Rejects a bad schedule | Run it now, get the exit code | Survives 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/nulland|| truelet it run on a box with no crontab yet, underset -euo pipefail.- The
$anchor keepscron_set backupfrom also deletingbackup2. printf '%s\n'always ends the file with a newline. Without one, Debian refuses the file (missing newline before EOF) and cronie calls it apremature 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:
| File | Debian 13, Ubuntu 26.04 | AlmaLinux 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:
| Distro | PATH cron gave the job |
|---|---|
| Debian 13 | /usr/bin:/bin |
| AlmaLinux 10 | /usr/bin:/bin:/usr/sbin:/sbin |
| Ubuntu 26.04 | from /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
validateloads the config exactly as the daemon would. A typo such asschedule =comes back asunknown 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.runruns 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.reloadapplies the change. A config that doesn’t validate is rejected, and the running jobs stay exactly as they were.runwisp schemaprints the JSON Schema for every key, for an editor or an agent to check against beforevalidate.
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:
- Never feed
crontab -from a pipe that can fail. Build the file, thencrontab FILE. - Tag the job, replace it by tag, so a second run changes nothing.
- End every crontab and
/etc/cron.dfile with a newline. - For
/etc/cron.d: mode644, a user field, no dot in the name, and check the journal after a minute. - 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?
How do I add a cron job only if it doesn't already exist?
Why did crontab - delete all my cron jobs?
Do I need to restart cron after adding a file to /etc/cron.d?
How do I check a crontab line for syntax errors?
How do I test a cron job right away instead of waiting for it?
Is there a cron replacement a script or AI agent can set up and verify?
Read next
-
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.
-
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.