You closed Module 2 with the judgement to decide who accesses what. You start Module 3 with something more intimate: the place where you work. Every time you open a session on srv-tramontana, Bash builds an environment around you — a set of variables, shortcuts, search paths and memory of what you have already done — that determines how every command you run afterwards behaves. Until now you have used it exactly as it came out of the box. From here on you understand it and you shape it.

This is not an aesthetic whim. 90% of a systems administrator's "but it worked for me" moments — the script that runs from your terminal and fails in cron, the sort that orders things differently on your laptop and on the server, the command that exists for operator but not for root — are environment problems. Understanding what is inherited, what is not, and from which file each thing is loaded is what separates debugging in five minutes from losing an afternoon.

Contents

  1. What a process's environment is
  2. Shell variables and environment variables
  3. Inheritance by child processes
  4. PATH: how Bash finds an executable
  5. The other variables that matter
  6. Variable expansion and quoting, precisely
  7. Command substitution
  8. Aliases
  9. Login, interactive and non-interactive shells
  10. Customising the PS1 prompt
  11. Persistent history and its security risk
  12. The recommended ~/.bashrc for operator

  1. What a process's environment is

When the kernel starts a process, it hands it three things: the command-line arguments, the open file descriptors (which you will see in 03-04) and a vector of KEY=value strings called the environment. It is not Bash magic: it is an operating system structure, available to any program written in any language.

Since on Linux everything is a file, you can look at it directly:

operator@srv-tramontana:~$ tr '\0' '\n' < /proc/$$/environ | head -6
SHELL=/bin/bash
PWD=/home/operator
LOGNAME=operator
HOME=/home/operator
LANG=en_GB.UTF-8
USER=operator

$$ is the PID of the current shell. The environ file stores the variables separated by null bytes, which is why tr is needed to read them. The important part: that content was fixed when the process started. It is a photograph, not a live link.

  1. Shell variables and environment variables

Bash handles two sets that look alike and are not the same thing.

Shell variable Environment variable
Created with VAR=value export VAR=value
Visible to the current shell Yes Yes
Visible to child processes No Yes
Listed with set env or printenv
Typical use temporary work within the session configuring programs

Assignment has one rule that causes errors on day one: there must be no spaces around the =.

operator@srv-tramontana:~$ RELEASE=3.2.1
operator@srv-tramontana:~$ RELEASE = 3.2.1
RELEASE: command not found

With spaces, Bash reads RELEASE as a command and = and 3.2.1 as its arguments. If the value contains spaces, it has to be quoted: MESSAGE="deploy ok".

To promote an existing variable to an environment variable, export RELEASE is enough. To remove either kind, unset RELEASE.

Looking at one in particular:

printenv VARIABLE prints its value and returns 1 if the variable is not in the environment, even if it exists as a shell variable. It is the quickest way to check whether you are missing an export.

  1. Inheritance by child processes

This is the concept that has to be nailed down, because it explains a whole family of errors. Inheritance is one-way and happens at start-up: the child receives a copy of the parent's environment, and nothing the child does comes back to the parent.

operator@srv-tramontana:~$ export ENVIRONMENT=production
operator@srv-tramontana:~$ bash -c 'echo "the child sees: $ENVIRONMENT"; ENVIRONMENT=test'
the child sees: production
operator@srv-tramontana:~$ echo "the parent still has: $ENVIRONMENT"
the parent still has: production

The child changed its copy and died with it. That is why a script cannot change your terminal's working directory, nor define a variable for you, unless you run it with source (or its synonym .), which does not create a child process: it reads the file in the current shell.

operator@srv-tramontana:~$ echo 'APP_VERSION=3.2.1' > /tmp/vars.sh
operator@srv-tramontana:~$ bash /tmp/vars.sh; echo "[$APP_VERSION]"     # child: it is lost
[]
operator@srv-tramontana:~$ source /tmp/vars.sh; echo "[$APP_VERSION]"   # current shell
[3.2.1]

Parentheses create a subshell, which is just another child and behaves the same way: ( cd /opt/tramontana/app && pwd ) prints /opt/tramontana/app and leaves you where you were. That pattern is genuinely useful: you move somewhere, do something and come back on your own, with no risk of forgetting a cd -.

  1. PATH: how Bash finds an executable

In Module 2 it was left as a notion. Now in detail. PATH is a list of directories separated by :, and Bash walks it from left to right, stopping at the first match.

operator@srv-tramontana:~$ echo "$PATH"
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games
operator@srv-tramontana:~$ type -a python3
python3 is /usr/bin/python3

type -a shows all the matches in order; if there were two versions installed, you would see which one wins. Bash also caches the paths it has resolved: if you install a binary that masks another one and the old one keeps running, hash -r clears that cache.

To add /home/operator/scripts, the correct way is to prepend or append without destroying what was already there:

operator@srv-tramontana:~$ export PATH="$HOME/scripts:$PATH"

Note two details. First, $PATH is reused inside the new value; writing a bare PATH=/home/operator/scripts leaves the system without ls or sudo until you close the session. Second, it is quoted.

Putting it in front, your scripts take priority over the system's, which lets you deliberately override a command; putting it behind (PATH="$PATH:$HOME/scripts") the system always wins, which is more conservative. At Tramontana we use the second order for the administrative accounts.

Why . never goes in the PATH

Including the current directory looks convenient and is a classic security hole. Imagine . is first and that Luis, while debugging, leaves a file called ls with malicious content in /srv/tramontana/backups/temp/. You go in there to have a look, you type ls, and instead of the system command you run that directory's file with your privileges. With sudo in the mix, it is a complete privilege escalation.

The rule is absolute: . does not go in the PATH, neither at the beginning nor at the end. To run something from the current directory you write ./program, explicitly. Those two characters are the difference between a conscious decision and an accident.

  1. The other variables that matter

Variable What it contains Practical note
HOME /home/operator It is what ~ expands to and where cd goes with no arguments
USER / LOGNAME operator Informational; for real identity use id -un
SHELL /bin/bash It is your login shell, not necessarily the one running now
PWD / OLDPWD current and previous directory cd - uses OLDPWD
LANG / LC_ALL language and locale Affects messages, ordering and date formats
EDITOR / VISUAL default editor Used by crontab -e, git, visudo
TERM terminal type xterm-256color locally, vt100 on old consoles
PS1 the prompt string We customise it in section 10

The locale deserves a paragraph of its own because it produces silent differences between machines:

operator@srv-tramontana:~$ printf 'house\nHouse\ncal-ferrer\nCan-Ventos\n' | sort | tr '\n' ' '
cal-ferrer Can-Ventos house House
operator@srv-tramontana:~$ printf 'house\nHouse\ncal-ferrer\nCan-Ventos\n' | LC_ALL=C sort | tr '\n' ' '
Can-Ventos House cal-ferrer house

With en_GB.UTF-8, sort ignores case and hyphens and orders things "like a dictionary". With LC_ALL=C it sorts by byte value, and all the capitals come before the lower-case letters. When the ordering has to be reproducible — comparing two listings, generating a checksum — set LC_ALL=C. Note too that the assignment goes in front of the command, with no export: that defines the variable for that one run only, without touching your session. It is the cleanest way to try things out.

LC_ALL overrides all the other LC_* variables and LANG; that is why it is the one used to force behaviour. The same effect turns up in dates: date will give you "Mon 18 Aug" or "Mon Aug 18" depending on the locale, and if a script trims that output by position, it breaks when you move machines.

  1. Variable expansion and quoting, precisely

Module 2 covered quoting in passing. Here is the exact rule, one of the three or four most profitable things in the whole course.

Form Variables expand Globbing happens Split on spaces
$VAR (unquoted) Yes Yes, over the result Yes
"$VAR" Yes No No
'$VAR' No No No

The difference between the first two rows breaks scripts every single day:

operator@srv-tramontana:~$ FILEPATH="/home/operator/data/report august.txt"
operator@srv-tramontana:~$ touch "$FILEPATH"
operator@srv-tramontana:~$ ls -l $FILEPATH
ls: cannot access '/home/operator/data/report': No such file or directory
ls: cannot access 'august.txt': No such file or directory
operator@srv-tramontana:~$ ls -l "$FILEPATH"
-rw-r----- 1 operator operator 0 Aug 18 10:12 '/home/operator/data/report august.txt'

Without quotes, Bash splits the value at the space and ls receives two arguments. The golden rule: always quote "$VAR", unless you deliberately want it split into words.

Braces, ${VAR}, delimit the name when what follows could be mistaken for part of it. With V=3.2.1, echo "release-$V_final" prints release- — Bash looks for a variable V_final, which does not exist — whereas echo "release-${V}_final" prints release-3.2.1_final.

Two expansions with a default value that you will use constantly:

operator@srv-tramontana:~$ echo "Destination: ${BACKUP_DIR:-/srv/tramontana/backups/temp}"
Destination: /srv/tramontana/backups/temp
operator@srv-tramontana:~$ echo "Version: ${VERSION:?the version must be specified}"
bash: VERSION: the version must be specified
  • ${VAR:-value} uses value if VAR is empty or undefined, without assigning it. Ideal for defaults.
  • ${VAR:?message} aborts with that message if it is missing. It is the short way to demand a mandatory parameter.

There is also ${VAR:=value}, which assigns as well, and ${VAR:+value}, which uses value only if the variable is defined.

  1. Command substitution

$(command) runs the command and replaces the expression with its output, stripping the trailing newlines.

operator@srv-tramontana:~$ ACTIVE="$(readlink /opt/tramontana/app)"
operator@srv-tramontana:~$ echo "Active release: $ACTIVE, checked on $(date +%F)"
Active release: releases/3.2.1, checked on 2026-08-18

You had already been using it in the backup convention cp app.conf app.conf.bak-$(date +%F). Now you know exactly what happens there.

There is an old form with backticks, `command`. Always use $(...): it can be nested without escaping anything and it is not visually confused with single quotes. And quote the result, "$(...)", for the same reason as in the previous section.

  1. Aliases

An alias is an abbreviation that Bash substitutes at the start of a command, before running it.

operator@srv-tramontana:~$ alias ll='ls -lh --group-directories-first'
operator@srv-tramontana:~$ alias logs='cd /var/log/tramontana'
operator@srv-tramontana:~$ alias | head -3
alias ll='ls -lh --group-directories-first'
alias logs='cd /var/log/tramontana'
alias ls='ls --color=auto'

They are removed with unalias ll. To run the original command bypassing the alias, put a backslash in front:

operator@srv-tramontana:~$ alias rm='rm -i'
operator@srv-tramontana:~$ \rm /tmp/vars.sh

command rm and the absolute path /bin/rm work too. This matters because aliases are local to your interactive session: they do not exist in a script, nor in cron, nor when another user runs the same thing. An rm='rm -i' alias gives you a false sense of a safety net that disappears in precisely the context where you would do the most damage.

Aliases are for your convenience, not for automation. If you need something to behave the same way always and everywhere, that is a script, and scripts are Module 4. In section 12 you will see the full set we use at Tramontana.

  1. Login, interactive and non-interactive shells

Here is the tangle that needs undoing once and for all. Bash reads different start-up files depending on how it was launched.

Type When it appears What it reads
Interactive login ssh operator@srv-tramontana, TTY console, su - /etc/profile, then the first of ~/.bash_profile, ~/.bash_login, ~/.profile that exists
Interactive non-login opening a terminal tab, typing bash /etc/bash.bashrc and ~/.bashrc
Non-interactive bash script.sh, cron, ssh server 'command' None of the above (only $BASH_ENV if it is defined)
flowchart TD
    S["bash starts"] --> L{"Is it a login<br/>shell?"}
    L -->|Yes| P["/etc/profile"] --> U["~/.bash_profile<br/>or ~/.bash_login<br/>or ~/.profile<br/>(the first that exists)"]
    U --> R["~/.profile usually includes:<br/>source ~/.bashrc"]
    R --> B["~/.bashrc"]
    L -->|No| I{"Is it<br/>interactive?"}
    I -->|Yes| G["/etc/bash.bashrc"] --> B
    I -->|No| N["It reads no start-up<br/>file at all"]
    B --> W["Session ready"]
    N --> W
    W --> X{"Is a login<br/>shell exiting?"}
    X -->|Yes| O["~/.bash_logout"]

Three consequences come out of that diagram and they resolve almost every doubt:

  1. Environment variables go in ~/.profile; aliases, functions and PS1 go in ~/.bashrc. The first are inherited by every process in the session; the second only make sense when there is somebody typing.
  2. On Ubuntu, ~/.profile ends with a block that does source ~/.bashrc. That is why you see your aliases when you connect over SSH: they arrive through that chain, not because the login shell reads .bashrc by itself. If you create a ~/.bash_profile, ~/.profile stops being read and that chain breaks.
  3. Cron reads none of this, and that is the root cause of the classic failure you will study in 03-07.

To find out which type of shell you are in: shopt -q login_shell && echo login tells you whether it is a login shell, and echo "$-" shows the active options, where i means interactive. And ~/.bash_logout runs when a login shell closes: it is used, for example, to clear the screen of a physical TTY with clear.

  1. Customising the PS1 prompt

PS1 is the string Bash prints before each command. It accepts its own escape sequences:

Sequence Meaning
\u user name
\h / \H short / full host name
\w / \W full path (with ~) / just the last directory
\$ # if you are root, $ if not
\t / \d time HH:MM:SS / date
\n newline

Colour is added with ANSI sequences, and there is one indispensable detail: anything that takes up no space on screen must go between \\[ and \\]. If you skip it, Bash miscalculates the width of the line and the history paints over the prompt when you recall long commands.

operator@srv-tramontana:~$ PS1='\[\e[1;32m\]\u@\h\[\e[0m\]:\[\e[1;34m\]\w\[\e[0m\]\$ '

In production we deliberately use a different colour:

operator@srv-tramontana:~$ PS1='\[\e[1;37;41m\] PROD \[\e[0m\] \u@\h:\w\$ '
 PROD  operator@srv-tramontana:~$

White text on a red background (41). At Tramontana, the prompt on srv-tramontana is red and the one on the laptop is green, for a very unaesthetic reason: nearly every catastrophic deletion begins with running a command meant for one window in the other one. A permanent visual warning that is impossible to ignore is more effective than any written rule. The same logic applies when you work as root, where \$ turns into # all by itself.

  1. Persistent history and its security risk

Bash keeps the commands in memory during the session and dumps them to ~/.bash_history on exit. That default behaviour has two problems: if you open several terminals, the last one to close overwrites the history of the others, and if the session dies suddenly, everything is lost.

Setting What it does
HISTSIZE=10000 commands kept in memory
HISTFILESIZE=20000 lines kept in the file
HISTCONTROL=ignoreboth ignores consecutive duplicates and lines starting with a space
HISTIGNORE='ls:ll:pwd:exit:history:clear' does not save these trivial commands
HISTTIMEFORMAT='%F %T ' adds date and time to each entry
shopt -s histappend appends to the file instead of overwriting it

ignoreboth includes ignorespace, and that enables a practical trick: if you type a command preceded by a space, it is not saved. With HISTTIMEFORMAT set, history 3 shows you the last three entries with their timestamps, which turns the history into a record of what you did and when.

The security warning

~/.bash_history is a plain text file. Everything you type on the command line ends up there, credentials included:

operator@srv-tramontana:~$ mysql -u tramontana -pSecret123 tramontana_db   # NEVER

That password is written into your history, and it is also visible in the process table to any user on the system while the command is running (you will see this with ps aux in 03-06). Two problems, not one.

What to do instead:

  • Let the tool ask for it interactively (mysql -u tramontana -p, without pasting it).
  • Store it in a credentials file with 600 permissions and pass it the path.
  • If you have already typed it: delete it from the history in memory and from the file.
operator@srv-tramontana:~$ history -d 512          # deletes entry 512 from memory
operator@srv-tramontana:~$ history -w              # rewrites ~/.bash_history

And check the permissions, which must be 600. Serious secret management is lesson 06-05; here just take away the minimum hygiene.

  1. The recommended ~/.bashrc for operator

Putting it all together, this is the block we add at the end of ~/.bashrc on Tramontana's administrative accounts. It applies the course convention: a backup before editing and diff -u afterwards.

operator@srv-tramontana:~$ cp ~/.bashrc ~/.bashrc.bak-$(date +%F)
operator@srv-tramontana:~$ nano ~/.bashrc
# --- Tramontana settings --------------------------------------------
umask 027                                    # nothing for "others"

HISTSIZE=10000
HISTFILESIZE=20000
HISTCONTROL=ignoreboth
HISTIGNORE='ls:ll:pwd:exit:history:clear'
HISTTIMEFORMAT='%F %T '
shopt -s histappend                          # do not overwrite between terminals
shopt -s checkwinsize cdspell

export EDITOR=nano
export PATH="$PATH:$HOME/scripts"            # our own scripts, at the end

alias ll='ls -lh --group-directories-first'
alias la='ls -lha'
alias grep='grep --color=auto'
alias df='df -h'
alias logs='cd /var/log/tramontana'
alias rel='ls -l /opt/tramontana/releases/'

PS1='\[\e[1;37;41m\] PROD \[\e[0m\] \[\e[1;32m\]\u@\h\[\e[0m\]:\[\e[1;34m\]\w\[\e[0m\]\$ '
# --------------------------------------------------------------------

It is activated without closing the session with source ~/.bashrc, and the change is verified with diff -u ~/.bashrc.bak-2026-08-18 ~/.bashrc. Note that umask 027 goes here and not in ~/.profile because we want it for interactive work; for services it is set somewhere else, and that is material for 05-05.

Common Mistakes and Tips

  • Spaces around the =. VAR = value is not an assignment. It is mistake number one.
  • Editing ~/.bashrc and expecting it to apply on its own. You have to run source ~/.bashrc or open a new session.
  • Breaking the PATH. Before touching it, save a copy: PATH_ORIG="$PATH". If you find yourself with no commands, export PATH="$PATH_ORIG" saves you without reconnecting.
  • Putting environment variables in ~/.bashrc. It works interactively and vanishes in non-interactive contexts. Their place is ~/.profile.
  • Creating ~/.bash_profile without knowing that it cancels ~/.profile. If you create it, include [ -f ~/.profile ] && . ~/.profile inside.
  • Trusting alias rm='rm -i'. It does not exist in scripts or in cron. The real safety net is looking with ls before the rm.
  • Forgetting \\[ \\] in the PS1 colours. The symptom is a prompt that becomes corrupted as you navigate the history.
  • Tip: env -i command starts a program with no variables at all. It is the best way to reproduce what cron sees, and you will come back to it in 03-07. And since set with no arguments lists variables and functions, always filter: set | grep '^HIST'.

Exercises

Exercise 1. Demonstrate on your VM the difference between a shell variable and an environment variable. Define DEPLOY=3.3.0 without exporting it, check that a child bash -c does not see it, export it, check that now it does, and explain why printenv failed before.

Exercise 2. Add /home/operator/scripts to the PATH in such a way that it does not take priority over the system commands and that the change survives reconnecting over SSH. Verify that it works without closing the session and explain which file you put it in and why.

Exercise 3. Generate a sorted, reproducible list of the houses in /home/operator/data/houses.txt that is identical on your laptop and on the server, even though they have different locales. Save the result in /home/operator/data/houses-sorted.txt and justify the decision.

Solutions

Solution 1.

operator@srv-tramontana:~$ DEPLOY=3.3.0
operator@srv-tramontana:~$ echo "$DEPLOY"
3.3.0
operator@srv-tramontana:~$ printenv DEPLOY; echo "exit code: $?"
exit code: 1
operator@srv-tramontana:~$ bash -c 'echo "child sees: [$DEPLOY]"'
child sees: []
operator@srv-tramontana:~$ export DEPLOY
operator@srv-tramontana:~$ bash -c 'echo "child sees: [$DEPLOY]"'
child sees: [3.3.0]

The variable existed in the current shell from the start — that is why echo displayed it — but it was not in the environment vector that gets copied to the children. printenv consults exactly that vector, so it did not find it and returned 1. export does not create the variable: it marks it for export, and from then on all new children receive it. Those already running do not: inheritance happens at the moment of the fork.

Solution 2. It must go in ~/.profile, because it is an environment variable and because ~/.profile is read by login shells, which is what SSH opens. At the end, not at the beginning, so that the system has priority.

operator@srv-tramontana:~$ cp ~/.profile ~/.profile.bak-$(date +%F)
operator@srv-tramontana:~$ printf '\n# Tramontana own scripts\nexport PATH="$PATH:$HOME/scripts"\n' >> ~/.profile
operator@srv-tramontana:~$ diff -u ~/.profile.bak-$(date +%F) ~/.profile
@@ -20,3 +20,6 @@
     fi
 fi
+
+# Tramontana own scripts
+export PATH="$PATH:$HOME/scripts"
operator@srv-tramontana:~$ source ~/.profile
operator@srv-tramontana:~$ echo "$PATH" | tr ':' '\n' | tail -2
/usr/games
/home/operator/scripts

source applies the change to the current session without reconnecting. If instead of that we had written PATH="$HOME/scripts:$PATH", a script of ours called df or tar would mask the system one, and that kind of surprise is hard to diagnose. Putting it at the end is the conservative option. Note too that the single quotes in the printf stop $PATH and $HOME from expanding as the file is written: we want the expansion to happen at each login, not now.

Solution 3.

operator@srv-tramontana:~$ LC_ALL=C sort /home/operator/data/houses.txt \
    > /home/operator/data/houses-sorted.txt
operator@srv-tramontana:~$ cat /home/operator/data/houses-sorted.txt
cal-ferrer
can-ventos
el-moli
la-solana
mas-figueres

The justification is the point of the exercise: without LC_ALL=C, the result depends on each machine's locale. With en_GB.UTF-8 the hyphen is ignored in the comparison and mas-figueres could end up in a different position relative to a hypothetical masfigueres; with C the comparison is byte by byte and the result is identical on any system. When the output is going to be compared, version-controlled or fed into another process, reproducibility matters more than the orthographic correctness of the ordering. And it goes in front of the command, with no export, so as not to disturb the rest of the session.

Conclusion

You have gone from inhabiting the shell environment to designing it.

  • The environment is a KEY=value vector that the kernel hands to each process; you read it in /proc/<pid>/environ.
  • You distinguish a shell variable from an environment variable: export makes the difference, and inheritance is one-way and at start-up, which is why a script cannot cd for you except with source.
  • You understand how Bash looks for an executable by walking the PATH from left to right, how to extend it without destroying it, and why . never goes in the PATH.
  • You know the real effect of the locale on sort and on dates, and you use LC_ALL=C when you need reproducible results.
  • You handle "$VAR" against $VAR and '$VAR' precisely, along with braces ${VAR}, the defaults ${VAR:-...} and ${VAR:?...}, and command substitution $(...).
  • You have untangled the mess of the start-up files: environment in ~/.profile, aliases and PS1 in ~/.bashrc, and none of it in a non-interactive shell.
  • You have made the production prompt red for an operational reason and configured a persistent history with histappend, knowing that passwords typed on the command line end up in a plain text file.

The next lesson takes the natural step. You already know how to describe one file by its path; now you will learn to describe sets of files and patterns of text. In Using Wildcards and Regular Expressions you will see the distinction that almost nobody explains properly — that globbing is done by the shell before the command runs, whereas regular expressions are interpreted by the program that receives them — and that distinction, which you will now understand because you already know how Bash processes a line before launching it, is what stops grep *.log from doing something completely different from what you expected.

Linux Course: From Beginner to System Administrator

Module 1: Introduction to Linux

Module 2: Basic Linux Commands

Module 3: Advanced Command-Line Skills

Module 4: Shell Scripting

Module 5: System Administration

Module 6: Networking and Security

Module 7: Advanced Topics

Module 8: Practical Projects

© Copyright 2026. All rights reserved