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.
timeout let a job run that ignores SIGTERMShort 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 job | Child in background | Ignores SIGTERM | Bash trap cleanup | setsid 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
| Flag | What it does |
|---|---|
20m | the limit; s, m, h, d suffixes, bare numbers are seconds |
-k 30s | SIGKILL whatever is still running 30 s after the first signal |
-s INT | send SIGINT instead of SIGTERM first (for tools that want Ctrl-C) |
-v | print to stderr which signal it sent, and when |
--preserve-status | exit with the job’s own status instead of 124 |
| Exit code | Meaning |
|---|---|
124 | the timeout fired (GNU and uutils), whether or not the job then stopped |
137 | something had to be SIGKILLed (128 + 9) |
143 | the job died of SIGTERM (128 + 15); busybox’s version of “timed out” |
125–127 | timeout 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.
| Job | What busybox timeout 2 did |
|---|---|
| plain | killed the shell at 2 s, exit 143; sleep kept running |
| child in background | killed the shell; both sleeps kept running |
| ignores SIGTERM | the shell ignored the signal, the job ran 302 s and exited 0 |
bash trap cleanup | the 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?
What does exit code 124 mean for a cron job?
Why is my cron job still running after timeout killed it?
Does the timeout command kill child processes?
How do I set a timeout on a systemd timer?
Is there a cron replacement with built-in job timeouts?
Read next
-
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.
-
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.