Ensuring Bash Script Portability Across Different Systems
Write portable Bash scripts that handle GNU, BSD, and BusyBox differences across Linux, macOS, and CI environments.
Ensuring Bash Script Portability Across Different Systems
Writing Bash scripts that work on your laptop, a Linux server, and a CI runner is harder than it looks. Bash script portability usually breaks on small differences: a sed -i flag that works on Linux but fails on macOS, a date option that exists only in GNU coreutils, or a script that assumes /bin/bash is the version you tested.
The core difficulty is that Bash is only part of the environment. Linux typically ships GNU utilities. macOS ships BSD-flavored utilities. BusyBox-based containers may provide smaller implementations with fewer options. Your script needs to be clear about what it requires.
This guide focuses on Bash scripts, not strictly POSIX sh scripts. If you need true /bin/sh portability, avoid Bash-only syntax entirely and test with shells such as dash.
Start with a Clear Shell Contract
Use a shebang that matches your intent. If the script requires Bash, say so:
#!/usr/bin/env bash
/usr/bin/env locates Bash through $PATH, which is useful when users install a newer Bash outside /bin. If your production hosts require a fixed interpreter path, document and enforce that path instead.
Strict mode catches many mistakes early, but it is not magic:
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
These options help, with caveats:
-e: Exits when many simple commands return a non-zero status.-u: Treats unset variables as errors.pipefail: Makes a pipeline fail if any command in the pipeline fails.
Handle expected failures explicitly:
if ! grep -q "ready" "$log_file"; then
echo "Service is not ready yet"
fi
Know Your Bash Version
Do not accidentally depend on a Bash feature your target systems do not have. macOS has historically shipped an older Bash in /bin/bash, while many Linux distributions ship newer versions.
Features to use carefully include:
- Associative arrays.
- Advanced globbing such as
**. - Process substitution such as
<(command). - Newer parameter expansion behavior.
If you need a minimum Bash version, check it near the top:
if (( BASH_VERSINFO[0] < 4 )); then
echo "This script requires Bash 4 or newer." >&2
exit 1
fi
Handle GNU, BSD, and BusyBox Differences
The biggest portability problems often come from external commands, not from Bash itself.
sed -i
GNU sed accepts -i without a backup extension. BSD sed on macOS requires an extension argument after -i, even if that extension is an empty string.
file="data.txt"
pattern="s/error/success/g"
case "$(uname -s)" in
Darwin)
sed -i '' "$pattern" "$file"
;;
*)
sed -i "$pattern" "$file"
;;
esac
For critical scripts, a safer pattern is to write to a temporary file and then move it into place. That avoids relying on in-place editing behavior at all.
date
Date math is different across systems:
| Goal | GNU date |
BSD date on macOS |
|---|---|---|
| 30 days ago | date -d "30 days ago" +%Y%m%d |
date -v-30d +%Y%m%d |
If your script needs complex date math, use a consistent dependency such as Python, or require GNU coreutils on macOS and call gdate explicitly. Do not silently assume date -d exists.
grep, find, and xargs
Stick to widely supported options when possible:
- Use
grep -Einstead of relying onegrep. - Avoid
grep -Punless you check for GNU grep with PCRE support. - Be careful with
findpredicates that differ between GNU and BSD implementations. - Prefer null-delimited pipelines for filenames when supported:
find "$root" -type f -name '*.log' -print0 | xargs -0 rm -f
Manage Dependencies and Paths
Use $PATH for normal command lookup, but check required tools before doing work:
check_dependency() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Error: required command '$1' not found." >&2
exit 1
fi
}
check_dependency jq
check_dependency curl
Prefer command -v over which because it is a shell builtin in Bash and behaves more predictably in scripts.
Quote variables unless you intentionally want word splitting:
cp "$source_file" "$target_dir/"
This matters for paths like Project Files/report.txt, and it also protects you from wildcard expansion in unexpected input.
Use Temporary Files Safely
Use mktemp for temporary work. A simple, portable pattern is to create one temporary directory and put files inside it:
tmp_dir=$(mktemp -d)
trap 'rm -rf "$tmp_dir"' EXIT
tmp_file="$tmp_dir/output.txt"
some_command > "$tmp_file"
The single-quoted trap keeps $tmp_dir from being expanded until the trap runs. Because the variable is still in scope, cleanup removes the right directory.
Watch Line Endings and Filesystem Case
Scripts edited on Windows may use CRLF line endings. A common symptom is:
/usr/bin/env: bash\r: No such file or directory
Configure your editor to save shell scripts with LF endings, or run dos2unix in your build process.
Also remember that most Linux filesystems are case-sensitive by default, while default macOS APFS setups are often case-insensitive. If your script writes Config.yml and later reads config.yml, it may work on your Mac and fail on Linux.
Test on the Systems You Support
The best portability check is a small test matrix:
- Linux with GNU utilities.
- macOS with BSD utilities.
- Minimal containers if your script runs in Alpine or BusyBox environments.
Run ShellCheck too. It will not catch every platform issue, but it catches many quoting, undefined variable, and fragile command patterns before your users do.
Takeaway
Bash script portability comes from making your assumptions explicit. Pick the shell, check dependencies, quote variables, avoid GNU-only flags unless you require them, and test on the same operating systems your users run. A small CI matrix with Linux and macOS catches most portability bugs before your automation reaches production.