← all posts

How to put a timeout on a cron job, and what it leaves running

timeout 20m in front of the command is the standard answer. We timed out the same five jobs with GNU, uutils and busybox timeout, systemd and RunWisp. Six of the seven setups left at least one process running.

12 min read

A nightly job hangs. Maybe an NFS mount went away, maybe an API stopped answering and the HTTP client has no timeout of its own. The job never exits, so it never fails, so nothing tells you. Cron starts another copy the next night, and that one hangs too. A week later ps shows seven of them, each holding a database connection.

The standard fix is one word in the crontab: timeout. Everyone knows that part. What fewer people know is what timeout actually kills, and the answer depends on which timeout you have. So we took five ordinary job shapes and timed each one out with seven different setups.

302 show long a 2-second timeout let a job run that ignores SIGTERM
6 of 7setups that left at least one of our five jobs running
0exit code busybox gave a job it failed to stop
90 ssystemd's default wait between SIGTERM and SIGKILL

Short answer

Use GNU timeout with a kill-after, so a job that ignores SIGTERM still dies:

0 3 * * * timeout -k 30s 20m /usr/local/bin/backup.sh

After 20 minutes the job and everything it started get SIGTERM; whatever is left 30 seconds later gets SIGKILL. Then check which timeout you’re actually running. On Ubuntu 25.10 and newer, the default one is uutils and its SIGKILL misses child processes, so write gnutimeout. On Alpine and other busybox images, timeout never signals children at all, so apk add coreutils first. For docker exec and ssh, put the timeout inside the remote command, because killing the client doesn’t stop the process on the other end.

systemd timers do this better than any timeout command, because they kill the whole cgroup. If you want per-job timeouts in config and every timed-out run recorded and alerted on, RunWisp, an open-source cron replacement that ships as a single binary, has a timeout key per task and reads your existing crontab.

Here’s the whole test. Each cell is one job timed out after 2 seconds. ✓ means nothing was left running afterwards and the job stopped at the timeout (or at the SIGKILL that follows it):

Plain jobChild in backgroundIgnores SIGTERMBash trap cleanupsetsid child
GNU timeout 2✓✓✗ ran 302 s✓✗
GNU timeout -k 5 2✓✓✓ killed at 7 s✓✗
uutils timeout 2✓✓✗ ran 302 s✓✗
uutils timeout -k 5 2✓✓✗ shell killed, child ran✓✗
busybox timeout 2✗ child ran on✗ both ran on✗ ran 302 s, exit 0✗ cleanup at 303 s✗
systemd TimeoutStartSec=2✓✓✓ killed at 92 s✓✓
RunWisp timeout = "2s"✓✓✓ killed at 7 s✓✗

The rest of this post goes through each ✗, what causes it, and the fix.

The timeout flags you need

FlagWhat it does
20mthe limit; s, m, h, d suffixes, bare numbers are seconds
-k 30sSIGKILL whatever is still running 30 s after the first signal
-s INTsend SIGINT instead of SIGTERM first (for tools that want Ctrl-C)
-vprint to stderr which signal it sent, and when
--preserve-statusexit with the job’s own status instead of 124
Exit codeMeaning
124the timeout fired (GNU and uutils), whether or not the job then stopped
137something had to be SIGKILLed (128 + 9)
143the job died of SIGTERM (128 + 15); busybox’s version of “timed out”
125–127timeout itself failed, or couldn’t find or run the command

The five jobs

Each job is a few lines of shell, run the way cron runs it, and each would take about five minutes if nothing stopped it:

# plain: the shell runs one command and waits for it
sleep 301

# background child: something started with & alongside the main work
sleep 304 &
sleep 305

# ignores SIGTERM: a job that blocks or handles the signal and carries on
trap '' TERM
sleep 302

# bash trap cleanup: removes a temp file or a lock on the way out
trap 'rm -f /tmp/import.tmp; exit 1' TERM
sleep 303

# setsid child: a process that leaves the job's process group, like a daemon
setsid sleep 306 &
sleep 307

sleep stands in for the real work: a pg_dump, a php artisan command, a curl that never returns. What matters is the shape. A shell script almost always runs its work as child processes, and that’s where the timeouts differ.

Gotcha 1: SIGTERM is a request

timeout sends SIGTERM by default. A process can ignore it, or catch it and keep going, and plenty of real ones do: a worker that finishes its current batch first, or a script with trap '' TERM someone added years ago. Without -k, timeout sends the signal once and then waits, as long as it takes.

Our “ignores SIGTERM” job was supposed to be stopped after 2 seconds. It ran its full 302. And GNU timeout still exited 124, so your log says the timeout fired, which it did. It just didn’t do anything.

$ time timeout 2 sh ignterm.sh; echo "exit $?"
real    5m2.008s
user    0m0.002s
sys     0m0.002s
exit 124
$ time timeout -k 5 2 sh ignterm.sh; echo "exit $?"
real    0m7.002s
user    0m0.001s
sys     0m0.001s
exit 137

Always pass -k. Make it long enough for a well-behaved job to clean up (10 to 60 seconds covers most) and short enough that you don’t care how it ends.

Gotcha 2: Ubuntu 26.04’s timeout isn’t GNU’s

Since Ubuntu 25.10, /usr/bin/timeout comes from uutils, the Rust rewrite of coreutils. The 26.04 LTS ships uutils 0.10.0. Run timeout --version: if it says uutils coreutils, this section is about you.

For SIGTERM, uutils behaves like GNU: the job and its children all got the signal. SIGKILL is different. Whether it comes from -k or from -s KILL, it reached only the top process. The shell died and timeout reported 137, while the actual work carried on underneath:

$ timeout --version | head -1
timeout (uutils coreutils) 0.10.0
$ timeout -k 1 1 sh ignterm.sh; echo "exit $?"
exit 137
$ ps -eo pid,ppid,args | grep 'sleep 302'
1952602    2674 sleep 302

Parent PID 2674 is systemd --user. The orphan has been reparented and will run until it’s done, with nothing waiting for its exit code. The same test with GNU timeout left nothing.

The fix is already installed. Ubuntu keeps GNU coreutils as a required package, with every command prefixed gnu:

0 3 * * * gnutimeout -k 30s 20m /usr/local/bin/backup.sh

This may well be fixed in a later uutils release. Until 26.04 picks that up, gnutimeout is the safe choice on Ubuntu, and it does nothing different from timeout on every other distro.

Gotcha 3: busybox only signals one process

Alpine, and most small Docker images built on it, have busybox’s timeout. It signals exactly one process, the one it started. Everything that process started is left alone.

JobWhat busybox timeout 2 did
plainkilled the shell at 2 s, exit 143; sleep kept running
child in backgroundkilled the shell; both sleeps kept running
ignores SIGTERMthe shell ignored the signal, the job ran 302 s and exited 0
bash trap cleanupthe cleanup ran, but only at 303 s

That exit 0 is the worst row in this post. The job blew through its limit by five minutes and reported success. busybox’s timeout does have a -k option, but it sends SIGKILL to the same single process, so it doesn’t help the children.

The trap row is a bash rule you’ll hit elsewhere too: bash runs a trap only after the command in the foreground returns. GNU timeout signals sleep as well, so it returns at once and the cleanup runs at 2 seconds. busybox signals only bash, which sits waiting for a five-minute sleep before it can clean up. If you write cleanup traps, start the long command with &, wait for it, and stop it from the trap. wait returns as soon as a signal arrives, so the trap runs right away:

trap 'kill "$pid"; rm -f /tmp/import.tmp; exit 1' TERM
php artisan import:orders &
pid=$!
wait "$pid"

Leave out the kill "$pid" and, under busybox, the cleanup runs on time while import:orders keeps going in the background.

The fix on Alpine is the real coreutils package, which replaces the busybox applet:

apk add coreutils   # /usr/bin/timeout is now GNU coreutils

We checked it in an alpine:3 container: with GNU timeout -k, the job that ignores SIGTERM and its background child were both gone after the kill.

Gotcha 4: some children leave the group

GNU timeout doesn’t track processes one by one. It puts the job in its own process group and signals the group. A child that calls setsid, or a program that daemonizes itself (double fork, new session), isn’t in that group anymore. Every timeout we tried left our setsid child running, and so did RunWisp, which uses the same mechanism.

nohup cmd & is not one of these. It ignores SIGHUP, but the process stays in the group and the SIGTERM reaches it.

Only systemd caught the setsid child, because it works on cgroups. A process can leave a process group but it can’t leave its cgroup. If your job starts things that daemonize, like a script that launches a headless browser or a tool that forks a background server, run it as a systemd service, or stop that child in your own trap handler.

Gotcha 5: docker exec and ssh only lose the client

0 3 * * * timeout -k 30s 20m docker exec app php artisan import:orders

This looks right, and the timeout fires right on time. It kills the docker CLI process on the host. The php process inside the container keeps going. We tested it with podman exec:

$ timeout 2 podman exec tctr sleep 309; echo "exit $?"
exit 124
$ podman exec tctr ps -o pid,args | grep 'sleep 309'
   14 sleep 309

Docker has the same design. The issue for it, moby/moby#9098, “Kill docker exec command will not terminate the spawned process”, was opened in 2014 and is still open. ssh host cmd without a terminal behaves the same way: the local ssh dies, and the remote command may never find out.

Move the timeout to the side where the work runs:

0 3 * * * docker exec app timeout -k 30s 20m php artisan import:orders
0 4 * * * ssh backup@nas 'timeout -k 30s 2h /usr/local/bin/snapshot.sh'

Keep in mind that the container’s timeout is whatever its image has. A PHP image on Alpine means busybox, and gotcha 3 applies inside it.

Picking the number

Set the timeout well above the job’s slowest normal run and well below “this is clearly stuck”. If you don’t know the slowest normal run, find out first: log date +%s before and after for a couple of weeks, or check your scheduler’s run history if it has one.

For jobs that run often, keep the timeout shorter than the interval. Then a stuck run is killed before the next one is due, and you won’t get two copies at once. If that’s not possible, add a lock as well. The order matters, timeout goes inside flock so the timeout also frees the lock:

*/5 * * * * flock -n -E 0 /run/lock/sync.lock timeout -k 30s 4m /usr/local/bin/sync.sh

More on that in how to stop a cron job from running twice.

Finding out it happened

A timeout turns a hang into a failure, which only helps if someone sees the failure. From cron, exit 124 goes where every other exit code goes, which on most servers is nowhere. At least log it:

0 3 * * * timeout -k 30s 20m /usr/local/bin/backup.sh || logger -t backup "exit $?"

$? there is timeout’s exit code, so 124 or 137 in the journal means a timeout and anything else is the job’s own failure. For actual alerts, we compared the options in how to get alerted when a cron job fails.

systemd timers: set it on the service

If the job is already a systemd timer, there’s no timeout command involved. The limit goes on the service unit the timer starts:

# /etc/systemd/system/backup.service
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh
TimeoutStartSec=20min
TimeoutStopSec=30s

Two defaults to know. A Type=oneshot service, which is what most timer jobs are, has no start timeout unless you set one. And when the timeout fires, systemd waits TimeoutStopSec= before SIGKILL, 90 seconds by default. That’s why our SIGTERM-ignoring job lasted 92 seconds under a 2-second limit. In exchange you get the only setup in our test that stopped everything, setsid child included, and the run ends up in the journal as Failed with result 'timeout'.

RunWisp: one key per task

RunWisp, our project, is an open-source cron replacement that ships as a single binary. A timeout is one line in the task:

[tasks.backup]
cron          = "0 3 * * *"
run           = "/usr/local/bin/backup.sh"
timeout       = "20m"
graceful_stop = "30s"   # SIGTERM, then SIGKILL after this (default 5s)

It does what timeout -k does, against the job’s process group: SIGTERM, a grace period, then SIGKILL. A [defaults] block sets one timeout for every task. The difference from a crontab is what happens afterwards. The run is recorded as timeout, separate from an ordinary failure, with its output up to the moment it was stopped, and with notify set it alerts you like any other failure.

The honest limits, from the same table: it works on the process group, not a cgroup, so our setsid child survived it too. RunWisp did close the run 10 seconds after the kill instead of waiting on the escaped process forever, but the process kept running. And it can’t cancel a docker exec any more than timeout can, so container tasks that exec into a running container need the timeout inside the command, as in gotcha 5. No timeout is set by default, same as cron.

How we tested

Ubuntu 26.04.1 on x86-64. GNU timeout from coreutils 9.7 (as gnutimeout), uutils timeout 0.10.0 (the default /usr/bin/timeout), BusyBox 1.37.0, systemd 259 user units started with systemd-run --wait -p Type=oneshot -p TimeoutStartSec=2, and RunWisp 1.6.0 with timeout = "2s" and the default graceful_stop. Each job ran under sh (dash), except the trap job, which needs bash. After the timeout returned, we waited a second and listed every sleep still running. The Alpine and podman exec checks ran in an alpine:3 container with BusyBox 1.37.0 and GNU coreutils 9.11.

Common questions

How do I set a timeout on a cron job?
Put GNU timeout in front of the command, with a kill-after: 0 3 * * * timeout -k 30s 20m /usr/local/bin/backup.sh. After 20 minutes it sends SIGTERM to the job and everything it started; if anything is still alive 30 seconds later, it sends SIGKILL. The job then exits 124 (stopped by SIGTERM) or 137 (needed SIGKILL), which you can alert on. On Ubuntu 25.10 and newer, use gnutimeout instead of timeout, and on Alpine install the coreutils package first.
What does exit code 124 mean for a cron job?
It means timeout ran out of time and signalled the command. GNU and uutils timeout return 124 whether or not the signal actually stopped the job: a job that ignores SIGTERM runs to completion and still exits 124. 137 means it had to be killed with SIGKILL (128 + 9). busybox timeout returns 143 (128 + 15) instead of 124, and 0 if the job ignored the signal and finished normally.
Why is my cron job still running after timeout killed it?
Four common causes. The job ignores or handles SIGTERM and you didn't pass -k. A child process moved itself out of the process group with setsid or by daemonizing, which no timeout command can follow. The command was docker exec or ssh, so timeout killed the client and the process on the other side kept going. Or you're on Alpine or Ubuntu 26.04, whose default timeout doesn't kill children in some or all cases.
Does the timeout command kill child processes?
GNU timeout does: it runs the command in its own process group and signals the whole group, unless you pass --foreground or a child leaves the group with setsid. busybox timeout signals only the one process it started, so its children are orphaned and keep running. uutils timeout 0.10, the default on Ubuntu 26.04, sends SIGTERM to the group but SIGKILL (from -k or -s KILL) only to the top process.
How do I set a timeout on a systemd timer?
Set TimeoutStartSec= on the timer's service unit, not on the timer. Type=oneshot services, which most timer jobs are, have no start timeout by default. When it fires, systemd sends SIGTERM to every process in the unit's cgroup and waits TimeoutStopSec= (90 seconds by default) before SIGKILL, so set that lower too. Because it works on the cgroup, it also kills children that escaped the process group.
Is there a cron replacement with built-in job timeouts?
Yes. systemd timers have TimeoutStartSec=, Kubernetes Jobs have activeDeadlineSeconds, and RunWisp, an open-source cron replacement that ships as a single binary, has a per-task timeout key. RunWisp sends SIGTERM to the job's process group, SIGKILLs it 5 seconds later (configurable with graceful_stop), records the run as timeout rather than failed, and can alert on it. Its limits: it works on the process group, not a cgroup, so a child started with setsid survives, and it can't cancel a docker exec any more than timeout can.
  1. Ubuntu 26.04 Rust coreutils (uutils) vs GNU: what changes for cron and shell scripts

    Which uutils commands behave differently from GNU coreutils on Ubuntu 26.04 and 26.10, tested on 567 commands: mkdir locks, mv and mtime, timeout, date and DST, df, chown. How to check which you have and how to use GNU instead.

  2. Debian cron vs cronie vs BusyBox crond: CRON_TZ, /etc/cron.d, env vars and syntax compared

    How cron differs on Ubuntu, Debian, Fedora, RHEL, Arch and Alpine: which crons support CRON_TZ, read /etc/cron.d, pass Docker env vars, handle a bad crontab line, and accept seconds, L, W and #. Tested on six distros.

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