Variables, quoting & command substitution

Store values, capture command output, quote from day one.

Beginner18 min · lesson 2 of 14

A variable in Bash (the Bourne Again Shell, the program that reads what you type and runs it) is a labeled box. You put a value in the box once, give the box a name, and every command after that reads the value by name instead of you retyping it. Store a server address in a box called `host`, and twenty later commands can point at it without one repeated keystroke. That sounds harmless, and mostly it is. In operations work it is also where a real share of outages begins, because the rules for filling and reading these boxes are stricter than they look, and Bash rarely warns you when you break one. This lesson covers the four moves you make every day: putting a value in a box, reading it back safely, telling the two kinds of quotes apart, and capturing the output of a command into a box of its own.

Put a value in a box: watch the spaces

To fill a box, write the name, an equals sign, then the value, with nothing touching the equals sign on either side. `greeting="hello"` works. `greeting = "hello"` does not, and the reason catches almost everyone the first time. In most languages you have seen, spaces around an equals sign are only there for readability. Bash reads a space differently. To Bash, a space is the wall between a command and its arguments, so the instant it sees `greeting` followed by a space it decides `greeting` is a program you want to run, and `=` and `"hello"` are arguments you are passing to it. No program named `greeting` exists, so you get a puzzling error about a command not being found.

~/secopslog — bash
$ greeting="hello" # correct: nothing touches the = echo "$greeting" greeting = "hello" # wrong: a space on each side of the =
hello bash: greeting: command not found

The first line stored the value and printed nothing, which is exactly right; assignments are silent. The second line read the box back and printed `hello`. The third line, with its stray spaces, was treated as an attempt to run a command called `greeting`. Keep the equals sign tight against the name and the value, and this whole family of errors never appears.

Read it back: $VAR and ${VAR}

To read a box, put a dollar sign in front of its name: `$greeting`. The dollar sign tells Bash to look up that name and paste in its value, a step called expansion. The plain form is fine most of the time. The braced form, `${greeting}`, does the same lookup but marks exactly where the name ends, which starts to matter the moment you want to glue a variable directly onto more text.

~/secopslog — bash
$ env="prod" echo "$env_server" # Bash looks for a box named env_server echo "${env}_server" # braces mark where the name ends
prod_server

`$env_server` failed without a sound. Bash went looking for a box named `env_server`, found none, and pasted in nothing, so the first line printed blank. `${env}_server` told Bash the name is `env` and the literal text `_server` comes after it, giving you `prod_server`. When you are unsure where a name ends, use the braces; two characters buy you complete clarity.

Single quotes stay literal, double quotes expand

Quotes in Bash are not decoration, and the two kinds are not interchangeable. They are two different switches. Double quotes leave expansion on: anything with a dollar sign inside them gets looked up and replaced by its value. Single quotes turn expansion fully off: whatever sits between them arrives as the exact characters you typed, dollar signs included. This one difference decides whether `$user` becomes `alice` or stays the literal text `$user`.

~/secopslog — bash
$ user="alice" echo "Logged in as $user" echo 'Logged in as $user'
Logged in as alice Logged in as $user

Same word, same variable, opposite results. The double-quoted line looked up `user` and printed `alice`. The single-quoted line printed the dollar sign and the name as plain text, because single quotes told Bash to keep its hands off. Single quotes earn their place often: a password that contains a `$`, a snippet of code you are handing to another tool, any string where a literal dollar sign is the whole point.

Why you write "$VAR", not $VAR

Build this habit from your first day: wrap a variable read in double quotes by default, `"$file"`, not bare `$file`. The reason is a behavior called word splitting. After Bash pastes a value in, it runs a pair of scissors over the result and cuts it into separate arguments at every space, tab, or newline it finds. If the value holds no spaces, you never notice. The first day it does, one argument quietly becomes several, and a command aimed at a single thing acts on several things instead.

~/secopslog — bash
$ file="q3 report.txt" touch "$file" # quoted: one filename, space and all ls rm $file # unquoted: Bash splits on the space
'q3 report.txt' rm: cannot remove 'q3': No such file or directory rm: cannot remove 'report.txt': No such file or directory

Read that failure closely. `touch "$file"` created one file whose name contains a space, and modern `ls` prints such names inside quotes so the boundary is visible. `rm $file`, left unquoted, reached `rm` as two separate words, `q3` and `report.txt`, so it tried to delete two files that do not exist and left the real one untouched. Here it failed out loud. Put two real files named `q3` and `report.txt` next to it and the unquoted line would have deleted the wrong ones with a clean exit code and no complaint. For anyone doing DevSecOps (Development, Security, and Operations) work, that gap between what a line looks like and what it actually does is where a hostile filename or an unexpected value turns an ordinary script into a weapon. Closing it costs one pair of quotes.

Command substitution: capture a command's output

Some values you can type yourself. Others you only learn by asking another program: today's date, the name of this machine, how many files sit in a folder. Command substitution is the way you ask and keep the answer. Write `$(command)`, and Bash runs the command, collects everything it sends to standard output (the normal output stream, usually your screen), trims the trailing newline off the end, and hands that text back as a value you can store in a box like any other.

~/secopslog — bash
$ today=$(date +%F) host=$(hostname) me=$(whoami) echo "$me on $host, $today"
deploy on web-01, 2026-07-17

Three commands, three captured values. `date +%F` printed the date in year-month-day form, `hostname` printed the machine's name, and `whoami` printed the account the shell is running as. Each value landed in a box, and the final `echo` stitched them into one line. Stamping who ran something, on which host, and when is a daily operations pattern: it is how log lines, backup filenames, and audit records get their context.

audit.sh
#!/usr/bin/env bash
conf_count=$(find /etc -maxdepth 1 -name '*.conf' | wc -l)
echo "$(whoami) on $(hostname): $conf_count .conf files in /etc"
~/secopslog — bash
$ ./audit.sh
deploy on web-01: 4 .conf files in /etc

This script counts before it reports. `find /etc -maxdepth 1 -name '*.conf'` lists the configuration files sitting directly inside `/etc`, and piping that list into `wc -l` (word count, told to count lines) collapses it to a single number. That number, captured in `conf_count`, drops into the report line beside two more substitutions run inline. You can nest `$(...)` right inside a double-quoted string and it still expands, because double quotes keep expansion switched on.

Shell variables versus environment variables

Every box so far has been a shell variable: it lives inside the one shell that created it and disappears when that shell closes. Its quiet limit is that child processes cannot see it. When your script runs another program, that program starts as a child process (a separate running program that your shell launches), and it inherits only the boxes you have marked for sharing. Marking a box for sharing is the entire job of `export`. An exported box is an environment variable, and every child the script starts receives its own copy.

~/secopslog — bash
$ token="s3cr3t" # a shell variable, private to this shell bash -c 'echo "child sees: [$token]"' # start a child process export token # promote it to the environment bash -c 'echo "child sees: [$token]"'
child sees: [] child sees: [s3cr3t]

The first child saw an empty box, because `token` was still a plain shell variable that stayed private to the parent. After `export token`, the second child got a copy and printed the value. That is the whole mechanism behind familiar names like `PATH` and `HOME`: a parent shell exports them once, and every program launched underneath inherits them. Programs read their configuration from the environment for exactly this reason, because it flows downhill to children with nobody passing it along by hand.

Shell variable vs environment variable
Shell variable (name=value)
Lives in this shell only
private to the process that set it
Child processes see nothing
a launched program reads an empty value
Gone when the shell exits
nothing survives the session
Environment variable (export name)
Copied into every child
each launched program gets its own copy
Readable via /proc and ps
visible to whoever can inspect the process
How PATH and HOME travel
the standard way config reaches programs
One word, `export`, is the entire difference between a value your shell keeps to itself and a value every child process inherits.

That downhill flow is convenient, and with secrets it is dangerous. An exported value does not reach only the one program you meant to give it to; it reaches every child the script starts, including tools you did not write and cannot inspect.

An exported secret leaks to every child process
Once you `export` a variable, every program your script launches inherits a copy of it, and on Linux the value sits in plain text in that process's environment. Anyone who can read `/proc/<process-id>/environ` for a running process, or who can run `ps eww`, can read an exported password or API (Application Programming Interface) token straight out of it. Reach for `export` for ordinary configuration like a region name or a log level. For a secret, hand it to the one program that needs it through a file with tight permissions or through standard input, so it never becomes an environment variable the whole process tree can see.
Quick check
01In a script you run `count=$(ls /etc | wc -l)`, then `echo 'Found $count files'` with single quotes. What does that echo print?
Correct — Single quotes switch off expansion, so Bash prints the dollar sign and the name as literal text. Use double quotes, "Found $count files", when you want the captured number.
Incorrect — That is what double quotes would give. Single quotes never expand $count, so the value is not substituted here.
Incorrect — $count is defined; the command substitution ran and stored a number. Single quotes just stop echo from reading it, printing the literal text instead.
Incorrect — There is no error. The substitution already finished on the previous line; the quote style only affects whether echo expands the variable.
02A script sets `api_key='abc123'` without using export, then launches an external tool as a child process. Can that tool read api_key from its environment?
Incorrect — only exported variables are copied into children; a plain assignment is not.
Correct — export is the single step that promotes a shell variable to an environment variable that children inherit.
Incorrect — inheritance depends on export, not on what language the child program is written in.
Incorrect — the value persists in the current shell; it just is not shared with children until exported.
03A backup script contains `dir="/srv/app data"` then `tar -czf out.tgz $dir`, and /srv really holds a directory named 'app data'. What goes wrong?
Incorrect — that is what quoting would do; left unquoted, the value is split first.
Incorrect — the quoted assignment stores '/srv/app data' correctly; the problem is on the tar line.
Incorrect — the flag order is fine; the failure comes from splitting the directory name.
Correct — Bash splits the expanded value at the space; wrapping it as "$dir" keeps it a single argument.

Related