All articles

Shell Scripts That Survive Beyond Bash

10 minutes read


Linux publication

Share this article

𝕏✉

Illustration for Shell Scripts That Survive Beyond Bash

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 -eu

If 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"
done

The 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"
fi

Caution: 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"
fi

If 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.

Sources and further reading
  1. POSIX — Shell Command Language
  2. ShellCheck wiki — SC2086
  3. GNU Bash manual — Bash startup files
  4. Debian manpages — dash(1)
  5. BusyBox — ash shell