DevOps Nimbus Bash

Bash errors, explained

What each error means, the causes that print it, a check that tells you which one you have, the fix for that cause and how to prevent it. Then learn it for good: break and fix each one yourself in the browser playground, or search them in Troubleshoot.

Running scripts

command not found

./deploy.sh: line 5: ehco: command not found

What it means

Bash tried to run the word at the start of the command. It looked for a function, then a builtin, then a program of that name in each directory of $PATH, and found none. ehco is the word it could not find; line 5 is where.

What you see

A script or the terminal stops on a word it cannot run. The exit status is 127.

Causes, most likely first

  1. A typo in the command name. Bash runs exactly the word you typed, so ehco is looked up as a program and not found.

    Check

    type -a <name>   # prints nothing: no function, builtin or program by that name anywhere
    command -v <correct-name>   # prints a path: the program exists and the name was wrong

    Fix

    Correct the name.

  2. The program is installed outside $PATH (for example in /usr/local/bin when cron runs the script). The search only looks in the directories listed in $PATH, so a program elsewhere is invisible.

    Check

    echo "$PATH"   # the directories searched; compare with where the program lives
    ls -l /usr/local/bin/<name> /opt/*/bin/<name> 2>/dev/null   # the program exists, just not in PATH

    Fix

    Call it by its full path, or set PATH at the top of the script: PATH=/usr/local/bin:/usr/bin:/bin. If it is not installed at all, install it.

  3. Spaces around = in an assignment: NAME = value. With spaces, bash reads NAME as a command and = and value as its arguments.

    Check

    grep -nE '^[[:space:]]*[A-Za-z_][A-Za-z0-9_]* +=' <script>   # a line here: an assignment with spaces

    Fix

    Remove the spaces: NAME=value.

  4. A script in the current directory run without ./. The current directory is not in $PATH, so deploy.sh is not found even when it is right there.

    Check

    ls -l ./<name>   # the file is here: run it as ./<name>

    Fix

    Run it as ./deploy.sh, or by its full path.

  5. A function defined in your terminal, which child scripts never see. Functions live in the shell that defined them; a script starts a new bash without them.

    Check

    type <name>   # 'is a function' in your terminal; run the same in the script with bash -c 'type <name>'

    Fix

    Put the function in a file and source it from the script, or turn it into a script on $PATH.

  6. The command runs under sudo, which replaces your PATH with its secure_path. The message then reads sudo: <name>: command not found.

    Check

    sudo printenv PATH   # the PATH sudo really uses; the program's directory is missing from it

    Fix

    Call the program by its full path under sudo, rather than widening secure_path.

Prevent

Set PATH explicitly in scripts that run from cron or CI, and check required tools up front with command -v.

Now learn it: break and fix it yourself in command not found · Spaces around = · Works in the terminal, not in cron, then take Level 00 · Your first script. Open in Troubleshoot

Permission denied

bash: ./report.sh: Permission denied

What it means

Bash found the file but the kernel refused to run it as you. ./report.sh is the file it tried to run, and the exit status is 126: found but not executable. The cause is the file's mode, the mount it is on, or a directory on the way to it.

What you see

Running a script by its path fails at once with exit status 126.

Causes, most likely first

  1. The script has no execute bit (-rw-r--r--). The kernel only runs files marked executable for you.

    Check

    ls -l <script>   # no x in -rw-r--r--: this is your cause

    Fix

    chmod +x script.sh, and commit it with the bit: git update-index --chmod=+x script.sh.

  2. The file system is mounted noexec, as /tmp is on many hardened systems. On such a mount the kernel refuses to run any file, whatever its mode.

    Check

    findmnt -no OPTIONS --target <script>   # noexec in the list: this is your cause

    Fix

    Run it through the interpreter, bash script.sh, or move the script to a normal directory. Do not remount /tmp without noexec to make it work.

  3. A directory on the path cannot be entered. To reach a file you need x on every directory above it, so a locked parent gives the same error.

    Check

    namei -l <script>   # a directory without x for you on one of the lines: this is your cause

    Fix

    Ask the owner to grant access to that directory, or run the script from a place you can reach. Do not open it up with chmod 777.

  4. A fixed file name in the shared /tmp that another user's job created first. Your script then tries to write or run a file it does not own.

    Check

    ls -l /tmp/<name>   # owned by another user: this is your cause

    Fix

    Create temporary files with mktemp instead of a fixed name.

  5. Writing to a file the shell cannot open, such as sudo echo x > /etc/file. The redirection is done by your shell, as you, before sudo runs; the message then names the file, not the script.

    Check

    ls -l /etc/file   # owned by root, not writable by you: this is your cause

    Fix

    Let a root process open the file: echo x | sudo tee /etc/file > /dev/null.

Prevent

Commit scripts with the execute bit (git update-index --chmod=+x) and keep them out of /tmp.

Now learn it: break and fix it yourself in Permission denied · sudo and a redirect · A fixed name in /tmp, then take Level 00 · Your first script. Open in Troubleshoot

No such file or directory

/opt/tools/report.sh: line 2: ./lib/common.sh: No such file or directory

What it means

A command was given a path that does not exist from where it is running. In the example, ./lib/common.sh is resolved from the current directory, not from the script's own directory, so the file is looked for in the wrong place.

What you see

A script, source, cd or a redirection fails on a path that you can see exists.

Causes, most likely first

  1. A relative path, run from cron or another directory. ./lib/common.sh means "in the current directory", which is wherever the script was started from.

    Check

    pwd   # in the script: not the script's directory means this is your cause
    ls -l -- "<path>"   # works from the script's directory, fails from here

    Fix

    Build paths from the script's directory: here=$(dirname "${BASH_SOURCE[0]}"); source "$here/lib/common.sh", or use absolute paths.

  2. An unquoted path with spaces is split into several arguments. cat $file with My Report.txt asks for My and Report.txt, which do not exist.

    Check

    printf '[%s]\n' $file   # more than one line: the path was split

    Fix

    Quote it: cat -- "$file".

  3. A carriage return at the end of the name, from a Windows file. The name really is report.txt\r, which is not the file on disk.

    Check

    printf '%q\n' "<path>"   # ends in $'\r' or shows other hidden characters: this is your cause

    Fix

    Strip \r from the data file: sed -i 's/\r$//' list.txt.

  4. The parent directory of a redirection does not exist. > /var/log/app/run.log cannot create the file when /var/log/app is missing.

    Check

    ls -ld "$(dirname "<path>")"   # No such file or directory: create the directory

    Fix

    Create it first: mkdir -p /var/log/app.

  5. A glob that matched nothing is passed on literally. With no .log files, *.log stays as the text *.log, which is not a file.

    Check

    echo *.log   # prints *.log itself: nothing matched

    Fix

    Use shopt -s nullglob before looping over globs, so an empty match gives no words.

Prevent

Start scripts with cd "$(dirname "${BASH_SOURCE[0]}")" || exit 1 when they rely on neighbouring files, and use shopt -s nullglob before looping over globs.

Now learn it: break and fix it yourself in source finds nothing from cron · Spaces in a path · A glob that matches nothing, then take Level 12 · Cron and the environment. Open in Troubleshoot

cannot execute: required file not found

bash: ./deploy.sh: cannot execute: required file not found

What it means

The kernel was asked to start the file and could not find the interpreter it names on the #! line, or the loader a program needs. The file itself exists; something it depends on does not. The exit status is 127.

What you see

A script that exists and is executable fails immediately with exit status 127. Older bash says /bin/bash^M: bad interpreter: No such file or directory; env says /usr/bin/env: 'bash\r': No such file or directory.

Causes, most likely first

  1. The file was saved with CRLF line endings. The first line becomes #!/bin/bash\r, and there is no program called bash\r.

    Check

    head -1 <script> | cat -A   # ^M$ at the end: this is your cause

    Fix

    Convert the line endings: sed -i 's/\r$//' script.sh or dos2unix script.sh.

  2. The #! line names an interpreter that is not installed, such as /bin/zsh, or has a typo in its path. The kernel looks for exactly that path.

    Check

    head -1 <script>   # the path after #!
    ls -l <that path>   # No such file or directory: install it or fix the line

    Fix

    Install the interpreter, or point #! at one that exists: #!/usr/bin/env bash.

  3. A program (not a script) whose dynamic loader is missing, such as a glibc build on Alpine (musl). The binary names its loader inside it, and that file does not exist here.

    Check

    file <program>   # 'interpreter /lib64/ld-linux-x86-64.so.2': check that this loader exists with ls -l

    Fix

    Use the build for this system's C library, such as a musl or static build on Alpine.

Prevent

Add *.sh text eol=lf to .gitattributes and set your editor to LF for scripts.

Now learn it: break and fix it yourself in cannot execute: required file not found, then take Level 12 · Cron and the environment. Open in Troubleshoot

$'\r': command not found

deploy.sh: line 2: $'\r': command not found

What it means

Bash read a carriage return (\r, shown as $'\r') as part of a word. Every line of the file ends with \r\n instead of \n, so a blank line becomes a command called \r.

What you see

bash script.sh prints $'\r': command not found on blank lines, and syntax error near unexpected token $'do\r''.

Cause

  1. The file was edited on Windows or copied from a web page. Those save lines with CRLF endings, and bash only strips the LF.

    Check

    cat -A <script> | head   # ^M$ at the end of lines: this is your cause
    file <script>   # 'with CRLF line terminators' says the same

    Fix

    sed -i 's/\r$//' script.sh, then run it again. Add *.sh text eol=lf to .gitattributes.

Prevent

.gitattributes with *.sh text eol=lf.

Now learn it: break and fix it yourself in cannot execute: required file not found · A Windows file breaks a comparison, then take Level 12 · Cron and the environment. Open in Troubleshoot

[[: not found

/home/dev/report.sh: 3: [[: not found

What it means

The script is running in sh, not bash. On Debian and Ubuntu sh is dash, on Alpine it is busybox ash, and they lack bash features. sh reports a missing feature as [[: not found or source: not found, with a bare line number after the script name.

What you see

A bash script fails under cron, sh script.sh or a Docker CMD, with errors such as [[: not found, source: not found, [: x: unexpected operator or Bad substitution.

Causes, most likely first

  1. No #! line, so /bin/sh runs it. Without one, the program that started it decides which shell runs it, and that is usually sh.

    Check

    head -1 <script>   # no #! line: this is your cause
    ls -l /bin/sh   # -> dash: sh is not bash here

    Fix

    Start the script with #!/usr/bin/env bash and make it executable.

  2. Running it as sh script.sh. Naming the shell on the command line ignores the #! line. Docker's shell-form CMD and some cron lines do the same.

    Check

    grep -rn 'sh .*<script>' crontab Dockerfile 2>/dev/null   # sh <script> in how it is started

    Fix

    Run the file directly (./script.sh), or use bash script.sh. In a Dockerfile use the exec form: CMD ["./script.sh"].

  3. #!/bin/sh on a script that uses bash features. The line asks for sh, and sh is not bash.

    Check

    shellcheck --shell=sh <script>   # lists the bash-only lines

    Fix

    Change the line to #!/usr/bin/env bash, or rewrite those lines in POSIX sh.

Prevent

Always write a #! line; check sh scripts with checkbashisms or ShellCheck's --shell=sh.

Now learn it: break and fix it yourself in [[: not found, then take Level 12 · Cron and the environment. Open in Troubleshoot

Argument list too long

bash: /usr/bin/rm: Argument list too long

What it means

The kernel refused to start the program because its arguments and environment together are larger than it allows (E2BIG). The message names the program, here /usr/bin/rm; usually a glob such as *.log expanded to too many names.

What you see

rm *.log, cp * or grep x * fail in a directory with many files.

Causes, most likely first

  1. Tens of thousands of matching files. Every file name becomes one argument.

    Check

    ls | wc -l   # tens of thousands: this is your cause
    getconf ARG_MAX   # the limit, in bytes

    Fix

    Let find do it: find . -name '*.log' -delete, or find . -name '*.log' -print0 | xargs -0 rm --.

  2. A very large environment, which counts against the same limit. Even a short command fails when the environment is huge.

    Check

    env | wc -c   # size of the environment in bytes, without printing it; a large share of ARG_MAX is your cause

    Fix

    Find what fills it (often a variable that holds a whole file) and stop exporting it.

  3. One argument longer than 128 KiB on Linux (MAX_ARG_STRLEN), such as a whole file passed with "$(cat file)". Each single argument has its own limit, separate from the total.

    Check

    wc -c < <file>   # over 131072: too big for one argument

    Fix

    Pass the file name or feed it on standard input instead: cmd < file.

Prevent

Use find for bulk operations on directories that grow.

Now learn it: take Level 16 · Performance and maintainability. Open in Troubleshoot

cannot execute binary file: Exec format error

bash: ./node_exporter: cannot execute binary file: Exec format error

What it means

The kernel opened the file and could not recognise it as a program it can run: it is built for another CPU architecture, is not a program at all, or is a script without a #! line started by something other than a shell. The exit status is 126.

What you see

A downloaded program fails at once with exit status 126, or a container stops at start with exec /usr/local/bin/entrypoint.sh: exec format error in its log.

Causes, most likely first

  1. A binary for another architecture: amd64 on an arm64 host, or the reverse, such as an image built on an Apple silicon Mac and run on an x86 server.

    Check

    file <program>   # ARM aarch64 or x86-64: compare with the next line
    uname -m   # x86_64 or aarch64: a mismatch is your cause

    Fix

    Use the build for this machine's architecture, or build images for the target with docker buildx build --platform linux/amd64.

  2. The download saved an HTML error page or an archive instead of the program.

    Check

    head -c 64 <program> | od -c | head -3   # <html or a gzip header instead of ELF: this is your cause

    Fix

    Download it again with curl -fL, and check it against its published checksum.

  3. A container entrypoint script with no #! line, or a blank line or byte-order mark before it. Docker runs the entrypoint directly, with no shell to fall back on.

    Check

    head -c 64 <script> | od -c | head -3   # the first two bytes are not # !

    Fix

    Make #!/usr/bin/env bash the very first line.

Prevent

Pick release files by uname -m in install scripts, check downloads against their published checksum, and build images for every platform you run.

Now learn it: take Level 12 · Cron and the environment. Open in Troubleshoot

declare: -A: invalid option (bash 3.2 on macOS)

./sync.sh: line 3: declare: -A: invalid option

What it means

macOS ships bash 3.2 as /bin/bash. Associative arrays (declare -A), mapfile, case conversion and other features arrived in bash 4, so 3.2 rejects them: -A is an invalid option, mapfile is not found, ${name,,} is a bad substitution.

What you see

A script that works on Linux fails on a Mac: declare -A is an invalid option, mapfile is not found, and ${name,,} is a bad substitution.

Causes, most likely first

  1. #!/bin/bash runs the system bash 3.2 even when a newer bash is installed.

    Check

    head -1 <script>   # #!/bin/bash: the system 3.2
    /bin/bash --version | head -1   # version 3.2: this is your cause

    Fix

    Install a current bash (brew install bash) and start the script with #!/usr/bin/env bash.

  2. A newer bash from Homebrew is in $PATH, but the script is run as bash script.sh from a shell where it is not.

    Check

    type -a bash   # /bin/bash listed first in that shell

    Fix

    Put Homebrew's bin directory first in PATH, or run the script directly. If it must run on a stock Mac, keep to bash 3.2 features.

Prevent

Scripts that need bash 4 can say so at the top: ((BASH_VERSINFO[0] >= 4)) || { echo 'needs bash 4 or newer' >&2; exit 1; }.

Now learn it: take Level 12 · Cron and the environment. Open in Troubleshoot

No space left on device

cp: error writing '/var/backups/db.sql': No space left on device

What it means

A write failed because the file system has no free blocks, or no free inodes (the per-file records a file system has a fixed number of). The message names the file being written. Bash writes here-documents to a temporary file, so they fail too.

What you see

Writes fail part way: a backup or log stops growing, cp or tar exits non-zero, and even small scripts fail with cannot create temp file for here-document.

Causes, most likely first

  1. Logs or old backups filled the disk.

    Check

    df -h <path>   # Use% 100
    du -xh --max-depth=1 <path> | sort -h | tail   # the directory that holds the space

    Fix

    Rotate or compress logs and remove old backups, then keep a fixed number (scripts/backup_rotate.sh -k).

  2. A deleted file is still open in a running process, so its space is not freed. df counts it, du does not.

    Check

    lsof +L1   # deleted files still open, with their size and process

    Fix

    Restart the process that holds it (scripts/deleted_open_files.sh lists them), or truncate it through /proc/<pid>/fd/<n>.

  3. Millions of small files used up the inodes while df -h still shows free space.

    Check

    df -i <path>   # IUse% 100: inodes, not bytes

    Fix

    Find the directory with the many small files (du --inodes -x <path> | sort -n | tail) and clean it up.

  4. On ext4, the blocks reserved for root (5% by default): Avail is 0 for other users while Used is below Size.

    Check

    df -h <path>   # Avail 0 but Used below Size: the reserved blocks

    Fix

    Free space as above. Do not just lower the reservation; it is what lets root recover a full disk.

  5. A large write to a small /tmp.

    Check

    df -h /tmp   # a small tmpfs

    Fix

    Write large temporary files elsewhere: TMPDIR=/var/tmp.

Prevent

Alert on disk and inode use well before 100%, rotate logs, and keep a fixed number of backups (scripts/backup_rotate.sh -k).

Now learn it: break and fix it yourself in Log cleanup deletes the wrong files, then take Level 13 · Safe file operations. Open in Troubleshoot

Syntax

syntax error near unexpected token

deploy.sh: line 9: syntax error near unexpected token `fi'

What it means

Bash was parsing the script and met a word or operator where its grammar does not allow one. It names the token it did not expect (fi here) and the line. Nothing in the script has run yet, and the real mistake is often a line or two earlier.

What you see

The script stops before running the command at that line, with exit status 2.

Causes, most likely first

  1. A missing then, do, ; or ;;. Without then, bash is still reading the condition when it reaches fi, which cannot appear there.

    Check

    bash -n <script>   # repeats the error without running anything
    sed -n '<line-2>,<line>p' <script>   # look for the missing keyword just before the named line

    Fix

    Add the missing keyword or separator.

  2. A missing space, as in if [$x = 1], or } without a ; before it. Bash needs spaces to see [ and ] as words, and a ; or newline before a closing }.

    Check

    shellcheck <script>   # names the missing space or separator on that line

    Fix

    Add the space or the ;: if [ "$x" = 1 ]; then, { cmd; }.

  3. Windows line endings. The token is then shown as $'do\r' or similar: the \r makes do an unknown word.

    Check

    cat -A <script> | sed -n '1,12p'   # ^M$ at line ends: this is your cause

    Fix

    sed -i 's/\r$//' script.sh.

  4. An unquoted ( or ) in an argument, such as echo (beta). Parentheses are shell syntax, so bash reads them as the start of a subshell in the wrong place.

    Check

    grep -n '[^$]([^)]*)' <script>   # an unquoted ( in a command's arguments

    Fix

    Quote it: echo "(beta)".

Prevent

Run bash -n and ShellCheck in CI on every script.

Now learn it: break and fix it yourself in cannot execute: required file not found, then take Level 11 · Debugging. Open in Troubleshoot

syntax error: unexpected end of file

deploy.sh: line 14: syntax error: unexpected end of file

What it means

Bash reached the end of the script while a block was still open. It points at the last line because that is where it gave up; the block that was never closed starts earlier.

What you see

The error points at the last line of the script.

Causes, most likely first

  1. A missing fi, done, esac or }. Bash keeps reading for the closing word and finds the end of the file instead.

    Check

    bash -n <script>   # repeats the error without running anything
    grep -nE '^\s*(if|fi|for|while|do|done|case|esac)\b' <script>   # count openers and closers; one more opener is your block

    Fix

    Close the block.

  2. A here-document whose end marker is indented or has trailing spaces. Bash never sees the marker, so the rest of the file becomes the here-document, and the block around it is never closed.

    Check

    grep -n 'EOF' <script> | cat -A   # spaces before the marker or after it (before $): this is your cause

    Fix

    Put the end marker at the start of its line with nothing after it, or use <<- with tab indentation.

  3. } on the same line as a command without ;. Then } is read as an argument, and the function is never closed.

    Check

    grep -nE '[^;{ ] *}$' <script>   # a } with no ; before it

    Fix

    Add the separator: f() { cmd; }.

Prevent

Indent consistently so open blocks are visible, and run bash -n in CI.

Now learn it: take Level 11 · Debugging. Open in Troubleshoot

unexpected EOF while looking for matching

deploy.sh: line 3: unexpected EOF while looking for matching `"'

What it means

A quote, $( or backtick was opened and never closed. Bash read to the end of the file looking for the matching character (shown in the message, here "). Exit status 2, and nothing after the quote runs.

What you see

The script stops with exit status 2; lines after the quote never run.

Causes, most likely first

  1. An unbalanced " or '. Bash treats everything after it as one long string.

    Check

    bash -n <script>   # repeats the error; the line it names is where the quote opened

    Fix

    Close the quote.

  2. An apostrophe inside single quotes: 'it's'. The apostrophe ends the string, and the final ' opens a new one that never closes.

    Check

    grep -n "'" <script>   # an odd number of ' on a line points to it

    Fix

    Write 'it'\''s', or use double quotes: "it's".

  3. A $( without its ). Bash keeps reading for the end of the command substitution.

    Check

    grep -n '\$(' <script>   # check each one has its )

    Fix

    Add the closing ).

Prevent

An editor with syntax highlighting shows the unclosed string at once.

Now learn it: take Level 01 · Variables and quoting. Open in Troubleshoot

event not found

-bash: !word: event not found

What it means

In an interactive shell, ! starts history expansion: !word means "the last command starting with word". Bash found no such command, so it refused the line before running it. Scripts run with history expansion off, which is why the same line works there.

What you see

A command typed in the terminal with ! in it fails before it runs, such as echo "Pa55!word". The same line works in a script.

Causes, most likely first

  1. ! inside double quotes in a typed command. Double quotes do not stop history expansion.

    Check

    set -o | grep histexpand   # histexpand on: this shell expands !

    Fix

    Use single quotes ('Pa55!word') or escape the ! with a backslash.

  2. A password or message containing ! pasted into the terminal.

    Check

    set -o | grep histexpand   # on in your terminal

    Fix

    Turn history expansion off for the session with set +H, and keep passwords off the command line altogether.

Prevent

Single-quote literal strings, and keep passwords off the command line altogether.

Now learn it: break and fix it yourself in Single quotes, then take Level 01 · Variables and quoting. Open in Troubleshoot

Variables

unbound variable

./sync.sh: line 3: DEBUG: unbound variable

What it means

The script read a variable that was never set, and set -u (nounset) makes that an error. DEBUG is the variable; a non-interactive shell exits with status 1 on the spot.

What you see

With set -u the script exits with status 1 as soon as it reads a variable that was never set.

Causes, most likely first

  1. An optional variable with no default. Without a default, reading it when nobody set it is an error under set -u.

    Check

    [[ -v NAME ]] && echo set || echo unset   # unset: this is your cause

    Fix

    Give it a default: ${DEBUG:-0}.

  2. A missing argument: $1 when the script was run with none. Positional parameters are variables too.

    Check

    echo "$#"   # at the top of the script: 0 means no arguments were passed

    Fix

    Require it with a message: ${1:?usage: sync.sh <dir>}.

  3. A typo in a variable name. The misspelled name was never set.

    Check

    grep -n 'NAME' <script>   # compare the spelling where it is set and where it is read

    Fix

    Fix the name.

  4. A variable set in your login shell but not under cron or CI, or set but not exported. A child process only receives exported variables.

    Check

    printenv NAME > /dev/null && echo exported || echo 'not in the environment'   # not in the environment: the job never received it

    Fix

    Export it in the caller (export NAME=value), or set it in the job's own configuration.

  5. An empty array expanded as "${arr[@]}" in bash older than 4.4 (macOS's 3.2). Those versions treat an empty array as unset.

    Check

    bash --version | head -1   # 3.x or 4.0 to 4.3: this can be your cause

    Fix

    Use ${arr[@]+"${arr[@]}"} on old bash, or run a current bash.

Prevent

Keep set -u: it finds typos. Document required variables at the top of the script.

Now learn it: break and fix it yourself in unbound variable · rm -rf with an empty variable · Log cleanup deletes the wrong files, then take Level 05 · Parameter expansion. Open in Troubleshoot

parameter null or not set

./migrate.sh: line 2: DEPLOY_ENV: DEPLOY_ENV is not set

What it means

A ${NAME:?message} guard stopped the script because the variable was unset or empty. The text after the name is the guard's message, so this is the script telling you what it needs. Exit status 1.

What you see

The script stops with status 1 and prints a variable name followed by a message.

Causes, most likely first

  1. The variable was not passed in, or not exported by the parent script. The guard sees an unset variable.

    Check

    [[ -v NAME ]] && echo "set, ${#NAME} characters" || echo unset   # unset: it never arrived
    printenv NAME > /dev/null && echo exported || echo 'not in the environment'   # in the caller: not exported

    Fix

    Pass the value: export NAME=value in the caller, or NAME=value ./script.sh.

  2. A missing command-line argument, as in ${1:?usage}. The guard protects a positional parameter.

    Check

    echo "$#"   # 0: no arguments were passed

    Fix

    Run the script with the argument its usage message asks for.

  3. It is set but empty. ${NAME:?} rejects empty values too; only ${NAME?}, without the colon, accepts an empty value.

    Check

    [[ -v NAME ]] && echo "set, ${#NAME} characters"   # set, 0 characters: empty, not missing

    Fix

    Find where it is set to an empty value (often a failed command substitution) and fix that.

Prevent

Keep the guard; add a usage message so the next person knows what to pass. If the message comes from a here-document, quote its delimiter (<<'EOF').

Now learn it: break and fix it yourself in A child script sees nothing · rm -rf with an empty variable · Here-document expands too early, then take Level 05 · Parameter expansion. Open in Troubleshoot

bad substitution

./report.sh: 4: Bad substitution

What it means

The shell met a ${...} expansion it does not understand. Either the script uses a bash feature but is running in sh (dash prints Bad substitution with a capital B and a line number without "line"), or the expansion is mistyped.

What you see

A ${...} expansion fails. In dash the message is Bad substitution with a capital B.

Causes, most likely first

  1. Bash-only forms like ${var//a/b} or ${var^^} run by sh. That happens with no #! line, or when the script is started as sh script.sh.

    Check

    head -1 <script>   # no #! line, or #!/bin/sh: sh runs it
    ls -l /bin/sh   # -> dash on Debian and Ubuntu: sh is not bash there

    Fix

    Start the script with #!/usr/bin/env bash and run it as ./script.sh, not sh script.sh.

  2. A typo, such as ${x!} or a space inside the braces. Bash itself rejects it too.

    Check

    bash -n <script>   # bash reports the same line: the expansion is wrong, not the shell

    Fix

    Correct the expansion.

Prevent

Start every script with a #! line for the shell you write for.

Now learn it: break and fix it yourself in [[: not found, then take Level 12 · Cron and the environment. Open in Troubleshoot

readonly variable

./deploy.sh: line 8: ENV: readonly variable

What it means

The script tried to assign to a variable that was marked read-only. The assignment fails with status 1 and the old value stays. ENV is the variable.

What you see

An assignment fails with status 1 and the old value stays.

Causes, most likely first

  1. A library or env file marks settings readonly, with readonly or declare -r. Any later assignment to that name fails.

    Check

    declare -p <NAME>   # declare -r: it is read-only
    grep -rn 'readonly <NAME>\|declare -r <NAME>' <script> <sourced files>   # where it was made read-only

    Fix

    Use another name, or set the value before the file that makes it read-only is sourced.

  2. Assigning to one of bash's own read-only variables, such as UID or EUID.

    Check

    declare -p <NAME>   # declare -ir UID=...: a bash variable

    Fix

    Rename yours. Do not reuse names like UID, PWD or PATH for your own data.

Prevent

Do not reuse names like UID, PWD or PATH for your own data.

Now learn it: take Level 05 · Parameter expansion. Open in Troubleshoot

not a valid identifier

./env.sh: line 2: export: `DB-HOST=db.internal': not a valid identifier

What it means

export, read, local, declare or unset was given a name that cannot be a variable. Names may contain only letters, digits and underscores, and cannot start with a digit. The message shows the rejected text.

What you see

export, read, local or unset rejects a name.

Causes, most likely first

  1. A dash in a name, as in DB-HOST. A dash is not allowed in variable names.

    Check

    grep -n 'export\|read\|local' <script>   # a name with - or . in it

    Fix

    Use a valid name: DB_HOST.

  2. A $ before the name, as in read $name. Bash expands it first, so read receives the value instead of the name.

    Check

    grep -nE '(read|export|unset) .*\$' <script>   # a $ where a name should be

    Fix

    Write the name without $: read -r name.

  3. Spaces around = with export: export A = 1. Bash passes A, = and 1 as three names, and = is not valid.

    Check

    grep -nE 'export [A-Za-z_]+ +=' <script>   # spaces around =

    Fix

    Remove the spaces: export A=1.

Prevent

Use UPPER_SNAKE_CASE for environment variables.

Now learn it: take Level 01 · Variables and quoting. Open in Troubleshoot

Words no longer split after changing IFS

restart-svc: unknown service 'nginx redis worker'

What it means

IFS is the list of characters bash splits unquoted words on, by default space, tab and newline. Setting it on its own line, such as IFS=,, changes splitting for the rest of the script, so spaces no longer separate words.

What you see

After some CSV parsing, a list of names arrives as one word, or paths are split at commas.

Causes, most likely first

  1. IFS=, (or IFS=$'\n') set globally to parse one line. Every later unquoted expansion splits on commas only.

    Check

    printf '%q\n' "$IFS"   # not $' \t\n': it was changed
    grep -n 'IFS=' <script>   # a line with only IFS=... on it

    Fix

    Set IFS only for the command that needs it: IFS=, read -r a b <<< "$line". Use arrays for lists.

  2. IFS was changed in a function or sourced file and not restored.

    Check

    grep -rn 'IFS=' <script> <sourced files>   # where it was changed

    Fix

    Use local IFS inside functions, so the change ends with the function.

Prevent

Never change IFS globally; if you must, do it in a function with local IFS.

Now learn it: break and fix it yourself in IFS changed for the whole script, then take Level 05 · Parameter expansion. Open in Troubleshoot

bad array subscript

./build.sh: line 6: arr: bad array subscript

What it means

The array index does not point to a valid element. A negative index counts back from the end, so it fails when it reaches before the first element; an associative array cannot take an empty key. The message names the array.

What you see

Reading the last element of an array, or storing a value under a key, fails with this message.

Causes, most likely first

  1. ${arr[-1]} on an empty array, or ${arr[-n]} with n larger than the array.

    Check

    echo "${#arr[@]}"   # 0, or less than n: this is your cause
    declare -p arr   # what the array really holds

    Fix

    Check the length before indexing: (( ${#arr[@]} )) && last=${arr[-1]}.

  2. map[$key]=value with key empty or unset. The message then shows map[$key].

    Check

    printf '%q\n' "$key"   # '' (empty): this is your cause

    Fix

    Check that the key is not empty before using it: [[ -n $key ]] || continue.

  3. Bash older than 4.2 (macOS's 3.2), which rejects negative indexes; assigning to one, such as arr[-1]=x, needs 4.3.

    Check

    bash --version | head -1   # 3.x or 4.0 to 4.1: this is your cause

    Fix

    Use ${arr[${#arr[@]}-1]} on old bash, or run a current bash.

Prevent

Use set -u, and test lengths and keys where the data comes from outside the script.

Now learn it: take Level 05 · Parameter expansion. Open in Troubleshoot

Tests

unary operator expected

./health.sh: line 3: [: !=: unary operator expected

What it means

The test command [ expected an operator with one operand and got an operator with none. An unquoted variable was empty, so it vanished: [ $x != ok ] became [ != ok ]. The test returns 2 and the if takes its else branch.

What you see

An if [ ... ] prints this error and takes the else branch (status 2), so alerts and checks silently do nothing.

Causes, most likely first

  1. An unquoted empty variable inside [ ]. Without quotes, an empty value turns into no word at all.

    Check

    bash -x <script>   # the trace shows [ != ok ]: the variable is gone

    Fix

    Quote the variable, [ "$x" != ok ], or use [[ $x != ok ]].

  2. A command substitution that printed nothing. [ $(get_status) = up ] becomes [ = up ] when the command fails or prints nothing.

    Check

    bash -x <script>   # shows the empty substitution

    Fix

    Quote it and handle the failure: s=$(get_status) || exit 1; [ "$s" = up ].

Prevent

Quote every expansion inside [ ], or prefer [[ ]] in bash scripts. The same mistake makes [ -n $var ] true when var is empty.

Now learn it: break and fix it yourself in unary operator expected · [ -n $var ] is always true, then take Level 02 · Exit codes and tests. Open in Troubleshoot

binary operator expected

./watch.sh: line 2: [: -q: binary operator expected

What it means

The test command [ got three words and expected the middle one to be a comparison such as = or -eq. Here it got -q, so the words are not a test. The test returns 2 and counts as false.

What you see

An if [ ... ] prints an error and is treated as false. With four or more words the message is a syntax error naming the unexpected word instead.

Causes, most likely first

  1. A command inside [ ], as in if [ grep -q x file ]. [ is a command that evaluates tests; it does not run other commands.

    Check

    bash -x <script>   # shows [ grep -q x file ]

    Fix

    Run the command directly: if grep -q x file; then.

  2. An unquoted variable with spaces in it. [ $a = b ] with a="x y" becomes [ x y = b ].

    Check

    bash -x <script>   # the trace shows the value split into several words

    Fix

    Quote variables: [ "$a" = "$b" ].

Prevent

Remember that [ is the test command, not grouping.

Now learn it: break and fix it yourself in [ with a command inside, then take Level 02 · Exit codes and tests. Open in Troubleshoot

too many arguments

./check.sh: line 4: [: too many arguments

What it means

The test command [ (or cd) received more words than it accepts. An unquoted variable split on spaces, or a glob expanded to several file names. The test returns 2 and counts as false.

What you see

A test fails with status 2 when a value has spaces or matches several files.

Causes, most likely first

  1. An unquoted variable with spaces, as in [ $name = x ]. Each word becomes a separate argument.

    Check

    bash -x <script>   # the trace shows the value split into several words

    Fix

    Quote: [ "$name" = x ].

  2. A glob with more than one match, as in [ -f *.log ]. -f takes one file, and the glob gives several.

    Check

    ls *.log | wc -l   # more than 1: this is your cause

    Fix

    Test for any match with compgen -G '*.log' > /dev/null, or loop over the files.

  3. cd $dir with spaces in the path. cd takes one directory.

    Check

    printf '[%s]\n' $dir   # more than one line: the path was split

    Fix

    Quote: cd -- "$dir" || exit 1.

Prevent

Quote every variable you did not intend to split.

Now learn it: break and fix it yourself in Spaces in a path · unary operator expected, then take Level 02 · Exit codes and tests. Open in Troubleshoot

integer expression expected

./disk.sh: line 3: [: 100%: integer expression expected

What it means

A numeric test (-gt, -lt, -eq and friends) was given something that is not a plain integer. The message shows the bad value, here 100%. The test returns 2 and counts as false, so thresholds never trigger.

What you see

A numeric test errors and is treated as false; thresholds never trigger.

Causes, most likely first

  1. A percentage or unit from df, free -h or du -h. 100% and 2.1G are text, not integers.

    Check

    printf '%q\n' "$value"   # a % or a unit letter: this is your cause

    Fix

    Strip the unit, v=${v%\%}, or ask the tool for raw numbers: df -P, df --output=pcent, du -b.

  2. A decimal such as the load average 1.52. Bash arithmetic and [ only handle integers.

    Check

    printf '%q\n' "$value"   # a decimal point: this is your cause

    Fix

    Compare with awk: awk -v l="$load" 'BEGIN { exit !(l > 4) }'.

  3. An empty value. [ "" -gt 90 ] has no integer on the left.

    Check

    printf '%q\n' "$value"   # '' (empty): the command that filled it printed nothing

    Fix

    Give it a default, ${v:-0}, and check the command that should have filled it.

  4. A carriage return from a Windows file. 90\r is not an integer.

    Check

    printf '%q\n' "$value"   # ends in $'\r': this is your cause

    Fix

    Strip it: v=${v%$'\r'}. Do not switch to >, which compares text (100 sorts before 90).

Prevent

Ask tools for raw numbers (df -P, --output, -b) rather than human-readable ones.

Now learn it: break and fix it yourself in integer expression expected, then take Level 03 · Conditionals and case. Open in Troubleshoot

[: missing `]'

./check.sh: line 8: [: missing `]'

What it means

[ is a command, and its last argument must be a separate ]. Bash reached the end of the command without finding one, so the test fails with status 2 and the if takes its else branch every time.

What you see

A test fails with exit status 2, so the if takes its else branch every time.

Causes, most likely first

  1. No space before the closing bracket: [ "$a" = "$b"]. "$b"] is one word, so there is no lone ].

    Check

    sed -n '8p' <script> | cat -A   # a ] touching the word before it
    shellcheck <script>   # SC1020: You need a space before the ]

    Fix

    Put spaces around every argument of [, including before ]: [ "$a" = "$b" ].

  2. && or || inside the brackets: [ -n "$a" && -n "$b" ]. The shell ends the [ command at &&, before the ].

    Check

    grep -nE '\[ [^]]*(&&|\|\|)' <script>   # && or || inside [ ]

    Fix

    Join tests outside the brackets, [ -n "$a" ] && [ -n "$b" ], or use [[ -n $a && -n $b ]] in bash.

  3. A ] written as ), or left out after a line break.

    Check

    sed -n '8p' <script>   # no ] at the end of the test

    Fix

    Close the test with ].

Prevent

Run ShellCheck in CI: it reports the missing space as SC1020.

Now learn it: take Level 02 · Exit codes and tests. Open in Troubleshoot

Arithmetic

syntax error: operand expected

./report.sh: line 6: 91%: syntax error: operand expected (error token is "%")

What it means

Bash was evaluating arithmetic ($(( )), (( )) or a variable used there) and found something that is not a number where it needed one. The text after "error token is" shows where it stopped, here %. Bash abandons the rest of that command line.

What you see

An arithmetic expansion fails and bash abandons the rest of that command line.

Causes, most likely first

  1. A value with a unit, such as 91%. The % is read as the modulo operator with nothing after it.

    Check

    printf '%q\n' "$value"   # a % or unit: this is your cause

    Fix

    Clean the value first: v=${v%\%}.

  2. An empty operand, as in $(( 3 + )). A variable that is empty leaves a gap in the expression.

    Check

    printf '%q\n' "$n"   # '' (empty): this is your cause

    Fix

    Give empty values a default: $(( ${n:-0} + 1 )).

  3. A variable holding text. Text that is not a number or a variable name cannot be used in arithmetic.

    Check

    printf '%q\n' "$value"   # words or symbols: this is your cause

    Fix

    Validate numbers before using them: [[ $n =~ ^[0-9]+$ ]] || exit 2.

Prevent

Validate numbers: [[ $n =~ ^[0-9]+$ ]] || exit 2.

Now learn it: break and fix it yourself in integer expression expected, then take Level 03 · Conditionals and case. Open in Troubleshoot

value too great for base

./rotate.sh: line 4: 08: value too great for base (error token is "08")

What it means

In bash arithmetic, a number with a leading zero is octal (base 8), where only the digits 0 to 7 exist. 08 and 09 are therefore invalid, which is why this fails on the 8th and 9th or at 08:00.

What you see

Arithmetic on dates or times works most of the month, then fails on the 8th or 9th, or at 08:00.

Cause

  1. $(date +%m), %d or %H used in arithmetic. Those print zero-padded numbers like 08.

    Check

    echo $(( 07 )) $(( 010 ))   # prints 7 8: a leading zero means octal
    grep -n 'date +%[mdHM]' <script>   # where padded values come from

    Fix

    Force base 10, $(( 10#$month + 1 )), or ask GNU date for no padding: date +%-m.

Prevent

Always use 10# on zero-padded numbers.

Now learn it: break and fix it yourself in value too great for base, then take Level 05 · Parameter expansion. Open in Troubleshoot

division by 0

./stats.sh: line 7: total / count: division by 0 (error token is "count")

What it means

An arithmetic expression divided by a value that was 0. The message shows the expression and the token that was zero, here count.

What you see

An average or percentage fails on an empty input.

Causes, most likely first

  1. No matching lines, so the count is 0. The input was empty or the filter matched nothing.

    Check

    echo "count=$count"   # count=0: the input had nothing to count

    Fix

    Check the divisor first: (( count > 0 )) && echo $(( total / count )).

  2. An unset or misspelled variable, which arithmetic treats as 0.

    Check

    [[ -v count ]] && echo set || echo unset   # unset: the name is wrong or it was never filled

    Fix

    Fix the name, or set it before dividing.

Prevent

Handle the empty case explicitly in reports.

Now learn it: take Level 05 · Parameter expansion. Open in Troubleshoot

Redirection

ambiguous redirect

./backup.sh: line 5: $LOG: ambiguous redirect

What it means

The target of a redirection (> $file, >> $LOG) did not expand to exactly one file name. The command is skipped with status 1. The message shows the unexpanded text, here $LOG.

What you see

A command with > $file or >> $LOG is skipped with status 1.

Causes, most likely first

  1. The variable is empty or unset. With no word after >>, there is no file to open.

    Check

    [[ -v LOG ]] && echo "set, ${#LOG} characters" || echo unset   # unset or 0 characters: this is your cause

    Fix

    Make sure it is set, and quote it: >> "${LOG:?LOG is not set}".

  2. An unquoted path with spaces. It expands to several words, and a redirection takes one.

    Check

    printf '[%s]\n' $LOG   # more than one line: the path was split

    Fix

    Quote it: >> "$LOG".

Prevent

Quote every redirection target that holds a variable.

Now learn it: take Level 06 · Pipes and redirection. Open in Troubleshoot

here-document delimited by end-of-file

./install.sh: line 12: warning: here-document at line 3 delimited by end-of-file (wanted `EOF')

What it means

Bash started a here-document and read to the end of the file without finding its end marker (wanted 'EOF'). Everything after it became part of the here-document, so those lines never ran. It is a warning, so the script may carry on silently wrong.

What you see

Everything after a here-document becomes part of it, and the script ends early.

Causes, most likely first

  1. The marker is indented. Only <<- allows leading tabs, and never spaces.

    Check

    grep -n 'EOF' <script> | cat -A   # spaces or ^I before the marker: this is your cause

    Fix

    Put the marker at the start of its line, or use <<- with tab indentation.

  2. Trailing spaces or a \r after the marker. The line must be exactly the marker.

    Check

    grep -n 'EOF' <script> | cat -A   # a space or ^M before $: this is your cause

    Fix

    Remove them; strip \r with sed -i 's/\r$//' script.sh.

  3. A different spelling: EOF versus END. The end marker must match the one after << exactly.

    Check

    grep -nE '<<-?.?[A-Z]+' <script>   # the marker it wants

    Fix

    Use the same marker in both places.

Prevent

Use <<'EOF' when the body should not expand, and keep markers flush left.

Now learn it: break and fix it yourself in Here-document expands too early, then take Level 06 · Pipes and redirection. Open in Troubleshoot

Functions

return: numeric argument required

./deploy.sh: line 3: return: production: numeric argument required

What it means

return sets a function's exit status, a number from 0 to 255. It was given text (production), so bash refused it and the function returns 2.

What you see

A function returns a string and the caller receives a number, often 2.

Causes, most likely first

  1. return "$value" used to hand back data. Exit statuses are numbers only.

    Check

    grep -n 'return' <script>   # a return followed by a word or a variable holding text

    Fix

    Print the result and capture it: get() { echo "$value"; } and v=$(get).

  2. return used outside a function or a sourced file. It is refused there with its own message (can only return from a function or sourced script).

    Check

    grep -n 'return' <script>   # a return at the top level

    Fix

    Use exit at the top level of a script.

Prevent

Use return for success or failure only.

Now learn it: break and fix it yourself in return: numeric argument required, then take Level 08 · Functions. Open in Troubleshoot

local: can only be used in a function

./setup.sh: line 4: local: can only be used in a function

What it means

local makes a variable private to the function it is in. It was run at the top level, where there is no function, so bash refused it and the assignment on that line did not happen.

What you see

The assignment on that line does not happen.

Causes, most likely first

  1. Code moved out of a function. The local keywords came along.

    Check

    sed -n '1,10p' <script>   # the local line is not inside a name() { ... } block

    Fix

    Drop local at the top level, or wrap the code in a function and call it.

  2. A line copied from a function and pasted into the terminal. The terminal is not inside a function either.

    Check

    type <function>   # check you meant to run the function, not its body

    Fix

    Run the function, or drop local when trying lines by hand.

Prevent

Keep script logic in a main function and call main "$@" at the end.

Now learn it: take Level 08 · Functions. Open in Troubleshoot

illegal option

./deploy.sh: illegal option -- x

What it means

getopts read an option that is not in its option string (illegal option -- x), or an option that needs a value and got none (option requires an argument -- e). It prints the message itself and sets the option variable to ?.

What you see

getopts prints this and the option is ignored; or an option value goes missing (option requires an argument -- e).

Causes, most likely first

  1. A letter missing from the option string. getopts only accepts the letters listed.

    Check

    grep -n getopts <script>   # the letter is not in the string

    Fix

    Add the letter, and a case for it.

  2. A missing : after an option that takes a value ("e" instead of "e:"). Without it, getopts does not read a value, and the value is left as a stray argument.

    Check

    grep -n getopts <script>   # the letter has no : after it

    Fix

    Add : after it, and shift $((OPTIND - 1)) after the loop.

  3. The script was simply called with a wrong option or without a value.

    Check

    ./<script> -h   # compare with its usage

    Fix

    Handle ? and : in the case statement with a usage message and exit status 2.

Prevent

Handle ? and : in the case statement with a usage message and exit status 2.

Now learn it: break and fix it yourself in Options are ignored, then take Level 09 · Arguments and options. Open in Troubleshoot

maximum function nesting level exceeded

./lib.sh: line 4: log: maximum function nesting level exceeded (200)

What it means

A function called itself (directly or through others) more times than FUNCNEST allows, here 200. When FUNCNEST is unset, bash recurses until it runs out of memory and crashes instead.

What you see

A script recurses until bash stops it (or crashes, when FUNCNEST is unset).

Causes, most likely first

  1. A wrapper with the same name as the command it wraps: ls() { ls --color "$@"; }. The ls inside calls the function again, forever.

    Check

    type <name>   # 'is a function': the name is shadowed

    Fix

    Call the real command with command ls inside the wrapper, or rename the function.

  2. Recursion without a stop condition.

    Check

    bash -x <script> 2>&1 | head -40   # the same call repeating

    Fix

    Add the condition that ends the recursion, or use a loop.

Prevent

Do not name functions after commands they call.

Now learn it: take Level 08 · Functions. Open in Troubleshoot

Text tools

sed: unknown option to `s'

sed: -e expression #1, char 13: unknown option to `s'

What it means

sed parsed an s/pattern/replacement/flags command and found unexpected characters where the flags go. The delimiter (/) appeared inside the pattern or replacement and ended it early. The char number points into the expression.

What you see

A sed s/.../.../ command fails when a variable is involved.

Causes, most likely first

  1. The replacement comes from a variable that contains slashes, as in sed "s/X/$PATH_VALUE/". The first / in the value ends the replacement, and the rest is read as flags.

    Check

    echo "s/X/$VALUE/"   # more than three / in the output: the value broke the command

    Fix

    Pick a delimiter that is not in the data: sed "s|X|$VALUE|".

  2. A missing or extra delimiter typed by hand. Too few delimiters gives unterminated s command instead; extra text after the last one gives this message.

    Check

    echo '<the sed expression>'   # count the delimiters: there must be exactly three

    Fix

    Fix the expression.

Prevent

Use | or # as the delimiter whenever values are paths or URLs.

Now learn it: break and fix it yourself in unknown option to `s', then take Level 07 · grep, sed, awk and friends. Open in Troubleshoot

find: paths must precede expression

find: paths must precede expression: `test.log'

What it means

find received more than one name where it expected a single pattern. The shell expanded an unquoted *.log into the matching file names before find ran, so find saw -name a.log b.log and took b.log for a misplaced path.

What you see

A find command works in one directory and fails in another, and find suggests a possible unquoted pattern after -name.

Causes, most likely first

  1. find . -name *.log run where two or more .log files exist. The shell, not find, expands the unquoted pattern.

    Check

    echo find . -name *.log   # shows what find really received: several names after -name

    Fix

    Quote the pattern: find . -name '*.log'.

  2. With exactly one match there is no error, which is the hidden form of the same bug. find quietly searches for that one name and misses every other file.

    Check

    echo find . -name *.log   # one file name after -name: the search is wrong even without an error

    Fix

    Quote the pattern: find . -name '*.log'.

Prevent

Always quote patterns meant for another program.

Now learn it: break and fix it yourself in find: paths must precede expression, then take Level 13 · Safe file operations. Open in Troubleshoot

cp: cannot stat

cp: cannot stat '/srv/reports/Q3': No such file or directory

What it means

cp (or mv, ls) tried to read the source path and it does not exist. The path in quotes is what the command actually received, here /srv/reports/Q3. When that is only part of the real name, the shell split it on spaces.

What you see

cp, mv or ls complain about a path that is only part of the real name.

Causes, most likely first

  1. The path is in an unquoted variable and has spaces, as in cp $SRC $DEST. /srv/reports/Q3 summary becomes two arguments.

    Check

    printf '[%s]\n' $SRC   # more than one line: the path was split

    Fix

    Quote: cp -r -- "$SRC" "$DEST/".

  2. A file list built with $(ls) or $(find). The output is split on spaces and newlines, so names with spaces break apart.

    Check

    grep -nE '\$\((ls|find)' <script>   # a loop or command over $(ls) or $(find)

    Fix

    Loop over a glob (for f in /srv/reports/*; do) or use find ... -print0 | xargs -0.

  3. The path really does not exist: a wrong name, or a mount that is missing.

    Check

    ls -ld -- "$SRC"   # No such file or directory even when quoted: the path is wrong

    Fix

    Fix the path, or check the mount before copying: mountpoint -q /srv || exit 1.

Prevent

Quote every variable that holds a path.

Now learn it: break and fix it yourself in Spaces in a path · for f in $(ls), then take Level 01 · Variables and quoting. Open in Troubleshoot

date: invalid date

date: invalid date 'notAfter=Nov 15 12:00:00 2026 GMT'

What it means

GNU date -d could not read the text as a date and printed it back in quotes. In a script, the command substitution is then empty, and arithmetic on it gives nonsense.

What you see

Date arithmetic produces nonsense, such as a certificate that expires in -20756 days.

Causes, most likely first

  1. A label in front of the date (notAfter=). openssl and other tools print name=value.

    Check

    printf '%q\n' "$end"   # starts with a label: this is your cause

    Fix

    Strip labels first: ${end#notAfter=}.

  2. A format GNU date does not read, or BSD date on macOS, which uses date -j -f instead of -d.

    Check

    date --version 2>/dev/null | head -1   # no output: BSD date
    date -d "$end" +%s   # still invalid after cleaning: the format

    Fix

    Convert the format first, or on macOS use date -j -f '%b %d %T %Y %Z' "$end" +%s.

  3. A carriage return at the end, from a Windows file or a web response.

    Check

    printf '%q\n' "$end"   # ends in $'\r': this is your cause

    Fix

    Strip it: end=${end%$'\r'}. Check the result: ts=$(date -d "$end" +%s) || exit 1.

Prevent

Validate parsed values before arithmetic; a failed $( ) inside $(( )) does not stop the script.

Now learn it: break and fix it yourself in date: invalid date, then take Level 07 · grep, sed, awk and friends. Open in Troubleshoot

jq: parse error: Invalid numeric literal

jq: parse error: Invalid numeric literal at line 1, column 10

What it means

jq tried to read its input as JSON and the first characters are not JSON. "Invalid numeric literal" means it met a letter or < where a value should start; line and column say where. The exit status is 5 in jq 1.7 and 2 in jq 1.6.

What you see

A curl | jq step fails with status 5 (jq 1.7) or 2 (jq 1.6). jq 1.6 prints the same message without the jq: prefix.

Causes, most likely first

  1. curl without -f received an error page (401, 404, 502) and passed it on.

    Check

    curl -sS -o body.json -w '%{http_code} %{content_type}\n' <url>   # not 200, or text/html: this is your cause
    head -c 200 body.json   # the start of the page instead of JSON

    Fix

    Use curl -fsS so HTTP errors stop the script before jq, and check the status code.

  2. A proxy or login page answered instead of the API.

    Check

    curl -sS -o /dev/null -w '%{http_code} %{redirect_url}\n' <url>   # a redirect to a login page

    Fix

    Call the API with its own credentials, past the proxy's login.

  3. A script printed log lines to stdout ahead of the JSON.

    Check

    <command> | head -3   # log lines before the {

    Fix

    Send log messages to stderr, and check input with jq -e . >/dev/null before reading values from it.

Prevent

Fail on HTTP errors, send log messages to stderr, and check input with jq -e . >/dev/null before reading values from it.

Now learn it: break and fix it yourself in curl says success on a 500, then take Level 07 · grep, sed, awk and friends. Open in Troubleshoot

jq: Cannot index array with string

jq: error (at <stdin>:3): Cannot index array with string "name"

What it means

The filter does not fit the shape of the data. "Cannot index array with string "name"" means .name was applied to a list; "Cannot iterate over null" means .[] met a missing key. (at <stdin>:3) is the input line. jq exits with status 5.

What you see

jq stops with status 5; the same family includes Cannot iterate over null and Cannot index string with string.

Causes, most likely first

  1. The API returns a list ([{...}]) and the filter treats it as one object.

    Check

    jq 'type' <file>   # "array": use .[] or .[0] first

    Fix

    Match the filter to the data: .[].name or .[0].name.

  2. Some records have no such key, so .items[] meets null.

    Check

    jq 'keys' <file>   # the key is missing from this record

    Fix

    Use .items[]? or (.items // [])[] where a key may be missing.

  3. An error response with a different shape from the normal one.

    Check

    jq '.[0]' <file>   # or jq . <file>: an error object instead of data

    Fix

    Check the HTTP status first (curl -fsS), and use jq -e so a missing value fails the script instead of printing null.

Prevent

Look at the shape once with jq type and jq keys, and use jq -e so a missing value fails the script instead of printing null.

Now learn it: take Level 07 · grep, sed, awk and friends. Open in Troubleshoot

Jobs and signals

A failed background job did not fail the script

upload-region: us: 403 Forbidden ... and the script exits 0

What it means

A job started with & reports its exit status only to a wait that names it (wait PID) or to wait -n. Plain wait with no arguments always returns 0, and set -e never sees background jobs, so their failures disappear.

What you see

Jobs started with & fail, yet the script reports success and exits 0, even with set -e.

Causes, most likely first

  1. cmd & ... wait with no PIDs. The bare wait returns 0 whatever the jobs did.

    Check

    grep -nE '^\s*wait\s*$' <script>   # a bare wait: this is your cause

    Fix

    Save each PID (pids+=($!)) and wait for each one: for p in "${pids[@]}"; do wait "$p" || failed=1; done, then exit non-zero if any failed.

  2. $! not saved after each job. Without the PID there is nothing to wait for by name.

    Check

    cmd & p=$!; wait "$p"; echo $?   # the status that was being lost

    Fix

    Save $! right after each &.

  3. Expecting set -e to cover jobs. It only sees commands in the foreground.

    Check

    jobs -l   # jobs still running when the script reports success

    Fix

    Wait for each job and check its status, or use xargs -P, whose exit status (123) reports a failure.

Prevent

Never use bare wait where failures matter; for many jobs use xargs -P, whose exit status (123) reports a failure.

Now learn it: break and fix it yourself in A failed background job is lost, then take Level 14 · Jobs, concurrency and retries. Open in Troubleshoot

wait: pid is not a child of this shell

./run.sh: line 7: wait: pid 4123 is not a child of this shell

What it means

wait can only wait for processes this same shell started. The PID in the message belongs to another shell, a subshell or another script, so wait returns 127 at once.

What you see

wait returns 127 straight away.

Causes, most likely first

  1. The job was started inside ( ), a pipeline or $( ). A subshell started it and owns it.

    Check

    jobs -l   # the PID is not in this shell's list

    Fix

    Start the job in the same shell that waits for it, and keep $! right after starting it.

  2. The PID came from a file or from pgrep, or the job was started by another script, even one this script called.

    Check

    ps -o ppid= -p <pid>   # its parent is not this shell's PID ($$)

    Fix

    To wait for an unrelated process, poll: while kill -0 "$pid" 2>/dev/null; do sleep 1; done.

Prevent

Keep job control in one place: start, record and wait in the same function.

Now learn it: break and fix it yourself in A failed background job is lost, then take Level 14 · Jobs, concurrency and retries. Open in Troubleshoot

Terminated

Terminated

What it means

A signal ended the process. The shell prints the signal's name: Terminated is TERM (exit status 143), Hangup is HUP (129), Killed is KILL (137). Its cleanup only ran if it had a trap.

What you see

A script or job stops part way and the shell prints Terminated (exit status 143), Hangup (129) or Killed (137).

Causes, most likely first

  1. systemd stopping or restarting the service, kill, or a deploy. They send TERM.

    Check

    journalctl -u <service> | tail   # Stopping or Restarting at that time: this is your cause

    Fix

    Clean up in trap '...' EXIT (bash runs it on TERM, HUP and INT too), and make reruns safe.

  2. The SSH session ending for a job started with &. The terminal going away sends HUP.

    Check

    echo $?   # 129: hangup

    Fix

    Run long work with nohup, setsid, tmux or a systemd unit.

  3. A timeout limit. timeout sends TERM when the time is up and returns 124.

    Check

    grep -n 'timeout' <script>   # the limit that applies

    Fix

    Raise the limit to what the work needs, or make the work faster.

  4. The out-of-memory killer. It sends KILL, which cannot be trapped; the shell prints Killed and the status is 137.

    Check

    dmesg | grep -i 'killed process'   # a line naming the process: memory

    Fix

    Give it the memory it needs, or reduce its memory use.

Prevent

Assume any script can be stopped at any line: register cleanup as soon as you create something, and make reruns safe.

Now learn it: break and fix it yourself in A stale lock after a stop · The job died with the SSH session, then take Level 10 · Strict mode and error handling. Open in Troubleshoot

nohup: ignoring input and appending output to 'nohup.out'

nohup: ignoring input and appending output to 'nohup.out'

What it means

Not an error. nohup makes a command ignore the hangup signal so it survives logout. Output to a terminal would be lost when the session ends, so nohup sends it to nohup.out in the current directory (or $HOME if that is not writable) and tells you so.

What you see

nohup prints this and the command's output goes to a file instead of the terminal.

Cause

  1. Running nohup cmd without redirecting its output. nohup only picks nohup.out when output would go to the terminal.

    Check

    tail nohup.out   # the command's output is here

    Fix

    Choose where output goes: nohup ./job.sh > job.log 2>&1 &.

Prevent

Always redirect output of long-running jobs to a named log file.

Now learn it: break and fix it yourself in The job died with the SSH session, then take Level 14 · Jobs, concurrency and retries. Open in Troubleshoot

Exit status 124 from timeout

curl: (28) Operation timed out after 10000 milliseconds with 0 bytes received

What it means

The command took longer than its limit. timeout sent it TERM and returned 124; curl -m gave up with 28, and the message says how long it waited and how much it received. Without a limit, network calls can wait for a very long time.

What you see

A command run under timeout ends with status 124, or curl reports (28) Operation timed out; or, without a limit, a cron job never finishes and copies pile up.

Causes, most likely first

  1. A server that accepts connections and never answers. The connection opens and then nothing comes back.

    Check

    curl -v --connect-timeout 5 -m 10 -o /dev/null <url>   # 'Connected' then silence: the server is slow or stuck

    Fix

    Fix the server, and keep a limit on the call: curl -m 10.

  2. A firewall or security group that drops the packets. The connection never opens, and curl waits until it times out.

    Check

    curl -v --connect-timeout 5 -m 10 -o /dev/null <url>   # stuck at 'Trying': nothing answers, usually a firewall

    Fix

    Allow the port from this host, or go through the right proxy.

  3. A limit shorter than the work needs, or no limit at all, so the job hangs.

    Check

    time <command>   # how long it really takes
    timeout 10 <command>; echo $?   # 124: it did not finish in 10 seconds

    Fix

    Give every network call a limit (timeout 10 cmd or curl -m 10) that fits the work, and treat 124 as a failure: || { echo 'timed out' >&2; exit 1; }.

Prevent

Put a time limit on everything a cron job calls, and on the job itself.

Now learn it: break and fix it yourself in A check that hangs for an hour, then take Level 14 · Jobs, concurrency and retries. Open in Troubleshoot

fork: retry: Resource temporarily unavailable

bash: fork: retry: Resource temporarily unavailable

What it means

Bash could not create a new process (fork failed with EAGAIN). Some limit on processes is reached: the user's, the service's, the container's or the system's. bash retries a few times, then gives up.

What you see

Commands fail to start, even ls, and a script that starts background jobs stalls or dies with this message.

Causes, most likely first

  1. A loop that starts & jobs without a limit, or a function that starts itself in the background.

    Check

    ps -L -u "$USER" --no-headers | wc -l   # threads, which is what the limit counts; near ulimit -u: the user limit
    ulimit -u   # the limit

    Fix

    Stop the runaway jobs (pkill -u <user> -f <script>), then cap concurrency: xargs -P 8, or count jobs and wait -n before starting another.

  2. A low per-user process limit on a shared host. It counts threads, so one Java or Node service can use most of it.

    Check

    ps -L -u "$USER" -o comm= | sort | uniq -c | sort -n | tail -3   # which programs hold the threads

    Fix

    Raise the limit for that user in /etc/security/limits.d/, or run the service as its own user.

  3. A systemd service with a small TasksMax.

    Check

    systemctl show -p TasksMax,TasksCurrent <unit>   # TasksCurrent near TasksMax: the unit's limit

    Fix

    Raise TasksMax= in a drop-in for that unit.

  4. A container's pids limit (--pids-limit, or the cgroup pids.max).

    Check

    cat /sys/fs/cgroup/pids.max /sys/fs/cgroup/pids.current   # inside a container (cgroup v2): current near max

    Fix

    Raise the container's pids limit, or reduce what runs in it.

Prevent

Put a limit on parallelism in every script that starts background jobs.

Now learn it: break and fix it yourself in One at a time with xargs, then take Level 14 · Jobs, concurrency and retries. Open in Troubleshoot

Exit status

What does exit status 126, 127, 130 or 137 mean?

(echo $? prints 1, 2, 126, 127, 128+n)

What it means

An exit status is the number a program hands back when it ends. 0 is success; 1 a general error; 2 misuse or a syntax error; 126 found but not executable; 127 not found; 128+n killed by signal n: 130 Ctrl+C, 137 SIGKILL (often the OOM killer), 139 SIGSEGV (the program crashed), 143 SIGTERM.

What you see

A job failed with a number and no message.

Causes, most likely first

  1. 126 or 127: the command could not be run at all. See the Permission denied and command not found entries.

    Check

    echo $?   # right after the command: 126 or 127

    Fix

    Fix the permission (126) or the name and PATH (127).

  2. 137: the process was killed with SIGKILL, in containers usually because it hit its memory limit.

    Check

    dmesg | grep -i 'killed process'   # a line naming it: the OOM killer
    kubectl describe pod <pod> | grep -i oomkilled   # in Kubernetes: Reason OOMKilled

    Fix

    Give it the memory it needs, or find what makes it grow.

  3. 130 or 143: it was stopped on purpose, by Ctrl+C or by kill, systemctl stop or timeout.

    Check

    journalctl -u <service> | tail   # a stop or restart at that time

    Fix

    See the Terminated entry; add cleanup with a trap.

Prevent

Have scripts exit with documented codes (0 ok, 1 failure, 2 bad usage).

Now learn it: break and fix it yourself in Permission denied · command not found, then take Level 00 · Your first script. Open in Troubleshoot

Script exits silently with set -e

(no message; exit status 1)

What it means

Under set -e, a command returned a non-zero status and bash ended the script. The command itself printed nothing, so there is no message. The exit status is that command's status, often 1.

What you see

A script with set -e stops part way with no error message.

Causes, most likely first

  1. grep found nothing. grep returns 1 for no match, and set -e treats that as a failure.

    Check

    bash -x <script> 2>&1 | tail -5   # the last traced line is the command that ended it

    Fix

    Add || true where no match is fine, or branch on it: if grep -q x file; then.

  2. (( count++ )) or let when the value was 0. An arithmetic command whose result is 0 returns status 1.

    Check

    bash -x <script> 2>&1 | tail -5   # (( count++ )) as the last line

    Fix

    Use count=$((count + 1)), which always succeeds.

  3. [ cond ] && action as the last line of a function, when cond is false. The function then returns 1, and the caller stops.

    Check

    trap 'echo "failed at line $LINENO: $BASH_COMMAND" >&2' ERR   # add near the top: prints the command that failed

    Fix

    Write if cond; then action; fi instead of cond && action.

  4. diff found differences, or cmp found the files differ. Both return 1 for "different", which is not an error.

    Check

    bash -x <script> 2>&1 | tail -5   # diff or cmp as the last line

    Fix

    Capture the status: rc=0; diff a b || rc=$?, and treat 2 or more as the real error.

Prevent

Add an ERR trap that prints the line, so silent exits are never silent.

Now learn it: break and fix it yourself in A clean day kills the script · ((count++)) ends the script, then take Level 10 · Strict mode and error handling. Open in Troubleshoot

set -e did not stop the script

(the script reports success after a failure)

What it means

set -e only stops the script for a failure in a simple command whose status nobody checks. It ignores failures inside pipelines (except the last command), in if and while conditions, left of && and ||, in local x=$(cmd), in a $(cmd) used as an argument (echo "$(cmd)"), and everywhere inside a function called from if, && or ||.

What you see

A step failed, yet the script carried on and said it succeeded.

Causes, most likely first

  1. cmd | gzip > file without pipefail. A pipeline's status is the last command's, so gzip's success hides cmd's failure.

    Check

    echo "${PIPESTATUS[@]}"   # right after the pipeline: a non-zero before the last number is your cause

    Fix

    Use set -o pipefail.

  2. local x=$(cmd). The status checked is that of local, which succeeded, not that of cmd.

    Check

    grep -nE 'local [a-z_]+=\$\(' <script>   # each line here hides a status

    Fix

    Declare, then assign: local x; x=$(cmd).

  3. The step is in a function called as if deploy; then or deploy || alert. bash turns set -e off for every line inside deploy while it runs in that position.

    Check

    bash -x <script>   # the trace continues past the failing line inside the function

    Fix

    Check the steps that matter explicitly inside the function (cmd || return 1), and do not rely on set -e there.

  4. $? read after another command. $? holds the status of the most recent command only, so an echo or [ in between replaces it.

    Check

    grep -n '\$?' <script>   # a line before it that is not the command you mean

    Fix

    Capture it at once: cmd; rc=$?, or test the command directly with if.

  5. A failed cd in a script without set -e, or in one of the places above. A plain failed cd does stop a set -e script.

    Check

    head -5 <script>   # no set -e: every unchecked command can fail and carry on

    Fix

    cd dir || exit 1, and start scripts with set -euo pipefail.

Prevent

Do not rely on set -e alone; check the steps that matter explicitly.

Now learn it: break and fix it yourself in set -e and a pipeline · local hides a failure · $? read too late · cd failed, rm ran anyway, then take Level 10 · Strict mode and error handling. Open in Troubleshoot

A variable is empty after a loop

(a counter or list set in the loop is gone afterwards)

What it means

The loop was on the right of a pipe, so bash ran it in a subshell, a separate copy of the shell. Variables it set died with that copy when the loop ended, and an exit inside it only leaves the copy.

What you see

A total or list built in a while read loop is 0 or empty after it, though the loop clearly ran.

Cause

  1. cmd | while read ...; do total=...; done. Each part of a pipeline runs in its own subshell.

    Check

    grep -n '| *while' <script>   # a line here that sets variables or calls exit is your cause

    Fix

    Redirect into the loop instead: while read -r line; do ...; done < file or done < <(cmd).

Prevent

Avoid piping into loops that set variables or call exit, and give commands inside read loops their own input (ssh -n).

Now learn it: break and fix it yourself in A counter stays at 0 · exit in a piped loop · ssh eats the loop's input, then take Level 04 · Loops. Open in Troubleshoot

The last line of a file is skipped

(while read never sees the final line)

What it means

read returns 1 when it hits the end of the file before a newline. The last line has no newline after it, so read fills the variable but reports failure, and the while loop ends without running the body.

What you see

A while read loop handles every line but the last.

Cause

  1. The file does not end with a newline. Files from some editors, exports or printf without a final \n do this.

    Check

    tail -c 1 <file> | od -c   # no \n shown: the last line is unterminated

    Fix

    while read -r line || [[ -n $line ]]; do.

Prevent

Use that loop form for any file you did not write yourself.

Now learn it: break and fix it yourself in The last line is skipped, then take Level 04 · Loops. Open in Troubleshoot

write error: Broken pipe (exit status 141)

cat: write error: Broken pipe

What it means

The reader at the end of a pipe exited before the writer finished. The writer was killed by SIGPIPE (signal 13), so its status is 128 + 13 = 141, and set -o pipefail reports that as the pipeline's failure. The message itself appears only when SIGPIPE is ignored, for example after trap '' PIPE.

What you see

With set -o pipefail, a pipeline such as cmd | head -1 fails with status 141, sometimes printing write error: Broken pipe.

Causes, most likely first

  1. head, grep -q or grep -m1 stopped reading early, as designed, and the pipeline runs under pipefail in strict mode.

    Check

    echo "${PIPESTATUS[@]}"   # right after it: 141 for the writer and 0 for head
    bash -o pipefail -c 'yes | head -1 >/dev/null; echo $?'   # prints 141: the same effect

    Fix

    Where stopping early is the point, avoid the pipe (grep -m1 pattern file, awk 'NR == 1' file), or accept 141 for that one pipeline: first=$(cmd | head -1) || [ $? -eq 141 ].

  2. The reading command crashed or failed.

    Check

    echo "${PIPESTATUS[@]}"   # a non-zero status for the last command too

    Fix

    Fix the reader; that is the real error.

Prevent

Keep pipefail on, and handle 141 only in the pipelines that are meant to stop early.

Now learn it: take Level 10 · Strict mode and error handling. Open in Troubleshoot

Cron and environment

Works in the terminal, fails from cron

(cron mails an error, or the job does nothing)

What it means

Cron runs the job in a much smaller environment than your terminal: /bin/sh for the crontab line, PATH=/usr/bin:/bin, your home directory as the working directory, and none of your profile or .bashrc. Anything the script takes from your terminal is missing.

What you see

A script works every time you run it, and fails when cron runs it.

Causes, most likely first

  1. A program outside /usr/bin and /bin. With cron's short PATH it is not found (look for command not found in cron's mail or log).

    Check

    env -i HOME=$HOME PATH=/usr/bin:/bin /bin/sh -c '/path/to/script.sh'   # fails the same way: the environment is the cause

    Fix

    Set PATH at the top of the script, or call programs by their full path.

  2. Variables exported in .bashrc or your profile. Cron does not read them.

    Check

    grep -n 'export' ~/.bashrc ~/.profile   # anything the script relies on

    Fix

    Have the script load its own config by absolute path: . /etc/app/env.

  3. Relative paths. Cron starts the job in your home directory, not the script's.

    Check

    grep -nE '(^| )\./' <script>   # paths starting with ./

    Fix

    Use absolute paths, or cd "$(dirname "${BASH_SOURCE[0]}")" || exit 1 first.

  4. No #! line, so sh runs it, or % in the crontab line, which cron turns into a newline.

    Check

    head -1 <script>   # no #!: sh runs it
    crontab -l | grep -n '%'   # an unescaped % cuts the command

    Fix

    Add #!/usr/bin/env bash, and escape % as \% in crontab.

  5. The job runs as another user (root's crontab or /etc/cron.d), or never started. Another user has a different HOME, keys and permissions.

    Check

    journalctl -u cron -u crond --since today   # or grep CRON /var/log/syslog: did it start, and as which user
    crontab -l; sudo ls /etc/cron.d   # where the job is really defined

    Fix

    Put the job in the crontab of the user it should run as.

Prevent

Test every cron job with env -i before adding it, and send its output to a log: >> /var/log/job.log 2>&1.

Now learn it: break and fix it yourself in Works in the terminal, not in cron · A variable from .bashrc · [[: not found · source finds nothing from cron · rm -rf with an empty variable, then take Level 12 · Cron and the environment. Open in Troubleshoot

curl: (22) The requested URL returned error

curl: (22) The requested URL returned error: 503

What it means

curl gave up and its exit status says why. 22 means the server answered with an HTTP error (400 or above) and -f was used; the code after the colon is the HTTP status. 6 is DNS, 7 a connection refused or unreachable, 28 a timeout, 60 a TLS certificate curl could not verify.

What you see

A download or API call fails in a script; also curl: (6) Could not resolve host and curl: (7) Failed to connect.

Causes, most likely first

  1. The server is down or busy (503, 502, 429). It answered, but with an error.

    Check

    curl -sS -o /dev/null -w '%{http_code} %{remote_ip}\n' <url>   # 5xx or 429: the server side; note the address curl reached

    Fix

    Retry with a limit and backoff (scripts/retry.sh), and alert if it keeps failing.

  2. A 401 or 403. The credentials are missing or wrong, or belong to another environment.

    Check

    curl -sS -o /dev/null -w '%{http_code}\n' <url>   # 401 or 403: authentication, not the network

    Fix

    Fix the credentials the job uses; do not retry.

  3. A wrong host name, or no DNS from that machine (6), or the wrong address.

    Check

    getent hosts <host>   # nothing: DNS fails here; an unexpected IP: wrong record

    Fix

    Fix the name or the resolver on that host.

  4. A firewall, or the service not listening (7).

    Check

    curl -v --connect-timeout 5 <url> 2>&1 | grep -E 'Trying|Connected|refused'   # refused: nothing listens; no Connected: blocked

    Fix

    Start the service, or open the port from this host.

  5. A proxy setting (https_proxy, no_proxy) present in your shell but not in the job, or the reverse.

    Check

    [ -n "${https_proxy}${HTTPS_PROXY}" ] && echo 'a proxy is set'   # compare your shell with the job, without printing it

    Fix

    Set the same proxy variables in the job's environment.

  6. (60) a missing CA bundle, or a proxy that re-signs TLS. curl cannot trust the server's certificate.

    Check

    curl -v <url> 2>&1 | grep -E 'issuer|CAfile'   # an issuer you do not expect: a re-signing proxy

    Fix

    Give curl the right CA bundle with --cacert. Do not use -k, which accepts any server.

Prevent

Always use curl -fsS in scripts so errors are not mistaken for content.

Now learn it: break and fix it yourself in Retry with backoff · curl says success on a 500 · unary operator expected, then take Level 14 · Jobs, concurrency and retries. Open in Troubleshoot

sudo: a terminal is required to read the password

sudo: a terminal is required to read the password; either use the -S option to read from standard input or configure an askpass helper

What it means

sudo needs a password for this command and there is no terminal to ask on. It does not wait silently: it fails. With sudo -n (never ask) the message is sudo: a password is required.

What you see

A script that works when you run it fails at its first sudo under cron, CI or ssh host command. With sudo -n the message is sudo: a password is required.

Causes, most likely first

  1. The job runs without a terminal (cron, CI, ssh host cmd), and the sudoers rule for this user asks for a password.

    Check

    sudo -n true; echo $?   # 1 and 'a password is required': no passwordless rule

    Fix

    Run the job as a user that already has the rights it needs, or allow exactly that command without a password in a drop-in edited with visudo -f /etc/sudoers.d/deploy: deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart app.

  2. The rule allows a different command or path than the one the script runs. sudo then falls back to asking for a password.

    Check

    sudo -n -l   # the commands allowed without a password; compare the exact path and arguments

    Fix

    Make the script run exactly the allowed command (full path), or correct the rule. Use sudo -n in scripts so they fail at once instead of waiting. Never feed a password to sudo -S from a script or a file.

Prevent

Never feed a password to sudo -S from a script or a file. Give automation narrow rules for the exact commands, and test the job with sudo -n.

Now learn it: take Level 15 · Security. Open in Troubleshoot

Cloud and databases

Unable to locate credentials

Unable to locate credentials. You can configure credentials by running "aws configure".

What it means

The AWS CLI went through its whole credential chain (environment variables, the shared credentials and config files, SSO, the container or instance role) and found nothing for this process. The CLI works, it has nothing to sign the request with.

What you see

An AWS CLI call that works in your terminal fails from cron, a container or CI.

Causes, most likely first

  1. Cron or another user runs the job with a different HOME, so ~/.aws is not the one you configured.

    Check

    aws sts get-caller-identity   # run as the job's user: the same error means it has no identity
    id -un; echo "$HOME"   # in the job: another user or HOME

    Fix

    Give that user its own profile, or better a role (below).

  2. AWS_PROFILE is set in your shell but not exported to the job.

    Check

    env | grep -E '^AWS_' | cut -d= -f1   # names only, never values; compare your shell with the job
    aws configure list   # the Type column shows where each value came from (env, shared-credentials-file, iam-role); keys are masked

    Fix

    Export AWS_PROFILE in the job's environment, or pass --profile.

  3. On EC2 the instance has no role, or the metadata service needs IMDSv2 and the hop limit is too low for a container.

    Check

    aws ec2 describe-instances --instance-ids <id> --query 'Reservations[].Instances[].[IamInstanceProfile.Arn,MetadataOptions.HttpPutResponseHopLimit]'   # None: no role; 1 with containers: raise to 2

    Fix

    Attach an instance role with only the permissions the job needs, and set the hop limit to 2 for containers.

  4. SSO credentials expired and need aws sso login.

    Check

    aws configure list   # Type sso, and get-caller-identity fails

    Fix

    Run aws sso login for people; give unattended jobs a role instead.

  5. The command runs under sudo, which uses root's HOME and drops your AWS_* variables.

    Check

    sudo aws configure list   # nothing found as root

    Fix

    Do not run the AWS CLI under sudo; run the job as the user that has the profile or role.

Prevent

Start AWS scripts with aws sts get-caller-identity (or a preflight check) so a missing identity fails loudly before any work.

Now learn it: take Level 18 · AWS from the shell. Open in Troubleshoot

ExpiredToken: the security token is expired

An error occurred (ExpiredToken) when calling the DescribeInstances operation: The security token included in the request is expired

What it means

The request was signed with temporary credentials (from an assumed role, SSO or a session token) whose lifetime has ended. They were valid when the job started and expired before it finished, so AWS rejects them.

What you see

A long job or a job started from an old shell fails part way with ExpiredToken (or RequestExpired).

Causes, most likely first

  1. Credentials copied into environment variables from an earlier aws sts assume-role or aws sso login. Exported keys win over the profile, so the CLI never refreshes them.

    Check

    aws configure list   # Type 'env' for access_key: exported keys are being used, not the profile

    Fix

    Let the CLI refresh credentials itself: use a profile with role_arn and source_profile (or credential_process), or the instance role, and unset the exported keys.

  2. A job that runs longer than the role's maximum session duration. Even a refreshable session can hit that limit when the keys were fixed at the start.

    Check

    aws iam get-role --role-name <role> --query Role.MaxSessionDuration   # in seconds; shorter than the job

    Fix

    Use a profile or role the CLI can renew, or split the job.

  3. The host clock is wrong. The CLI does not refresh in time, and requests may fail with RequestExpired or Signature expired instead.

    Check

    date -u; timedatectl show -p NTPSynchronized   # minutes off, or NTPSynchronized=no: the clock

    Fix

    Fix the clock with NTP.

Prevent

Do not export temporary keys into long-running jobs; use profiles or roles the CLI can renew.

Now learn it: take Level 18 · AWS from the shell. Open in Troubleshoot

aws s3 sync --delete removed the backups

delete: s3://acme-backups/db/shop-20261029-020000.dump

What it means

aws s3 sync --delete makes the destination match the source, removing anything the source does not have. Each delete: line is an object it removed. When the source is empty (a disk that did not mount, a wrong path, a failed export), matching it means deleting everything.

What you see

After a backup sync the bucket is empty or missing objects, and the job reported success.

Causes, most likely first

  1. The backup volume was not mounted, so the folder existed but was empty.

    Check

    mountpoint <source>   # 'is not a mountpoint': the volume was missing
    ls -A <source> | head   # empty: this is your cause

    Fix

    Check the mount before syncing (mountpoint -q) and refuse an empty source. scripts/s3_backup_sync.sh does both.

  2. A variable for the source path was empty or wrong, so sync read the wrong directory.

    Check

    aws s3 sync <source> s3://<bucket>/<prefix> --delete --dryrun | grep -c delete:   # a large count before a real run: stop

    Fix

    Require the variable (${SRC:?}) and run --dryrun first.

  3. The order of source and destination was swapped, so the bucket was made to match an old local copy.

    Check

    grep -n 's3 sync' <script>   # the first path is the source

    Fix

    Put the source first.

  4. Recovery, for any of the above: on a versioned bucket a deleted object is only hidden behind a delete marker.

    Check

    aws s3api list-object-versions --bucket <bucket> --prefix <prefix> --max-items 5   # DeleteMarkers listed: the old versions can be restored

    Fix

    Restore the objects by deleting the delete markers (or copying the previous versions back), then fix the job.

Prevent

Turn on bucket versioning and Object Lock or a lifecycle rule for old versions. Check the source (mountpoint -q, a minimum file count), and run --dryrun in review. scripts/s3_backup_sync.sh refuses an empty source.

Now learn it: break and fix it yourself in sync --delete emptied the bucket, then take Level 18 · AWS from the shell. Open in Troubleshoot

pg_dump: server version mismatch

pg_dump: error: aborting because of server version mismatch

What it means

pg_dump connected, found the server is a newer major version than itself, and stopped, because it may not understand the newer catalogue. The detail line gives both versions: the server's first, then pg_dump's.

What you see

The nightly backup starts failing after the database was upgraded. The next line says pg_dump: detail: server version: 16.4; pg_dump version: 15.8.

Causes, most likely first

  1. The database (often a managed service such as RDS) was upgraded and the backup host was not.

    Check

    psql -Atc 'show server_version'   # the server's major version
    pg_dump --version   # an older major version: this is your cause

    Fix

    Install the client tools of the server's major version (or newer) on the backup host.

  2. Several PostgreSQL versions are installed and PATH picks the old pg_dump.

    Check

    type -a pg_dump   # the one the job runs comes first
    ls /usr/lib/postgresql/   # Debian and Ubuntu; /usr/pgsql-<n>/bin on RHEL: the newer one is already there

    Fix

    Call the right one by its full path, for example /usr/lib/postgresql/16/bin/pg_dump on Debian.

Prevent

Upgrade the backup host's client tools as part of every database upgrade, and alert on a failed backup the same night.

Now learn it: take Level 19 · Database backups you can restore. Open in Troubleshoot

fe_sendauth: no password supplied

pg_dump: error: connection to server at "db.internal" (10.0.2.15), port 5432 failed: fe_sendauth: no password supplied

What it means

The server asked the client to authenticate with a password, and libpq (the PostgreSQL client library) had none to send: no matching ~/.pgpass line it could use, and no PGPASSWORD. The message gives the host, address and port it connected to.

What you see

The backup works when you run it by hand and fails from cron, or it hangs waiting for a password prompt.

Causes, most likely first

  1. ~/.pgpass belongs to another user, or cron runs as a different user or HOME. libpq reads the file of the user running the job.

    Check

    id -un; echo "$HOME"; ls -l "${PGPASSFILE:-$HOME/.pgpass}"   # in the job: No such file, or another owner, is your cause

    Fix

    Create ~/.pgpass for the user that runs the job, with host:port:database:user:password.

  2. Its permissions are wider than 0600, so libpq ignores it. It prints a warning saying so just before this error.

    Check

    stat -c '%a %U' "${PGPASSFILE:-$HOME/.pgpass}"   # anything but 600 and the job's user

    Fix

    chmod 600 ~/.pgpass.

  3. The host, port, database or user in the file does not match the connection. The host is compared as text, so db.internal and its IP are different entries.

    Check

    cut -d: -f1-4 "${PGPASSFILE:-$HOME/.pgpass}"   # the host:port:db:user keys, without the passwords; compare with -h, -p, -d and -U

    Fix

    Add a line that matches exactly how the job connects, or use * for a field.

  4. By hand you connect over the Unix socket, where pg_hba.conf allows peer; the job uses -h, which hits a host rule that needs a password.

    Check

    PGCONNECT_TIMEOUT=5 psql -w -h db.internal -U backup -d shop -c 'select 1'   # the same connection as the job, never prompting: this error here, but psql without -h works

    Fix

    Give the job a .pgpass entry for the TCP connection, and pass --no-password (or -w) so a missing password fails at once instead of waiting.

Prevent

Run backups non-interactively with -w; never put the password on the command line or in the script.

Now learn it: take Level 19 · Database backups you can restore. Open in Troubleshoot

mysqldump needs the PROCESS privilege

mysqldump: Error: 'Access denied; you need (at least one of) the PROCESS privilege(s) for this operation' when trying to dump tablespaces

What it means

mysqldump tried to read tablespace information, which since MySQL 8.0.21 (and 5.7.31) needs the global PROCESS privilege. The account it logged in as does not have it, so the server refused.

What you see

After a MySQL upgrade, the backup user's dumps fail or print this error.

Causes, most likely first

  1. The backup user has SELECT, SHOW VIEW, TRIGGER, EVENT and LOCK TABLES, but not PROCESS. That is the usual least-privilege set.

    Check

    mysql --defaults-extra-file=/etc/mysql/backup.cnf -e 'SELECT CURRENT_USER(); SHOW GRANTS'   # with the job's own credentials: the account really used; no PROCESS in its grants

    Fix

    Add --no-tablespaces: most dumps do not need tablespace definitions. Grant PROCESS only if you do need them.

  2. The job logs in as a different account from the one you granted. MySQL picks the account by user name and the host it sees you connect from.

    Check

    mysql --defaults-extra-file=/etc/mysql/backup.cnf -e 'SELECT USER(), CURRENT_USER()'   # CURRENT_USER() is not the account you granted: another one matched

    Fix

    Grant to the account in CURRENT_USER(), or fix the job's user and host.

  3. The client was upgraded and the dump options were not.

    Check

    mysqldump --version   # 8.0.21 or later

    Fix

    Pin the dump options in one script (scripts/mysql_backup.sh) and test it before upgrading.

Prevent

Pin the dump options in one script (scripts/mysql_backup.sh) and test it against the new version before upgrading production.

Now learn it: take Level 19 · Database backups you can restore. Open in Troubleshoot

Using a password on the command line interface can be insecure

mysqldump: [Warning] Using a password on the command line interface can be insecure.

What it means

The mysql or mysqldump client was given a password on its command line (-p<password> or --password=) and warns you. Other users on the host can read command lines in ps and /proc, and the password also lands in shell history and cron files.

What you see

Cron mails this warning every night, or it shows up in the backup log.

Causes, most likely first

  1. mysqldump -u backup -pS3cret ... in a script or crontab.

    Check

    grep -nE -- '-p[^ ]|--password=' <script> | cut -d: -f1   # line numbers only, not the password
    crontab -l | grep -cE -- '-p[^ ]|--password='   # a count above 0: the crontab has one

    Fix

    Put the credentials in an option file ([client] with user= and password=), chmod 600 it, and pass --defaults-extra-file=/etc/mysql/backup.cnf as the first option. Then change the password, because it has been exposed.

  2. --password=... added by a wrapper that builds the command line, such as a backup tool or a Makefile.

    Check

    ps -o args= -C mysqldump | grep -c -- '--password='   # while it runs: a count above 0, without printing it

    Fix

    Configure the wrapper to use an option file. Do not move the password to MYSQL_PWD: that hides the warning but is deprecated and no safer.

Prevent

Review scripts for passwords on command lines; scripts/scan_for_secrets.sh and a secret scanner in CI catch many.

Now learn it: break and fix it yourself in A token in the CI log, then take Level 19 · Database backups you can restore. Open in Troubleshoot

Kubernetes

deployment exceeded its progress deadline

error: deployment "api" exceeded its progress deadline

What it means

kubectl rollout status waited for the new pods of the Deployment and they did not become ready within progressDeadlineSeconds (600 by default). Kubernetes marks the rollout failed but does not roll back on its own, so some old pods keep serving next to broken new ones.

What you see

kubectl rollout status fails after several minutes; new pods crash or never become ready while some old ones keep serving.

Causes, most likely first

  1. The new image crashes on start (CrashLoopBackOff) or fails its readiness probe. The pods exist but never count as ready.

    Check

    kubectl get pods -l <selector>   # CrashLoopBackOff, or Running with 0/1 READY: this is your cause
    kubectl logs <new-pod> --previous   # why the last start crashed

    Fix

    Roll back with kubectl rollout undo deployment/<name> and confirm with rollout status. Then fix the image or the probe and release again.

  2. The image cannot be pulled (ImagePullBackOff): a wrong tag, or no pull secret for a private registry.

    Check

    kubectl describe pod <new-pod> | tail -20   # Failed to pull image ... not found, or unauthorized

    Fix

    Fix the tag, or add the pull secret to the service account. Roll back first if users are affected.

  3. Not enough CPU or memory to schedule the new pods. They stay Pending.

    Check

    kubectl describe pod <new-pod> | tail -20   # FailedScheduling: Insufficient cpu or memory

    Fix

    Lower the requests to what the app needs, or add capacity.

  4. A ResourceQuota or LimitRange rejects the new pods, so the ReplicaSet never creates them.

    Check

    kubectl get events -n <ns> --sort-by=.lastTimestamp | tail   # 'exceeded quota' or FailedCreate: no pods were made at all

    Fix

    Raise the quota, or lower the requests, so the rollout's extra pods fit.

Prevent

Make the deploy script undo automatically when rollout status --timeout fails, and fail the job (scripts/k8s_rollout.sh).

Now learn it: break and fix it yourself in A failed rollout left running, then take Level 20 · Kubernetes operations. Open in Troubleshoot

Cannot evict pod: it would violate the disruption budget

error when evicting pods/"api-7c9d8b6f5-x2lq4" -n "shop" (will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.

What it means

kubectl drain evicts pods through the Eviction API, which respects PodDisruptionBudgets (PDBs). Evicting this pod now would leave fewer healthy pods than its budget allows, so the API refused, and drain retries every 5 seconds.

What you see

kubectl drain retries the same pod forever, or until --timeout.

Causes, most likely first

  1. A PDB that can never allow a disruption: minAvailable equal to the replica count, or maxUnavailable: 0.

    Check

    kubectl get pdb -A   # ALLOWED DISRUPTIONS 0 with all replicas healthy: the budget itself is the cause

    Fix

    Scale the Deployment up first so the budget allows one eviction, then fix the budget (maxUnavailable: 1).

  2. Other replicas are not ready, so the budget is already used up.

    Check

    kubectl get pods -n <ns> -l <selector> -o wide   # replicas not READY elsewhere

    Fix

    Fix or wait for the unready replicas; the drain then continues.

  3. All replicas run on the node being drained, so the next one cannot leave until the first is ready elsewhere.

    Check

    kubectl get pods -n <ns> -l <selector> -o wide   # every replica on this node

    Fix

    Wait for the evicted replicas to become ready on other nodes, and spread replicas with topology spread constraints. Do not bypass the budget with --disable-eviction.

Prevent

Run at least two replicas spread across nodes, and set budgets that allow one disruption (maxUnavailable: 1).

Now learn it: take Level 20 · Kubernetes operations. Open in Troubleshoot

cannot delete Pods with local storage

error: unable to drain node "ip-10-0-3-17": cannot delete Pods with local storage (use --delete-emptydir-data to override): shop/cache-0

What it means

drain found pods with an emptyDir volume, scratch space that lives on the node and is deleted with the pod. It stops before evicting anything so you do not lose that data without saying so. The message lists the pods as namespace/name.

What you see

Drain stops before evicting anything and names pods with emptyDir volumes.

Causes, most likely first

  1. Caches, scratch space or buffers kept in emptyDir. That data is meant to be disposable.

    Check

    kubectl get pod <pod> -n <ns> -o jsonpath='{.spec.volumes}'   # emptyDir used for cache or tmp

    Fix

    Drain with --delete-emptydir-data (-e in scripts/k8s_node_drain.sh).

  2. Data that should be on a PersistentVolume is in emptyDir.

    Check

    kubectl exec -n <ns> <pod> -- du -sh <mount path>   # data you need to keep

    Fix

    Move it to a PersistentVolume first, then drain.

Prevent

Keep anything you need to keep out of emptyDir.

Now learn it: take Level 20 · Kubernetes operations. Open in Troubleshoot

cannot delete Pods declare no controller

error: unable to drain node "ip-10-0-3-17": cannot delete Pods declare no controller (use --force to override): default/debug-shell

What it means

drain found a pod with no controller (no Deployment, StatefulSet, Job, ReplicaSet or DaemonSet owns it). Once evicted, nothing recreates it, so drain refuses unless you pass --force. The message names the pod as namespace/name.

What you see

Drain refuses to start because of a bare pod.

Causes, most likely first

  1. A debug pod started with kubectl run, left behind.

    Check

    kubectl get pod <pod> -n <ns> -o jsonpath='{.metadata.ownerReferences}'   # empty: no controller

    Fix

    Delete it yourself if it is a leftover, then drain.

  2. A workload created from a manifest as a bare Pod.

    Check

    kubectl get pod <pod> -n <ns> -o yaml | head -20   # kind: Pod with no ownerReferences, and labels of a real app

    Fix

    Recreate it under a controller (a Deployment) first. --force deletes it for good.

Prevent

Run workloads only through controllers, and clean up debug pods.

Now learn it: take Level 20 · Kubernetes operations. Open in Troubleshoot

Fleet and drift

Host key verification failed

Host key verification failed.

What it means

ssh does not trust the server's host key: it is not in known_hosts, or it is different from the one stored there. In BatchMode, or without a terminal, ssh will not ask, so it refuses to connect. The host key is how ssh knows it is talking to the right server.

What you see

A loop over hosts fails on new or rebuilt servers, or hangs on Are you sure you want to continue connecting (yes/no)? when run by hand.

Causes, most likely first

  1. A new host not yet in known_hosts.

    Check

    ssh-keygen -F <host>   # nothing: the host is unknown

    Fix

    Check the key's fingerprint against the host (console, cloud API or your inventory), then add it: ssh-keyscan <host> >> ~/.ssh/known_hosts.

  2. A rebuilt host with a new key. ssh prints REMOTE HOST IDENTIFICATION HAS CHANGED.

    Check

    ssh-keyscan <host> 2>/dev/null | ssh-keygen -lf -   # the fingerprint the host offers now; compare with the console or inventory

    Fix

    If it matches the rebuilt host, remove the old line with ssh-keygen -R <host> and add the new key.

  3. A different user (cron, CI) with its own empty known_hosts.

    Check

    sudo -u <job user> ssh-keygen -F <host>   # nothing for that user

    Fix

    Give that user the checked keys too, or a shared /etc/ssh/ssh_known_hosts.

  4. An IP address or name now used by a different server (common with cloud IPs), or a real man-in-the-middle.

    Check

    ssh-keyscan <host> 2>/dev/null | ssh-keygen -lf -   # a fingerprint that matches no server you own: stop and investigate

    Fix

    Do not connect until you know which server it is. Never set StrictHostKeyChecking=no to make a loop pass: that accepts any server, including an attacker's.

Prevent

Distribute host keys from your inventory or use SSH certificates. Never set StrictHostKeyChecking=no to make a loop pass: that accepts any server, including an attacker's.

Now learn it: break and fix it yourself in ssh eats the loop's input, then take Level 21 · Fleet operations and drift. Open in Troubleshoot

Permission denied (publickey)

deploy@web-02: Permission denied (publickey).

What it means

The connection reached sshd, but the server accepted none of the keys ssh offered for that user, and public keys were the only method it allowed. The text before : is the user and host that were tried.

What you see

Some hosts in a fleet run fail at once; others work.

Causes, most likely first

  1. The key is not in that host's authorized_keys for this user, or the user name is wrong for that host.

    Check

    ssh -v -o BatchMode=yes <user>@<host> true 2>&1 | grep -E 'Offering|Authentications'   # the key was offered and refused: not authorised for this user

    Fix

    Add the job's public key to the host for the right user.

  2. Cron or CI runs without your ssh agent or key file, so it offers nothing.

    Check

    ssh-add -l   # in the job: 'Could not open a connection' or no identities
    ssh -v -o BatchMode=yes <user>@<host> true 2>&1 | grep -c Offering   # 0: no key offered

    Fix

    Point ssh at the key with -i in the job.

  3. authorized_keys, ~/.ssh or the home directory has permissions or an owner sshd rejects. sshd then ignores the file.

    Check

    sudo journalctl -u ssh -u sshd | tail   # on the server: 'bad ownership or modes' names the file

    Fix

    chmod go-w ~; chmod 700 ~/.ssh; chmod 600 ~/.ssh/authorized_keys, owned by that user.

  4. An RSA key used with an old server or client that can only sign with ssh-rsa (SHA-1), which OpenSSH 8.8 and later refuse by default.

    Check

    ssh -v <user>@<host> true 2>&1 | grep -iE 'no mutual signature|ssh-rsa'   # a line here: the signature algorithm

    Fix

    Replace old RSA keys with ed25519, rather than re-enabling ssh-rsa.

Prevent

Use one deploy identity managed by configuration management, and test new hosts in the inventory before they join the fleet.

Now learn it: take Level 21 · Fleet operations and drift. Open in Troubleshoot

ssh: connect to host port 22: Connection refused

ssh: connect to host 10.0.2.7 port 22: Connection refused

What it means

ssh could not open a TCP connection to the host. The last words say how it failed. Refused: the host (or a firewall that rejects rather than drops) answered that nothing listens on that port. Timed out: nothing answered. No route to host: often a host firewall rejecting, as firewalld does by default. Could not resolve hostname: DNS.

What you see

A run over many hosts fails on one host at once (refused), after a long wait (timed out), or on a name (could not resolve hostname).

Causes, most likely first

  1. sshd is stopped or listens on another port (refused).

    Check

    nc -vz -w 5 <host> 22   # refused: nothing listens on 22
    ssh -v -o ConnectTimeout=5 <user>@<host> true   # 'Connection refused' after 'Connecting to'

    Fix

    Start sshd on the host (through the console), or connect to the right port with -p.

  2. A firewall or security group drops port 22 from where you are (timed out), or a host firewall rejects it (refused or no route to host).

    Check

    nc -vz -w 5 <host> 22   # timed out: dropped; compare with a host that works from the same place

    Fix

    Allow port 22 from your address only, or go through the bastion.

  3. The host is down, or its name points at an old address.

    Check

    getent hosts <host>   # the address ssh will use; compare with the inventory

    Fix

    Fix the DNS record or the inventory, or start the host.

  4. Connecting to a private address from outside the network.

    Check

    getent hosts <host>   # 10.x, 172.16-31.x or 192.168.x from outside

    Fix

    Connect through the VPN or the bastion (ssh -J bastion <host>).

Prevent

Use -o ConnectTimeout=10 -o BatchMode=yes in loops so one host cannot stall the run, and report unreachable hosts apart from failed commands.

Now learn it: take Level 21 · Fleet operations and drift. Open in Troubleshoot

UNPROTECTED PRIVATE KEY FILE: permissions are too open

Permissions 0644 for '/home/deploy/.ssh/id_ed25519' are too open.

What it means

ssh refuses to use a private key that other users can read, and says so in a warning box. The message gives the file and its mode, here 0644 (readable by everyone). It then carries on without the key, which usually ends in Permission denied (publickey).

What you see

ssh prints a warning box, ignores the key, and then fails with Permission denied (publickey).

Causes, most likely first

  1. The key was copied or unpacked with default permissions (0644), or a CI job wrote it from a secret without setting its mode.

    Check

    stat -c '%a %U' <key>   # anything but 600 or 400, or another owner: this is your cause

    Fix

    chmod 600 <key> and chmod 700 ~/.ssh, owned by the user who runs ssh. In CI, write it with install -m 600.

  2. The key is on a mounted drive that does not keep Unix permissions, such as a Windows drive under WSL. chmod has no effect there.

    Check

    chmod 600 <key>; stat -c '%a' <key>   # still 777 or 644: the drive cannot keep the mode

    Fix

    Copy the key into ~/.ssh on the Linux file system and use it from there.

Prevent

Create key files with umask 077 or install -m 600, and keep them out of shared folders.

Now learn it: take Level 21 · Fleet operations and drift. Open in Troubleshoot

nginx: [emerg] ... configuration file test failed

nginx: [emerg] unknown directive "proxy_passs" in /etc/nginx/sites-enabled/shop:12

What it means

nginx could not load its configuration or open something it names, and [emerg] means it will not run with it. The message names the problem and the file and line. A reload keeps the old workers running; a restart stops them and cannot start new ones.

What you see

A reload changes nothing, or a restart takes the site down, after a config change.

Causes, most likely first

  1. A typo, a missing semicolon, or a directive from a module that is not loaded.

    Check

    sudo nginx -t   # names the file and line of the parse error

    Fix

    Fix that line, run nginx -t again, then reload (not restart) and check the site answers.

  2. An include that points to a missing file or certificate.

    Check

    sudo nginx -t   # 'No such file' or 'cannot load certificate' with the path

    Fix

    Restore the file or fix the path.

  3. The port is taken: bind() to 0.0.0.0:80 failed (98: Address already in use) at start, which nginx -t does not catch.

    Check

    sudo ss -ltnp 'sport = :80'   # another process holds the port

    Fix

    Stop the other process, or move one of them to another port.

  4. You edited a file that is not loaded, or the service starts nginx with another -c file.

    Check

    sudo nginx -T | grep '^# configuration file'   # the files really loaded
    systemctl cat nginx | grep ExecStart   # a -c option points elsewhere

    Fix

    Edit the file that is loaded, or include yours from it.

Prevent

Deploy config with a test-then-reload step (scripts/nginx_safe_reload.sh) and never with systemctl restart nginx.

Now learn it: take Level 21 · Fleet operations and drift. Open in Troubleshoot

terraform plan -detailed-exitcode exits 2

Error: Process completed with exit code 2.

What it means

With -detailed-exitcode, terraform plan exits 0 for no changes, 1 for an error and 2 for "the plan has changes". Terraform worked; the script or CI treated 2 as a failure like 1.

What you see

A drift check or CI job using -detailed-exitcode fails, or pages on-call, whenever there are changes, though Terraform itself worked.

Causes, most likely first

  1. The status is not captured, so set -e (or the CI step) stops the script on 2.

    Check

    terraform plan -detailed-exitcode -input=false > /dev/null; echo $?   # 2: changes, not an error

    Fix

    Capture the status and branch on it: rc=0; terraform plan -detailed-exitcode ... || rc=$?; case $rc in 0) ...;; 2) ...;; *) ...;; esac.

  2. if terraform plan ...; then sends drift to the error branch, because 2 is not 0.

    Check

    grep -n 'detailed-exitcode' <script>   # used inside if or with &&

    Fix

    Use the || rc=$? and case form. scripts/terraform_drift_check.sh reports 0, 1 drift and 2 error.

Prevent

Treat any multi-valued status (terraform, grep, diff) with || rc=$? and case. scripts/terraform_drift_check.sh reports 0, 1 drift and 2 error.

Now learn it: break and fix it yourself in Drift pages on-call as an outage, then take Level 21 · Fleet operations and drift. Open in Troubleshoot

Error acquiring the state lock

Error: Error acquiring the state lock

What it means

Terraform takes a lock on the state before reading or writing it, so two runs cannot change it at once. It could not get the lock. When another run holds it, Terraform prints Lock Info: the lock ID, who holds it, the operation and when it was taken.

What you see

A plan or apply fails at once, naming a lock ID, who holds it and when it was taken.

Causes, most likely first

  1. Someone is applying right now, or a drift check or CI plan holds the lock while it runs.

    Check

    terraform plan -lock-timeout=60s   # succeeds after a wait: it was a run in progress

    Fix

    Wait, or use -lock-timeout.

  2. A run was killed and left a stale lock.

    Check

    aws dynamodb get-item --table-name <lock-table> --key '{"LockID":{"S":"<bucket>/<key>"}}'   # Info shows who holds it and since when (the <key>-md5 item is the state checksum, not the lock)
    aws s3api head-object --bucket <bucket> --key <key>.tflock   # with use_lockfile = true (Terraform 1.10 and later)

    Fix

    Remove a lock with terraform force-unlock <ID> only after you know its run is dead.

  3. The lock call itself failed: no permission on the lock table (or the S3 lock file), or a wrong table name or region. The detail then shows an AWS error such as AccessDeniedException instead of Lock Info.

    Check

    aws sts get-caller-identity   # the identity Terraform runs as
    aws dynamodb describe-table --table-name <lock-table> --region <region>   # ResourceNotFoundException: wrong name or region

    Fix

    Give that identity dynamodb:GetItem, PutItem and DeleteItem on the lock table only, or fix the backend's table name and region.

Prevent

Read-only checks such as drift detection can plan with -lock=false so they never block an apply; applies keep the lock.

Now learn it: take Level 21 · Fleet operations and drift. Open in Troubleshoot

Data hygiene

Personal data found in application logs

/var/log/shop/app.log:1042: Aadhaar (…46)

What it means

Logs hold personal data (e-mail addresses, phone numbers, PAN or Aadhaar numbers). The application logged whole requests, objects or error messages, and every copy of those logs (log platform, backups, tickets) now holds the data too. The example line gives the file, the line and the kind of data found, masked.

What you see

A scan, an audit or a customer complaint finds e-mail addresses, phone numbers, PAN or Aadhaar numbers in logs that many people and systems can read.

Causes, most likely first

  1. Logging request bodies or query strings.

    Check

    ./scripts/pii_scan.sh -c /var/log/shop   # counts per kind of data and file
    grep -c 'email=' /var/log/shop/app.log   # a count above 0: query strings with personal data are logged

    Fix

    Log ids and events instead of values.

  2. print(user) or a debug log level left on in production.

    Check

    grep -rn 'log.*level\|DEBUG' /etc/shop/   # debug level in the production config

    Fix

    Set the production log level to info or above.

  3. Exceptions that include the input value, or access logs with personal data in URLs (/users/priya@example.com).

    Check

    ./scripts/pii_scan.sh -c /var/log/nginx   # hits in access logs: personal data in URLs

    Fix

    Keep personal data out of URLs, and mask what you cannot change on the way out (app | scripts/pii_redact.sh).

  4. The existing copies, for any of the above.

    Check

    ./scripts/pii_scan.sh -c <log platform export or backup>   # the copies hold it too

    Fix

    Redact or expire the existing files and copies, and record what was exposed in case it counts as a breach.

Prevent

Run scripts/pii_scan.sh in CI on test logs and fixtures, review log statements like code, and keep log retention short.

Now learn it: break and fix it yourself in Customers' details in the log, then take Level 22 · Data hygiene: personal data in logs and files. Open in Troubleshoot

Production personal data in a test environment

fixtures/customers.csv: Mobile 12000, Email 12000

What it means

A non-production system holds real customers' data. The data was collected to run the service, not to test it; test systems usually have weaker access control, more users and no retention. That breaks purpose limitation and multiplies the places a breach can happen.

What you see

A staging database, a fixture file or a laptop holds a copy of real customers' data.

Causes, most likely first

  1. A production dump was restored into staging to reproduce a bug.

    Check

    psql -h staging -Atc 'select count(*) from customers where email not like ''%@example.%'''   # above 0: real addresses in staging

    Fix

    Delete the copy, and reproduce bugs with synthetic data or a masked extract made inside production's controls.

  2. A few real rows were exported as fixtures.

    Check

    ./scripts/pii_scan.sh -c fixtures/ testdata/   # hits in fixtures

    Fix

    Replace them with synthetic data: generated names, @example.com addresses, numbers that fail the Aadhaar checksum.

  3. A dump was shared with a vendor or a contractor.

    Check

    grep -rl 'pg_dump\|mysqldump' /var/log/<transfer logs>   # where dumps were sent

    Fix

    Ask for the copy to be deleted, and share only masked extracts under an agreement.

Prevent

Make masked or synthetic data the only way into non-production, and scan fixtures in CI.

Now learn it: take Level 22 · Data hygiene: personal data in logs and files. Open in Troubleshoot

Database backups readable by anyone on the host

-rw-r--r-- 1 postgres postgres 2.1G Oct 31 02:00 /var/backups/shop-20261031.sql.gz

What it means

Backup files can be read by more people than should. A backup holds every row of the database, so whoever can read it has all the personal data, without any of the application's access controls. The example -rw-r--r-- means anyone on the host can read it.

What you see

Backup files are world-readable, unencrypted, or copied to a bucket without encryption or with public access.

Causes, most likely first

  1. The default umask 022 creates files as 644.

    Check

    ls -l /var/backups/   # r for group or other: readable by others on the host

    Fix

    chmod 600 the files, and set umask 077 in the backup job.

  2. Off-site copies protected only by S3's default SSE-S3 (on for every new object since January 2023), which anyone who can read the bucket can still read, or copied to storage with no encryption at all.

    Check

    aws s3api get-bucket-encryption --bucket <bucket>   # AES256 is SSE-S3; aws:kms with your own key limits who can decrypt

    Fix

    Encrypt off-site copies with SSE-KMS under a key only the backup and restore roles can use, or client-side.

  3. A bucket policy or ACL that allows public or cross-account reads.

    Check

    aws s3api get-public-access-block --bucket <bucket>   # all four settings should be true

    Fix

    Block public access on the bucket and remove the grants.

  4. Old backups never deleted, so years of data sit in one place.

    Check

    ls -lt /var/backups/ | tail   # the oldest files and their dates

    Fix

    Delete backups beyond the retention period, and rotate in the job (scripts/backup_rotate.sh).

Prevent

Use backup scripts that set umask 077, encrypt uploads and rotate (scripts/pg_backup.sh, scripts/mysql_backup.sh, scripts/s3_backup_sync.sh).

Now learn it: take Level 23 · Data hygiene: retention, erasure and breach evidence. Open in Troubleshoot

Personal data kept with no retention limit

find /srv/exports -name '*.csv' -mtime +365 | wc -l  →  48211

What it means

Files with personal data go back years and nothing deletes them. Storage limitation means erasing personal data once its purpose is served or consent is withdrawn, unless a law requires keeping it; old files are pure risk. The example counts CSV files older than a year.

What you see

Exports, reports, uploads or logs with personal data go back years, long after anyone needs them.

Causes, most likely first

  1. Export jobs write files and no job removes them.

    Check

    ./scripts/retention_cleanup.sh -d 365 -p '*.csv' /srv/exports   # dry run: lists what a 365-day limit would delete
    du -sh /srv/exports   # how much is kept

    Fix

    Agree a retention period per data set, review the dry run, then schedule it with -y and a deletion log.

  2. Log retention left at "forever" on the log platform.

    Check

    aws logs describe-log-groups --query 'logGroups[?!retentionInDays].logGroupName'   # on CloudWatch: groups that never expire

    Fix

    Set a retention period on every log group.

  3. Nobody deletes for fear of deleting the wrong thing.

    Check

    ./scripts/retention_cleanup.sh -d 365 -p '*.csv' /srv/exports   # the dry run shows exactly what would go

    Fix

    Make the dry run part of review, and keep the deletion log as the record.

Prevent

Every job that creates personal-data files gets a retention job at the same time (scripts/retention_cleanup.sh), and log platforms get a retention setting.

Now learn it: break and fix it yourself in Log cleanup deletes the wrong files, then take Level 23 · Data hygiene: retention, erasure and breach evidence. Open in Troubleshoot