Why 'It Works On My Machine' Fails With Shell Scripts
Portability problems in shell scripting come from three independent sources that are easy to conflate: the shell interpreter itself (bash 3.2 on macOS vs bash 5.x on Linux, or dash masquerading as /bin/sh on Debian-based systems), the userland utilities (BSD sed/grep/date on macOS have different flags than GNU coreutils on Linux), and the environment (PATH ordering, locale, and default umask differing between an interactive login shell and a CI runner's non-interactive shell). A script that runs perfectly in a developer's iTerm session can fail in a Docker container's sh or in a cron job precisely because all three of these differ silently between the two contexts.
Cricket analogy: It's like a bowler who is lethal on a seaming Headingley pitch suddenly looking ordinary on a flat, dry Chepauk surface — the same action produces wildly different results depending on the underlying conditions.
POSIX sh vs bash: Know What You're Targeting
The single biggest portability decision is the shebang line and the feature set it implies: #!/bin/sh on Debian/Ubuntu points to dash, a minimal POSIX-compliant shell that does not support bash-only features like arrays, [[ ]], local, process substitution (<(cmd)), or ${var,,} case conversion, while #!/usr/bin/env bash explicitly requires bash and unlocks all of those. Writing #!/bin/sh and then using bash-only syntax is a common and confusing bug because the script may work on macOS or RHEL (where /bin/sh is often actually bash in POSIX mode) while silently breaking on Debian, Ubuntu, or Alpine (which uses the even more minimal busybox sh).
Cricket analogy: It's like entering a T20 tournament roster but then trying to bowl the 50-over format's extra bouncer allowance — the ruleset you declared for doesn't support the moves you're attempting.
#!/bin/sh
# POSIX-portable: no arrays, no [[ ]], no local, no ${var,,}
set -eu
find_config() {
# Use POSIX test [ ] instead of bash's [[ ]]
if [ -f "$HOME/.myapprc" ]; then
printf '%s\n' "$HOME/.myapprc"
else
printf '%s\n' "/etc/myapp/config"
fi
}
config_path=$(find_config)
# GNU vs BSD date differ; use POSIX-compatible options only
timestamp=$(date +%Y%m%d)
printf 'Using config: %s (as of %s)\n' "$config_path" "$timestamp"GNU vs BSD Userland Differences
Even when the shell itself is identical, the utilities a script calls out to often are not: GNU sed -i 's/foo/bar/' file edits in place, but BSD sed on macOS requires an explicit (even if empty) backup extension argument, sed -i '' 's/foo/bar/' file, and omitting it either errors or corrupts the file depending on the version. Similarly, GNU date -d '1 day ago' has no BSD equivalent (date -v-1d is the BSD form), and GNU grep -P (Perl-compatible regex) simply doesn't exist on BSD grep. The safest portable approach is to detect the platform explicitly with uname -s and branch, or better, depend on a known-consistent tool like python3 or a vendored GNU coreutils install for the specific operations that must behave identically everywhere.
Cricket analogy: It's like a bowling action that's legal under ICC rules but gets called for throwing under a stricter domestic league's biomechanical review — the same physical motion is judged differently by different rule enforcers.
A reliable trick for portable in-place editing across GNU and BSD sed is to avoid -i entirely: write to a temp file and mv it into place, e.g. sed 's/foo/bar/' file > file.tmp && mv file.tmp file, which behaves identically on every sed implementation.
Never assume /bin/sh is bash. On Debian, Ubuntu, and their derivatives it is dash; on Alpine it is busybox sh. Using bash-only syntax ([[ ]], arrays, local, source, brace expansion {1..10}) under a #!/bin/sh shebang will fail unpredictably depending on the distribution, not fail consistently everywhere.
- Portability issues stem from three independent layers: the shell interpreter, the userland utilities (GNU vs BSD), and the environment (PATH, locale, umask).
- The shebang line is a contract: #!/bin/sh means POSIX-only features, #!/usr/bin/env bash unlocks bash-specific syntax.
- /bin/sh is dash on Debian/Ubuntu, busybox sh on Alpine, and sometimes bash-in-POSIX-mode elsewhere — never assume its identity.
- GNU and BSD versions of sed, date, and grep differ in flags and behavior; the safest fix is avoiding version-specific flags or detecting the platform explicitly.
- In-place sed editing is a classic portability trap; writing to a temp file and mv-ing it is a version-agnostic alternative.
- ShellCheck can be configured to lint against POSIX sh compliance specifically, catching bash-isms before they reach a dash-only system.
- When true cross-platform consistency matters, delegating to a consistent runtime like python3 is often safer than chasing every shell/tool version difference.
Practice what you learned
1. On Debian and Ubuntu systems, what shell does `/bin/sh` actually point to?
2. What is the safest portable way to do an in-place text substitution that behaves identically on GNU and BSD sed?
3. Which bash-only feature would break a script running under dash despite the script working fine on macOS's default /bin/sh?
4. Why can't you rely on `date -d '1 day ago'` working the same way across macOS and Linux?
5. What are the three independent layers that commonly cause shell script portability failures?
Was this page helpful?
You May Also Like
Defensive Bash Scripting
Techniques for writing bash scripts that fail safely, validate their inputs, and avoid the common footguns that turn a small bug into a production incident.
Bash Strict Mode
An explanation of the `set -euo pipefail` pattern known as bash strict mode, what each flag actually does, and where its edge cases can still surprise you.
Bash Script Debugging Techniques
Practical techniques for finding and fixing bugs in bash scripts, from tracing execution with set -x to using ShellCheck and traps for structured diagnostics.
Related Reading
Related Study Notes in DevOps
Browse all study notesNginx Study Notes
DevOps · 30 topics
DevOpsAnsible Study Notes
DevOps · 30 topics
DevOpsAdvanced Kubernetes Study Notes
Kubernetes · 30 topics
DevOpsApache Kafka Study Notes
Kafka · 30 topics
DevOpsDocker & Kubernetes Study Notes
YAML · 40 topics
DevOpsCI/CD Tools & Pipelines Study Notes
YAML · 37 topics