Bash .bash_logout, BASH_ENV, set vs shopt and env -i

Once you know which startup files Bash reads, a handful of less famous features start to make sense, and most of them are exactly what you need when a script behaves differently in cron than in your terminal. This post covers four of them: .bash_logout and BASH_ENV, why aliases belong in your terminal and functions in your scripts, the two option systems (set and shopt), and how to reproduce a script’s real environment with env.

It builds on Bash environment variables and .bashrc vs .bash_profile. All output below is from Ubuntu 26.04 and RHEL 9, including a couple of results that surprised me.

How .bash_logout works

~/.bash_logout is the mirror image of ~/.bash_profile: Bash runs it when a login shell exits. Ubuntu ships one that clears the screen when you leave a console session:

cat /etc/skel/.bash_logout   # Ubuntu
# ~/.bash_logout: executed by bash(1) when login shell exits.

# when leaving the console clear the screen to increase privacy

if [ "$SHLVL" = 1 ]; then
    [ -x /usr/bin/clear_console ] && /usr/bin/clear_console -q
fi

RHEL’s version is just a comment. To see exactly when it runs, I added one line to a test user’s .bash_logout that appends a timestamp to ~/logout.log, then ended SSH sessions four different ways and counted the lines:

start -> lines in logout.log: 0
1) login shell, typed exit -> lines in logout.log: 1
2) started bash inside, exited both -> lines in logout.log: 2
3) typed logout -> lines in logout.log: 3
4) connection dropped, no exit typed -> lines in logout.log: 3

Case 2 added only one line for two shells: the inner bash wasn’t a login shell, so only the outer one ran the file. And case 4 is the one to remember. When the SSH connection simply dropped, the file didn’t run at all. Bash got a hangup signal and was gone.

So treat .bash_logout as a convenience for tidying up (clearing the screen, removing temp files from an interactive session), never as something you rely on. If cleanup has to happen, do it with a trap in the script that creates the mess, or let systemd handle it.

How BASH_ENV works

Non-interactive shells, the kind that run your scripts, read no startup files at all. BASH_ENV is the exception: if it’s set when Bash starts a script, Bash sources the file it names first. Here’s a small environment file and a script that reports what it can see:

cat script-env.sh
export APP_ENV=production
export PATH="$HOME/.local/bin:$PATH"
echo "[BASH_ENV file ~/envdemo/script-env.sh loaded]" >&2

Run the script with a minimal PATH, then again with BASH_ENV pointing at that file:

env PATH=/usr/bin:/bin ./show.sh
APP_ENV=[] backup=missing

BASH_ENV=~/envdemo/script-env.sh PATH=/usr/bin:/bin ./show.sh
[BASH_ENV file ~/envdemo/script-env.sh loaded]
APP_ENV=[production] backup=~/.local/bin/backup

Two limits worth knowing, both visible in testing. A #!/bin/sh script ignores it, because dash isn’t Bash:

BASH_ENV=~/envdemo/script-env.sh ./show-sh.sh
sh script: APP_ENV=[]

And interactive shells ignore it too (they read ~/.bashrc instead), so BASH_ENV=... bash -i -c "echo interactive" printed only interactive, with no “loaded” line.

The practical use: giving cron jobs an environment

Cron runs jobs with /bin/sh and a bare environment, which is why scripts that work in your terminal fail there. Two lines at the top of a crontab change that for every job:

crontab -l
SHELL=/bin/bash
BASH_ENV=/home/asif/envdemo/script-env.sh
* * * * * /home/asif/envdemo/show.sh > /tmp/cron-bashenv.txt 2>&1

cat /tmp/cron-bashenv.txt   # written by cron
[BASH_ENV file ~/envdemo/script-env.sh loaded]
APP_ENV=[production] backup=~/.local/bin/backup

The job found ~/.local/bin/backup, which a plain cron job can’t see (the shebang side of that story is in our first Bash script post). One environment file, used by every cron job, instead of copying PATH lines into each script. Don’t point BASH_ENV at your full ~/.bashrc, though: it’s full of interactive settings and tends to print things scripts don’t want.

Bash aliases for interactive work, and why functions suit scripts

Aliases are text substitutions for your typing. Ubuntu’s default ~/.bashrc comes with a few:

alias
alias egrep='egrep --color=auto'
alias fgrep='fgrep --color=auto'
alias grep='grep --color=auto'
alias l='ls -CF'
alias la='ls -A'
alias ll='ls -alF'
alias ls='ls --color=auto'

type ll
ll is aliased to `ls -alF'

(There’s also a long alert alias for desktop notifications, trimmed here.) In your terminal they’re great. In scripts, they don’t work by default:

cat alias-in-script.sh
#!/bin/bash
alias ll="ls -lh"
ll /etc/hostname

./alias-in-script.sh
./alias-in-script.sh: line 3: ll: command not found

Non-interactive Bash doesn’t expand aliases. You can switch it on with shopt -s expand_aliases at the top of the script, and it works, but a function does the same job without any special setting:

#!/bin/bash
ll() { ls -lh "$@"; }
ll /etc/hostname

./func.sh
-rw-r--r-- 1 root root 7 Feb  6 01:31 /etc/hostname

Arguments are where aliases really fall down. An alias can only stick text in front of whatever you type, so it has no $1:

asif@linux1:~$ alias mkcd='mkdir -p "$1" && cd "$1"'
asif@linux1:~$ mkcd /tmp/aliasdemo
mkdir: cannot create directory '': No such file or directory

$1 was empty, so mkdir got an empty name. The function version works:

asif@linux1:~$ mkcd() { mkdir -p "$1" && cd "$1"; }
asif@linux1:~$ mkcd /tmp/funcdemo
asif@linux1:/tmp/funcdemo$ pwd
/tmp/funcdemo

Functions can also be handed to child shells with export -f. Aliases can’t be exported at all:

asif@linux1:~$ export -f mkcd
asif@linux1:~$ bash -c 'type -t mkcd; type -t ll || echo "ll: not found in child"'
function
ll: not found in child

The rule: aliases for shortening what you type, functions for anything with logic or arguments, and only functions in scripts. To run the real command when an alias shadows it, use command ls or \ls.

Bash set vs shopt: how to inspect and change shell options

Bash has two separate switchboards. set handles the options inherited from older POSIX shells; shopt handles options Bash added itself. On this Ubuntu box there are 27 of the first kind and 59 of the second:

set -o | head -4
allexport      	off
braceexpand    	on
emacs          	off
errexit        	off

shopt | head -4
array_expand_once   	off
assoc_expand_once   	off
autocd              	off
bash_source_fullpath	off

The syntax differs, which is the main source of confusion:

  • set: set -o name turns on, set +o name turns off. Many have a short letter too, like set -e. Active single-letter options show in $- (here hBc for a non-interactive shell).
  • shopt: shopt -s name turns on, shopt -u name turns off. shopt name shows the state. shopt -o name can also read set options, as a bridge between the two.

The set options that matter for scripts

errexit (-e), nounset (-u) and xtrace (-x) get covered everywhere. pipefail is the one people forget:

without pipefail: exit=0
with pipefail:    exit=1

That’s false | true. Without pipefail, a pipeline reports only the last command’s status, so a failure earlier in the pipe is invisible to set -e and to if.

The shopt options that matter for scripts

The glob options fix a quiet bug. A loop over *.log in a directory with no log files:

processing: *.log
(nullglob: loop ran zero times)
bash: line 11: no match: *.log

By default, a glob that matches nothing stays as the literal text *.log, so the loop runs once on a file that doesn’t exist. shopt -s nullglob makes it expand to nothing, so the loop simply doesn’t run. shopt -s failglob makes it an error, and note how hard it fails: it aborted the rest of that command line, so my echo "exit=$?" after the loop never even printed. Pick nullglob when “no files” is normal, failglob when it means something’s wrong.

globstar makes ** match across directories:

without globstar: a/x.conf
with globstar:    a/b/c/y.conf a/x.conf

Saving and restoring options

Changing an option inside a function or a sourced file leaks into the rest of the shell. Both commands can print themselves in a form you can feed back in:

saved=$(shopt -p nullglob; set +o | grep pipefail)

saved: shopt -u nullglob
set +o pipefail
changed: shopt -s nullglob / pipefail       	on
restored: shopt -u nullglob / pipefail       	off

Save with shopt -p and set +o, change what you need, then eval "$saved" puts everything back.

How to reproduce a script environment with env and a controlled PATH

When a script fails under cron, systemd or CI but works for you, stop guessing and recreate its environment. Step one: capture the real thing. A one-line cron job, env > ~/envdemo/cron-env.txt, gave me this:

cat ~/envdemo/cron-env.txt   # captured from a real cron job
HOME=/home/asif
LOGNAME=asif
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin
LANG=en_US.UTF-8
SHELL=/bin/sh
PWD=/home/asif

Six variables. Your terminal has dozens. Step two: replay it. env -i starts with an empty environment, and you add back exactly what cron had:

bash -lc ./show.sh                               # my login shell
APP_ENV=[] backup=~/.local/bin/backup

env -i $(cat cron-env.txt) /bin/sh -c ./show.sh   # replay cron
APP_ENV=[] backup=missing

Same script, same machine, and now you can reproduce the cron failure in your terminal as many times as you like while you fix it. (The $(cat ...) trick works because none of these values contain spaces; for messier files, read them with a loop.)

For a fully controlled run, also stop Bash from reading any startup files:

env -i HOME="$HOME" PATH=/usr/bin:/bin bash --noprofile --norc -c "./show.sh; env"
APP_ENV=[] backup=missing
PWD=~/envdemo
HOME=~
SHLVL=0
PATH=/usr/bin:/bin
_=/usr/bin/env

That environment is tiny and completely known. If a script passes this test, it doesn’t secretly depend on anything in your personal setup. env has a few more handy flags: -u NAME removes one variable and -C DIR changes directory first:

env -u LANG -C /tmp bash -c "echo LANG=[\$LANG] PWD=\$PWD"
LANG=[] PWD=/tmp

A small surprise along the way: on Ubuntu 26.04 this isn’t GNU env any more.

env --version | head -1
env (uutils coreutils) 0.8.0

Ubuntu has switched to uutils, the Rust rewrite of coreutils. Every flag used in this post behaved the same, but if an old script relies on an obscure GNU-only option, that’s worth knowing. More on scheduling in our cron jobs guide.

Frequently asked questions

Does .bash_logout run when I close a terminal window?

Only if that terminal’s shell is a login shell and it exits normally. Most desktop terminals start non-login shells, so they never run it, and as the test above showed, a dropped SSH connection doesn’t run it either.

Is BASH_ENV a security risk?

It can be: whoever controls BASH_ENV can run code in every Bash script you start. Bash ignores it when running with elevated privileges (setuid), and sudo strips it from the environment by default. Only set it in places you control, like your own crontab or a systemd unit.

Should every script start with set -euo pipefail?

It’s a strong default for most scripts: stop on errors, on unset variables and on failures inside pipes. Know its quirks, though. -e doesn’t trigger inside if conditions or && chains, so test the error paths you care about.

How do I see every option that’s currently set?

set -o lists the POSIX-style options, shopt lists the Bash ones, and echo $- shows the active single-letter flags. set +o and shopt -p print them as commands you can save and replay.

Why does my alias work in the terminal but not over ssh host ‘command’?

That command runs in a non-interactive shell. Aliases aren’t expanded there, and Ubuntu’s ~/.bashrc returns early for non-interactive shells before it even defines them. Use a function or a small script in your PATH instead.

Asif Khan

Asif Khan

DevOps, Linux, and Cloud engineer with 10+ years building infrastructure that stays up, and automation so I do not have to fix it at 2am. My focus is automation, security, and systems built to survive failure, using Infrastructure as Code, CI/CD, containers, monitoring, and cloud platforms instead of manual fixes. This blog is where I write down what worked, and just as often what didn't the first time around. Real tutorials you can run yourself, troubleshooting notes pulled from actual outages, and the kind of detail you only get once you've hit the problem firsthand. If you're trying to get Linux, DevOps, and cloud infrastructure to behave, that's what you'll find here: reproducible solutions you can understand, test, and apply in your own environment.