The deploy script read DEPLOY_ENV and fell back to staging when it was empty, which was a sensible default right up until the afternoon someone set DEPLOY_ENV=production in their terminal, ran ./deploy.sh, watched it print "deploying to staging", and assumed the prompt had lagged. It had not. DEPLOY_ENV=production without export creates a variable in the terminal's shell and nowhere else. ./deploy.sh is a new process; it inherited the environment, the variable was not in it, and the fallback did precisely what it was written to do. The production release went to staging, the staging release that followed went to staging, and the incident report's root cause was one missing word.
The rules for which process can see which variable are short, but they are the kind of short that gets guessed at instead of learned. The script on this page runs each rule and prints what happened, so the version in your head matches the version in the kernel.
set -u turns a silent empty into a loud error
Every trap on this page gets louder with set -u: a reference to an unset variable stops the script with its name instead of expanding to nothing. The Safe Bash Script Template puts set -euo pipefail on line 4 for exactly this reason and walks through what each flag catches. And when the variable that is missing is PATH itself — or has three copies of the same directory — paste it into the Bash $PATH Debugger before reading further.
The Script
What does it print?
Verbatim from bash 5.3 on the machine this was written on:
Section 1 is the deploy incident in two lines. Both variables hold the same value in the parent; the child sees only the exported one.
Why can't a child process see my variable?
Because there are two kinds of variable and only one of them travels. PLAIN=value creates a shell variable: a name in the memory of this one bash process. export PLAIN (or export PLAIN=value) additionally marks it for the environment, which is the block of NAME=value strings the kernel hands to every program this shell starts. A script you execute is a program this shell starts. It gets the environment and nothing else — not your functions, not your aliases, not your plain variables.
The subshell in section 2 is the exception that confuses people. ( ... ) does not start a new program; it forks a copy of the current bash, memory and all, so PLAIN is visible inside it. Command substitution $( ... ) and each side of a pipeline are subshells too. The rule is not "child processes see exports" — it is "a new program sees exports; a forked copy of bash sees everything." ./script.sh and bash -c are new programs.
What is the difference between running a script and sourcing it?
Section 3 runs the same one-line file both ways. bash "$SETTER" starts a child, the child sets FROM_FILE, the child exits, and the value dies with it — the parent still reports <unset>. source "$SETTER" reads the file's lines into the current shell as if you had typed them, so the assignment lands in the parent and survives. . "$SETTER" is the same thing in POSIX spelling.
This is why a config.sh full of KEY=value lines has to be sourced, not executed, and why a "setup script" that says "run me to configure your shell" has to be sourced to have any effect. It is also why sourcing an untrusted file is running it with full access to your shell's state — treat source as eval with a filename.
Why does my script work in the terminal but not in cron?
Section 4 is the answer, and env -i is how you see it before cron does. env -i command runs the command with the environment emptied first: no HOME, no LANG, none of the exports from .bashrc, and a PATH that only exists because bash falls back to a compiled-in default when the environment supplies none. cron's environment is nearly that sparse — it sets HOME, LOGNAME, a SHELL of /bin/sh, and a PATH that is typically /usr/bin:/bin, and reads none of your dotfiles.
So a script that calls aws, node, or anything installed under /usr/local or ~/.local/bin fails with command not found under cron, and a script that reads $API_TOKEN from an export in .bashrc reads an empty string. env -i bash ./script.sh reproduces both failures on demand. The fix is to stop depending on inherited state: set PATH explicitly at the top of the script, and load secrets from a file the script sources itself. The cron half of that story — locking, logging, timeouts — is the Bash Scripts That Survive Cron guide.
How do I read a variable with a default, or require one?
${PORT:-8080} in section 5 expands to 8080 because PORT is unset. It does not assign anything; use ${PORT:=8080} if you also want PORT to hold the default afterwards. ${API_TOKEN:?must be set before deploy} is the opposite: when the variable is unset or empty, bash prints the message to stderr with the variable's name and the script stops. The demo runs it inside bash -c so the page's script survives; in a real script you want the abort. bash 5.3 on this box exits 127 for it — treat that as "non-zero", not as a number to match on. Put one : "${VAR:?message}" line per required variable at the top of every script, and a missing secret stops the run with a name on it instead of an empty-string request to production. That belongs next to set -euo pipefail in the error handling pattern, and set -u catches the plain references the :? lines did not cover.
Section 6 shows the one subtlety: EMPTY="" is set, so ${EMPTY-x} yields the empty string and only ${EMPTY:-x} supplies the fallback. Use the colon forms unless you have a reason not to. unset EXPORTED removes the variable from both the shell and the environment — EXPORTED="" would have kept an empty one in the environment, which is a different state and a different bug.
How do I see what is actually in the environment?
printenv prints the environment and nothing else; printenv NAME prints one variable and exits 1 when it is absent, which section 7 uses as a test. env with no arguments does the same as printenv. export -p lists exported variables in re-sourceable form. set prints everything — shell variables, environment, and functions. On this box printenv | wc -l is 121 and set | wc -l is 245; the gap is the set of things a child process will never see.
Frequently Asked Questions
What is the difference between a shell variable and an environment variable in bash?
A shell variable exists only inside the bash process that created it: NAME=value. An environment variable is a shell variable that has also been marked for export with export NAME or export NAME=value. The distinction only matters when bash starts a child process: the child receives a copy of every exported variable and none of the plain ones. Inside the shell that set them, they behave identically, which is why the difference stays invisible until a script runs as a child and gets nothing.
Why can't my script see a variable I set in the terminal?
Because you assigned it without export, or you exported it in a different shell — another tab, a previous session, or a .bashrc never re-read. Confirm with printenv NAME in the terminal you run the script from: no output means it is not in the environment. Fix it with export NAME=value in that terminal, or put the export in ~/.bashrc and open a new shell. A ( ) subshell would have seen it either way, but ./script.sh is a new bash process, not a subshell.
Why does my script work in the terminal but fail in cron?
cron does not read .bashrc or .profile. A cron job starts with a nearly empty environment — HOME, LOGNAME, SHELL=/bin/sh, and a minimal PATH such as /usr/bin:/bin — so every variable you exported interactively and every directory you added to PATH is missing. Reproduce it with env -i bash ./script.sh. Then set the variables at the top of the crontab (cron accepts NAME=value lines) or source a config file explicitly inside the script.
How do I make an environment variable permanent in Linux?
For one user's interactive shells, add export NAME=value to ~/.bashrc and open a new terminal. For every user and for non-shell services, /etc/environment takes plain NAME=value lines with no export keyword — PAM reads it at login, not bash, so no shell syntax works there. For a systemd service use Environment= or EnvironmentFile= in the unit; for cron, put NAME=value at the top of the crontab. None of these change processes already running.
What is the difference between ${VAR:-default} and ${VAR-default}?
The colon widens the test. ${VAR-default} substitutes only when VAR is unset — a variable set to an empty string yields the empty string. ${VAR:-default} substitutes when VAR is unset or empty. The same rule applies to :=, :? and :+. You almost always want the colon versions, because an empty value is nearly always a mistake: a config with TOKEN= and nothing after it should behave like a missing token.
Part of the bash snippets collection