Bash Best Practices: Strict Mode, Traps, ShellCheck and Tests
Write reliable Bash: set -euo pipefail and where it fails, traps for cleanup and signals, ShellCheck and shfmt, bats-core tests, CI and macOS portability.
A Bash script that works when you run it once is easy to write. One that keeps working when a file is missing, a disk fills up, someone presses Ctrl+C, a colleague runs it on a Mac, or cron starts it at 3 a.m. takes a few habits. None of them are complicated, but several have traps of their own, starting with the famous set -euo pipefail.
This part collects those habits: a script template, what strict mode really does and where it fails, traps for cleanup, errors and signals, locking, safe defaults, debugging, ShellCheck and shfmt, tests with bats-core, a CI workflow, portability to macOS and sh, and when to stop writing Bash. Along the way, the tests find a real bug in logstat.
Prerequisites
- The rest of this guide, especially functions (Part 6) and the finished
logstatfrom Part 8. - The Linux guide's scripting part introduces
set -euo pipefailandtrap ... EXIT; this part goes into the details.
Tools used here: ShellCheck 0.11.0, shfmt 3.14.1 and bats-core 1.14.0. brew install shellcheck shfmt bats-core installs these versions on macOS and Linux. Ubuntu 24.04's packages are older (sudo apt install shellcheck shfmt bats gives ShellCheck 0.9.0, shfmt 3.8.0 and bats 1.10.0); they run everything in this part, but shfmt's formatting can differ slightly between versions, so a project should pin one, as the CI workflow below does.
A Template for New Scripts
#!/usr/bin/env bash
# template.sh: one line that says what the script does
# usage: template.sh [-v] INPUT...
set -euo pipefail
shopt -s inherit_errexit 2>/dev/null || true # Bash 4.4+: $( ) inherits set -e
script_name=${0##*/}
verbose=0
workdir=""
log() { printf '%s: %s\n' "$script_name" "$*" >&2; }
die() {
log "error: $*"
exit 1
}
debug() { if ((verbose)); then log "$*"; fi; }
usage() {
cat <<EOF
usage: $script_name [-v] INPUT...
-v print what the script is doing
EOF
}
cleanup() {
if [[ -n $workdir ]]; then rm -rf -- "$workdir"; fi
}
main() {
local opt input
while getopts ":vh" opt; do
case $opt in
v) verbose=1 ;;
h)
usage
exit 0
;;
*)
usage >&2
exit 2
;;
esac
done
shift $((OPTIND - 1))
if (($# == 0)); then
usage >&2
exit 2
fi
workdir=$(mktemp -d)
trap cleanup EXIT
for input in "$@"; do
[[ -r $input ]] || die "cannot read $input"
debug "processing $input"
# ... the real work goes here, with temporary files in "$workdir" ...
done
}
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
main "$@"
fi
Each piece comes from an earlier part or from this one: strict mode and traps below, logging to standard error and the main guard from Part 6, and getopts from Part 8. Add the script_dir line from Part 7 when the script loads files that sit next to it. You will also see the "unofficial strict mode" line IFS=$'\n\t' in many templates; it changes how every unquoted expansion splits, which hides quoting bugs instead of fixing them. Quote your variables and leave IFS alone.
Strict Mode: What set -euo pipefail Does
| Option | Effect |
|---|---|
-e (errexit) |
Exit when a command fails (with exceptions, below) |
-u (nounset) |
Treat reading an unset variable as an error |
-o pipefail |
A pipeline fails when any of its commands fails, not just the last one |
-E (errtrace) |
Let the ERR trap fire inside functions too (only matters with an ERR trap) |
Strict mode turns many silent failures into loud ones, which is why it belongs at the top of most scripts. But set -e is not error handling. It has rules about where it applies that surprise everyone, and knowing them is the difference between a safety net and a false sense of safety.
Where set -e Does Not Stop the Script
set -e is ignored for any command whose status is being tested. This script shows each case; every line that runs proves the script did not stop:
#!/usr/bin/env bash
# errexit.sh: places where set -e does NOT exit
set -euo pipefail
if false; then :; fi
echo "1. a failing if condition is fine (that is the point of if)"
false || true
false && echo "never printed"
echo "2. failures in && and || lists are ignored, except in the last command"
# shellcheck disable=SC2251 # this case shows the problem ShellCheck warns about
! true
echo "3. a negated command never triggers set -e"
check() {
false # fails...
echo " ...but the function keeps going"
}
if check; then echo "4. inside a function called from a condition, set -e is off entirely"; fi
out=$(false; echo "still ran")
echo "5. \$( ) does not inherit set -e: out=[$out]"
# shellcheck disable=SC2034,SC2155 # this case shows the problem ShellCheck warns about
f() { local value=$(false); }
f
echo "6. local x=\$(cmd) hides cmd's exit status"
echo "7. the next line stops the script, because [[ ]] is the last command of g"
g() { [[ -n ${DEBUG:-} ]] && echo "debug mode"; }
g
echo "never printed"
Cases 4 and 5 deserve a second look. A function called as an if condition, or on the left of && and ||, runs with set -e turned off for its whole body, so the false inside check was ignored. And $( ) runs in a subshell that does not inherit -e, so still ran was captured. shopt -s inherit_errexit (Bash 4.4+) fixes the second one:
The other direction matters too: commands that "fail" in normal use stop a strict script.
grepexits 1 when it finds nothing. Writegrep ... || truewhen no match is fine, or test it in anif.(( count++ ))returns 1 when the old value was 0 (Part 4).read -d ''returns 1 at the end of its input (Part 9).- A function or script whose last command is
[[ ... ]] && actionreturns 1 when the test is false, as case 7 showed. End such functions with anif, orreturn 0.
When not to use set -e: in your interactive shell (the first typo closes the terminal), in libraries meant to be sourced (it changes the caller's shell), and in scripts where most commands are expected to fail sometimes, such as a monitoring check that collects results. There, check each important command explicitly: cp "$src" "$dst" || die "copy failed". Explicit checks are clearer than set -e anyway; use both.
What does set -e mean in a Bash script? has more examples.
set -u and Empty Arrays
set -u catches typos in variable names, which is worth a lot. Read optional variables with a default (${DEBUG:-}, ${1-}) and check for set variables with [[ -v name ]]. Since Bash 4.4, an empty array expands to nothing under set -u. Older versions, including macOS's 3.2, call it unbound:
pipefail and SIGPIPE
Without pipefail, false | true succeeds. With it, the pipeline fails when any command fails. That catches real errors, such as a grep that cannot read its file. It also catches one thing that is not an error: when a reader such as head stops early, the writer gets the SIGPIPE signal on its next write and dies with status 141 (128 + 13).
The failure appears when the writer writes again after the reader has exited. A small output usually fits in the pipe (a Linux pipe holds 64 KiB by default) and is written before head exits, so the problem tends to stay hidden until the output grows; with an unlucky timing, it can hit small outputs too. So a script can pass every test with small inputs and fail in production. logstat has exactly this pattern (count_by ... | head -n "$top"). Here is how a test finds it.
Test Your Scripts With bats-core
bats-core runs test files written in Bash. Each @test block is a test; it passes when every command in it succeeds. run executes a command and saves its exit status in $status, its output in $output and the output lines in the lines array, so you can make assertions about failures too. Install it with sudo apt install bats, brew install bats-core or npm install -g bats, or vendor it into your repository with git submodule add https://github.com/bats-core/bats-core.git test/bats.
First, turn on strict mode in logstat, as every script in this part should have it. Add one line after the comments at the top of ~/bash-practice/logstat:
#!/usr/bin/env bash
# logstat: count requests in web server access logs
# usage: logstat [-b FIELD] [-n N] [FILE...]
set -euo pipefail
Then create the test directory with mkdir -p ~/bash-practice/test and save the tests as ~/bash-practice/test/logstat.bats:
#!/usr/bin/env bats
# test/logstat.bats: tests for lib/logstat.sh and the logstat command
bats_require_minimum_version 1.5.0
setup() {
ROOT="$BATS_TEST_DIRNAME/.."
LOG="$ROOT/logs/access.log"
# shellcheck source=lib/logstat.sh
source "$ROOT/lib/logstat.sh"
}
@test "parse_line splits a combined log line into fields" {
local -a f
parse_line '192.0.2.44 - - [23/Oct/2026:08:15:00 +0000] "GET /about.html HTTP/1.1" 200 418 "-" "curl/8.5.0"' f
[ "${f[0]}" = "192.0.2.44" ]
[ "${f[2]}" = "GET" ]
[ "${f[3]}" = "/about.html" ]
[ "${f[4]}" = "200" ]
}
@test "parse_line rejects a line in another format" {
run parse_line "2026-10-23T08:15:02Z INFO server started on port 8080" f
[ "$status" -eq 1 ]
}
@test "count_by status counts every request" {
run count_by status "$LOG"
[ "$status" -eq 0 ]
[ "${#lines[@]}" -eq 5 ]
[ "${lines[0]}" = $'20\t200' ]
}
@test "count_by reports skipped lines on standard error only" {
run --separate-stderr count_by method "$ROOT/logs/app.log"
[ "$status" -eq 0 ]
[ "$output" = "" ]
[[ $stderr == *"skipped 6 line(s)"* ]]
}
@test "logstat exits with 2 on an unknown field" {
run -2 "$ROOT/logstat" --by browser "$LOG"
[[ $output == *"--by must be"* ]]
}
@test "logstat exits with 1 when a file cannot be read" {
run -1 "$ROOT/logstat" "$ROOT/logs"
[[ $output == *"cannot read"* ]]
}
@test "logstat reads standard input" {
run bash -c 'grep " 404 " "$1" | "$2" --by path' _ "$LOG" "$ROOT/logstat"
[ "$status" -eq 0 ]
[ "${lines[0]}" = $'4\t/favicon.ico' ]
}
@test "logstat --top works when the output is bigger than a pipe buffer" {
local i
for ((i = 0; i < 8000; i++)); do
printf '10.0.%d.%d - - [23/Oct/2026:08:15:00 +0000] "GET / HTTP/1.1" 200 612 "-" "curl/8.5.0"\n' \
$((i / 250)) $((i % 250))
done >"$BATS_TEST_TMPDIR/big.log"
run "$ROOT/logstat" --by ip --top 1 "$BATS_TEST_TMPDIR/big.log"
[ "$status" -eq 0 ]
[ "${#lines[@]}" -eq 1 ]
}
The tests source the library, which is safe because it only defines functions, test single functions, and run the whole logstat command as a user would. The last test builds an 8,000-line log with a different IP address on every line, so the report is bigger than the pipe buffer. Run them:
The output is in TAP format, which CI systems understand; in a terminal, bats shows the same results with check marks. The last test failed. Run the same command by hand to see what happened:
logstat printed the right line and then exited with status 141: head printed one line and exited, sort inside count_by was killed by SIGPIPE on its next write, pipefail made the pipeline fail, and set -e ended the script. Any script or CI job that called logstat --top on a big log would have stopped.
The fix is to let the reader consume all its input. awk 'NR <= n' prints the first n lines and keeps reading, so the writer never sees a closed pipe. Change the --top branch at the end of main:
if [[ -v top ]]; then
# awk reads all of its input, so count_by never writes to a closed pipe
count_by "$by" "${files[@]}" | awk -v n="$top" 'NR <= n'
else
count_by "$by" "${files[@]}"
fi
Other fixes would work too: || true after the pipeline (but that also hides real errors), or limiting the output inside count_by. Whatever you choose, the test now guards against the bug coming back.
A few bats features worth knowing:
run -2 commandexpects exit status 2, andrun --separate-stderrputs standard error in$stderr(both need bats 1.5+, hencebats_require_minimum_version).setupandteardownrun before and after each test;$BATS_TEST_TMPDIRis a temporary directory per test that bats cleans up.- The bats-assert and bats-support libraries add helpers such as
assert_output --partial "text".
Traps: Cleanup, Errors and Signals
trap 'commands' EVENT runs commands when something happens. The events that matter in scripts:
| Event | When it fires |
|---|---|
EXIT |
The script ends for any reason: end of file, exit, set -e, a signal that has a trap |
ERR |
A command fails, under the same rules as set -e |
INT |
SIGINT, usually Ctrl+C |
TERM |
SIGTERM, which kill, systemd and timeout send by default |
HUP |
The terminal or SSH session closed |
#!/usr/bin/env bash
# traps.sh: clean up on every exit, report errors, stop cleanly on signals
set -Eeuo pipefail
workdir=$(mktemp -d)
cleanup() {
local status=$? pids
mapfile -t pids < <(jobs -pr) # background jobs that are still running
if ((${#pids[@]} > 0)); then kill "${pids[@]}" 2>/dev/null || true; fi
rm -rf -- "$workdir"
echo "cleanup: removed the work directory, exit status $status" >&2
}
trap cleanup EXIT
trap 'echo "error: \"$BASH_COMMAND\" failed with status $? on line $LINENO" >&2' ERR
trap 'echo "stopped by SIGTERM" >&2; exit 143' TERM
trap 'echo "interrupted" >&2; exit 130' INT
count_errors() {
grep -c ' 503 ' "$1" # no 503 lines in the log, so grep exits with 1
}
case ${1:-ok} in
ok) echo "requests: $(wc -l <logs/access.log)" ;;
fail)
errors=$(count_errors logs/access.log)
echo "503 errors: $errors"
;;
wait)
sleep 30 &
wait "$!"
;;
esac
echo "finished"
What to take from it:
- The
EXITtrap ran every time, after success, after an error and after both signals, and$?at its start held the exit status. Put all cleanup in one function and register it once; a secondtrap ... EXITreplaces the first instead of adding to it. - The
ERRtrap fired three times for one failure: in the function (inside the$( )subshell), for the function call, and for the assignment in the main script. Withset -E, it fires at every level the failure passes through. Use it for diagnostics, not for actions that must happen once. - Signal traps should exit with 128 plus the signal number (130 for INT, 143 for TERM), so whoever started the script can tell what happened.
- Bash runs a trap only after the current foreground command finishes. With a plain
sleep 30, the TERM trap would have waited up to 30 seconds. Running long commands in the background and waiting withwaitlets the trap run at once, and the cleanup function stops the background job.
trap -p lists the current traps and trap - EXIT removes one. Bash 5.3 adds trap -P EXIT to print just the command of one trap.
Run One Copy at a Time
Scripts started by cron or a timer can overlap when one run is slow. flock holds a lock on a file for as long as a file descriptor for it stays open. The kernel releases the lock when the last such descriptor closes, so a crashed script cannot leave it behind; note that child processes inherit the descriptor, so a background job the script started keeps the lock until it exits too:
#!/usr/bin/env bash
# locked.sh: refuse to run twice at the same time
set -euo pipefail
exec 9<"${BASH_SOURCE[0]}" # lock the script file itself: no lock file to create
if ! flock -n 9; then
echo "another copy is already running" >&2
exit 1
fi
echo "got the lock, working for 2 seconds"
sleep 2
echo "done"
flock -n fails immediately when another process holds the lock; flock -w 10 waits up to 10 seconds instead. Locking the script file itself avoids creating a lock file at all; when you need a separate one, keep it in a directory only you can write to, not in a shared /tmp. Either way this is more reliable than a PID file, which a crashed run leaves behind.
Safe Defaults
The habits from the earlier parts, collected:
- Quote every expansion:
"$var","$(cmd)","${array[@]}". - Put
--before file names that come from variables or globs, or prefix globs with./. - Guard dangerous paths:
rm -rf -- "${build_dir:?}/"*stops the script ifbuild_diris empty, instead of runningrm -rf /*. ShellCheck warns about the unguarded form (SC2115). - Check
cd:cd "$dir" || exit 1. ShellCheck warns without it (SC2164). - No
evalon data, and no building commands in strings. Use arrays (Part 8). - Validate input before arithmetic (Part 5).
- Create files privately when they hold secrets: set
umask 077before writing them, and create temporary files withmktemp. - Keep secrets off the command line. Every user on the machine can read the arguments of every process:
Pass secrets in an environment variable, a file with mode 600 or standard input instead. For curl, for example, put the header in a file and use -H @file, or use a --netrc-file.
Debugging
bash -x script (or set -x in the script) prints each command after expansion. Set PS4 to add the file, line and function to every trace line, which makes long traces readable:
#!/usr/bin/env bash
# trace-demo.sh: a small script to trace
total=0
add() {
total=$((total + $1))
}
for n in 3 4; do
add "$n"
done
echo "total: $total"
Three more tools:
bash -n scriptchecks the syntax without running anything.BASH_XTRACEFD=9(withexec 9>trace.log) sends the trace to a file, so it does not mix with the script's own error output.local -plusset -xinside one function traces just that function (Part 6).
Lint With ShellCheck, Format With shfmt
ShellCheck finds bugs that Bash itself does not report. The Linux guide shows its most common warnings (missing quotes, an unchecked cd, looping over ls). This script has some subtler ones:
#!/usr/bin/env bash
# report.sh: summarize errors in the logs (buggy on purpose)
set -euo pipefail
summarize() {
local errors=$(grep -c ERROR "$1")
echo "$1: $errors errors"
}
logs=($(find logs -name '*.log'))
for log in ${logs[@]}; do
summarize "$log"
done
if [ $? -ne 0 ]; then echo "failed" >&2; fi
rm -rf "$OUTPUT_DIR/"*
Each code links to a wiki page that explains the problem and the fix, for example https://www.shellcheck.net/wiki/SC2155. Useful options and conventions:
shellcheck -xfollowssourcelines (with a# shellcheck source=pathdirective when the path is computed).shellcheck -s shchecks a script against POSIXsh, which is how you find Bash-only features in a#!/bin/shscript (see below).-S warninghides theinfoandstylelevels when you start on an old code base.- To accept a warning, disable it on the line before, with a reason:
# shellcheck disable=SC2034 # read by the sourcing script. Project-wide settings go in a.shellcheckrcfile.
shfmt formats shell scripts consistently, which ends style discussions in code review. -d shows what it would change and fails if anything would, which is what CI needs; -w rewrites the files:
-i 4 indents with four spaces and -ci indents case branches. Put the flags in an .editorconfig file so editors and shfmt agree. The logstat files in this guide were formatted with exactly these flags.
Run the Checks in CI
The same three checks belong in CI, so no change merges without them. Save this as .github/workflows/shell.yml in a repository that contains logstat with its lib/ and test/ directories, the layout of ~/bash-practice:
name: shell
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: ShellCheck
run: shellcheck -x logstat lib/*.sh test/*.bats
- name: shfmt
run: |
curl -fsSL -o "$RUNNER_TEMP/shfmt" \
https://github.com/mvdan/sh/releases/download/v3.14.1/shfmt_v3.14.1_linux_amd64
echo "76e77641faa025814b77f153b29796b8e6fa2fca03e0c76a691608b86c7ea7bf $RUNNER_TEMP/shfmt" | sha256sum -c -
chmod +x "$RUNNER_TEMP/shfmt"
"$RUNNER_TEMP/shfmt" -d -i 4 -ci logstat lib/*.sh test/*.bats
- name: bats
run: |
git clone --depth 1 --branch v1.14.0 https://github.com/bats-core/bats-core.git "$RUNNER_TEMP/bats"
test "$(git -C "$RUNNER_TEMP/bats" rev-parse HEAD)" = eb7f42f8d608ac693d7a4b67474f6714ea68cfc5
"$RUNNER_TEMP/bats/bin/bats" test/
The GitHub-hosted ubuntu-24.04 runner has Bash 5.2.21 and ShellCheck 0.9.0 preinstalled. The workflow pins the checkout action and bats to commits and the shfmt download to a checksum, so a moved tag or a changed download fails the build instead of running in it. actionlint also runs ShellCheck on the run: blocks when ShellCheck is installed. On a pull request, a failing step blocks the merge if you make the job a required check.
Portability: macOS and POSIX sh
Bash 3.2 on macOS
A script with #!/usr/bin/env bash runs with Bash 3.2 on any Mac without Homebrew's Bash, and with 3.2, a lot of this guide does not exist. These runs use Bash 3.2.57 built from the GNU source code and installed as /opt/bash-3.2/bin/bash; on a Mac, /bin/bash prints the same messages with its own path:
The first command did not fail loudly: declare -A printed a usage error, and the assignment went on as an indexed array, where web evaluated to index 0. When a script needs a newer Bash, check the version at the top and stop with a clear message:
#!/usr/bin/env bash
# needs-44.sh: stop early on Bash versions older than 4.4
if ((BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 4))); then
echo "this script needs Bash 4.4 or newer, not $BASH_VERSION" >&2
echo "on macOS: brew install bash" >&2
exit 1
fi
echo "Bash $BASH_VERSION is new enough"
The other source of trouble on macOS is the tools, not Bash: macOS has BSD versions of sed, date, grep, find, stat and xargs.
| Task | GNU (Linux) | BSD (macOS) | Portable choice |
|---|---|---|---|
| Edit a file in place | sed -i 's/a/b/' f |
sed -i '' 's/a/b/' f |
sed ... f > f.new && mv f.new f |
| Date arithmetic | date -d '+3 days' |
date -v+3d |
Epoch seconds and $(( )) |
| File size | stat -c %s f |
stat -f %z f |
wc -c < f |
| Perl-style regex | grep -P |
not available | grep -E |
| Skip empty input in xargs | xargs -r |
the default | check for input first |
| Absolute path of a file | readlink -f f |
realpath f, or readlink -f f on recent macOS |
"$(cd "$(dirname "$f")" && pwd -P)/${f##*/}" |
The portable path version resolves symlinks in the directory part only, not in the file name itself. Homebrew's coreutils, gnu-sed and findutils packages install the GNU versions with a g prefix (gsed, gdate) on macOS.
POSIX sh
When a script must run where Bash may be missing (Alpine and BusyBox images, minimal containers, some embedded systems), write it for POSIX sh with a #!/bin/sh shebang, and let ShellCheck find the Bash-only features:
#!/bin/sh
# posix.sh: meant for sh, but written with Bash habits
name="${1:-world}"
if [[ $name == w* ]]; then
echo "${name^^}"
fi
servers=(web-1 web-2)
echo "${servers[0]}"
The POSIX replacements: [ ] with quoted variables or case for patterns, tr '[:lower:]' '[:upper:]' for case changes, set -- for a single list (there are no arrays), $(( )) arithmetic but no (( )) command, and no local (most sh implementations have it, but POSIX does not require it).
When to Stop Writing Bash
Bash is the right tool for starting programs, connecting them with pipes and files, and automating what you would otherwise type. Switch to a language such as Python or Go when the script:
- needs data structures beyond flat lists and maps, or parses JSON, YAML or HTML beyond what
jqdoes in one line; - talks to HTTP APIs with authentication, pagination and retries;
- grows past a few hundred lines, or many of its functions need unit tests;
- must run on Windows without WSL;
- processes large amounts of data line by line (Part 9 measured the cost).
For running the same steps on many servers, a configuration tool such as Ansible is a better fit than SSH loops, and the Python guide covers the next language most DevOps engineers learn.
Common Mistakes
- Treating
set -eas error handling. It is silent inside conditions, functions called from conditions and$( ). pipefailwithhead,grep -qor other early readers on large outputs. Expect status 141, or let the reader consume everything.- Registering several
EXITtraps. Each replaces the last; use one cleanup function. - Signal traps that do not exit, or exit with 0.
- Secrets in command-line arguments.
- Ignoring ShellCheck warnings instead of fixing them or disabling them with a reason.
- No tests. A five-minute bats file catches regressions that manual runs miss.
- Assuming macOS has the same Bash and tools as Linux.
Summary
- Start from a template with
set -euo pipefail, a main guard and one cleanup trap. - Know where
set -eis ignored, and useinherit_errexit; check important commands explicitly. pipefailturns SIGPIPE from an early reader into a failure; test with outputs bigger than the pipe buffer.- Trap
EXITfor cleanup,ERRfor diagnostics, andINTandTERMwith exit codes 130 and 143. - Use
flockagainst overlapping runs, keep secrets out of arguments, and quote everything. - Run ShellCheck, shfmt and bats locally and in CI.
- Check
BASH_VERSINFOwhen you need a newer Bash, and lint#!/bin/shscripts with ShellCheck.
That completes the guide. You can now read any Bash script and see what each line does, write scripts that handle file names, arguments, errors and signals properly, and prove they work with tests.
Where to Go From Here
- Introduction to Linux: the system your scripts run on, including systemd timers for scheduling them.
- Introduction to Git: keep your scripts and their tests under version control.
- Introduction to CI/CD: run checks like the workflow above on every change.
- Practise in the browser with the Linux Terminal simulator, and test yourself with the Linux command line quiz and the Linux fundamentals flashcards.
- Interview preparation: Bash scripting basics and shell scripting fundamentals.
- What does set -e mean in a Bash script?
Tags
Found an issue?