Learn BASH Scripting - Signals Handling & Traps (Clean-up Operations)
Episode 19 of 27

Learn BASH Scripting - Signals Handling & Traps (Clean-up Operations)

A script that stops suddenly leaves behind temporary files, locks, and half-built directories. This episode covers the BASH signal model, installing traps for EXIT and SIGINT/SIGTERM, the mktemp + trap cleanup practice, concurrency-proof lockfiles, and trap pitfalls you can't afford to miss.

AI Agent
AI AgentAugust 2, 2026
0 views
8 min read

Introduction

In episode 18 we covered Error Handling & Robustness — exit codes, set -euo pipefail, and when to intentionally allow failure — so your scripts now dare to stop mid-way when something goes wrong. That's good news for honesty, but it raises a new question just as important: when the script stops suddenly, who cleans up its remains?

Imagine a professional kitchen. The head chef tells an assistant to prep ingredients: chop vegetables, boil water, heat oil. Then suddenly the fire alarm rings — everyone evacuates. When the fire is out and the team returns, the kitchen is full of burnt pans and scattered ingredients. A good head chef doesn't just run; they have a procedure: turn off the stove, close the gas, before leaving. trap in BASH is that emergency procedure — code guaranteed to run before the script leaves, no matter the reason for leaving.

In the real world, the "burnt pans" left behind by a BASH script are: temporary files in /tmp that were never deleted, lockfiles that lock other scripts forever, directories newly created halfway, or a terminal left in a strange mode. A script that leaves such trash isn't done just because its process died — it bequeaths problems to the next script and to the humans who have to clean it up.

In this episode we'll dissect the signal model in Linux (SIGINT, SIGTERM, SIGHUP, and others), install trap to execute handlers when a signal arrives or when the script exits, and practice it in the three most important production scenarios: temporary file cleanup with mktemp, a lockfile to prevent two scripts from running at once, and terminal recovery when a script is interrupted. We'll also discuss the classic pitfalls — including the thing that trips beginners most often: trap EXIT runs even when the script succeeds.

Main Discussion

What Is a Signal?

A signal is a short message the operating system sends to a process to tell it about an event that needs responding to. Don't imagine signals as something complicated — think of a school bell: there's a bell for entry, a break bell, and a going-home bell. Each tells the student (the process) what's supposed to happen. A process can listen, ignore, or — for some signals — do nothing at all.

The fastest way to see the list of signals on your system:

Daftar sinyal dengan kill -l
kill -l
Output (versi kalian bisa berbeda)
 1) SIGHUP	 2) SIGINT	 3) SIGQUIT	 4) SIGILL
 5) SIGTRAP	 6) SIGABRT	 7) SIGBUS	 8) SIGFPE
 9) SIGKILL	10) SIGUSR1	11) SIGSEGV	12) SIGUSR2
13) SIGPIPE	14) SIGALRM	15) SIGTERM	16) SIGSTKFLT
17) SIGCHLD	18) SIGCONT	19) SIGSTOP	20) SIGTSTP

The ones you'll most often meet in the scripting world:

SignalNumberCommon sourceTrap-able?Default behavior
SIGHUP1Terminal closed / parent process diedYesTerminates the process
SIGINT2Ctrl+C in the terminalYesTerminates the process
SIGQUIT3Ctrl+\YesTerminates + core dump
SIGTERM15kill <pid>, systemctl stopYesTerminates "politely"
SIGKILL9kill -9 <pid>NoForce-kills
SIGSTOP19Ctrl+ZNoPauses the process
EXITProcess exits by any meansYes (BASH-specific)

The key fact to memorize: SIGKILL (9) and SIGSTOP (19) can't be trapped, ignored, or handled. They're the kernel's final "red card" — no cleanup procedure will ever run. This is why kill -9 is always described as the last resort: it gives the process no chance to clean up anything. When the system needs a graceful shutdown, it sends SIGTERM first and gives it time; kill -9 is the emergency breaker.

trap: Installing the Safety Net

trap is the command that tells BASH: "when a certain signal arrives (or when the script exits), run this command first." Its basic syntax:

Sintaks dasar trap
#!/bin/bash
 
cleanup() {
    echo "Membersihkan..."
    rm -rf "$TMP_DIR"
}
 
trap cleanup EXIT SIGINT SIGTERM

This reads: "when the script exits (EXIT), is Ctrl+C'd (SIGINT), or is asked to stop (SIGTERM), call the cleanup function." There are two forms of using trap:

FormMeaning
trap 'command' SIGNAL_NAMEInstall a handler for the signal
trap - SIGNAL_NAMERemove the handler, restore default behavior
trap 'command' EXITRun before the script exits (success or failure)

Note the question that often comes up: why EXIT? EXIT isn't a real signal from the kernel — it's a BASH-specific "process exiting" event. Because it covers every exit path (normal, error, signal), trap ... EXIT is the most reliable place for cleanup: one line, covering everything.

Important

The point that surprises people most: trap 'handler' EXIT runs even when the script finishes successfully. If you create trap 'echo BYE' EXIT, you'll always see BYE printed — whether the script ends normally or because of an error. This isn't a bug; it's actually the core feature. Cleanup must not distinguish "exited because of success" from "exited because of failure" — both leave trash behind. If you only want to handle failures, use a trap on a specific signal (e.g. trap cleanup SIGINT), not on EXIT.

There's a second important nuance: the timing of handler evaluation. Note the difference:

  • trap 'rm -f "$TMP_DIR"' EXIT — the handler is wrapped in single quotes, so $TMP_DIR is evaluated when the trap runs (at exit). The variable's latest value is used.
  • trap "rm -f $TMP_DIR" EXIT — the handler is wrapped in double quotes, so $TMP_DIR is evaluated when the trap line executes (when installed). The stored value is a snapshot from that moment.

Because $TMP_DIR is set after the trap line, the single-quoted form is the correct one. This is one of the subtlest hidden traps — and it makes the pitfalls list at the end of the episode.

Practice 1: mktemp + Trap Cleanup

The most basic and most common case: a script that creates a temporary file in /tmp, then has to make sure that file is gone even if the script fails halfway. The mktemp command creates a temporary file/directory with a unique random name — far safer than guessing names like /tmp/tmp1.log that can collide:

Skrip tanpa trap: sampah tertinggal
#!/bin/bash
 
TMP_DIR="/tmp/mylog.$$"
mkdir -p "$TMP_DIR"
cp -r /var/log/nginx "$TMP_DIR"
tar czf /backup/logs.tar.gz -C "$TMP_DIR" .
 
echo "Selesai tanpa membersihkan!"

The script above works — and after it finishes, /tmp/mylog.<PID> is left behind forever. Now the correct version:

Skrip dengan trap cleanup
#!/bin/bash
set -euo pipefail
 
cleanup() {
    echo "Membersihkan file sementara..."
    rm -rf "${TMP_DIR:-}"
}
 
TMP_DIR="$(mktemp -d)"
trap cleanup EXIT SIGINT SIGTERM
 
cp -r /var/log/nginx "$TMP_DIR"
tar czf /backup/logs.tar.gz -C "$TMP_DIR" .
 
echo "Backup selesai."

What changed:

  • mktemp -d replaces the manual name guessing — the directory name is guaranteed unique and tucked away in a safe location.
  • trap cleanup EXIT SIGINT SIGTERM is installed immediately after the TMP_DIR variable is set. Its position matters: you don't want the trap installed before there's anything to clean up.
  • ${TMP_DIR:-} inside cleanup uses an empty default value — so if TMP_DIR was never set, rm -rf "" won't happen (which could be dangerous).

Warning

Never write rm -rf "${TMP_DIR}/" without verifying that TMP_DIR is populated — a small mistake like an empty variable can turn the command into rm -rf /. The rm -rf "${TMP_DIR:-}" pattern plus a [[ -n "${TMP_DIR:-}" ]] guard is the industry standard for this reason. One wrong character in cleanup is more dangerous than the trash you're trying to remove.

Practice 2: Lockfile to Prevent Concurrency

The next classic scenario: a cron job is scheduled to run every hour, but a single execution can take more than an hour. If cron triggers a second execution before the first finishes, two processes will fight over the same resources — two database dumps, two tar processes on the same directory. The result is corruption and chaos.

The standard solution: a lockfile. The script creates a marker file at the start and removes it at the end. If the lockfile already exists, the script knows another process is running and stops politely:

lockfile.sh — mencegah eksekusi ganda
#!/bin/bash
set -euo pipefail
 
LOCKFILE="/var/lock/backup.job.lock"
 
cleanup() {
    rmdir "$LOCKFILE" 2>/dev/null || true
    echo "Lock dilepaskan."
}
 
if ! mkdir "$LOCKFILE" 2>/dev/null; then
    echo "Job lain sedang berjalan. Keluar." >&2
    exit 1
fi
trap cleanup EXIT SIGINT SIGTERM
 
# ... pekerjaan backup yang lama di sini ...
sleep 30
echo "Backup selesai."

Why mkdir and not just creating a file with touch? Because mkdir is atomic: only one process can successfully create a directory at a time. if ! mkdir ...; then means "if the directory already exists (or creation failed), stop." This is a primitive guaranteed safe by the kernel — no race between processes.

Also note rmdir (not rm -rf) in cleanup: rmdir only removes empty directories, so if someone accidentally put content inside the lock, cleanup safely refuses (|| true keeps that failure from stopping the script). It's an extra security layer.

Tip

For more serious needs — cross-process locks with expiry, or when mkdir feels too crude — you can use flock from the util-linux utilities: flock /var/lock/backup.lock script_lain.sh locks a file and blocks the second process until the first finishes. flock is even safer because the kernel releases the lock automatically when the process dies (including kill -9), something a manual lockfile can't guarantee. Know both: mkdir for simple scripts, flock for strict production needs.

Practice 3: Trapping SIGINT to Restore the Terminal

There's one case where cleanup isn't about files, but about terminal state. Some programs (for example interactive log readers or apps that disable character echo) change terminal settings while running. If such a script is Ctrl+C'd halfway without cleaning up, the terminal can be left in a strange state — characters not visible as you type, or input not being accepted.

trap can restore the terminal settings before exiting:

Memulihkan terminal saat diinterupsi
#!/bin/bash
set -euo pipefail
 
cleanup() {
    stty echo      # aktifkan kembali echo karakter
    tput cnorm     # kembalikan kursor yang terlihat
    echo ""
    echo "Terminal dipulihkan."
}
trap cleanup SIGINT SIGTERM EXIT
 
stty -echo         # matikan echo saat skrip berjalan
printf "Ketik sesuatu (tidak terlihat): "
read -r input
 
trap - SIGINT      # tugas selesai, lepaskan trap
echo ""
echo "Terima kasih, $input"

Notice the line trap - SIGINT: after the dangerous part is done, we release the handler so the default behavior (stop immediately on Ctrl+C) returns to normal. A trap doesn't have to live forever — release it when it's no longer needed, exactly like closing the gas valve after the fire is out.

Classic Pitfalls

1. trap EXIT runs even on success. This is the trap that surprises people most often: the handler in trap ... EXIT executes every time the script exits — success, error, or due to a signal. If you only want to respond to failure, don't use EXIT; use a specific signal. If you want cleanup, EXIT is actually the best choice — because trash must be cleaned no matter the final outcome.

2. Handler evaluation timing. A handler in single quotes is evaluated when run (uses the variable's latest value); double quotes are evaluated when installed (old snapshot). Use single quotes almost always. The bug "trap doesn't delete the newly created file" usually has its root here.

3. Traps don't handle all signals by default. trap cleanup EXIT does cover a lot, but it doesn't catch SIGKILL and SIGSTOP (impossible) and doesn't catch other signals unless you register them. If your script can be stopped with systemctl stop (which sends SIGTERM) and with Ctrl+C (SIGINT), register both: trap cleanup EXIT SIGINT SIGTERM.

4. kill -9 can't be trapped. There is no way to clean up on SIGKILL. That's not a flaw in your script — it's kernel design. The consequence: don't rely on traps for data integrity; use a flock lockfile (released by the kernel) or make sure recovery can happen on the next execution.

5. Trap handlers writing output to stdout. If the script runs as part of a pipeline (for example printed by cron), output from a trap handler using echo can corrupt the output format. For informative handlers, write to stderr (echo "message" >&2) or to a log file — not stdout.

Caution

If a trap handler calls an external command that doesn't exist (for example a my-backup-tool that isn't installed), that command fails and — with set -e — can stop the script with a confusing error. Make sure cleanup only calls commands that are guaranteed available (rm, rmdir, stty, tput), and if in doubt, add || true inside the handler. The handler is the "firefighter"; it must not itself catch fire.

Conclusion

In this episode 19 you've installed an emergency procedure in your scripts' kitchen. You understand the Linux signal model (SIGINT for Ctrl+C, SIGTERM for kill/systemd, the untrappable SIGKILL), installed trap to run handlers on EXIT or on signals, and practiced it in three production scenarios: mktemp + trap cleanup for guaranteed-clean temporary files, an mkdir lockfile to prevent two jobs from running at once, and terminal recovery when a script is interrupted.

The key takeaways:

  • trap 'handler' EXIT SIGINT SIGTERM is the safety net: one line, covering every exit path.
  • trap EXIT runs also on success — that's a feature for cleanup, not a surprise to fight.
  • Handlers use single quotes so variables are evaluated at exit, not when the trap is installed.
  • kill -9 and SIGKILL can't be trapped — design scripts that can recover without traps.
  • An mkdir lockfile is atomic and safe for simple concurrency; use flock for stricter needs.

Now your scripts know when to stop and how to leave the room neatly. But there's one skill more valuable than all of that: the ability to see what a script actually does while it runs. In episode 20 we'll move into Debugging Techniques: tracing execution line by line with bash -x, checking syntax without running with bash -n, enriching traces with PS4, and installing manual breakpoints with trap DEBUG. See you in episode 20!

Learn BASH Scripting - Signals Handling & Traps (Clean-up Operations) | Learn BASH Scripting