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, Permission denied, No such file or directory, cannot execute: required file not found, $'\r': command not found, [[: not found, Argument list too long, cannot execute binary file: Exec format error, declare: -A: invalid option (bash 3.2 on macOS), No space left on device
- Syntax: syntax error near unexpected token, syntax error: unexpected end of file, unexpected EOF while looking for matching, event not found
- Variables: unbound variable, parameter null or not set, bad substitution, readonly variable, not a valid identifier, Words no longer split after changing IFS, bad array subscript
- Tests: unary operator expected, binary operator expected, too many arguments, integer expression expected, [: missing `]'
- Arithmetic: syntax error: operand expected, value too great for base, division by 0
- Redirection: ambiguous redirect, here-document delimited by end-of-file
- Functions: return: numeric argument required, local: can only be used in a function, illegal option, maximum function nesting level exceeded
- Text tools: sed: unknown option to `s', find: paths must precede expression, cp: cannot stat, date: invalid date, jq: parse error: Invalid numeric literal, jq: Cannot index array with string
- Jobs and signals: A failed background job did not fail the script, wait: pid is not a child of this shell, Terminated, nohup: ignoring input and appending output to 'nohup.out', Exit status 124 from timeout, fork: retry: Resource temporarily unavailable
- Exit status: What does exit status 126, 127, 130 or 137 mean?, Script exits silently with set -e, set -e did not stop the script, A variable is empty after a loop, The last line of a file is skipped, write error: Broken pipe (exit status 141)
- Cron and environment: Works in the terminal, fails from cron, curl: (22) The requested URL returned error, sudo: a terminal is required to read the password
- Cloud and databases: Unable to locate credentials, ExpiredToken: the security token is expired, aws s3 sync --delete removed the backups, pg_dump: server version mismatch, fe_sendauth: no password supplied, mysqldump needs the PROCESS privilege, Using a password on the command line interface can be insecure
- Kubernetes: deployment exceeded its progress deadline, Cannot evict pod: it would violate the disruption budget, cannot delete Pods with local storage, cannot delete Pods declare no controller
- Fleet and drift: Host key verification failed, Permission denied (publickey), ssh: connect to host port 22: Connection refused, UNPROTECTED PRIVATE KEY FILE: permissions are too open, nginx: [emerg] ... configuration file test failed, terraform plan -detailed-exitcode exits 2, Error acquiring the state lock
- Data hygiene: Personal data found in application logs, Production personal data in a test environment, Database backups readable by anyone on the host, Personal data kept with no retention limit
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
-
A typo in the command name. Bash runs exactly the word you typed, so
ehcois 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 wrongFix
Correct the name.
-
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 PATHFix
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. -
Spaces around
=in an assignment:NAME = value. With spaces, bash readsNAMEas a command and=andvalueas its arguments.Check
grep -nE '^[[:space:]]*[A-Za-z_][A-Za-z0-9_]* +=' <script> # a line here: an assignment with spacesFix
Remove the spaces:
NAME=value. -
A script in the current directory run without
./. The current directory is not in$PATH, sodeploy.shis 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. -
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
sourceit from the script, or turn it into a script on$PATH. -
The command runs under
sudo, which replaces your PATH with itssecure_path. The message then readssudo: <name>: command not found.Check
sudo printenv PATH # the PATH sudo really uses; the program's directory is missing from itFix
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
-
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 causeFix
chmod +x script.sh, and commit it with the bit:git update-index --chmod=+x script.sh. -
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 causeFix
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. -
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 causeFix
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. -
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 causeFix
Create temporary files with
mktempinstead of a fixed name. -
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 causeFix
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
-
A relative path, run from cron or another directory.
./lib/common.shmeans "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 hereFix
Build paths from the script's directory:
here=$(dirname "${BASH_SOURCE[0]}"); source "$here/lib/common.sh", or use absolute paths. -
An unquoted path with spaces is split into several arguments.
cat $filewithMy Report.txtasks forMyandReport.txt, which do not exist.Check
printf '[%s]\n' $file # more than one line: the path was splitFix
Quote it:
cat -- "$file". -
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 causeFix
Strip
\rfrom the data file:sed -i 's/\r$//' list.txt. -
The parent directory of a redirection does not exist.
> /var/log/app/run.logcannot create the file when/var/log/appis missing.Check
ls -ld "$(dirname "<path>")" # No such file or directory: create the directoryFix
Create it first:
mkdir -p /var/log/app. -
A glob that matched nothing is passed on literally. With no
.logfiles,*.logstays as the text*.log, which is not a file.Check
echo *.log # prints *.log itself: nothing matchedFix
Use
shopt -s nullglobbefore 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
-
The file was saved with CRLF line endings. The first line becomes
#!/bin/bash\r, and there is no program calledbash\r.Check
head -1 <script> | cat -A # ^M$ at the end: this is your causeFix
Convert the line endings:
sed -i 's/\r$//' script.shordos2unix script.sh. -
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 lineFix
Install the interpreter, or point
#!at one that exists:#!/usr/bin/env bash. -
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 -lFix
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
-
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 sameFix
sed -i 's/\r$//' script.sh, then run it again. Add*.sh text eol=lfto.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
-
No
#!line, so/bin/shruns 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 hereFix
Start the script with
#!/usr/bin/env bashand make it executable. -
Running it as
sh script.sh. Naming the shell on the command line ignores the#!line. Docker's shell-formCMDand some cron lines do the same.Check
grep -rn 'sh .*<script>' crontab Dockerfile 2>/dev/null # sh <script> in how it is startedFix
Run the file directly (
./script.sh), or usebash script.sh. In a Dockerfile use the exec form:CMD ["./script.sh"]. -
#!/bin/shon 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 linesFix
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
-
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 bytesFix
Let find do it:
find . -name '*.log' -delete, orfind . -name '*.log' -print0 | xargs -0 rm --. -
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 causeFix
Find what fills it (often a variable that holds a whole file) and stop exporting it.
-
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 argumentFix
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
-
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 causeFix
Use the build for this machine's architecture, or build images for the target with
docker buildx build --platform linux/amd64. -
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 causeFix
Download it again with
curl -fL, and check it against its published checksum. -
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 bashthe 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
-
#!/bin/bashruns 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 causeFix
Install a current bash (
brew install bash) and start the script with#!/usr/bin/env bash. -
A newer bash from Homebrew is in
$PATH, but the script is run asbash script.shfrom a shell where it is not.Check
type -a bash # /bin/bash listed first in that shellFix
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
-
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 spaceFix
Rotate or compress logs and remove old backups, then keep a fixed number (
scripts/backup_rotate.sh -k). -
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 processFix
Restart the process that holds it (scripts/deleted_open_files.sh lists them), or truncate it through
/proc/<pid>/fd/<n>. -
Millions of small files used up the inodes while
df -hstill shows free space.Check
df -i <path> # IUse% 100: inodes, not bytesFix
Find the directory with the many small files (
du --inodes -x <path> | sort -n | tail) and clean it up. -
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 blocksFix
Free space as above. Do not just lower the reservation; it is what lets root recover a full disk.
-
A large write to a small /tmp.
Check
df -h /tmp # a small tmpfsFix
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
-
A missing
then,do,;or;;. Withoutthen, bash is still reading the condition when it reachesfi, 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 lineFix
Add the missing keyword or separator.
-
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 lineFix
Add the space or the
;:if [ "$x" = 1 ]; then,{ cmd; }. -
Windows line endings. The token is then shown as
$'do\r'or similar: the\rmakesdoan unknown word.Check
cat -A <script> | sed -n '1,12p' # ^M$ at line ends: this is your causeFix
sed -i 's/\r$//' script.sh. -
An unquoted
(or)in an argument, such asecho (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 argumentsFix
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
-
A missing
fi,done,esacor}. 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 blockFix
Close the block.
-
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 causeFix
Put the end marker at the start of its line with nothing after it, or use
<<-with tab indentation. -
}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 itFix
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
-
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 openedFix
Close the quote.
-
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 itFix
Write
'it'\''s', or use double quotes:"it's". -
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
-
!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. -
A password or message containing
!pasted into the terminal.Check
set -o | grep histexpand # on in your terminalFix
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
-
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 causeFix
Give it a default:
${DEBUG:-0}. -
A missing argument:
$1when the script was run with none. Positional parameters are variables too.Check
echo "$#" # at the top of the script: 0 means no arguments were passedFix
Require it with a message:
${1:?usage: sync.sh <dir>}. -
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 readFix
Fix the name.
-
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 itFix
Export it in the caller (
export NAME=value), or set it in the job's own configuration. -
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 causeFix
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
-
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 exportedFix
Pass the value:
export NAME=valuein the caller, orNAME=value ./script.sh. -
A missing command-line argument, as in
${1:?usage}. The guard protects a positional parameter.Check
echo "$#" # 0: no arguments were passedFix
Run the script with the argument its usage message asks for.
-
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 missingFix
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
-
Bash-only forms like
${var//a/b}or${var^^}run by sh. That happens with no#!line, or when the script is started assh 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 thereFix
Start the script with
#!/usr/bin/env bashand run it as./script.sh, notsh script.sh. -
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 shellFix
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
-
A library or env file marks settings readonly, with
readonlyordeclare -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-onlyFix
Use another name, or set the value before the file that makes it read-only is sourced.
-
Assigning to one of bash's own read-only variables, such as
UIDorEUID.Check
declare -p <NAME> # declare -ir UID=...: a bash variableFix
Rename yours. Do not reuse names like
UID,PWDorPATHfor 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
-
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 itFix
Use a valid name:
DB_HOST. -
A
$before the name, as inread $name. Bash expands it first, soreadreceives the value instead of the name.Check
grep -nE '(read|export|unset) .*\$' <script> # a $ where a name should beFix
Write the name without
$:read -r name. -
Spaces around
=with export:export A = 1. Bash passesA,=and1as 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
-
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 itFix
Set IFS only for the command that needs it:
IFS=, read -r a b <<< "$line". Use arrays for lists. -
IFS was changed in a function or sourced file and not restored.
Check
grep -rn 'IFS=' <script> <sourced files> # where it was changedFix
Use
local IFSinside 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
-
${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 holdsFix
Check the length before indexing:
(( ${#arr[@]} )) && last=${arr[-1]}. -
map[$key]=valuewithkeyempty or unset. The message then showsmap[$key].Check
printf '%q\n' "$key" # '' (empty): this is your causeFix
Check that the key is not empty before using it:
[[ -n $key ]] || continue. -
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 causeFix
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
-
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 goneFix
Quote the variable,
[ "$x" != ok ], or use[[ $x != ok ]]. -
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 substitutionFix
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
-
A command inside
[ ], as inif [ 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. -
An unquoted variable with spaces in it.
[ $a = b ]witha="x y"becomes[ x y = b ].Check
bash -x <script> # the trace shows the value split into several wordsFix
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
-
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 wordsFix
Quote:
[ "$name" = x ]. -
A glob with more than one match, as in
[ -f *.log ].-ftakes one file, and the glob gives several.Check
ls *.log | wc -l # more than 1: this is your causeFix
Test for any match with
compgen -G '*.log' > /dev/null, or loop over the files. -
cd $dirwith spaces in the path.cdtakes one directory.Check
printf '[%s]\n' $dir # more than one line: the path was splitFix
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
-
A percentage or unit from
df,free -hordu -h.100%and2.1Gare text, not integers.Check
printf '%q\n' "$value" # a % or a unit letter: this is your causeFix
Strip the unit,
v=${v%\%}, or ask the tool for raw numbers:df -P,df --output=pcent,du -b. -
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 causeFix
Compare with awk:
awk -v l="$load" 'BEGIN { exit !(l > 4) }'. -
An empty value.
[ "" -gt 90 ]has no integer on the left.Check
printf '%q\n' "$value" # '' (empty): the command that filled it printed nothingFix
Give it a default,
${v:-0}, and check the command that should have filled it. -
A carriage return from a Windows file.
90\ris not an integer.Check
printf '%q\n' "$value" # ends in $'\r': this is your causeFix
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
-
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" ]. -
&&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. -
A
]written as), or left out after a line break.Check
sed -n '8p' <script> # no ] at the end of the testFix
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
-
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 causeFix
Clean the value first:
v=${v%\%}. -
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 causeFix
Give empty values a default:
$(( ${n:-0} + 1 )). -
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 causeFix
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
-
$(date +%m),%dor%Hused in arithmetic. Those print zero-padded numbers like08.Check
echo $(( 07 )) $(( 010 )) # prints 7 8: a leading zero means octal grep -n 'date +%[mdHM]' <script> # where padded values come fromFix
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
-
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 countFix
Check the divisor first:
(( count > 0 )) && echo $(( total / count )). -
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 filledFix
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
-
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 causeFix
Make sure it is set, and quote it:
>> "${LOG:?LOG is not set}". -
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 splitFix
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
-
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 causeFix
Put the marker at the start of its line, or use
<<-with tab indentation. -
Trailing spaces or a
\rafter the marker. The line must be exactly the marker.Check
grep -n 'EOF' <script> | cat -A # a space or ^M before $: this is your causeFix
Remove them; strip
\rwithsed -i 's/\r$//' script.sh. -
A different spelling:
EOFversusEND. The end marker must match the one after<<exactly.Check
grep -nE '<<-?.?[A-Z]+' <script> # the marker it wantsFix
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
-
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 textFix
Print the result and capture it:
get() { echo "$value"; }andv=$(get). -
returnused 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 levelFix
Use
exitat 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
-
Code moved out of a function. The
localkeywords came along.Check
sed -n '1,10p' <script> # the local line is not inside a name() { ... } blockFix
Drop
localat the top level, or wrap the code in a function and call it. -
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 bodyFix
Run the function, or drop
localwhen 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
-
A letter missing from the option string. getopts only accepts the letters listed.
Check
grep -n getopts <script> # the letter is not in the stringFix
Add the letter, and a case for it.
-
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 itFix
Add
:after it, andshift $((OPTIND - 1))after the loop. -
The script was simply called with a wrong option or without a value.
Check
./<script> -h # compare with its usageFix
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
-
A wrapper with the same name as the command it wraps:
ls() { ls --color "$@"; }. Thelsinside calls the function again, forever.Check
type <name> # 'is a function': the name is shadowedFix
Call the real command with
command lsinside the wrapper, or rename the function. -
Recursion without a stop condition.
Check
bash -x <script> 2>&1 | head -40 # the same call repeatingFix
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
-
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 commandFix
Pick a delimiter that is not in the data:
sed "s|X|$VALUE|". -
A missing or extra delimiter typed by hand. Too few delimiters gives
unterminated s commandinstead; extra text after the last one gives this message.Check
echo '<the sed expression>' # count the delimiters: there must be exactly threeFix
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
-
find . -name *.logrun 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 -nameFix
Quote the pattern:
find . -name '*.log'. -
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 errorFix
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
-
The path is in an unquoted variable and has spaces, as in
cp $SRC $DEST./srv/reports/Q3 summarybecomes two arguments.Check
printf '[%s]\n' $SRC # more than one line: the path was splitFix
Quote:
cp -r -- "$SRC" "$DEST/". -
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 usefind ... -print0 | xargs -0. -
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 wrongFix
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
-
A label in front of the date (
notAfter=). openssl and other tools printname=value.Check
printf '%q\n' "$end" # starts with a label: this is your causeFix
Strip labels first:
${end#notAfter=}. -
A format GNU date does not read, or BSD date on macOS, which uses
date -j -finstead of-d.Check
date --version 2>/dev/null | head -1 # no output: BSD date date -d "$end" +%s # still invalid after cleaning: the formatFix
Convert the format first, or on macOS use
date -j -f '%b %d %T %Y %Z' "$end" +%s. -
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 causeFix
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
-
curl without
-freceived 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 JSONFix
Use
curl -fsSso HTTP errors stop the script before jq, and check the status code. -
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 pageFix
Call the API with its own credentials, past the proxy's login.
-
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/nullbefore 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
-
The API returns a list (
[{...}]) and the filter treats it as one object.Check
jq 'type' <file> # "array": use .[] or .[0] firstFix
Match the filter to the data:
.[].nameor.[0].name. -
Some records have no such key, so
.items[]meets null.Check
jq 'keys' <file> # the key is missing from this recordFix
Use
.items[]?or(.items // [])[]where a key may be missing. -
An error response with a different shape from the normal one.
Check
jq '.[0]' <file> # or jq . <file>: an error object instead of dataFix
Check the HTTP status first (
curl -fsS), and usejq -eso a missing value fails the script instead of printingnull.
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
-
cmd & ... waitwith no PIDs. The bare wait returns 0 whatever the jobs did.Check
grep -nE '^\s*wait\s*$' <script> # a bare wait: this is your causeFix
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. -
$!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 lostFix
Save
$!right after each&. -
Expecting
set -eto cover jobs. It only sees commands in the foreground.Check
jobs -l # jobs still running when the script reports successFix
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
-
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 listFix
Start the job in the same shell that waits for it, and keep
$!right after starting it. -
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
-
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 causeFix
Clean up in
trap '...' EXIT(bash runs it on TERM, HUP and INT too), and make reruns safe. -
The SSH session ending for a job started with
&. The terminal going away sends HUP.Check
echo $? # 129: hangupFix
Run long work with
nohup,setsid, tmux or a systemd unit. -
A
timeoutlimit. timeout sends TERM when the time is up and returns 124.Check
grep -n 'timeout' <script> # the limit that appliesFix
Raise the limit to what the work needs, or make the work faster.
-
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: memoryFix
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
-
Running
nohup cmdwithout redirecting its output. nohup only picks nohup.out when output would go to the terminal.Check
tail nohup.out # the command's output is hereFix
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
-
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 stuckFix
Fix the server, and keep a limit on the call:
curl -m 10. -
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 firewallFix
Allow the port from this host, or go through the right proxy.
-
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 secondsFix
Give every network call a limit (
timeout 10 cmdorcurl -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
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
-
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 127Fix
Fix the permission (126) or the name and PATH (127).
-
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 OOMKilledFix
Give it the memory it needs, or find what makes it grow.
-
130 or 143: it was stopped on purpose, by Ctrl+C or by
kill,systemctl stoportimeout.Check
journalctl -u <service> | tail # a stop or restart at that timeFix
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
-
grepfound nothing. grep returns 1 for no match, andset -etreats that as a failure.Check
bash -x <script> 2>&1 | tail -5 # the last traced line is the command that ended itFix
Add
|| truewhere no match is fine, or branch on it:if grep -q x file; then. -
(( count++ ))orletwhen 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 lineFix
Use
count=$((count + 1)), which always succeeds. -
[ cond ] && actionas 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 failedFix
Write
if cond; then action; fiinstead ofcond && action. -
difffound differences, orcmpfound 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 lineFix
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
-
cmd | gzip > filewithout 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 causeFix
Use
set -o pipefail. -
local x=$(cmd). The status checked is that oflocal, which succeeded, not that of cmd.Check
grep -nE 'local [a-z_]+=\$\(' <script> # each line here hides a statusFix
Declare, then assign:
local x; x=$(cmd). -
The step is in a function called as
if deploy; thenordeploy || alert. bash turns set -e off for every line insidedeploywhile it runs in that position.Check
bash -x <script> # the trace continues past the failing line inside the functionFix
Check the steps that matter explicitly inside the function (
cmd || return 1), and do not rely on set -e there. -
$?read after another command.$?holds the status of the most recent command only, so anechoor[in between replaces it.Check
grep -n '\$?' <script> # a line before it that is not the command you meanFix
Capture it at once:
cmd; rc=$?, or test the command directly withif. -
A failed
cdin a script withoutset -e, or in one of the places above. A plain failedcddoes stop aset -escript.Check
head -5 <script> # no set -e: every unchecked command can fail and carry onFix
cd dir || exit 1, and start scripts withset -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
-
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 causeFix
Redirect into the loop instead:
while read -r line; do ...; done < fileordone < <(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
-
The file does not end with a newline. Files from some editors, exports or
printfwithout a final\ndo this.Check
tail -c 1 <file> | od -c # no \n shown: the last line is unterminatedFix
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
-
head,grep -qorgrep -m1stopped 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 effectFix
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 ]. -
The reading command crashed or failed.
Check
echo "${PIPESTATUS[@]}" # a non-zero status for the last command tooFix
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
-
A program outside /usr/bin and /bin. With cron's short PATH it is not found (look for
command not foundin 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 causeFix
Set PATH at the top of the script, or call programs by their full path.
-
Variables exported in
.bashrcor your profile. Cron does not read them.Check
grep -n 'export' ~/.bashrc ~/.profile # anything the script relies onFix
Have the script load its own config by absolute path:
. /etc/app/env. -
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 1first. -
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 commandFix
Add
#!/usr/bin/env bash, and escape%as\%in crontab. -
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 definedFix
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
-
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 reachedFix
Retry with a limit and backoff (scripts/retry.sh), and alert if it keeps failing.
-
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 networkFix
Fix the credentials the job uses; do not retry.
-
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 recordFix
Fix the name or the resolver on that host.
-
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: blockedFix
Start the service, or open the port from this host.
-
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 itFix
Set the same proxy variables in the job's environment.
-
(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 proxyFix
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
-
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 ruleFix
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. -
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 argumentsFix
Make the script run exactly the allowed command (full path), or correct the rule. Use
sudo -nin scripts so they fail at once instead of waiting. Never feed a password tosudo -Sfrom 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
-
Cron or another user runs the job with a different HOME, so
~/.awsis 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 HOMEFix
Give that user its own profile, or better a role (below).
-
AWS_PROFILEis 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 maskedFix
Export
AWS_PROFILEin the job's environment, or pass--profile. -
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 2Fix
Attach an instance role with only the permissions the job needs, and set the hop limit to 2 for containers.
-
SSO credentials expired and need
aws sso login.Check
aws configure list # Type sso, and get-caller-identity failsFix
Run
aws sso loginfor people; give unattended jobs a role instead. -
The command runs under
sudo, which uses root's HOME and drops yourAWS_*variables.Check
sudo aws configure list # nothing found as rootFix
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
-
Credentials copied into environment variables from an earlier
aws sts assume-roleoraws 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 profileFix
Let the CLI refresh credentials itself: use a profile with
role_arnandsource_profile(orcredential_process), or the instance role, and unset the exported keys. -
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 jobFix
Use a profile or role the CLI can renew, or split the job.
-
The host clock is wrong. The CLI does not refresh in time, and requests may fail with RequestExpired or
Signature expiredinstead.Check
date -u; timedatectl show -p NTPSynchronized # minutes off, or NTPSynchronized=no: the clockFix
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
-
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 causeFix
Check the mount before syncing (
mountpoint -q) and refuse an empty source. scripts/s3_backup_sync.sh does both. -
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: stopFix
Require the variable (
${SRC:?}) and run--dryrunfirst. -
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 sourceFix
Put the source first.
-
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 restoredFix
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
-
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 causeFix
Install the client tools of the server's major version (or newer) on the backup host.
-
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 thereFix
Call the right one by its full path, for example
/usr/lib/postgresql/16/bin/pg_dumpon 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
-
~/.pgpassbelongs 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 causeFix
Create
~/.pgpassfor the user that runs the job, withhost:port:database:user:password. -
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 userFix
chmod 600 ~/.pgpass. -
The host, port, database or user in the file does not match the connection. The host is compared as text, so
db.internaland 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 -UFix
Add a line that matches exactly how the job connects, or use
*for a field. -
By hand you connect over the Unix socket, where
pg_hba.confallowspeer; the job uses-h, which hits ahostrule 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 worksFix
Give the job a
.pgpassentry 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
-
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 grantsFix
Add
--no-tablespaces: most dumps do not need tablespace definitions. Grant PROCESS only if you do need them. -
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 matchedFix
Grant to the account in
CURRENT_USER(), or fix the job's user and host. -
The client was upgraded and the dump options were not.
Check
mysqldump --version # 8.0.21 or laterFix
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
-
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 oneFix
Put the credentials in an option file (
[client]withuser=andpassword=),chmod 600it, and pass--defaults-extra-file=/etc/mysql/backup.cnfas the first option. Then change the password, because it has been exposed. -
--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 itFix
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
-
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 crashedFix
Roll back with
kubectl rollout undo deployment/<name>and confirm withrollout status. Then fix the image or the probe and release again. -
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 unauthorizedFix
Fix the tag, or add the pull secret to the service account. Roll back first if users are affected.
-
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 memoryFix
Lower the requests to what the app needs, or add capacity.
-
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 allFix
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
-
A PDB that can never allow a disruption:
minAvailableequal to the replica count, ormaxUnavailable: 0.Check
kubectl get pdb -A # ALLOWED DISRUPTIONS 0 with all replicas healthy: the budget itself is the causeFix
Scale the Deployment up first so the budget allows one eviction, then fix the budget (
maxUnavailable: 1). -
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 elsewhereFix
Fix or wait for the unready replicas; the drain then continues.
-
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 nodeFix
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
-
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 tmpFix
Drain with
--delete-emptydir-data(-ein scripts/k8s_node_drain.sh). -
Data that should be on a PersistentVolume is in emptyDir.
Check
kubectl exec -n <ns> <pod> -- du -sh <mount path> # data you need to keepFix
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
-
A debug pod started with
kubectl run, left behind.Check
kubectl get pod <pod> -n <ns> -o jsonpath='{.metadata.ownerReferences}' # empty: no controllerFix
Delete it yourself if it is a leftover, then drain.
-
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 appFix
Recreate it under a controller (a Deployment) first.
--forcedeletes 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
-
A new host not yet in
known_hosts.Check
ssh-keygen -F <host> # nothing: the host is unknownFix
Check the key's fingerprint against the host (console, cloud API or your inventory), then add it:
ssh-keyscan <host> >> ~/.ssh/known_hosts. -
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 inventoryFix
If it matches the rebuilt host, remove the old line with
ssh-keygen -R <host>and add the new key. -
A different user (cron, CI) with its own empty
known_hosts.Check
sudo -u <job user> ssh-keygen -F <host> # nothing for that userFix
Give that user the checked keys too, or a shared
/etc/ssh/ssh_known_hosts. -
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 investigateFix
Do not connect until you know which server it is. Never set
StrictHostKeyChecking=noto 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
-
The key is not in that host's
authorized_keysfor 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 userFix
Add the job's public key to the host for the right user.
-
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 offeredFix
Point ssh at the key with
-iin the job. -
authorized_keys,~/.sshor 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 fileFix
chmod go-w ~; chmod 700 ~/.ssh; chmod 600 ~/.ssh/authorized_keys, owned by that user. -
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 algorithmFix
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
-
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. -
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 placeFix
Allow port 22 from your address only, or go through the bastion.
-
The host is down, or its name points at an old address.
Check
getent hosts <host> # the address ssh will use; compare with the inventoryFix
Fix the DNS record or the inventory, or start the host.
-
Connecting to a private address from outside the network.
Check
getent hosts <host> # 10.x, 172.16-31.x or 192.168.x from outsideFix
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
-
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 causeFix
chmod 600 <key>andchmod 700 ~/.ssh, owned by the user who runs ssh. In CI, write it withinstall -m 600. -
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 modeFix
Copy the key into
~/.sshon 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
-
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 errorFix
Fix that line, run
nginx -tagain, then reload (not restart) and check the site answers. -
An include that points to a missing file or certificate.
Check
sudo nginx -t # 'No such file' or 'cannot load certificate' with the pathFix
Restore the file or fix the path.
-
The port is taken:
bind() to 0.0.0.0:80 failed (98: Address already in use)at start, whichnginx -tdoes not catch.Check
sudo ss -ltnp 'sport = :80' # another process holds the portFix
Stop the other process, or move one of them to another port.
-
You edited a file that is not loaded, or the service starts nginx with another
-cfile.Check
sudo nginx -T | grep '^# configuration file' # the files really loaded systemctl cat nginx | grep ExecStart # a -c option points elsewhereFix
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
-
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 errorFix
Capture the status and branch on it:
rc=0; terraform plan -detailed-exitcode ... || rc=$?; case $rc in 0) ...;; 2) ...;; *) ...;; esac. -
if terraform plan ...; thensends 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=$?andcaseform. 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
-
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 progressFix
Wait, or use
-lock-timeout. -
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. -
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 regionFix
Give that identity
dynamodb:GetItem,PutItemandDeleteItemon 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
-
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 loggedFix
Log ids and events instead of values.
-
print(user)or a debug log level left on in production.Check
grep -rn 'log.*level\|DEBUG' /etc/shop/ # debug level in the production configFix
Set the production log level to info or above.
-
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 URLsFix
Keep personal data out of URLs, and mask what you cannot change on the way out (
app | scripts/pii_redact.sh). -
The existing copies, for any of the above.
Check
./scripts/pii_scan.sh -c <log platform export or backup> # the copies hold it tooFix
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
-
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 stagingFix
Delete the copy, and reproduce bugs with synthetic data or a masked extract made inside production's controls.
-
A few real rows were exported as fixtures.
Check
./scripts/pii_scan.sh -c fixtures/ testdata/ # hits in fixturesFix
Replace them with synthetic data: generated names,
@example.comaddresses, numbers that fail the Aadhaar checksum. -
A dump was shared with a vendor or a contractor.
Check
grep -rl 'pg_dump\|mysqldump' /var/log/<transfer logs> # where dumps were sentFix
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
-
The default umask 022 creates files as 644.
Check
ls -l /var/backups/ # r for group or other: readable by others on the hostFix
chmod 600the files, and setumask 077in the backup job. -
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 decryptFix
Encrypt off-site copies with SSE-KMS under a key only the backup and restore roles can use, or client-side.
-
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 trueFix
Block public access on the bucket and remove the grants.
-
Old backups never deleted, so years of data sit in one place.
Check
ls -lt /var/backups/ | tail # the oldest files and their datesFix
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
-
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 keptFix
Agree a retention period per data set, review the dry run, then schedule it with
-yand a deletion log. -
Log retention left at "forever" on the log platform.
Check
aws logs describe-log-groups --query 'logGroups[?!retentionInDays].logGroupName' # on CloudWatch: groups that never expireFix
Set a retention period on every log group.
-
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 goFix
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