Featured image of post Functions in Bash: Reuse Code Effectively for DevOps

Functions in Bash: Reuse Code Effectively for DevOps

Learn how to write functions in Bash: syntax, parameters, local variables, return codes, sourcing other files, and building a reusable logging library for DevOps scripts.

Bash Functions for DevOps

In the previous article we used grep, awk, and sed to process logs and configuration files. As scripts grow longer, a new problem appears: repeated blocks of code — checking files, writing logs, validating environment variables, retrying commands, or creating backups before editing config.

Functions let you group a set of commands into a named block that can be called many times. For DevOps work, this is an important step from scripts that merely “work” to scripts that are easier to read, test, and maintain.


Declaring a Function in Bash

Bash supports two common function declaration styles:

1
2
3
4
5
6
7
log_info() {
  echo "INFO: $1"
}

function log_error {
  echo "ERROR: $1" >&2
}

In practice, the name() { ...; } style is used often because it is short and more portable across POSIX-like shells. In this article we focus on Bash, so both styles work.

Call a function like a command:

1
2
log_info "Starting deploy"
log_error "Deploy failed"

Pay attention to spaces and the ; when writing a one-line function:

1
say_hello() { echo "hello"; }

When writing multiple lines, the ; before } is not required because the newline already ends the command.


Passing Parameters with $1, $@, and $#

Inside a function, Bash uses positional parameters just like a script:

  • $1, $2, …: the first, second, … argument.
  • $@: all arguments, preserving each argument separately when quoted as "$@".
  • $#: the number of arguments.
  • $0: the script name, not the function name.

Example function for checking a service:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
check_service() {
  local service_name="$1"

  if systemctl is-active --quiet "${service_name}"; then
    echo "OK: ${service_name} is running"
  else
    echo "ERROR: ${service_name} is not running" >&2
    return 1
  fi
}

check_service "nginx"
check_service "docker"

When you need to pass all arguments to another command, use "$@":

1
2
3
4
5
6
run_cmd() {
  echo "+ $*"
  "$@"
}

run_cmd ls -lah /var/log

"$@" preserves argument boundaries. It is safer than $* when arguments contain spaces.


Validating Input Parameters

A function should validate required parameters early so errors are clear.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
require_arg() {
  local name="$1"
  local value="${2:-}"

  if [[ -z "${value}" ]]; then
    echo "ERROR: Missing required argument: ${name}" >&2
    return 2
  fi
}

backup_file() {
  local file_path="${1:-}"

  require_arg "file_path" "${file_path}" || return $?

  if [[ ! -f "${file_path}" ]]; then
    echo "ERROR: Not a regular file: ${file_path}" >&2
    return 1
  fi

  cp -- "${file_path}" "${file_path}.bak.$(date +%Y%m%d%H%M%S)"
}

Using ${1:-} avoids an “unbound variable” error when the script enables set -u and the function is called without enough arguments.


Return Value in Bash Means Exit Code

A Bash function does not return string data like many programming languages. In Bash, return returns an exit code from 0 to 255:

  • 0: success.
  • Non-zero: error or a special state.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
is_readable_file() {
  local file_path="$1"
  [[ -f "${file_path}" && -r "${file_path}" ]]
}

if is_readable_file "./app.env"; then
  echo "app.env is readable"
else
  echo "Cannot read app.env" >&2
fi

In this example, the function does not need an explicit return. The exit code of the last command ([[ ... ]]) becomes the function exit code.

If you want a function to “return data”, print it to stdout and use command substitution:

1
2
3
4
5
get_timestamp() {
  date +%Y%m%d%H%M%S
}

backup_name="app.env.$(get_timestamp).bak"

A practical rule: use exit codes for success/failure, and stdout for data.


Use Local Variables to Avoid Side Effects

By default, variables inside Bash functions are global within the current shell. Use local to limit a variable to the function scope.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
build_backup_path() {
  local source_file="$1"
  local timestamp
  local base_name

  timestamp="$(date +%Y%m%d%H%M%S)"
  base_name="$(basename "${source_file}")"

  echo "./backup/${base_name}.${timestamp}.bak"
}

Without local, variables like timestamp or base_name can accidentally overwrite variables with the same name elsewhere in the script. This becomes hard to debug as scripts grow.

A good habit is to declare local near where the variable is used and quote variables when passing them to commands:

1
2
local target_dir="${1:-./backup}"
mkdir -p "${target_dir}"

Split Functions into Another File with source

When multiple scripts need the same logging, validation, or retry logic, you can move functions into a small library file.

Example structure:

1
2
3
4
scripts/
├── deploy.sh
└── lib/
    └── log.sh

File scripts/lib/log.sh:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#!/usr/bin/env bash

log_info() {
  printf '[INFO] %s\n' "$*"
}

log_warn() {
  printf '[WARN] %s\n' "$*" >&2
}

log_error() {
  printf '[ERROR] %s\n' "$*" >&2
}

File scripts/deploy.sh:

1
2
3
4
5
6
7
8
9
#!/usr/bin/env bash
set -euo pipefail

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

log_info "Starting deploy"
log_warn "This is a warning message"
log_error "This is an error message"

source runs a file in the current shell, so functions from log.sh become available to deploy.sh. In Bash, ${BASH_SOURCE[0]} is better than $0 for locating the current script file when the file is sourced or called from another directory.


DevOps Practice: A Small Logging Library

A minimal logging library should include timestamp, level, and proper stdout/stderr separation.

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

_log() {
  local level="$1"
  shift

  printf '%s [%s] %s\n' "$(date -Is)" "${level}" "$*"
}

log_info() {
  _log "INFO" "$@"
}

log_warn() {
  _log "WARN" "$@" >&2
}

log_error() {
  _log "ERROR" "$@" >&2
}

Naming the internal function _log is a simple convention that signals “do not call this directly from outside.” Bash does not have true private functions, so this is only a convention.

Use it in a healthcheck script:

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

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

check_url() {
  local url="$1"

  if curl --fail --silent --show-error --max-time 5 "${url}" > /dev/null; then
    log_info "Healthcheck OK: ${url}"
  else
    log_error "Healthcheck failed: ${url}"
    return 1
  fi
}

check_url "https://example.com/health"

Here, curl --fail makes HTTP 4xx/5xx responses count as errors, --silent --show-error reduces noise while still showing useful errors, and --max-time prevents the script from hanging too long.


DevOps Practice: Retry a Command with a Function

Retry is a very common pattern when calling APIs, pulling images, checking a service after restart, or running commands that can fail temporarily.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
retry() {
  local max_attempts="$1"
  local delay_seconds="$2"
  shift 2

  local attempt=1

  while (( attempt <= max_attempts )); do
    if "$@"; then
      return 0
    fi

    echo "WARN: attempt ${attempt}/${max_attempts} failed: $*" >&2

    if (( attempt == max_attempts )); then
      return 1
    fi

    sleep "${delay_seconds}"
    ((attempt++))
  done
}

retry 5 3 curl --fail --silent --show-error https://example.com/health

Important details:

  • shift 2 removes the two configuration arguments, leaving the command to run.
  • "$@" runs the command while preserving argument boundaries.
  • The function returns 0 as soon as the command succeeds, or 1 after all attempts fail.

You can combine it with the logging library:

1
2
3
4
5
6
if retry 5 3 curl --fail --silent --show-error https://example.com/health; then
  log_info "Service is healthy"
else
  log_error "Service is still unhealthy after retry"
  exit 1
fi

DevOps Practice: A Deploy Script with Clear Functions

This small deploy script splits each step into a function so the flow is easier to read:

 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
#!/usr/bin/env bash
set -euo pipefail

APP_DIR="${APP_DIR:-/opt/example-app}"
SERVICE_NAME="${SERVICE_NAME:-example-app}"

log_info() {
  printf '%s [INFO] %s\n' "$(date -Is)" "$*"
}

log_error() {
  printf '%s [ERROR] %s\n' "$(date -Is)" "$*" >&2
}

require_dir() {
  local dir_path="$1"

  if [[ ! -d "${dir_path}" ]]; then
    log_error "Directory not found: ${dir_path}"
    return 1
  fi
}

pull_latest_code() {
  git -C "${APP_DIR}" pull --ff-only
}

restart_service() {
  sudo systemctl restart "${SERVICE_NAME}"
}

verify_service() {
  systemctl is-active --quiet "${SERVICE_NAME}"
}

main() {
  require_dir "${APP_DIR}"
  log_info "Pulling latest code"
  pull_latest_code

  log_info "Restarting ${SERVICE_NAME}"
  restart_service

  log_info "Verifying ${SERVICE_NAME}"
  if verify_service; then
    log_info "Deploy completed"
  else
    log_error "Service is not active after restart"
    return 1
  fi
}

main "$@"

The main "$@" pattern gives the script a clear entrypoint. Functions above it define behavior, while main describes the primary execution flow.


Common Mistakes

  • Forgetting local: Variables inside functions can overwrite global variables unexpectedly.
  • Using return "text": return is only for numeric exit codes; use echo/printf to stdout for text data.
  • Not quoting "$@": Command wrappers can break when arguments contain spaces.
  • Calling a function before declaring it: Bash reads and executes from top to bottom; a function must be defined before it is called.
  • Using $1 directly with set -u: If the argument is missing, the script fails immediately. Use ${1:-} and then validate.
  • Writing error logs to stdout: Warnings and errors should go to stderr (>&2) so stdout remains available for data or pipelines.

Implementation Notes

  • When applying this to your own project, start with small functions around the parts you repeat most:
    • log_info, log_warn, log_error.
    • require_file, require_dir, require_env.
    • retry for commands that may fail temporarily.
    • backup_file before editing config.
  • Best practices:
    • Use local for variables inside functions.
    • Use return for success/failure state, and stdout for data.
    • Use "$@" when a wrapper function calls another command.
    • Put shared libraries in scripts/lib/*.sh and load them with source using a path based on ${BASH_SOURCE[0]}.
    • Keep each function focused on one clear responsibility; if a function grows too long, split it again.
  • Troubleshooting:
    • Function returns the wrong status? → Print $? right after calling it or run bash -x script.sh.
    • Variable changed unexpectedly? → Check whether a function is missing local.
    • source cannot find the file? → Check SCRIPT_DIR and test the script from another directory.

🎯 Conclusion

Functions are the natural next step when you write Bash for DevOps beyond a few lines. They help reuse logic, centralize logging/validation/retry code, reduce copy-paste, and make deploy or healthcheck flows easier to understand.

In the next article, we will explore variables and environment management in Bash: local variables vs export, .env, getopts, $IFS, and special variables such as $?, $$, and $!. 🚀


References