making scripts portable step by step
Calling a script POSIX compliant is quick to claim and rarely verified. This checklist provides concrete steps, tools and a test matrix to systematically secure an existing script against Dash, BusyBox ash and other lean shells.
Table of Contents
- 1. Why a checklist instead of a feeling
- 2. Step 1: define the shebang and target shells
- 3. Step 2: static analysis with ShellCheck and checkbashisms
- 4. Step 3: consistently remove arrays and [[ ]]
- 5. Step 4: replace echo with printf
- 6. Step 5: check local, functions and scoping
- 7. Step 6: build a multi-shell test matrix
- 8. Step 7: set up a CI gate for new scripts
- 9. The checklist at a glance
- 10. Summary
- 11. FAQ
1. Why a checklist instead of a feeling
Many teams claim their scripts are POSIX compliant because they were tested under Dash at some point. Without systematic checking, that claim is worthless, because a single new code block with an array or a [[ ]] test is enough to silently break POSIX compliance again. A POSIX checklist turns a vague claim into a repeatable, automatable verification process that runs again on every change.
The value of such a checklist lies not in the theory of the POSIX standard but in the practical order of steps: first define the target scope, then check automatically, then rebuild deliberately, and finally test against several real shells. This order prevents teams from spending time on manual code reviews that a tool can perform more reliably in seconds. The following checklist follows exactly this order and works both for a one time migration of existing scripts and for the ongoing verification of new scripts in the CI pipeline.
2. Step 1: define the shebang and target shells
The first step in any POSIX checklist is a deliberate decision, not a technical check: which shells does this script actually need to support? A script that only runs inside a controlled Docker image with Bash 5.x does not need POSIX compliance. A script that can end up as a package postinst hook on arbitrary Debian, Alpine or RHEL systems, on the other hand, genuinely needs to work under Dash, BusyBox ash and possibly ksh.
This decision is documented directly in the shebang: #!/bin/sh signals a genuine POSIX compliance claim, #!/usr/bin/env bash signals deliberate Bash dependency. A comment right below the shebang listing the tested target shells makes the decision visible to everyone who touches the file afterward and prevents someone from accidentally adding Bash syntax to a supposedly portable script without knowing the consequences.
#!/bin/sh
# Target shells: dash 0.5.x, busybox ash 1.36, POSIX ksh
# Tested with: dash, busybox sh, mksh — see CI matrix in step 6
set -eu
# From here on: only POSIX-defined syntax is allowed in this file
3. Step 2: static analysis with ShellCheck and checkbashisms
Once the target scope is set, automated checking follows. shellcheck --shell=sh script.sh reports every spot that does not comply with the POSIX standard, with an explanation and line number. checkbashisms from the Debian devscripts package adds patterns specifically known from Debian's migration from Bash to Dash. Together, both tools cover practically every relevant POSIX violation in practice before a human even reads the script.
Important for the checklist: this check has to come before any manual rework, not after. Anyone who rebuilds manually first and only checks afterward tends to miss spots that look harmless at first glance, such as a single echo with an escape sequence or a local combined with command substitution on the same line. The tools reliably find these spots and provide the line number for the next step at the same time.
#!/usr/bin/env bash
set -euo pipefail
echo "=== Step 2: static POSIX analysis ==="
# ShellCheck restricted to POSIX sh rules
shellcheck --shell=sh --severity=warning deploy.sh
# Debian's dedicated bashism scanner
checkbashisms --posix deploy.sh
# Exit non-zero if either tool reported anything, for use as a CI gate
4. Step 3: consistently remove arrays and [[ ]]
The third step of the checklist is the targeted removal of the two most common POSIX violations: arrays and the extended test bracket [[ ]]. Arrays are replaced by strings separated with spaces or another delimiter, combined with set -- to split them into positional parameters, or by several clearly named individual variables when the number of elements is known upfront. [[ ]] pattern matches are replaced by case statements, which work identically across POSIX shells and are often even more readable than complex regex expressions.
These rewrites are the most labor intensive part of the checklist because they cannot be automated mechanically, they require understanding the original logic. A good intermediate step is to comment each spot found individually, explaining why the POSIX alternative delivers the same behavior before the code gets merged. That also makes it easier later for reviewers who do not know the POSIX restriction themselves to follow the change.
#!/bin/sh
set -eu
# BEFORE (bash-only, fails the checklist)
# declare -a hosts=(web1 web2 web3)
# for h in "${hosts[@]}"; do ping -c1 "$h"; done
# AFTER (POSIX-safe, passes the checklist)
hosts="web1 web2 web3"
for h in $hosts; do
ping -c 1 "$h"
done
# BEFORE (bash-only [[ ]] pattern match)
# if [[ "$env" == prod* ]]; then echo "production"; fi
# AFTER (POSIX case statement)
case "$env" in
prod*) echo "production" ;;
esac
5. Step 4: replace echo with printf
The fourth checklist item concerns a detail that is often overlooked: echo does not behave identically across POSIX shells, particularly with escape sequences and the -n flag for suppressing the newline. POSIX deliberately defines echo's behavior as implementation dependent, which in practice means every shell is allowed to apply its own rules. printf, by contrast, is fully and unambiguously specified in POSIX and behaves identically in Dash, BusyBox ash, ksh and Bash.
The rework is mechanically simple: printf '%s\n' "$var" replaces echo "$var" directly in almost all cases. For formatted output with multiple variables, printf additionally provides real format strings that are more readable than chained echo calls. This change should be tracked as its own small step in the checklist because it is easy to automate and rarely needs discussion in code reviews.
6. Step 5: check local, functions and scoping
Function definitions must consistently follow the POSIX form name() { ... }, the function keyword has to be removed. For local, which is strictly speaking not a POSIX feature but is supported as an extension by practically every relevant shell, the checklist verifies that declaration and assignment with command substitution never sit on the same line, to avoid losing the exit code. This rule holds even more strictly in Dash than in Bash and is therefore a common stumbling block during migration.
The checklist should additionally check whether global variables get accidentally overwritten due to a missing local, a mistake that is especially easy to miss in longer scripts with many functions. A simple test: every function should be called twice in a row with the same inputs and produce identical results. If the result differs on the second call, that points to a missing local scope or an accidental global side effect.
7. Step 6: build a multi-shell test matrix
Static analysis alone is not enough to guarantee POSIX compliance because some differences only show up at runtime. The checklist therefore requires a genuine test matrix that runs the script under at least three different shell implementations: Dash as the reference for Debian and Ubuntu, BusyBox ash as the reference for Alpine and minimal containers, and mksh or another ksh derivative as a third, independent implementation that surfaces errors that happen to be identical between the first two by chance.
In practice, a simple Bash script that calls the same target script in sequence with dash, busybox sh and mksh, comparing exit code and output, is enough. Differences in output between the three shells are a reliable signal of remaining non POSIX compliant constructs that neither ShellCheck nor checkbashisms detected. This test matrix should be a permanent part of the CI pipeline, not just a one time manual test before migration.
#!/usr/bin/env bash
set -euo pipefail
TARGET="./deploy.sh"
SHELLS=(dash busybox mksh)
for shell in "${SHELLS[@]}"; do
if ! command -v "$shell" >/dev/null 2>&1; then
echo "[SKIP] $shell not installed"
continue
fi
echo "=== Testing under: $shell ==="
if [ "$shell" = "busybox" ]; then
busybox sh "$TARGET" && echo "[OK] busybox sh" || echo "[FAIL] busybox sh"
else
"$shell" "$TARGET" && echo "[OK] $shell" || echo "[FAIL] $shell"
fi
done
8. Step 7: set up a CI gate for new scripts
The final step of the checklist secures the achieved result permanently: every new commit that changes or adds a script with a #!/bin/sh shebang automatically runs through ShellCheck, checkbashisms and the test matrix from step 6 before it may be merged. Without this gate, any POSIX migration decays within a few months, because new developers do not know the original decision and accidentally introduce Bash syntax.
A pragmatic approach is a single Makefile target or a pre commit hook that automatically finds all files with a #!/bin/sh shebang and applies the checklist to them, instead of relying on individual developers to remember. This gate is the difference between a one time migration that slowly decays again and a POSIX checklist that is permanently upheld.
9. The checklist at a glance
The following table compactly summarizes all seven steps, including the respective tool and the expected outcome.
| Step | Action | Tool | Outcome |
|---|---|---|---|
| 1 | Define target shells | Shebang, comment | Documented decision |
| 2 | Static analysis | ShellCheck, checkbashisms | List of violations |
| 3 | Remove arrays, [[ ]] | manual rework | POSIX syntax |
| 4 | Replace echo with printf | search and replace | Consistent output |
| 5 to 7 | Functions, test matrix, CI gate | dash, busybox, mksh, CI | Permanent POSIX compliance |
The overview shows that a POSIX checklist is not a one time audit, but a repeatable process spanning from the deliberate decision to the automated CI gate. Anyone who goes through all seven steps ends up not just with a currently portable script, but with a mechanism that keeps it portable through future changes too.
Mironsoft
Shell automation, DevOps tooling and portable infrastructure scripts
Scripts that actually pass your own POSIX checklist?
We build the entire checklist as a CI gate, migrate existing scripts to genuine POSIX compliance, and set up the test matrix across Dash, BusyBox and ksh.
POSIX audit
Run ShellCheck and checkbashisms across the entire script inventory
Migration
Deliberately replace arrays, [[ ]] and bashisms with POSIX alternatives
CI gate
Permanently integrate a multi shell test matrix into the pipeline
10. Summary
A POSIX checklist turns a vague claim about portability into a concrete, repeatable process. The seven steps, from the deliberate target shell decision through static analysis with ShellCheck and checkbashisms to a genuine test matrix across Dash, BusyBox ash and mksh, systematically cover exactly the spots where scripts typically lose POSIX compliance. Arrays, the extended test bracket and inconsistent echo behavior are the three most common causes.
The most important part of the checklist, however, is not the one time migration but the final CI gate. Without automated enforcement, any POSIX compliance decays within a few months because new changes do not know about the original decision. Anyone who consistently establishes the checklist as a pipeline step keeps scripts permanently portable instead of only cleaning them up once.
POSIX checklist: the essentials at a glance
Target scope
Before any migration, define which shells the script must actually support, and document that in the shebang.
Automated checking
shellcheck --shell=sh and checkbashisms find most violations before a human ever reads the script.
Test matrix
Test Dash, BusyBox ash and mksh together to find runtime differences that analysis tools miss.
CI gate
Run the entire checklist automatically on every commit, otherwise POSIX compliance decays silently.