$linuxjunkies
>

How to Write Portable POSIX Shell Scripts

Write shell scripts that run correctly under dash, BusyBox sh, and Bash by avoiding Bashisms, using POSIX constructs, and linting with shellcheck.

IntermediateUbuntuDebianFedoraArch9 min readUpdated June 7, 2026

Before you start

  • Basic shell scripting knowledge (variables, loops, conditionals)
  • A working shell script you want to make portable, or a new script to write
  • Root or sudo access to install packages
  • Optional: Docker for cross-shell container testing

Bash is everywhere on Linux, but it is not everywhere else—and even on Linux, your script may run under dash, busybox sh, or another POSIX-compatible shell. Writing portable shell scripts means they work correctly regardless of which shell executes them, making them suitable for packaging, container entrypoints, embedded systems, and cross-platform tooling. This guide walks through the common Bash-specific pitfalls, their POSIX replacements, and how to catch issues automatically before they cause runtime failures.

Why Portability Matters

On Debian and Ubuntu, /bin/sh is dash, not Bash. Scripts that start with #!/bin/sh but use Bash extensions will silently misbehave or crash. On Alpine Linux, /bin/sh is BusyBox sh. On macOS, /bin/sh is a version of Bash so old that some extensions still work—hiding bugs that only appear in production. The safest rule: if the shebang says sh, write POSIX sh.

Set Up Your Testing Environment

Install dash and shellcheck on your development machine. These two tools together catch the vast majority of portability issues.

Debian / Ubuntu

sudo apt install dash shellcheck

Fedora / RHEL / Rocky

sudo dnf install dash ShellCheck

Arch

sudo pacman -S dash shellcheck

Run any script through dash during testing—even if your production system uses Bash—because dash is strict about POSIX compliance and will fail fast on Bashisms.

dash -n myscript.sh   # syntax check only
dash myscript.sh      # execute under dash

Use the Correct Shebang

For a portable script, the shebang must request sh, not bash.

#!/bin/sh
set -eu

set -e exits on error; set -u treats unset variables as errors. Both are POSIX. The common set -o pipefail is not POSIX—it is supported by Bash, Zsh, and some others but not by dash or BusyBox sh. Remove it from scripts with a #!/bin/sh shebang.

If you genuinely need Bash features, be honest about it and use #!/usr/bin/env bash so the script is never run under a lesser shell.

Bashisms to Avoid

Double Brackets and Extended Tests

[[ ... ]] is a Bash keyword, not a POSIX construct. Replace it with single-bracket [ ... ] and be mindful of quoting.

# Bash only — do not use in portable scripts
if [[ $name == foo* ]]; then

# POSIX replacement
case "$name" in
  foo*) : ;;
esac

Arrays

Bash arrays (arr=(a b c), ${arr[@]}) have no POSIX equivalent. For ordered lists, use positional parameters or IFS-delimited strings carefully.

# Bash only
files=(one.txt two.txt three.txt)
for f in "${files[@]}"; do process "$f"; done

# POSIX replacement — use set to load positional parameters
set -- one.txt two.txt three.txt
for f; do process "$f"; done

Here-Strings and Process Substitution

<<< (here-string) and <(command) / >(command) (process substitution) are Bash-only. Use a pipeline or a temporary file instead.

# Bash only
read -r line <<< "hello world"

# POSIX replacement
line=$(printf '%s' "hello world")
# Bash only
diff <(sort file1) <(sort file2)

# POSIX replacement — use temp files
tmp1=$(mktemp) tmp2=$(mktemp)
trap 'rm -f "$tmp1" "$tmp2"' EXIT
sort file1 > "$tmp1"
sort file2 > "$tmp2"
diff "$tmp1" "$tmp2"

Local Variables

local is widely supported but is technically not in POSIX sh. In practice it works in dash, BusyBox sh, and every common shell, so most projects treat it as safe. If you need strict purity, avoid it in functions and use unique global names. shellcheck will warn you if your target shell does not support it.

Arithmetic and let

let and (( )) are Bash extensions. POSIX arithmetic uses $(( )).

# Bash only
(( count++ ))
let count=count+1

# POSIX
count=$(( count + 1 ))

String Manipulation

Bash has ${var:offset:length}, ${var,,} (lowercase), and ${var^^} (uppercase). None are POSIX. Use parameter expansion patterns or external tools.

# Bash only
echo "${name,,}"

# POSIX replacement
echo "$name" | tr '[:upper:]' '[:lower:]'

POSIX parameter expansions that are safe to use: ${var#pattern}, ${var##pattern}, ${var%pattern}, ${var%%pattern}, ${var:-default}, ${var:=default}, ${var:?error}.

The source Builtin

Use . (dot) instead of source; the latter is Bash-only.

# Bash only
source ./lib.sh

# POSIX
. ./lib.sh

Echo Portability

The behavior of echo with flags like -n and -e varies between shells and implementations. Always use printf when you need formatting or suppressed newlines.

# Unreliable across shells
echo -n "Enter value: "
echo -e "Line1\nLine2"

# POSIX and reliable
printf 'Enter value: '
printf 'Line1\nLine2\n'

Run ShellCheck

ShellCheck is a static analyzer that flags both bugs and portability issues. Tell it to target POSIX sh specifically with --shell=sh, which enables stricter checks than the default.

shellcheck --shell=sh myscript.sh

Sample output (varies; shown for illustration):

myscript.sh:12:5: warning: In POSIX sh, arrays are undefined. [SC2039]
myscript.sh:18:8: warning: In POSIX sh, 'source' is undefined. [SC2039]

You can also embed a directive at the top of the script to lock in the target shell for editors that support ShellCheck integration:

#!/bin/sh
# shellcheck shell=sh

Integrate ShellCheck into CI so portability regressions are caught before merge:

# Example GitHub Actions step
- name: Lint shell scripts
  run: shellcheck --shell=sh scripts/*.sh

Verify Your Script

After fixing ShellCheck warnings, test execution under dash directly:

dash -x myscript.sh

The -x flag prints each command before execution (like Bash's set -x), making it easy to trace failures. If you have Docker available, you can also test under Alpine's BusyBox sh with zero setup:

docker run --rm -v "$PWD":/work -w /work alpine sh myscript.sh

Troubleshooting Common Failures

  • Script works in Bash but fails under dash with a syntax error: Look for [[, ((, process substitutions, or Bash-only builtins. Run ShellCheck first—it will point directly at the line.
  • set -u causes unexpected failures: Variables that are intentionally optional need a default: ${var:-} expands to empty string without triggering the unset error.
  • read behaves differently: Always use read -r to prevent backslash interpretation; this is POSIX-compliant and consistent across shells.
  • Subshell variable changes not visible to parent: This is POSIX behavior—assignments inside a pipeline subshell do not propagate. Restructure the code to avoid relying on side effects inside pipelines.
  • Temp files not cleaned up on failure: Always pair mktemp with a trap '...' EXIT as shown above; trap on EXIT fires even when set -e triggers an exit.
tested on:Ubuntu 24.04Fedora 40Arch rollingAlpine 3.19

Frequently asked questions

Can I use local variables in a POSIX sh script?
local is not in the POSIX specification but is supported by dash and BusyBox sh in practice. Most projects treat it as safe; shellcheck will warn you if your declared target shell rejects it.
Is set -o pipefail safe in a #!/bin/sh script?
No. pipefail is not POSIX and will cause a fatal error under dash. If you need pipeline failure detection, either switch to #!/usr/bin/env bash or redesign the pipeline to avoid relying on intermediate exit codes.
What is the POSIX-safe way to read a file line by line?
Use while IFS= read -r line; do ...; done < file. The -r flag is POSIX and prevents backslash interpretation; setting IFS= preserves leading and trailing whitespace.
My script uses grep -P for Perl-compatible regex. Is that portable?
No. grep -P depends on PCRE support, which is not available in all grep implementations (notably BusyBox grep lacks it). Use POSIX ERE with grep -E and POSIX character classes instead.
How do I check whether a command exists portably?
Use command -v name rather than which name. command -v is POSIX; which is an external utility whose behavior and exit codes vary between distributions.

Related guides