curl | sh, done carefully: writing install scripts people can trust
Piping curl into sh is controversial for good reasons. How we write shell install scripts that are safe to pipe: main(), set -eu, checksums, no sudo and no surprises.
A lot of developer tools install with one line: curl -fsSL https://example.com/install | sh. It is quick and it works everywhere, and a fair number of people wince when they see it. Both reactions are reasonable. This post is about writing a curl | sh install script that deserves the trust it asks for, based on the rules we follow for our own installers.
The short version: a good install script is boring. It does one small thing, says what it did, and fails loudly and harmlessly when anything looks wrong.
Why piping to sh makes people nervous
The objections are worth taking seriously, because most of them are correct.
You run code you have not read. That is true, although it is also true of the binary the script downloads, and of most installers with a friendly window. Piping does not create the trust problem. It just makes it visible.
A cut-off download can run half a script. The shell reads and runs input line by line as it arrives. If the connection drops in the middle of a line, the shell may run whatever part it received. The classic horror story is a cleanup line such as rm -rf "$tmp_dir" that arrives as rm -rf "$tmp, or worse.
The server can tell who is piping. It has been shown that a server can, in principle, spot the difference between a browser viewing the script and a shell executing it, and send different content to each. Reading the script in your browser does not prove that the same bytes reach your shell.
Scripts do too much. Many install scripts ask for sudo, write into system folders, add lines to your shell profile, install extra packages and set up background services. Some of that is needed for some software. Most of it is just surprising.
None of these problems are really about curl or sh. They are about what the script does. So the rest of this post is a list of things the script should and should not do.
Structure: main(), set -eu and plain POSIX sh
The first two rules cover the half-script problem and the "carry on after an error" problem.
#!/bin/sh
set -eu
main() {
# everything happens in here
}
main "$@"
Wrapping all the work in a function and calling it on the very last line means that a truncated download defines, at most, part of a function and then stops. Nothing runs until the shell has read the final line. If the file is cut short, the function definition is incomplete and the shell reports a syntax error instead of running half an install. This one habit removes the scariest failure.
set -e makes the script stop when a command fails, instead of continuing with a missing file or an empty variable. set -u makes it stop when it uses a variable that was never set, which catches typos such as $INSTAL_DIR before they become a path. Neither is perfect (set -e has well-known gaps inside conditions and some pipelines), so we still check the important steps explicitly and exit with a clear message.
We write for plain POSIX sh, not Bash. On many Linux systems /bin/sh is dash, and on a small container it may be BusyBox. Bash arrays, [[ ]] and pipefail are not available in all of them, so we stay away from those. The script is short enough that we do not miss them.
Detect the platform, fetch only from the project
The script has to pick the right file for the machine. uname is enough for that:
case "$(uname -s)" in
Darwin) os=macos ;;
Linux) os=linux ;;
*) fail "unsupported OS $(uname -s)" ;;
esac
case "$(uname -m)" in
arm64|aarch64) arch=aarch64 ;;
x86_64|amd64) arch=x86_64 ;;
*) fail "unsupported CPU $(uname -m)" ;;
esac
Note the two spellings of 64-bit ARM. macOS reports arm64 and Linux usually reports aarch64. Anything unknown gets a clear error and a link to the release page, not a guess.
Then it downloads from exactly one place: the project's own release page. Not a CDN chosen at runtime, not a package from a third-party repository, not a second script. Our installers fetch from the project's GitHub releases. To find the latest version without an API token, they follow the /releases/latest redirect and read the tag from the final URL.
Pinning a version should be easy, because reproducible installs matter in CI and on servers. We read an environment variable and fall back to the latest release:
curl -fsSL https://example.com/install | VERSION=v1.2.3 sh
Always use curl -fsSL. The -f is the important letter: without it, a 404 page from the server would be passed to the shell as if it were a script. -L follows redirects, and -sS hides the progress bar but still shows errors.
Verify before installing anything
Every release file we publish has a matching .sha256 file. The installer downloads both into a temporary directory, computes the digest of the archive and compares:
expected="$(awk '{print $1; exit}' "$tmp/$asset.sha256")"
actual="$(sha256_of "$tmp/$asset")"
if [ -z "$expected" ] || [ "$expected" != "$actual" ]; then
fail "checksum mismatch for $asset, not installing"
fi
sha256_of uses sha256sum if it exists (most Linux systems) and shasum -a 256 otherwise (macOS). If neither is there, the script stops rather than skipping the check. An empty expected value also counts as a failure, so a missing or blank checksum file cannot slip through as a match.
We should be clear about what this buys. As we explained in checksums, explained, a checksum from the same release page catches corrupt and truncated downloads, and the wrong file. It does not protect against someone who controls the release page and can replace both files. That needs signatures, and it is a layer we think about separately.
The temporary directory is created with mktemp -d and removed by a trap on exit, whether the install worked or not. Nothing is left in /tmp.
Install into the user's space, change nothing else
This is where most install scripts overreach, so these are the rules we are strictest about.
- No sudo by default. The binary goes into
$HOME/.local/bin, which the user owns. If someone wants it in/usr/local/bin, they can setINSTALL_DIRand run the script with the privileges that folder needs. That is their decision, made on purpose. - One file. Our tools are single binaries, so the whole install is one copy and a
chmod 755. No package manager, no dependencies, no background service. - Leave shell profiles alone. If
~/.local/binis not on the user'sPATH, the script says so and prints the line to add. It does not edit.zshrc,.bashrcor.profile. Those files belong to the user, and scripts that append to them are a common source of broken shells. - Say what happened. A few plain lines: which file was downloaded, that the checksum matched, and the full path of the installed binary. Anyone can undo the whole install with one
rm.
Windows is out of scope for these scripts. The Windows builds are plain downloads from the app's page on our site, next to the other platforms on desktop apps.
Make it easy to read first
The safest way to use any install script is not to pipe it at all. Download it, read it, then run the file you read:
curl -fsSL https://example.com/install -o install.sh
less install.sh
sh install.sh
This also closes the "server can tell who is piping" gap, since the shell runs exactly the bytes you looked at. We would like more projects to show this form next to the one-liner.
A few small things make reading easier. Serve the script as text/plain, so opening the install URL in a browser shows the text instead of downloading a file. Put a comment block at the top that says, in plain words, what the script does and what it will not do, and list every environment variable it reads. Keep it short. Ours fit in about a hundred lines, and most of that is error messages.
If a script is too long to read in a couple of minutes, it is probably doing too much. That is a good test to apply before you write the first line, not only after.