Skip to main content

Shell scripting and environment variables — export and process inheritance

"I set the environment variable but the app doesn't see it." A common shell-script trap.

The cause is usually one thing: you set the variable but didn't configure it to be passed to child processes. Grasping this distinction speeds up triage of "the setting didn't arrive" problems.

What you'll learn in this chapter​

  • The difference between shell variables and environment variables
  • What export actually does
  • Correct use of the env command
  • What tilde expansion expands to
  • How to choose a way to run a script
Prerequisites

Builds on running commands from Shell basics.

Shell variables vs. environment variables​

Writing NAME=value in a shell creates a variable. But that's a variable only inside that shell.

APP_LOG_LEVEL=INFO # Shell variable. Only visible inside this shell
export APP_LOG_LEVEL # Promoted to env var. Inherited by child processes
Shell variableEnvironment variable
CreateNAME=valueexport NAME=value
ScopeOnly that shellThat shell and every child process
Listsetenv or printenv

Verify it​

Confirm the behavior with a parent-child pair of scripts.

# child.sh
#!/usr/bin/env bash
echo "child sees: [${APP_LOG_LEVEL:-unset}]"
# parent.sh
#!/usr/bin/env bash
APP_LOG_LEVEL=INFO
./child.sh # → child sees: [unset]
export APP_LOG_LEVEL
./child.sh # → child sees: [INFO]

Save both in the same directory, make them executable, then run the parent.

chmod +x parent.sh child.sh
./parent.sh

The output:

child sees: [unset]
child sees: [INFO]

Before export, nothing reaches the child. Inside the parent shell echo $APP_LOG_LEVEL prints INFO, so it looks set. That visual mismatch is where the confusion comes from.

What export does is "include this variable in the environment passed when spawning a child process". A copy of the value goes to the child, so modifying it in the child doesn't propagate back to the parent. It's a copy because the child is created as a separate process (Processes and threads).

Notes on writing​

APP_LOG_LEVEL=INFO # Correct
export APP_LOG_LEVEL=INFO # Correct. Assign and export together
export APP_LOG_LEVEL # Correct. Promote existing variable to env var

$APP_LOG_LEVEL=INFO # Wrong. $ means "get the value"
APP_LOG_LEVEL = INFO # Wrong. No spaces around =

$ is used when referencing a value. Not when assigning. $APP_LOG_LEVEL=INFO tries to execute "the current value of APP_LOG_LEVEL" as a command name, which isn't the intent.

Not allowing spaces around = is a shell-specific constraint. With spaces, APP_LOG_LEVEL is interpreted as a command name receiving = and INFO as arguments.

The env command​

env sounds like "the command to set environment variables", but its actual roles are two:

env # List environment variables
env NAME=value command # Run a command with an added env var, temporarily
# For this run only, set APP_LOG_LEVEL to DEBUG
env APP_LOG_LEVEL=DEBUG ./serve

Passing only a variable name means nothing.

env APP_LOG_LEVEL
# → env: APP_LOG_LEVEL: No such file or directory

env treats arguments not in NAME=value form as command names to execute. It looks for a program named APP_LOG_LEVEL and exits when not found. To make a variable into an environment variable, use export.

Tilde expansion​

~ expands to the home directory.

echo ~/srv/worker
# → /Users/yourname/srv/worker

It does not become /srv/worker. The home directory path is prepended.

Written asExpands to
~/srv/worker$HOME/srv/worker (e.g., /Users/name/srv/worker)
/srv/worker/srv/worker (directly under root)

If you meant a system directory but wrote ~, you get a nonexistent path. cd fails, but subsequent commands may run anyway, making it a hard-to-notice bug.

When you don't know where a script will run, use absolute paths. Relative paths depend on the runtime's current directory; called from cron or CI, they don't point where you meant.

Ways to run a script​

The same script behaves differently by how you call it.

FormWhat happensDo variable / cwd changes stick?
./script.shRun in a new processDo not stick in the caller
bash script.shSame (works without execute permission)Do not stick
. script.sh / source script.shRun inside the current shellStick

. (dot) and ./ look similar but are different.

./setup.sh # Run in a child. exports in setup.sh don't stick in the parent
. setup.sh # Run in the current shell. exported variables carry over

To load environment variables into the current shell, use source. This is what loading .env looks like. Conversely, to run something as an independent program, use ./.

Confusing these two makes things that should work not work. Writing . serve where you meant ./serve makes the current shell try to load the binary as a script, which fails.

Shebang​

The line beginning with #! at the top of a script is called the shebang, and it specifies the interpreter to run under.

#!/usr/bin/env bash # Look up bash on PATH and run
#!/bin/bash # Run with the bash at this path

Going through /usr/bin/env is widely used. Even if bash lives at different paths in different environments (/bin/bash vs. /usr/local/bin/bash), PATH finds it.

The shebang takes effect for direct execution like ./script.sh. When explicitly invoked as bash script.sh, the interpreter is what you specified regardless of the shebang.

Boilerplate for writing safely​

There are settings to put at the top of a script.

#!/usr/bin/env bash
set -euo pipefail
OptionEffect
-eExit on any command failure
-uError on referencing an undefined variable
-o pipefailIf any stage of a pipe fails, the whole pipe fails

The default shell moves to the next line even after a failure. Accidents like "cd fails and rm runs anyway" happen. set -e stops that.

-u catches variable-name typos. By default, undefined variables are treated as empty strings, so rm -rf "$TAGET_DIR/" can become rm -rf /. How to classify and handle failures is covered by Error handling.

Common misconceptions​

"NAME=value makes it an env var." — It's a variable inside that shell only. export is needed to pass it to children.

"$NAME=value assigns." — It doesn't. $ is for referencing. Don't use it on assignment.

". script.sh and ./script.sh are the same." — Not the same. The former runs in the current shell; the latter in a new process.

"Changing env vars in the child propagates to the parent." — It doesn't. What's inherited is a copy, one-way from parent to child.

Exercises​

Q1. A server startup script sets a log level, but the app runs at the default. Triage.

#!/usr/bin/env bash
APP_LOG_LEVEL=INFO
/srv/worker/serve
Answer and explanation

Answer: No export, so APP_LOG_LEVEL stays a shell variable and doesn't reach the child.

Triage in two steps.

1. Confirm it's visible inside the script

APP_LOG_LEVEL=INFO
echo "shell var: $APP_LOG_LEVEL" # → prints INFO
env | grep APP_LOG_LEVEL # → prints nothing

Visible to echo but absent from env? That's the shell-variable-only case. The gap is what "set it but doesn't arrive" is.

2. Pass it to the child

export APP_LOG_LEVEL=INFO
/srv/worker/serve

export says "include this variable in the environment when spawning a child". Variables without it stay inside the shell.

To apply only for this one run:

APP_LOG_LEVEL=INFO /srv/worker/serve # Place right before the command
env APP_LOG_LEVEL=INFO /srv/worker/serve

The NAME=value form matters. Passing only a name — like env APP_LOG_LEVEL — makes env look for a command by that name.

Q2. You want to load env vars written in .env into the current shell. How to run it?

Answer and explanation

Answer: Use source (or .).

# Correct: runs in the current shell; exported vars persist
source .env
. .env

# Wrong: runs in a child; disappears with the process
./.env
bash .env

Executing as a child like ./.env means anything exported inside is only effective in that child. When the process ends, the environment goes with it.

Environment inheritance is one-way from parent to child, so there's no way to send what the child set back to the parent. That's why source — which runs in the current shell — is needed.

Note: if .env has only KEY=value lines without export, source still leaves them as shell variables. In that case:

set -a # Auto-export subsequent assignments
source .env
set +a # Restore

Q3. A script run from cron fails with "command not found". It works when you run it by hand. What might be the cause?

Answer and explanation

Answer: cron's environment (especially PATH) differs from an interactive shell.

An interactive shell reads the user's startup files at launch (.bashrc, .zshrc, .bash_profile, and the like — which one depends on the shell and how it was started), so PATH contains many directories. cron doesn't, so PATH is minimal.

Order of investigation and fix:

# 1. Actually print the environment cron sees
* * * * * env > /tmp/cron-env.txt

# 2. Use absolute paths in the script
/usr/local/bin/node /opt/app/script.js

# 3. Or set PATH explicitly in the script
export PATH=/usr/local/bin:/usr/bin:/bin

For the same reason, the current directory isn't necessarily what you expect either. cron starts in the home directory, so relative paths in file references miss.

The principle boils down to two: for scripts called from where the environment is unclear, use absolute paths and set required env vars yourself. "Works on my machine" is not evidence it works elsewhere.

Summary​

  • NAME=value is a shell variable — inside that shell only. Passing to children needs export
  • Environment inheritance is one-way from parent to child. Changes in the child don't reach the parent
  • env NAME does not make a variable into an env var. Arguments not in NAME=value are treated as command names
  • Don't use $ on assignment. No spaces around =
  • ~ expands to the home directory. Use absolute paths for system directories like /srv
  • ./script.sh runs in a new process; . script.sh runs in the current shell
  • For scripts where the runtime location is unclear, use absolute paths and explicit environment setup
  • Placing set -euo pipefail at the top stops failures and typos early
Related references