What teams need to know: POSIX, bashisms, macOS vs. Linux, CI
A shell script that runs fine on a developer's MacBook fails inside an Alpine-based CI container, because macOS ships Bash 3.2 and Alpine only includes BusyBox sh. Understanding the line between portable POSIX shell and Bash-specific features is a prerequisite for shell code that works reliably across a team.
Table of Contents
- 1. The Portability Problem in Practice
- 2. What POSIX Shell Means and What It Can Do
- 3. Bashisms: Features That Only Work in Bash
- 4. macOS and Bash: The 3.2 Problem and Its Consequences
- 5. Alpine Linux, BusyBox, and CI Containers Without Bash
- 6. Spotting Bashisms: shellcheck, checkbashisms, and Testing
- 7. Team Strategy: When POSIX, When Explicit Bash?
- 8. Shebang, PATH, and Shell Selection in a Team
- 9. POSIX vs. Bash: Feature Comparison for Everyday Work
- 10. Summary
- 11. FAQ
1. The Portability Problem in Practice
The portability problem with shell scripts is more concrete than it sounds. A developer on their Ubuntu machine writes a deployment script using Bash arrays, associative arrays, ${var,,} for lowercase conversion, and [[ ]] conditions. The script runs perfectly locally. In the GitHub Actions pipeline it runs on Ubuntu with Bash 5, also no problem. But then there's the edge case: the CI system for a different deployment path uses an Alpine-based Docker container where /bin/sh is BusyBox sh, and Bash isn't installed. The script fails with a syntax error.
A second common scenario: the script also runs on macOS developer machines. macOS has shipped Bash 3.2 for years, for licensing reasons (GPLv3 vs. GPLv2). Bash 3.2 lacks associative arrays (declare -A), the ${var,,} expansion, and several other features introduced in Bash 4.x (released 2009). A script containing declare -A map fails on the macOS default Bash with syntax error near 'declare'. This situation is an everyday occurrence in teams mixing Linux and macOS developers, and it is the root cause of a significant share of "works on my machine" problems with shell scripts.
Solving the portability problem in shell scripting requires a deliberate decision: either the script is written consistently as portable POSIX shell and declared with #!/bin/sh, or it explicitly uses Bash-specific features and is declared with #!/usr/bin/env bash. The mistake is not making that decision and instead randomly mixing portable and Bash-specific features, with a shebang that does not match the reality of the code.
2. What POSIX Shell Means and What It Can Do
The POSIX standard defines a shell specification implemented by sh implementations such as dash (Debian/Ubuntu), BusyBox sh (Alpine), and macOS's own sh (based on zsh running in POSIX mode). Portable POSIX shell can do: variables, simple parameter expansion (${var:-default}, ${var#prefix}), functions, loops, conditions with [ ] (test), redirects, pipes, and basic arithmetic with $(( )). What POSIX shell cannot do: arrays (except $@), [[ ]] conditions, declare, local (widely supported in practice, but not part of the POSIX standard), process substitution, and Bash-specific parameter expansion such as ${var,,}.
One important aspect: dash, the default sh on Debian and Ubuntu, is considerably faster than Bash. It starts faster and carries less overhead. For simple system scripts, init scripts, and scripts executed thousands of times per hour, portable sh instead of Bash can bring a measurable improvement in system performance. That's why Debian's system init scripts and many package maintainer scripts are deliberately restricted to POSIX sh. The overhead of Bash, a larger binary, longer startup time, and greater memory footprint, is only justified when Bash-specific features are actually used.
#!/bin/sh
# portable-example.sh: POSIX-compatible, works on dash, busybox, bash, zsh --sh
# NO bashisms: no arrays, no [[, no ${var,,}, no declare, no local (debated)
log() {
# POSIX: printf is more portable than echo with flags
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >&2
}
die() {
log "ERROR: $*"
exit 1
}
# POSIX parameter expansion: portable on all shells
TARGET_DIR="${TARGET_DIR:-/var/www/html}"
APP_ENV="${APP_ENV:?APP_ENV must be set}"
# POSIX condition: single [ ] not [[ ]]
if [ ! -d "$TARGET_DIR" ]; then
die "Target directory does not exist: $TARGET_DIR"
fi
# POSIX string prefix removal (no bashism)
filename="backup-2026-05-09.tar.gz"
without_prefix="${filename#backup-}" # 2026-05-09.tar.gz (POSIX)
without_suffix="${filename%.tar.gz}" # backup-2026-05-09 (POSIX)
# POSIX: case statement for pattern matching (no [[ =~ regex ]])
case "$APP_ENV" in
production|staging) log "Deploying to: $APP_ENV" ;;
development) log "Dev mode: skipping checks" ;;
*) die "Unknown environment: $APP_ENV" ;;
esac
log "Done."
3. Bashisms: Features That Only Work in Bash
Bashisms are shell syntax and features that are specific to Bash and do not work in POSIX sh. The most common bashisms in practice: [[ ]] conditions (with regex, &&, ||, pattern matching), arrays (declare -a arr=()) and associative arrays (declare -A, available only from Bash 4.0), ${var,,} and ${var^^} for case conversion (available only from Bash 4.0), nameref declare -n (available only from Bash 4.3), process substitution <() and >(), here-strings <<<, the BASH_SOURCE array, MAPFILE/readarray, and extended glob patterns such as extglob.
A lesser-known bashism: the source builtin exists in Bash, while POSIX sh uses . (a dot) as the equivalent. Both load a file into the current shell, but source is Bash-specific. This has a practical consequence: scripts using source ./lib.sh fail under dash. Another common bashism: $(( )) for arithmetic is POSIX-compatible, but (( )) as a standalone command is Bash-specific. In POSIX sh you have to use : $((var=var+1)) or an external tool for arithmetic.
4. macOS and Bash: The 3.2 Problem and Its Consequences
macOS has shipped zsh as the default login shell since macOS 10.15 (Catalina), but /bin/bash remains Bash 3.2.57, and it stays that way for licensing reasons. Bash 3.2 was released in 2006, and the GPLv2-versus-GPLv3 license conflict has kept Apple from bundling newer versions into macOS. The consequence for Bash-specific features on macOS: anything introduced from Bash 4.0 onward does not work on the macOS system Bash. Developers using Homebrew or MacPorts often have Bash 5.x available under /opt/homebrew/bin/bash, but /usr/bin/env bash on M1 Macs with the default PATH typically resolves to Bash 3.2 if Homebrew isn't prioritized correctly in the PATH.
The safe strategy for teams with macOS developers: either write portable POSIX shell for all scripts meant to run on macOS, or add an explicit Bash version check at the top of the script: if [[ ${BASH_VERSINFO[0]} -lt 4 ]]; then echo "Bash 4.0+ required" >&2; exit 1; fi. Alternatively, the script can rely on the Homebrew Bash: set the shebang to #!/usr/bin/env bash and document clearly in the team wiki that Homebrew Bash, installed via brew install bash, is a prerequisite. That's explicit and transparent, which beats a script that fails on the macOS default Bash with mysterious errors.
5. Alpine Linux, BusyBox, and CI Containers Without Bash
Alpine Linux, thanks to its minimal footprint, is the preferred base for Docker images in CI/CD environments, but Alpine does not include Bash. Instead, /bin/sh points to the BusyBox implementation of the shell, which implements a subset of POSIX sh with a few extensions. BusyBox sh is deliberately minimal: no Bash, no arrays, no declare, no [[ ]]. Anyone running a Bash script inside an Alpine-based CI container gets /usr/bin/env: 'bash': No such file or directory, unless Bash has been explicitly installed.
The pragmatic CI strategy depends on the use case. For build and deployment scripts that only ever run in CI, you can install Bash explicitly in the CI image (apk add bash) and use Bash-specific features without worry. For scripts that also need to run inside production containers, portable POSIX shell is the safer choice, since production containers often share the same Alpine base. Another strategy: for complex logic that genuinely needs Bash features, use Python or another scripting language that's available in most CI images. That's not a weakness, it's often the more pragmatic solution.
#!/usr/bin/env bash
# bash-version-guard.sh: Enforce minimum Bash version and document bashisms
set -euo pipefail
# Version guard: fail early with clear message on old Bash (e.g., macOS 3.2)
readonly REQUIRED_BASH_MAJOR=4
readonly REQUIRED_BASH_MINOR=3 # declare -n nameref requires 4.3+
if (( BASH_VERSINFO[0] < REQUIRED_BASH_MAJOR ||
(BASH_VERSINFO[0] == REQUIRED_BASH_MAJOR &&
BASH_VERSINFO[1] < REQUIRED_BASH_MINOR) )); then
echo "ERROR: Bash ${REQUIRED_BASH_MAJOR}.${REQUIRED_BASH_MINOR}+ required." >&2
echo " Current: ${BASH_VERSION}" >&2
echo " macOS: brew install bash; ensure Homebrew bash is in PATH" >&2
exit 1
fi
# Bashisms used below (document them explicitly for team awareness):
# - declare -A (assoc. array): Bash 4.0+
# - ${var,,} (lowercase): Bash 4.0+
# - declare -n (nameref): Bash 4.3+
# - readarray/mapfile: Bash 4.0+
# - [[ ]] conditions: Bash only
declare -A config=(
[env]="production"
[region]="eu-west-1"
)
env_lower="${config[env],,}" # lowercase, Bash 4.0+ only
echo "Deploying to: ${env_lower} in ${config[region]}"
# readarray: read lines into array without subshell
declare -a servers=()
readarray -t servers < /etc/deploy/servers.txt
echo "Target servers: ${#servers[@]}"
# Nameref: write to caller's variable (Bash 4.3+)
set_result() {
local -n _ref="$1"
_ref="computed"
}
declare result
set_result result
echo "Result: $result"
6. Spotting Bashisms: shellcheck, checkbashisms, and Testing
The most reliable tool for spotting bashisms in shell scripts is shellcheck. Running shellcheck --shell sh script.sh analyzes the script as if it should be POSIX sh and flags every bashism as an error or warning. That's considerably more precise than manual code review. ShellCheck reads the shebang and automatically picks the check mode: #!/bin/sh enables POSIX mode, #!/usr/bin/env bash enables Bash mode. The tool integrates easily into CI pipelines: shellcheck -S error script.sh returns exit code 1 when errors are found.
As a complement to ShellCheck there's checkbashisms from the Debian devscripts package. This tool specializes in bashism detection and catches many subtle cases that ShellCheck misses. The most robust portability test is running the script directly with dash: dash -n script.sh checks the syntax under dash without executing it. Better still: actually run the script under dash with a representative set of test inputs, which also catches runtime errors from incompatible constructs that a syntax check alone would miss. For CI pipelines, a matrix strategy is recommended: test the same script across several containers, such as Ubuntu (Bash 5), Alpine (BusyBox), and macOS (Bash 3.2 via a custom-built image).
7. Team Strategy: When POSIX, When Explicit Bash?
The decisive question for teams is: when does the benefit of Bash-specific features justify the loss of portability? A clear answer for common scenarios: for system scripts that might run on unknown Linux distributions, containers, or appliances, use POSIX sh. For complex deployment scripts, build tools, and DevOps automation running in a controlled environment with Bash explicitly installed, use Bash with a version guard. For simple utility scripts that only ever run on the team's own standard system, use whichever is most readable and maintainable.
A proven team convention is the three-tier strategy. Tier 1 covers portable POSIX scripts (#!/bin/sh) that work everywhere with no preconditions, used for bootstrapping, container entrypoints, and scripts running in unknown environments. Tier 2 covers Bash 4.x scripts (#!/usr/bin/env bash with a 4.0+ version guard) for internal tooling scripts where Bash is guaranteed to be present. Tier 3 covers scripts written for specific environments with Bash 5.x, explicitly relying on newer features. Keeping this classification visible in the team codebase, for example through directory structure or header comments, avoids surprises and makes portability decisions transparent.
#!/bin/sh
# posix-portable-deploy.sh: POSIX-only, works on dash, busybox, bash 3.2+
# Tier 1 script: no bashisms, no arrays, no [[, no declare
# POSIX: . instead of source
. "$(dirname "$0")/lib/utils.sh"
# POSIX: [ ] instead of [[ ]]
if [ -z "${DEPLOY_TARGET}" ]; then
printf 'ERROR: DEPLOY_TARGET not set\n' >&2
exit 1
fi
# POSIX: case instead of [[ =~ ]]
case "${DEPLOY_TARGET}" in
*production*) loglevel="warn" ;;
*staging*) loglevel="info" ;;
*) loglevel="debug" ;;
esac
# POSIX: no arrays, use positional parameters or newline-separated vars
# WRONG (bashism): servers=("web1" "web2" "web3")
# RIGHT (POSIX): read servers from file, process line by line
while IFS= read -r server; do
[ -z "$server" ] && continue
case "$server" in '#'*) continue ;; esac # Skip comments
printf 'Deploying to: %s\n' "$server"
# Actual deployment logic here
done < "${DEPLOY_TARGET}.hosts"
# POSIX arithmetic: $(( )) is fine; (( )) standalone is bashism
count=0
count=$(( count + 1 )) # POSIX
# (( count++ )) # BASHISM: only in bash
printf 'Deployed to %d servers.\n' "$count"
8. Shebang, PATH, and Shell Selection in a Team
The shebang (#!) is the most explicit signal for a script's shell requirements. #!/bin/sh guarantees the POSIX shell on Linux systems, typically dash. #!/usr/bin/env bash is the best choice for Bash-specific scripts, because env searches the PATH for the bash binary, which finds the newer Homebrew Bash on macOS as long as the PATH is configured correctly. #!/bin/bash is a fixed path that always resolves to Bash 3.2 on macOS, regardless of Homebrew. That's the single most common mistake in portability-conscious teams: using #!/bin/bash instead of #!/usr/bin/env bash.
Another common problem is the mismatch between the shebang and the features actually used. A script with #!/bin/sh that contains [[ ]] conditions or arrays in the body sends contradictory signals: the shebang promises portability, the code delivers bashisms. The result is a script that works only when /bin/sh happens to point to Bash (as on some older systems), and breaks everywhere else. ShellCheck catches exactly this inconsistency and reports it as an error. The enforcement principle for teams: every script has a shebang, and ShellCheck in the CI pipeline checks the shebang against the code.
9. POSIX vs. Bash: Feature Comparison for Everyday Work
The table below shows the most important everyday shell tasks and how they're solved in portable POSIX shell versus Bash. This comparison helps teams quickly spot during code review whether a given solution is portable or introduces a Bash dependency.
| Task | POSIX sh (portable) | Bash-specific | Bash Version |
|---|---|---|---|
| Conditions | [ -f "$f" ] |
[[ -f "$f" && ... ]] |
All Bash |
| Arrays | Not available | declare -a arr=() |
Bash 2+ |
| Associative Arrays | Not available | declare -A map=() |
Bash 4.0+ |
| Lowercase | $(echo "$v" | tr A-Z a-z) |
${v,,} |
Bash 4.0+ |
| Include a File | . ./lib.sh |
source ./lib.sh |
All Bash |
| Process Substitution | Not available | <(cmd), >(cmd) |
All Bash |
The practical takeaway: POSIX sh is sufficient for most everyday scripting tasks, as long as you're willing to give up the convenient Bash features or use workarounds instead. The features that most often introduce a Bash dependency and cause the biggest portability problems are associative arrays and ${var,,} expansion, both available only from Bash 4.0 onward and therefore missing on the macOS default Bash.
Mironsoft
Shell portability, CI/CD engineering, and cross-platform automation
Want shell scripts that behave the same on Linux, macOS, and in CI?
We analyze your shell code for bashisms and portability issues, develop a clear team strategy for POSIX versus Bash, and integrate ShellCheck along with portability tests into your CI pipeline.
Bashism Audit
Analyze your shell code for portability issues and document bashisms
Team Standard
Define a POSIX vs. Bash strategy and enforce it in code reviews
CI Integration
ShellCheck and portability matrix tests in GitHub Actions and GitLab CI
10. Summary
The distinction between portable POSIX shell and Bash-specific features is not an academic question. It has a direct impact on the reliability of shell automation within a team. The key takeaways: #!/bin/sh is a promise, POSIX sh, not Bash. #!/usr/bin/env bash is Bash, and on macOS without Homebrew that means Bash 3.2. Associative arrays and ${var,,} require Bash 4.0+ and are therefore unavailable on the macOS default Bash. Alpine containers have no Bash; BusyBox sh is the default.
The pragmatic team recommendation: integrate ShellCheck into every CI pipeline. Choose the shebang deliberately and keep it consistent with the code. For scripts running in uncontrolled environments, prefer POSIX sh. For complex internal tooling scripts, declare Bash explicitly and add a version guard at the top. This discipline eliminates the most common class of "works only on my machine" problems in shell scripts.
Portable Shell vs. Bash: The Essentials at a Glance
The macOS Trap
macOS ships Bash 3.2. declare -A, ${var,,}, and declare -n are missing. Use a version guard at the top of the script, or write POSIX sh instead.
Alpine / BusyBox
Alpine does not include Bash. Use BusyBox sh for POSIX scripts, or apk add bash in the CI image. Test scripts under dash.
ShellCheck
shellcheck --shell sh script.sh flags every bashism. Integrate it into CI with -S error to fail the pipeline on violations.
Shebang Discipline
#!/bin/sh means a POSIX promise, not Bash. #!/usr/bin/env bash means Bash resolved from PATH. #!/bin/bash always means Bash 3.2 on macOS.