Portable shell scripts run wherever sh runs — Alpine containers with busybox ash, BSD machines, minimal rescue environments, and the CI runner that has no bash. The discipline is a subset of bash: POSIX sh plus the restraint to stay inside it.

The shebang decides the dialect #

Two dialects, one file difference
#!/bin/sh          # POSIX: runs on every Unix-like system
#!/usr/bin/env bash # bash: arrays, [[ ]], local niceties

#!/bin/sh on Debian-family systems is dash — noticeably stricter and faster than bash, and an honest test bench: if your script runs under dash, it is portable. The first debugging question for "works here, fails there" is which shell is actually running it — readlink /bin/sh answers.

What POSIX sh does not have #

Bash featurePOSIX substitute
[[ ... ]][ ... ] with careful quoting
ArraysPositional params, or newline-separated strings
localSupported by most shells; POSIX-pedantic scripts avoid it
${var,,} case opstr 'A-Z' 'a-z'
${var//a/b}sed or loop
read -a, here-stringswhile read -r, heredocs
echo -e / \\u escapesprintf — the portable print

The substitutions are all covered elsewhere in this series — tests, strings, printing. printf deserves special mention: printf '%s\n' "$x" behaves identically everywhere; echo's flags do not.

A worked conversion #

Before and after the same logic
#!/usr/bin/env bash
files=(*.log)
for f in "${files[@]}"; do
  [[ -e $f ]] || continue
  gzip "${f,,}"
done
The portable equivalent
#!/bin/sh
for f in ./*.log; do
  [ -e "$f" ] || continue
  gzip "$(printf '%s' "$f" | tr '[:upper:]' '[:lower:]')"
done

Same structure, more typing — which is exactly the trade to make deliberately: write bash when you control the environment, POSIX when you do not.

The workflow that keeps it honest #

  1. Shebang #!/bin/sh from the start — retrofitting portability is harder.
  2. shellcheck -s sh script.sh — the -s sh flag checks POSIX compliance specifically.
  3. Test under dash: dash script.sh — two minutes that find every bashism.
  4. Avoid echo with backslashes; avoid local in functions if you want full pedantry (most pragmatic scripts keep it).

The strictness habits carry over directly: quoting and exit codes matter more, not less, without bash's safety nets. The scripting series continues.