
Portability is less about avoiding Bash and more about making the boundary visible. A script should declare its interpreter, quote data, check commands, and fail in a way that leaves useful evidence. POSIX defines a portable shell language; it does not define every command, flag, or shell option installed by a Linux distribution.
The tested boundary
The lab ran the same script in official debian:13.6 at digest
sha256:34cd9e9fd437c0a095ec39cb2e73422c9f30821b0d0848ed74fd0d43bae4d958
and alpine:3.24.1 at digest
sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b.
The shells and versions were:
| Environment | Shell | Result |
|---|---|---|
| Debian 13.6 | GNU Bash 5.2.37 | exit 0; quoted path, missing-tool path, and handled failure printed |
| Debian 13.6 | Dash 0.5.12-12 | exit 0; same bounded output |
| Alpine 3.24.1 | BusyBox ash 1.37.0 | exit 0; same bounded output |
The lab used a temporary directory, a filename containing spaces, a deliberately
missing command, and a controlled false branch. Every shell printed this
shape, with its own temporary path:
quoted=/tmp/shell-portability.<pid>/name with spaces
missing-tool=handled
failure=handled
shell=<bash|dash|sh>The script exited zero in all three shells. It did not rely on aliases, startup files, arrays, or an interactive terminal.
Declare the interpreter
Use a shebang that matches the language you are writing:
#!/bin/sh
set -euIf the script needs Bash arrays, [[ ... ]], or mapfile, say so with
#!/usr/bin/env bash instead of accidentally depending on the caller’s shell.
Quote data and check tools
Treat paths and user input as data, even when they look harmless:
if ! command -v rsync >/dev/null 2>&1; then
printf '%s\n' "rsync is required" >&2
exit 127
fi
for path in "$@"; do
printf 'checking %s\n' "$path"
doneThe explicit check produces a useful error in a minimal container rather than a cryptic “not found” several lines later.
command -v is a POSIX shell builtin. It returns non-zero for a missing tool;
handle that branch before trying to execute the command. Do not use which as
if it were a shell language feature: its presence and output vary by image.
Test failure as a first-class path
Run scripts with empty input, spaces in filenames, a missing dependency, and a command that returns non-zero. A short script that behaves predictably in those cases is more portable than a clever one that only works on one workstation.
set -eu
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
name="$tmp/name with spaces"
printf '%s\n' "portable" >"$name"
[ -f "$name" ]
printf 'quoted=%s\n' "$name"
if command -v command-that-is-not-installed >/dev/null 2>&1; then
exit 2
fi
if false; then
exit 3
else
printf '%s\n' "failure=handled"
fiCaution: The trap removes only the printed temporary directory. Never replace its quoted variable with
/, an unreviewed glob, or a path supplied by an untrusted caller.
pipefail is useful, but not POSIX
The lab also ran set -o pipefail and a failing command at the left side of a
pipeline. Bash 5.2.37, Dash 0.5.12-12, and BusyBox ash 1.37.0 all accepted the
option and returned pipeline status 1 for false | cat. That is evidence for
those exact versions, not a POSIX promise. Test the shell you will run before
depending on it:
if set -o pipefail 2>/dev/null; then
if false | cat >/dev/null; then
status=0
else
status=$?
fi
printf 'pipefail pipeline status=%s\n' "$status"
else
printf '%s\n' "pipefail unavailable; use explicit status handling"
fiIf a script needs arrays, [[ ... ]], mapfile, Bash error traps, or another
non-POSIX feature, say so with #!/usr/bin/env bash. A #!/bin/sh shebang is a
language contract, not a request for Bash. Also remember that non-interactive
Bash startup files follow different rules from interactive shells; do not rely
on a developer’s profile to provide PATH or functions.
The result is a small, testable contract: quote data, declare the shell, check tools, make failure visible, and record the exact distribution and shell when a non-POSIX option is part of the design.

