Featured image of post Managing Variables and Environment in Bash for DevOps

Managing Variables and Environment in Bash for DevOps

Learn how to manage variables in Bash: local variables, export, env, set/unset, .env files, getopts, IFS, and common special variables for DevOps scripts.

Why environment variables matter in Bash

In the previous article we split logic into functions so scripts are easier to reuse. Once a script starts running across multiple environments — local, staging, production, CI/CD runners — the next question is: where does configuration come from, and how should it be passed in?

Variables in Bash store temporary values inside a script. Environment variables pass configuration to child processes such as docker, kubectl, aws, curl, or the application launched by the script. If variables are managed poorly, a script can deploy to the wrong environment, leak secrets, or fail because required config is missing.


Local variables and assignment syntax

Bash variable assignment has no spaces around =:

1
2
3
APP_NAME="blog-api"
ENVIRONMENT="staging"
RETRY_COUNT=3

To read a variable, use $VAR or ${VAR}. In real scripts, ${VAR} is often clearer when concatenating strings:

1
2
echo "Deploying ${APP_NAME} to ${ENVIRONMENT}"
LOG_FILE="/var/log/${APP_NAME}.log"

A very common mistake is adding spaces:

1
APP_NAME = "blog-api"   # wrong: Bash treats APP_NAME as a command

When a value contains spaces or special characters, always quote the variable:

1
2
BACKUP_DIR="/opt/backups/blog api"
mkdir -p "${BACKUP_DIR}"

Unquoted variables can split paths into multiple arguments and cause hard-to-debug operational failures.


export: when a variable becomes an environment variable

A normal Bash variable only exists in the current shell. Child processes do not see it automatically:

1
2
APP_ENV="staging"
bash -c 'echo "APP_ENV=$APP_ENV"'   # empty

To make a child process read it, use export:

1
2
export APP_ENV="staging"
bash -c 'echo "APP_ENV=$APP_ENV"'   # APP_ENV=staging

In DevOps, export is commonly used before calling a CLI or starting an app:

1
2
3
4
export AWS_PROFILE="staging"
export KUBECONFIG="${HOME}/.kube/staging-config"

kubectl get pods

You can also pass a variable to a single command only:

1
APP_ENV="staging" ./run-migration.sh

This is cleaner and safer when the variable only needs to exist for one command.


Inspect, set, and remove variables with env, set, unset

A few useful commands when debugging a script environment:

1
2
3
4
env                  # print environment variables
printenv APP_ENV     # print a specific environment variable
set                  # print shell variables, functions, and environment variables
unset APP_ENV        # remove a variable

env is useful for checking what child processes will see. set is more detailed, but its output is long and may contain sensitive data, so avoid pasting the full output into tickets or logs.

Example for validating required variables:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
require_env() {
  local name="$1"

  if [[ -z "${!name:-}" ]]; then
    echo "ERROR: missing required env ${name}" >&2
    return 1
  fi
}

require_env "APP_ENV"
require_env "DATABASE_URL"

${!name} is indirect expansion: if name="APP_ENV", Bash reads the value of the APP_ENV variable.


Loading configuration from a .env file

A .env file keeps configuration separate from code:

1
2
3
APP_ENV=staging
APP_PORT=8080
BACKUP_DIR=/opt/backups/blog

The simplest way to load it:

1
2
3
set -a
source .env
set +a

set -a automatically exports variables assigned after it, so child processes can read them.

However, source .env executes the file content as Bash code. Only use it with files you control. Never source files uploaded by users or fetched from untrusted sources.

A safer pattern for deployment scripts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
ENV_FILE="${1:-.env}"

if [[ ! -f "${ENV_FILE}" ]]; then
  echo "ERROR: env file not found: ${ENV_FILE}" >&2
  exit 1
fi

set -a
source "${ENV_FILE}"
set +a

: "${APP_ENV:?APP_ENV is required}"
: "${APP_PORT:?APP_PORT is required}"

: "${VAR:?message}" makes the script stop immediately if the variable is unset or empty. This fail-fast pattern is very useful before running a deployment.


Reading CLI arguments with getopts

Not every config value belongs in .env. Values that change per run, such as target environment or dry-run mode, should be passed as flags:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
#!/usr/bin/env bash
set -euo pipefail

ENVIRONMENT="staging"
DRY_RUN="false"

while getopts ":e:n" opt; do
  case "${opt}" in
    e)
      ENVIRONMENT="${OPTARG}"
      ;;
    n)
      DRY_RUN="true"
      ;;
    ?)
      echo "Usage: $0 [-e environment] [-n]" >&2
      exit 2
      ;;
  esac
done

echo "environment=${ENVIRONMENT} dry_run=${DRY_RUN}"

Run it:

1
./deploy.sh -e production -n

getopts is a good fit for short options such as -e production and -n. If you need long options such as --environment, you can parse them manually with case, but keeping the format simple makes scripts easier to maintain.


$IFS: controlling how Bash splits strings

IFS means Internal Field Separator — the characters Bash uses for word splitting in some expansions and in the read command. By default, it contains space, tab, and newline.

When reading a file line by line, use this pattern to preserve leading/trailing spaces and avoid special backslash handling:

1
2
3
4
5
while IFS= read -r server; do
  [[ -z "${server}" || "${server}" == \#* ]] && continue
  echo "Checking ${server}"
  ssh "deploy@${server}" "hostname && uptime"
done < servers.txt

When splitting a simple CSV string:

1
2
3
4
5
6
TARGETS="web-01,web-02,web-03"
IFS=',' read -r -a servers <<< "${TARGETS}"

for server in "${servers[@]}"; do
  echo "Deploy to ${server}"
done

Avoid changing IFS globally unless you really need to. If you must change it, keep the change limited to one line or a small scope so it does not break another part of the script.


Common special variables

Bash has many special variables that are useful in operational scripts:

VariableMeaning
$0Name of the running script
$1, $2Positional arguments
$@All arguments; usually use "$@"
$#Number of arguments
$?Exit code of the last command
$$PID of the current shell
$!PID of the most recent background process
${BASH_SOURCE[0]}Path to the current script file in Bash

Example using $! and wait to track a background job:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
./long-healthcheck.sh &
healthcheck_pid=$!

echo "Healthcheck PID: ${healthcheck_pid}"

if wait "${healthcheck_pid}"; then
  echo "Healthcheck passed"
else
  echo "Healthcheck failed" >&2
  exit 1
fi

Example for finding the directory that contains the script, regardless of where you run it from:

1
2
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/lib/log.sh"

This pattern is useful when a script needs to load a config file or library located next to it.


DevOps example: deploy script with .env, flags, and validation

The example below simulates a small deployment script. It accepts the environment through -e, supports dry-run with -n, loads .env.<environment>, validates required variables, and then runs the deploy command.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#!/usr/bin/env bash
set -euo pipefail

ENVIRONMENT="staging"
DRY_RUN="false"

usage() {
  echo "Usage: $0 [-e staging|production] [-n]" >&2
}

while getopts ":e:n" opt; do
  case "${opt}" in
    e) ENVIRONMENT="${OPTARG}" ;;
    n) DRY_RUN="true" ;;
    ?) usage; exit 2 ;;
  esac
done

case "${ENVIRONMENT}" in
  staging|production) ;;
  *)
    echo "ERROR: unsupported environment: ${ENVIRONMENT}" >&2
    exit 2
    ;;
esac

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="${SCRIPT_DIR}/.env.${ENVIRONMENT}"

if [[ ! -f "${ENV_FILE}" ]]; then
  echo "ERROR: env file not found: ${ENV_FILE}" >&2
  exit 1
fi

set -a
source "${ENV_FILE}"
set +a

: "${APP_NAME:?APP_NAME is required}"
: "${IMAGE_TAG:?IMAGE_TAG is required}"
: "${DEPLOY_HOST:?DEPLOY_HOST is required}"

cmd=(ssh "deploy@${DEPLOY_HOST}" "docker service update --image ${APP_NAME}:${IMAGE_TAG} ${APP_NAME}")

printf 'Environment: %s\n' "${ENVIRONMENT}"
printf 'Command: %q ' "${cmd[@]}"
printf '\n'

if [[ "${DRY_RUN}" == "true" ]]; then
  echo "Dry-run mode: command was not executed"
  exit 0
fi

"${cmd[@]}"

A .env.staging file can look like this:

1
2
3
APP_NAME=blog-api
IMAGE_TAG=2026.06.24
DEPLOY_HOST=webserver-01

Key points in this example:

  • Do not hardcode host names or image tags in the script.
  • Validate the allowed environment before sourcing a file.
  • Use set -a to export config for child processes if needed.
  • Use dry-run mode to inspect the command before executing it.
  • Quote variables when building paths and arguments.

Common mistakes

  • Spaces around = in assignment: NAME = value is invalid in Bash.
  • Not quoting variables: Paths with spaces or glob characters such as * can make scripts behave incorrectly.
  • Exporting too much: Only export variables child processes need to read.
  • Sourcing untrusted .env files: With source, .env is Bash code and can execute commands.
  • Logging secrets: Avoid set -x around token/password handling.
  • Using $1 directly with set -u: Use ${1:-} or validate first.
  • Changing IFS globally: It can break loops or argument parsing later in the script.

Implementation notes

  • When applying this to your own project, classify configuration:
    • Values fixed per environment → .env.staging, .env.production, or a separate config file.
    • Values that change per run → CLI flags through getopts.
    • Secrets → environment variables from a CI/CD secret store or vault; do not commit them to git.
  • Best practices:
    • Enable set -u to catch undeclared variables, but use ${VAR:-} for optional variables.
    • Validate required variables early with : "${VAR:?message}".
    • Use printenv VAR to debug one variable instead of printing the whole environment.
    • Prefix app-specific variables, for example BLOG_APP_ENV, to avoid name collisions.
    • Do not commit .env files containing secrets; commit only .env.example.
  • Troubleshooting:
    • Child process cannot see a variable? → Check whether you used export.
    • Script works locally but fails in cron/CI? → Check PATH, working directory, and runner-provided environment variables.
    • .env does not load correctly? → Check that the file uses valid Bash syntax and has no spaces around =.

🎯 Conclusion

Good variable management makes Bash scripts more stable across environments. Keep code and config separate, validate required variables before real operations, quote variables when using them in commands, and export only what child processes need.

In the next article, we will cover cron jobs with Bash: schedule syntax, crontab, @daily, @reboot, cron logs, PATH issues, and automated backup/healthcheck examples. 🚀


References