Featured image of post Loops in Bash: for, while, and until for DevOps Automation

Loops in Bash: for, while, and until for DevOps Automation

Learn how to use for, while, until, break, and continue in Bash. Includes practical DevOps examples for iterating over server lists, reading logs line by line, and retrying failed commands.

Bash Loops for DevOps

In the previous article we used conditionals so scripts could branch based on system state. In DevOps work, many tasks do not run only once: checking multiple servers, reading logs line by line, retrying a command that may fail temporarily, or processing batches of configuration files.

Loops in Bash let you repeat a block of commands over a list, while a condition is true, or until a condition becomes true. This article covers for, while, until, break, continue, and practical examples that are close to daily operations work.


for in — iterate over a list

According to the GNU Bash Manual, the for name in words; do ...; done form expands the list of words, then runs the command block once for each item. On each iteration, the variable name receives the current value.

Common syntax:

1
2
3
for item in item1 item2 item3; do
  echo "Processing: ${item}"
done

Example — quickly check multiple hosts with ping:

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

HOSTS=("web-01" "web-02" "db-01")

for HOST in "${HOSTS[@]}"; do
  echo "Checking ${HOST}..."
  if ping -c 1 -W 2 "${HOST}" >/dev/null 2>&1; then
    echo "OK: ${HOST} reachable"
  else
    echo "WARN: ${HOST} unreachable"
  fi
done

The important part is using "${HOSTS[@]}" instead of bare ${HOSTS[@]}. This quoting keeps each array element intact, even when a value contains spaces.


C-style for — loop with a counter

Bash also supports for (( expr1; expr2; expr3 )), similar to the C language. This form is useful when you need an explicit counter: run exactly N times, number retry attempts, or generate filenames by index.

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

for (( i = 1; i <= 5; i++ )); do
  echo "Attempt ${i}/5"
done

Example — create 3 demo log files for a test environment:

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

LOG_DIR="/tmp/bash-loop-demo"
mkdir -p "${LOG_DIR}"

for (( i = 1; i <= 3; i++ )); do
  LOG_FILE="${LOG_DIR}/app-${i}.log"
  echo "$(date -Is) demo log ${i}" > "${LOG_FILE}"
  echo "Created ${LOG_FILE}"
done

Inside (( ... )), you can use familiar arithmetic operators such as <=, ++, and +=. Variable names do not need a leading $ there.


while — loop while a condition is true

while runs a command block as long as the test command returns exit code 0. It is a good choice when you do not know the number of iterations in advance: reading a file to the end, waiting for a service to become ready, or polling an endpoint.

Syntax:

1
2
3
while <test_command>; do
  <commands_to_repeat>
done

Example — read a log file line by line and filter error lines:

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

LOG_FILE="${1:-/var/log/app/app.log}"

if [[ ! -r "${LOG_FILE}" ]]; then
  echo "ERROR: Cannot read log file: ${LOG_FILE}"
  exit 1
fi

while IFS= read -r LINE; do
  if [[ "${LINE}" == *"ERROR"* ]]; then
    echo "Found error: ${LINE}"
  fi
done < "${LOG_FILE}"

IFS= read -r LINE is the recommended pattern when reading lines:

  • IFS= prevents Bash from trimming leading or trailing whitespace.
  • read -r preserves \ characters instead of treating them as escapes.
  • done < "${LOG_FILE}" feeds the file into the loop’s stdin, avoiding an unnecessary cat.

until — loop until a condition becomes true

until is close to while, but the logic is reversed: it runs the command block while the test command returns non-zero, and stops when the condition returns 0.

Syntax:

1
2
3
until <test_command>; do
  <commands_to_repeat>
done

Example — wait for a healthcheck endpoint to become ready:

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

HEALTH_URL="${1:-http://localhost:8080/health}"
MAX_WAIT_SECONDS="30"
WAITED="0"

until curl -fsS "${HEALTH_URL}" >/dev/null; do
  if (( WAITED >= MAX_WAIT_SECONDS )); then
    echo "ERROR: Service is not healthy after ${MAX_WAIT_SECONDS}s"
    exit 1
  fi

  echo "Waiting for service healthcheck... (${WAITED}s)"
  sleep 2
  WAITED=$(( WAITED + 2 ))
done

echo "OK: Service is healthy"

until reads naturally in “repeat until success” situations. If your team finds while ! command; do ... easier to understand, using while is also completely fine.


break and continue

break exits the current loop. continue skips the rest of the current iteration and moves to the next one. According to the GNU Bash Manual, both can be used in for, while, until, and select.

Example — skip non-.log files and stop when a log is too large:

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

for FILE in /var/log/*.log; do
  [[ -e "${FILE}" ]] || continue

  if [[ "${FILE}" != *.log ]]; then
    continue
  fi

  SIZE_MB="$(du -m "${FILE}" | awk '{print $1}')"

  if (( SIZE_MB > 1024 )); then
    echo "Stop: ${FILE} is larger than 1GB"
    break
  fi

  echo "Process ${FILE} (${SIZE_MB}MB)"
done

The line [[ -e "${FILE}" ]] || continue handles the case where the glob /var/log/*.log matches no files. Without it, Bash may keep the pattern as a literal string, so you should check that the file exists before processing it.


DevOps practice: iterate over an SSH server list

This example reads a server list from a file, connects to each host with SSH, and runs a short check command. The server names, user, and command use generic placeholders and are not tied to a real environment.

Create servers.txt:

1
2
3
webserver-01
webserver-02
worker-01

Create check_servers.sh:

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

SERVER_FILE="${1:-servers.txt}"
SSH_USER="${SSH_USER:-deploy}"
REMOTE_COMMAND="uptime"

if [[ ! -r "${SERVER_FILE}" ]]; then
  echo "ERROR: Cannot read server file: ${SERVER_FILE}"
  exit 1
fi

while IFS= read -r SERVER; do
  [[ -n "${SERVER}" ]] || continue
  [[ "${SERVER}" != \#* ]] || continue

  echo "===== ${SERVER} ====="
  if ssh -o BatchMode=yes -o ConnectTimeout=5 "${SSH_USER}@${SERVER}" "${REMOTE_COMMAND}"; then
    echo "OK: ${SERVER}"
  else
    echo "WARN: command failed on ${SERVER}"
  fi
  echo
done < "${SERVER_FILE}"

Try it:

1
2
chmod +x check_servers.sh
./check_servers.sh servers.txt

A few notable points:

  • BatchMode=yes prevents SSH from asking for a password or passphrase in automation. If the key is not ready, the command fails instead of hanging the script.
  • ConnectTimeout=5 avoids waiting too long when a host does not respond.
  • Blank lines and comment lines starting with # are skipped with continue.
  • Do not hardcode sensitive users: the script reads SSH_USER from an environment variable and defaults to deploy.

DevOps practice: retry a failed command

In operations, some failures are temporary: unstable network, an unresponsive registry, or an endpoint that has just restarted. Loops let you retry in a controlled way instead of rerunning commands manually.

 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

URL="${1:-https://example.com/health}"
MAX_RETRIES="5"
SLEEP_SECONDS="3"

for (( attempt = 1; attempt <= MAX_RETRIES; attempt++ )); do
  echo "Attempt ${attempt}/${MAX_RETRIES}: ${URL}"

  if curl -fsS "${URL}" >/dev/null; then
    echo "OK: endpoint is reachable"
    exit 0
  fi

  if (( attempt == MAX_RETRIES )); then
    echo "ERROR: endpoint is still unreachable after ${MAX_RETRIES} attempts"
    exit 1
  fi

  sleep "${SLEEP_SECONDS}"
done

Here, curl -f returns an error for HTTP status codes 4xx/5xx, while -sS keeps output quiet but still prints errors on failure. The final exit code tells a pipeline whether it should continue or stop.


Common mistakes

  • Looping over ls output: for FILE in $(ls) breaks easily when filenames contain spaces or special characters. Use a glob (for FILE in *.log) or find ... -print0 for more complex cases.
  • Forgetting to quote variables: Always use "${VAR}", especially for paths, hostnames, or data read from files.
  • Using a pipe that loses variables outside the loop: cat file | while read ... often runs the loop in a subshell, so variable changes inside it may disappear after the loop. Prefer while ...; done < file.
  • Infinite loops without a stopping condition: When using while true, always include break, a timeout, or a maximum attempt count.
  • Not handling blank lines or comments: When reading server lists or config files, skip blank lines and comments to make scripts more robust.

Implementation notes

  • When applying this to your own project, first identify whether the loop is driven by a list or by state:
    • Existing list → usually use for.
    • Read line by line or wait while a condition remains true → usually use while.
    • Wait until a command succeeds → consider until.
  • Best practices:
    • Use IFS= read -r when reading files line by line.
    • Quote arrays as "${ARRAY[@]}" when iterating over lists.
    • For retry logic, always define clear MAX_RETRIES and SLEEP_SECONDS values.
    • For SSH automation, add timeouts and avoid interactive prompts.
  • Troubleshooting:
    • Loop does not run? → Check whether the input list is empty or whether the glob matches any files.
    • Script stops halfway with set -e? → A command inside the loop returned non-zero without being wrapped in if, ||, or explicit exit-code handling.
    • Reading a file loses whitespace? → Make sure you use IFS= read -r LINE.

🎯 Conclusion

Loops are the foundation that moves Bash from one-off scripts to real automation: handling many servers, many files, many log lines, and multiple retry attempts. When you combine for, while, and until with the conditionals from the previous article, you can already write small but useful DevOps scripts for checks, deployments, and daily operations.

In the next article, we will cover file handling in Bash: reading, writing, redirecting output, using tee, find, xargs, and safer patterns for managing logs and configuration files. 🚀


References