Eleven ideas that the rest of the document assumes. Read these once and most bash behaviour stops being arbitrary.
What the shell actually is
Three things people call 'the terminal', and why only one of them runs your code
Three separate programs are involved every time you type a command, and confusing them is the source of a surprising number of stuck moments.
The terminal emulator — iTerm, GNOME Terminal, the VS Code panel — is a window that draws characters and captures keystrokes. It knows nothing about bash. Its ancestor was a physical device with a keyboard and a screen wired to a serial line, and the emulation is faithful enough that terminal control codes, window size signals and the idea of a 'tty' all survive from that era.
The shell — bash, zsh, fish — is an ordinary program running inside that window. It reads a line of text, transforms it according to a fixed set of rules, and asks the kernel to run programs on your behalf. That is the whole job.
Bash has no special privileges; it is a userspace binary at /usr/bin/bash like any other.
The kernel is what actually creates processes, opens files and moves bytes between them. The shell cannot do any of those things itself. It can only ask, via system calls, and report back what happened.
So the shell is a read-eval-print loop where 'eval' means 'expand this text, then ask the kernel to execute the result'. Everything in this document is either a rule about the expansion, or a program the shell can be asked to run.
Builtins, functions, and real programs
Most commands are separate executables on disk. ls is /usr/bin/ls; the shell finds it, asks the kernel to run it, and waits. But some commands are builtins, implemented inside bash itself, and the reason is usually that they need to change the shell's own state.
cd is the classic example. Directory is a property of a process. If cd were an external program, the kernel would start it, it would change its own directory, and then it would exit — leaving your shell exactly where it was.
So cd has to be a builtin. The same logic explains export, read, source, trap, set and exit.
type -a echo
# echo is a shell builtin
# echo is /usr/bin/echo
type -a cd
# cd is a shell builtin
type -a ll
# ll is aliased to `ls -lah`
type -a shows every meaning a name has, in the order bash will try them.
Why this matters
When a command behaves differently in a script than in your terminal, the first question is whether you are even running the same thing. Aliases are not loaded in scripts, and /usr/bin/echo handles flags differently from the bash builtin.
Which shell, which config file
Bash reads different startup files depending on how it was started, and this catches everyone eventually. A login shell (SSH session, console login) reads /etc/profile then the first of ~/.bash_profile, ~/.bash_login, ~/.profile. A non-login interactive shell (a new terminal tab) reads ~/.bashrc.
A non-interactive shell (running a script) reads neither, unless BASH_ENV is set.
The alias you added to .bashrc works in your terminal but not over ssh host 'command'. Many .bash_profile files end with a line that sources .bashrc, to paper over the distinction.
Processes, and why children cannot change their parents
fork, exec, and the one-way flow of environment
When bash runs an external command it does two things. First it forks: the kernel makes a near-identical copy of the bash process, with a new process ID. Then, in the copy, it execs: the process replaces its own program image with the new binary, keeping its PID, its open file descriptors and its environment.
The parent waits for the child to finish and collects its exit status.
Almost everything surprising about shell scope falls out of that one mechanic. The child got a copy of the parent's environment. Nothing the child does can reach back up.
# In the child, x is set. In the parent, it never existed.
( x=5 )
echo "${x:-unset}" # unset
# Same story with cd
( cd /tmp; pwd ) # /tmp
pwd # unchanged
An ordinary variable lives only in the current shell. export exists to mark it for copying into the environment of any child the shell creates. There is no reverse operation, because there is no mechanism for one: a child cannot write into a parent's memory.
Where subshells hide
You get a subshell explicitly with ( ... ), but bash also creates them silently in several places, and that silence is where the bugs live:
- Every stage of a pipeline runs in its own process.
a | b | cis three children. - Command substitution
$(...)runs in a subshell whose stdout is captured. - Backgrounding with
&creates one. - So does a command group that is piped as a whole. Redirection alone does not:
{ ...; } > fileandwhile ...; done < fileboth run in the current shell, which the< <(...)fix below relies on.
Combine the first of those with a loop and you get the most famous bash surprise there is:
count=0
grep ERROR app.log | while read -r line; do
(( count++ ))
done
echo "$count" # 0. Always 0.
The while loop is the right-hand side of a pipe, so it runs in a child. count is incremented — in a process that then exits.
The fix is not to make the loop smarter; it is to stop putting the loop in a child. Process substitution feeds the same data in through a redirect instead of a pipe, which keeps the loop in the current shell:
count=0
while read -r line; do
(( count++ ))
done < <(grep ERROR app.log)
echo "$count" # correct
source versus execute
./script.sh forks and execs: a new bash reads the file, and whatever it sets dies with it. source script.sh (or . script.sh) reads the file into the current shell, as if you had typed it. Environment setup files must be sourced for the change to survive, and a script that ends in exit will close your terminal if you source it instead of running it.
Reading the tree
ps -ef --forest or pstree -p shows the parent-child structure. When a parent dies before its child, the orphan is re-parented to PID 1. That is how a nohup-ed job ends up owned by systemd after you log out.
Everything is a file descriptor
The table behind redirection, pipes, and the ordering rule people get wrong
Every process has a small numbered table of open things. Entry 0 is standard input, 1 is standard output, 2 is standard error. A process reads from 0 and writes to 1 and 2 without knowing or caring what is on the other end — a terminal, a file, a socket, another process.
That indifference is what makes redirection possible at all.
Redirection is the shell rearranging that table in the forked child, in the instant between fork and exec. The program itself is never told about it. sort < data.txt does not pass a filename to sort; it opens the file, wires it to descriptor 0, and then runs a program that reads from descriptor 0 exactly as it always does.
Why 2>&1 has to come last
Redirections are processed strictly left to right, and 2>&1 means make descriptor 2 point at whatever descriptor 1 currently points at. It copies a destination; it does not create a link. Read it that way and the classic confusion resolves itself:
cmd > out.log 2>&1
# 1. stdout -> out.log
# 2. stderr -> wherever stdout is now = out.log both captured
cmd 2>&1 > out.log
# 1. stderr -> wherever stdout is now = the terminal
# 2. stdout -> out.log stderr still on screen
The second form is not a mistake in all cases — it is how you send errors to the screen while piping clean output onward — but it is almost never what someone typing it meant.
Note also that the shell opens the target file before the command runs. sort file.txt > file.txt truncates the file to zero bytes and then asks sort to read it. Write output to a new name and move it into place.
A pipe is a buffer with two ends
a | b asks the kernel for a pipe: a small in-memory buffer, typically 64KB, with a write end and a read end. The shell wires a's descriptor 1 to the write end and b's descriptor 0 to the read end, then starts both processes at the same time. They are not sequential.
If b is slow, the buffer fills and a blocks on write; if a is slow, b blocks on read. That is backpressure, for free.
It also explains why head on a huge file is instant: once head has its ten lines it exits, the read end closes, the writer gets SIGPIPE on its next write, and the whole thing stops.
The buffering trap
The C library switches stdout from line-buffered to 4KB block-buffered when it is not a terminal. It takes a stage whose own stdout is a pipe: in tail -F app.log | grep ERROR | grep -v healthcheck the first grep block-buffers and the output stalls.
The two-stage version does not show it: GNU tail -F flushes after every write, and a grep writing to your terminal is line-buffered. grep --line-buffered, sed -u, awk with fflush(), or wrapping the whole pipeline in stdbuf -oL restores the behaviour you expected.
Process substitution
<(cmd) runs a command and hands you a filename — usually /dev/fd/63 — that reads from its output. This lets you pass a command's output to a program that insists on file arguments, which is most of them.
diff <(ssh prod cat /etc/app.conf) <(cat local.conf)
comm -23 <(sort a.txt) <(sort b.txt)
Neither diff nor comm can read stdin twice. Process substitution gives each input its own descriptor.
The expansion pipeline
The most useful thing to understand about bash
Before bash runs anything, it rewrites the line you typed. It does this in a fixed order, and nearly every bash bug that feels like witchcraft is a misunderstanding of that order. The stages are:
- Brace expansion —
{1..5},{a,b}c - Tilde expansion —
~,~user - Parameter expansion —
$var,${var#pat},${var:-default} - Command substitution —
$(cmd) - Arithmetic expansion —
$(( 2 + 2 )) - Word splitting — the result is chopped at every character in
IFS - Filename expansion (globbing) — any word containing
*,?or[is matched against the filesystem - Quote removal — the quotes themselves are stripped off before exec
Stages 6 and 7 are the dangerous ones, and they happen to the result of the earlier stages. Bash does not know that the text now sitting there came from a variable. It just sees words.
file="my report.txt"
rm $file
# after stage 3: rm my report.txt
# after stage 6: rm [my] [report.txt] two arguments, two wrong filenames
rm "$file"
# stages 6 and 7 skipped: rm [my report.txt] one argument, correct
That is the entire purpose of double quotes: they turn off word splitting and globbing for whatever is inside. The rule that follows is to quote every expansion, not just the ones that might contain a space. You rarely know in advance, and the failure is silent.
Ordering consequences
Because brace expansion is stage 1 and parameter expansion is stage 3, {1..$n} cannot work: by the time bash knows what $n is, the brace stage is long over. Use a C-style loop or seq.
Because word splitting is stage 6, unquoted command substitution gets shredded the same way variables do. for f in $(ls) breaks on any filename with a space, which is one of several reasons not to parse ls.
Because globbing is stage 7 and happens against the real filesystem, a pattern that matches nothing is, by default, passed through literally. for f in *.log on a directory with no logs runs the loop once with f set to the string *.log. shopt -s nullglob makes non-matching globs vanish instead, which is almost always what you wanted.
The four kinds of quoting
- No quotes — everything expands, then splits and globs.
- Double quotes
"..."—$, backticks and\still work; splitting and globbing do not. - Single quotes
'...'— nothing at all is special. You cannot put a single quote inside. $'...'— no expansion, but backslash escapes are interpreted, so$'\n'is a real newline and$'\t'a real tab.
The one exception worth memorising
"$@" expands to each positional argument as its own word, correctly quoted. "$*" joins them into one string. Unquoted $* is joined and then word-split again, shredding arguments exactly like an unquoted $@.
When forwarding arguments — which is most of what wrapper functions do — it is always "$@".
Exit status is the type system
One integer, and every control structure built on it
Every process that ends returns a number between 0 and 255 to its parent. Zero means success. Anything else means a failure, and the specific value is the program's way of saying which one.
This is inverted relative to boolean logic in every programming language you know, and it is inverted for a good reason: there is exactly one way to succeed and many ways to fail, so success gets the single value.
Bash's control flow reads nothing but this integer. if cmd; then runs the body when cmd exits 0. while cmd; do loops while it keeps exiting 0.
a && b runs b only if a exited 0; a || b runs b only if a did not. ! inverts it.
There is no boolean type hiding underneath. [[ -f x ]] is a command that exits 0 or 1, and if treats it like any other.
if grep -q ERROR app.log; then
echo 'found errors'
fi
# same thing, no if:
grep -q ERROR app.log && echo 'found errors'
curl -fsS "$url" || die 'request failed'
Conventions worth knowing
0success1generic failure2misuse — bad arguments, usually126found but not executable127command not found128+Nkilled by signal N, so130is Ctrl-C (SIGINT is 2) and137is SIGKILL (9)
Seeing 137 in a container's logs is not a crash; it is the kernel or the OOM killer telling you the process was killed outright. 143 is a clean SIGTERM. Reading these saves a lot of guessing.
What set -e really does
set -e tells bash to exit as soon as a command returns non-zero. The rule has an important exemption: a command whose status is already being examined does not trigger it. That covers the condition of if, while and until, anything to the left of && or ||, and anything negated by !.
The exemption is what makes set -e usable at all. Without it, every if grep -q would kill your script. But it also creates the trap that catches people:
set -e
# Neither of these exits. set -e ignores the LEFT side of &&.
(( VERBOSE )) && set -x
if (( VERBOSE )); then set -x; fi
# THIS exits when VERBOSE is 0 — bare, with nothing examining it.
(( VERBOSE ))
(( 0 )) does return status 1, but set -e exempts any command that is not the last in an && or || list — so the && form is safe. A bare (( ... )) on its own line is the real trap.
The second trap is that a function's failure is only seen where the function is called. And the third is that local x=$(cmd) always succeeds, because the status you get back is local's, not the command's. Declare first, assign on the next line, when the status matters.
Pipelines and pipefail
A pipeline's exit status is, by default, the status of its last command. So curl bad-url | jq . exits 0, because jq successfully processed nothing. set -o pipefail changes this to the rightmost non-zero status, which is what almost everyone assumed was happening.
${PIPESTATUS[@]} holds the individual statuses if you need to know which stage broke.
Traps are the exception handler
trap cleanup EXIT runs cleanup on every exit path — success, failure, or exit from anywhere in the script. trap ... ERR fires on exactly the conditions set -e would act on. Together they are as close as bash gets to try/finally.
Text as the universal interface
Why so few tools compose so widely, and where the model gives out
Unix made an unusual bet: instead of rich data types passed between programs, everything is a stream of bytes, and by convention those bytes are lines of text. Any tool can therefore be attached to any other.
There are no adapters, no schemas, no serialisation format to agree on. sort does not know or care whether it is sorting hostnames, log lines or CSV.
The price is that structure is not carried by the format. It is carried by convention instead, and conventions can be violated. That is where the sharp edges are.
Where the model breaks
The record separator is the newline and the field separator is whitespace. Filenames on Linux may contain both. So the moment a filename has a space in it, whitespace-separated tools mis-split it; and a filename containing a newline can break a line-oriented pipeline entirely.
This is not theoretical. It is what happens when someone drags a file called flight test 2026.csv onto a server.
The escape hatch is the null byte, which is the one character a filename cannot contain. Tools that deal in filenames grew matching options for it:
find . -name '*.log' -print0 | xargs -0 gzip
grep -rlZ 'TODO' src/ | xargs -0 sed -i 's/TODO/DONE/'
while IFS= read -r -d '' f; do
echo "$f"
done < <(find . -type f -print0)
-print0, -0, -Z, -z and read -d '' are all the same idea: separate records with NUL instead of newline.
The tool ladder
The classic text tools form a ladder of increasing power, and the skill is knowing when to stop climbing:
- cut / tr — fixed columns and character swaps. No logic. Fast to type, fails on aligned whitespace.
- grep — select lines by pattern. Answers 'which lines'.
- sed — a line editor as a stream. Answers 'change these lines'.
- awk — a small programming language with automatic field splitting, variables, arrays and arithmetic. Answers 'compute something per line, then summarise'.
- Python / a real program — when you need data structures, error handling, or anything you will still be maintaining in six months.
A useful heuristic: if the awk program has grown past one line, or you have started nesting sed expressions, you have climbed one rung too high and should write a script.
Regex is three languages wearing a trenchcoat
BRE (basic) is what grep and sed use by default, and it requires backslashes for +, ?, ( and {. ERE (extended) is what grep -E, sed -E and awk use, where those characters work as you expect. PCRE (grep -P, most languages) adds lookarounds, lazy quantifiers and \d.
rg does not belong in that group by default. Its Rust engine has no lookaround or backreferences at all, and needs rg -P to get them. When a pattern mysteriously fails, dialect is the first thing to check.
Use -F when you can
grep -F treats the pattern as a literal string. It is dramatically faster and it removes an entire class of bug where a dot or a bracket in your search term quietly means something else.
Structured data needs a structured tool
JSON is not line-oriented, and every attempt to parse it with grep and sed works right up until a field contains a brace, a value spans lines, or the key order changes. jq exists because it understands the grammar: it parses JSON into a real tree, applies a filter language to it, and emits JSON or raw text. The same argument gives you yq for YAML.
Reach for them from the first attempt, not after the regex has failed twice.
Files, names, and permissions
Inodes, links, and why a deleted file can still fill your disk
A file's data and metadata live in an inode. A filename is not part of the file: it is an entry in a directory that points at an inode.
A directory is just a file containing a list of names and inode numbers. This indirection is not a detail; it explains most filesystem behaviour that seems strange.
A hard link is a second directory entry pointing at the same inode. Both names are equally real; neither is the original. The inode keeps a link count, and the data is freed only when that count reaches zero.
A symlink is a different thing entirely: a tiny file whose contents are a path, resolved at access time. It can cross filesystems, can point at nothing, and breaks if the target moves.
Deleting an open file
The kernel frees an inode's blocks when both the link count is zero and no process has it open. rm on a 40GB log that nginx still has open removes the name, so du can no longer see it. But the space is not returned until nginx closes the descriptor or restarts.
df -h /var # 100% full
du -sh /var/log # only 2G accounted for
sudo lsof -nP | grep '(deleted)'
# nginx 1204 www-data 2w REG 8,1 41231872000 /var/log/nginx/access.log (deleted)
The fix is to signal the process to reopen its logs, or restart it. This is exactly what logrotate's copytruncate and postrotate hooks exist to avoid.
Rename is atomic, and that is a deployment strategy
Renaming within a filesystem is a single atomic operation: readers see either the old name or the new one, never a half-written state. The safe way to write a config file follows from this: write config.new, then mv it into place. The symlink-swap release pattern relies on the same guarantee: you point current at a fully-built directory in one instant.
ln -sfn /srv/releases/2026-08-20 /srv/current
# -n stops ln following the existing symlink and creating
# /srv/current/2026-08-20 instead
Reading permission bits
-rwxr-x--- is a type character followed by three triplets: owner, group, everyone else. Each is read, write, execute, numbered 4, 2, 1, so 750 means owner gets 7 (rwx), group gets 5 (r-x), others get 0.
On a directory, the bits mean something different and it trips people up. Read means you may list the names. Execute means you may traverse into it and access things by name.
A directory with r-- lets you see the names but stat nothing; one with --x lets you open a file you already know the path of, but not list the contents. Execute is the one that matters.
The group membership delay
The kernel checks permissions using the credentials attached to your process, and those were set at login. usermod -aG docker $USER changes the database, not your running session: you need a new login before the group takes effect. newgrp docker starts a subshell with it applied, if you cannot log out.
Signals, sessions, and staying alive
What kills your job when you close the laptop, and the three ways to stop it
A signal is a small asynchronous notification the kernel delivers to a process. Most can be caught and handled; two cannot. SIGTERM (15) is a polite request to shut down: the process can flush buffers, close connections and exit cleanly.
SIGKILL (9) is not delivered to the process at all; the kernel destroys it outright, with no chance to clean up. SIGINT (2) is what Ctrl-C sends. SIGHUP (1) originally meant the modem hung up.
The order to try is always TERM, wait a few seconds, then KILL. Reaching for kill -9 first is how you end up with corrupt files and stale lock files.
Why Ctrl-C stops the whole pipeline
Your terminal has a foreground process group, and Ctrl-C sends SIGINT to every process in it. So a | b | c dies as a unit instead of leaving orphans behind.
When your SSH connection drops, the kernel sends SIGHUP to the session's processes, and the default action for SIGHUP is to terminate. That is the mechanism behind 'my long job died when my laptop slept'.
Three answers, for three different problems
- nohup / disown / setsid — detach the process from the terminal so SIGHUP never reaches it. Cheap, but you cannot reattach, and you get whatever you redirected to a file and nothing more.
- tmux / screen — the session lives in a server process that owns its own pseudo-terminals. Your client attaches and detaches at will; the programs inside never notice. This is what you want for interactive work, or when you might need to look at something and type a response.
- systemd unit — the process is owned by the init system, starts at boot, restarts on failure, logs to the journal, and has resource limits. This is what you want for anything that should still be running next month.
The choice is really about who should own the process. If the answer is 'me, for the next hour', use tmux. If it is 'the machine, indefinitely', write a unit file.
# right now, and I might want to watch it
tmux new -d -s import './import.sh > import.log 2>&1'
tmux attach -t import
# forever, and it should come back after a reboot
sudo systemctl enable --now app.service
Trapping signals in a script
trap installs a handler. Handling INT and TERM lets a script kill its children and remove its lock file before dying, instead of leaving debris. You cannot trap KILL or STOP: that is the point of them.
trap 'echo "shutting down" >&2; kill 0; exit 130' INT TERM
trap 'rm -rf -- "$TMP_DIR"' EXIT
kill 0 signals every process in the current process group, which is how you take your background children with you.
Where a command comes from
PATH resolution, and the reason your cron job cannot find anything
When you type a name, bash resolves it in a strict order: aliases first, then shell functions, then builtins, then a search along PATH. PATH is a colon-separated list of directories, searched left to right, first match wins. It is not recursive and it does not look in the current directory.
Bash caches the results in a hash table, so installing a new binary sometimes does not take effect until you run hash -r or open a new shell.
type -a python3 # every meaning, in resolution order
command -v jq # the path bash would actually use
echo "$PATH" | tr ':' '\n'
type understands aliases, functions and builtins. which is a separate program that knows about none of them, and will happily lie to you about an alias.
The environment is inherited, not global
There is no global environment on a Unix system. Each process has its own copy, received from its parent at exec time.
So an environment variable is only visible to things started after it was exported, by that shell or its descendants. Setting something in one terminal has no effect on another, and a running service will not pick up a change to /etc/environment until it restarts.
Why it works in your shell and fails in cron
This is the most common Linux support question, and it has one answer. Cron does not run your shell's startup files.
A cron job gets a minimal environment — often just PATH=/usr/bin:/bin, HOME, SHELL and LOGNAME. No nvm, no virtualenv, no direnv, none of your aliases, and a PATH that is missing /usr/local/bin.
The same applies, for the same reason, to systemd units, Docker CMD entries, and ssh host 'command'. The fix is always one of three things: use absolute paths, set the environment explicitly, or source the profile you depend on.
# fragile
*/5 * * * * sync-logs.sh
# survives
*/5 * * * * /usr/local/bin/sync-logs.sh >> /var/log/sync.log 2>&1
# or, in the script itself
export PATH="/usr/local/bin:/usr/bin:/bin"
source /srv/app/.venv/bin/activate
Debug it directly
Do not guess at the environment a job will get. Print it: add env > /tmp/cron-env.txt as a temporary cron entry, or run systemctl show-environment, and compare against your own env. The difference is the bug.
Debugging a machine, in layers
A fixed order of questions, so you stop guessing
When something is down, the useful skill is not knowing more commands. It is asking the questions in an order that halves the search space each time.
A request to your service passes through a stack of layers, and each layer has one tool that tests it in isolation. Work outward from the process, or inward from the client, but do work in order.
Is the process even alive?
systemctl status app answers this and shows the last few log lines. If it is restarting in a loop, journalctl -u app -n 100 shows why. Exit code 137 means it was killed: check dmesg -T | grep -i 'killed process' for the OOM killer before assuming a crash.
Is it listening, and on what?
ss -tulpn shows every listening socket and the process that owns it. The common finding here is a service bound to 127.0.0.1 when it needed to be on 0.0.0.0, which looks completely healthy from the box and completely dead from anywhere else.
Can I reach the port?
nc -zv host port from the client side tests reachability with no application logic involved. A hang means a firewall is dropping packets; an immediate 'connection refused' means the packet arrived and nothing was listening. Those are different problems with different owners.
Is the name resolving to what I think?
dig +short name from the machine that is failing. Stale DNS, split-horizon resolvers and an /etc/hosts entry someone added during an incident three months ago all show up here.
Is TLS the problem?
openssl s_client -connect host:443 -servername host shows the certificate actually being served, its chain and its dates. Expired certificates and a missing intermediate are the two findings, and the second one is invisible in a browser that has the intermediate cached.
What does the application say?
Only now do you look at HTTP. curl -v shows the full exchange; curl -o /dev/null -sw '%{http_code}\n' gives you just the status. A 502 from nginx means nginx is fine and the upstream is not — which sends you back to the first question, one layer down.
systemctl is-active app # 1. alive?
ss -tulpn | grep 8000 # 2. listening?
nc -zv 10.0.4.13 8000 # 3. reachable?
dig +short api.example.com # 4. resolving?
curl -sI https://api.example.com # 5. answering?
journalctl -u app -p err -n 50 # 6. complaining?
The resource questions, in parallel
- Disk —
df -hfor space,df -ifor inodes,du -xh --max-depth=2 | sort -hto find the culprit. Ifdfanddudisagree, look for deleted-but-open files. - Memory —
free -h, and read theavailablecolumn, notfree. Linux fills spare RAM with page cache on purpose; that is not a leak. - CPU —
uptimeload average compared againstnproc. Load counts processes waiting on disk too, so high load with idle CPU means I/O. - Descriptors —
ulimit -nand/proc/PID/limits. 'Too many open files' under load is almost always this.
The shell as an agent's hands
Why CLI is the interface, and what changes when the operator is a model
An AI agent needs a way to act on the world, and the command line turns out to be close to ideal for it. It is text in and text out, so it needs no adapter. It is composable, so small capabilities combine into large ones without anybody writing integration code.
It is auditable: every action is a command you can read, log and replay. And it already has forty years of tools, so the agent inherits git, rg, docker and curl without any of them being designed for it.
The consequence is that agent design is largely shell design. The same instincts that make a good deploy script — fail loudly, be idempotent, support a dry run, keep the blast radius small — are what make an agent's tools safe to hand over.
Headless mode makes the model a pipeline stage
claude -p runs non-interactively: prompt in, result out, exit. That single property lets a language model sit inside a pipeline next to grep and jq, instead of being a place you go and paste things into.
git diff main...HEAD | claude -p 'review for auth and injection risks'
journalctl -u app -p err --since '1 hour ago' \
| claude -p 'group these by root cause, most frequent first'
claude -p 'list exported functions in src/api.ts as JSON' \
--output-format json | jq -r '.result'
Read and write are different risks
Reading is cheap and reversible; writing is neither. It is worth splitting an agent's capabilities along that line and granting them separately — read tools freely, write tools with a confirmation step or a scoped allowlist. --allowedTools 'Bash(npm test:*)' 'Read' 'Edit' is a meaningfully different grant from --allowedTools 'Bash', and the specificity is the whole point.
Idempotency and dry runs
An agent will retry. A tool that is safe to run twice is a tool you do not have to reason about carefully. mkdir -p is idempotent; mkdir is not.
rsync is; cp -r into a moving target is not. Where a command is destructive, give it a preview mode and make the agent use it first — rsync -n, git clean -nd, find ... -print before -delete.
git is the undo layer
The reason it is reasonable to let a model edit your files at all is that git makes every change reviewable and reversible. Work on a branch, keep commits small enough to revert individually, and read the diff instead of the model's description of the diff.
For parallel work, git worktree gives each agent its own directory and branch backed by one repository, so two of them are not fighting over the same index and the same dev server port.
for t in graph auth export; do
git worktree add -b "feat/$t" "../app-$t" main
done
# one agent per directory, one branch each, one shared history
Containers for blast radius
Permission prompts are a control that assumes a human is reading them. When you skip them for an unattended run, the container becomes the control instead: mount only the directory that matters, drop the network if the task does not need it, and treat the whole thing as disposable. The question to answer before starting is not 'will it behave' but 'what is the worst thing that survives if it does not'.
Content is data, not instructions
Anything an agent reads — a web page, an issue description, a dependency's README, a log line — can contain text addressed to the model. Treat retrieved content as untrusted input, keep the tools it can reach narrow, and be suspicious of any workflow where fetched text can trigger a write. This is the shell injection problem in a new costume, and the old defence applies: never let data become a command.
The master script
Below is a single script that uses almost every pattern in the scripting section at once. It is not a toy: strip the deploy-specific parts out of main and you have a skeleton worth starting every real script from.
Read it in five passes.
The preamble sets strict mode and resolves the script's own directory, so the script behaves the same whether it is run from its folder or from /. It does not resolve a symlink to the script itself: for that, run readlink -f on ${BASH_SOURCE[0]} first.
The output helpers send every diagnostic to stderr, which keeps stdout clean for piping, and run gives the whole script a dry-run mode for free by routing every side effect through one function.
The trap block guarantees the temp directory is removed on every exit path, including Ctrl-C and a mid-script failure, because EXIT fires for all of them.
The argument parser uses getopts with a leading colon so it reports its own errors instead of bash's.
main does the actual work last, after the whole file has parsed.
Two details are easy to miss and worth stealing.
The verbose flag is applied with an if instead of (( VERBOSE )) && set -x, purely for readability. Contrary to a widespread belief, the && form is safe: set -e ignores any command that is not the last in an && list. What does kill a script is a bare (( VERBOSE )) on its own line.
And the deploy does not finish at the restart. It polls the health endpoint, so a service that comes back broken fails the deploy instead of passing it.
#!/usr/bin/env bash
#
# deploy.sh - copy a build to a host and restart the service
#
# Usage: deploy.sh [-e ENV] [-n] [-v] TARGET
#
set -euo pipefail
IFS=$'\n\t'
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
SCRIPT_NAME="${0##*/}"
TMP_DIR="$(mktemp -d)"
readonly SCRIPT_DIR SCRIPT_NAME TMP_DIR
# --- configuration: environment wins, defaults fill the gaps ----------------
ENVIRONMENT="${ENVIRONMENT:-staging}"
DRY_RUN=0
VERBOSE=0
# --- output -----------------------------------------------------------------
# Everything diagnostic goes to stderr so stdout stays pipeable.
_ts() { date +%H:%M:%S; }
log() { printf '%s %s\n' "$(_ts)" "$*" >&2; }
warn() { printf '%s WARN %s\n' "$(_ts)" "$*" >&2; }
die() { printf '%s FATAL %s\n' "$(_ts)" "$*" >&2; exit 1; }
# run: echo a command, then run it - or just echo it during a dry run.
run() {
if (( DRY_RUN )); then
log "DRY $*"
else
log "RUN $*"
"$@"
fi
}
need() { command -v "$1" >/dev/null 2>&1 || die "missing dependency: $1"; }
usage() {
cat <<EOF
$SCRIPT_NAME - copy a build to a host and restart the service
Usage: $SCRIPT_NAME [options] TARGET
Options:
-e ENV environment to deploy to (default: $ENVIRONMENT)
-n dry run: print commands without running them
-v verbose: trace every command
-h show this help
EOF
}
# --- cleanup runs on every exit path, including errors and Ctrl-C -----------
cleanup() {
local code=$?
rm -rf -- "$TMP_DIR"
if (( code != 0 )); then
warn "exited with status $code"
fi
return "$code"
}
trap cleanup EXIT
trap 'die "interrupted"' INT TERM
# --- arguments ---------------------------------------------------------------
while getopts ':e:nvh' opt; do
case "$opt" in
e) ENVIRONMENT="$OPTARG" ;;
n) DRY_RUN=1 ;;
v) VERBOSE=1 ;;
h) usage; exit 0 ;;
:) die "option -$OPTARG requires a value" ;;
\?) usage >&2; die "unknown option: -$OPTARG" ;;
esac
done
shift $(( OPTIND - 1 ))
# Either form works: set -e ignores the left side of &&. What WOULD
# exit is a bare `(( VERBOSE ))` on its own line.
if (( VERBOSE )); then
PS4='+ ${BASH_SOURCE##*/}:${LINENO}:${FUNCNAME[0]:-main}: '
set -x
fi
if (( $# != 1 )); then
usage >&2
die "expected exactly one TARGET"
fi
readonly TARGET="$1"
# --- the actual work ---------------------------------------------------------
main() {
need rsync
need ssh
[[ -d "$SCRIPT_DIR/dist" ]] || die "no build found at $SCRIPT_DIR/dist"
log "deploying $TARGET to $ENVIRONMENT"
run rsync -az --delete \
--exclude '.DS_Store' \
"$SCRIPT_DIR/dist/" "deploy@${TARGET}:/srv/app/"
run ssh "deploy@${TARGET}" 'sudo systemctl restart app'
# Wait for the service to answer before declaring victory.
local i
for (( i = 1; i <= 10; i++ )); do
if run ssh "deploy@${TARGET}" 'curl -fsS localhost:8000/health >/dev/null'; then
log "healthy after ${i}s"
return 0
fi
sleep 1
done
die "service did not come back healthy"
}
main "$@"
Bash scripting
The language itself: quoting, expansion, control flow, and the safety rails that stop a script from doing half a job and calling it success.
Bash is not a general-purpose language and it is not trying to be. It is a language for gluing programs together, and it is excellent at exactly that: launching processes, wiring their inputs and outputs, branching on whether they worked, and passing text between them. Everything it does well is in service of that. Everything it does badly — arithmetic, data structures, error handling, anything with a float in it — is a sign you have wandered outside its purpose.
The practical consequence is a rule of thumb about size. Under fifty lines and mostly calls to other programs, bash is the right choice and any alternative is overhead. Past a few hundred lines, or the moment you find yourself wanting a dictionary of lists, you are writing the wrong thing in the wrong language and should move to Python before it gets worse. Most scripts that became a maintenance problem crossed that line gradually and nobody noticed.
The other thing to internalise is that bash defaults are wrong for scripts. They were designed for an interactive shell, where a failed command should not close your terminal and an unset variable expanding to nothing is convenient. In a script those same defaults mean a failure in step two is invisible and steps three through nine run anyway, on incomplete state. set -euo pipefail is not paranoia; it is switching from interactive defaults to programming defaults.
So the material below is ordered accordingly: the safety preamble first, then the expansion rules that quoting protects you from, then control flow, then the plumbing — redirection, traps, arguments — that turns a sequence of commands into something you can hand to someone else.
Skeleton & safety
The first ten lines of a script decide whether its failures are loud or silent. Strict mode, a resolved script directory, dependency checks and a cleanup trap cost nothing and remove whole categories of bug.
#!/usr/bin/env bash
Finds bash on PATH instead of hardcoding a location. Portable across Linux, macOS and containers.
#!/usr/bin/env bash
set -euo pipefail
Note
#!/bin/sh gets you dash on Debian/Ubuntu, where arrays, [[ ]] and ${var^^} all break. If you write bash, say bash.
set -e
Aborts the script the moment any command exits non-zero.
set -e
cp missing.txt /tmp/ # script stops here
echo 'never printed'
Note
Commands inside if, while, && and || are exempt on purpose. That is why if grep -q x f; then still works under set -e.
set -u
Treats reading an unset variable as a fatal error, so typos surface instead of silently expanding to nothing.
set -u
rm -rf "$BULID_DIR/" # typo -> hard error, not `rm -rf /`
Note
For genuinely optional values use ${VAR:-} to supply an empty default rather than turning -u off.
set -o pipefail
Makes a pipeline fail if any stage fails, not just the final one.
set -o pipefail
curl -sf https://api.example.com/x | jq .id # curl 404 now fails the line
Note
Without pipefail, curl bad | jq . exits 0 because jq succeeded at reading nothing.
set -euo pipefail
The standard opening line: stop on error, stop on unset variables, stop on broken pipelines.
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
Restricts word-splitting to newlines and tabs so filenames with spaces stop shattering into pieces.
IFS=$'\n\t'
for f in $(find . -name '*.log'); do echo "$f"; done
Note
It changes behaviour everywhere in the script, including read -a. Quoting variables properly is the real fix; treat this as a seatbelt, not a substitute.
SCRIPT_DIR=...
Resolves the directory the script lives in, so it works no matter where it is called from.
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/lib/common.sh"
Note
Use ${BASH_SOURCE[0]} rather than $0 so it still resolves correctly when the file is sourced.
command -v
Checks whether a dependency exists before you rely on it.
need() { command -v "$1" >/dev/null || { echo "missing: $1" >&2; exit 1; }; }
need jq; need rsync
Note
Prefer this to which, which is an external binary with inconsistent exit codes across distros.
readonly
Locks a variable after assignment so later code cannot clobber it.
readonly CONFIG="/etc/app/config.yml"
readonly -a REGIONS=(us-west us-east)
mktemp -d
Creates a uniquely named temp directory, safely, with no race against other processes.
TMP="$(mktemp -d)"
trap 'rm -rf -- "$TMP"' EXIT
Note
Always pair it with a trap. A script that dies mid-run should not leave litter in /tmp.
source vs ./script
source runs the file in the current shell (so it can set your variables); executing it spawns a child that cannot.
source ./env.sh # exports land in this shell
./env.sh # exports die with the child
set -a; source .env; set +a
Loads a .env file and exports every variable it defines.
set -a
source .env
set +a
python -m app
Note
This runs the file as bash. A .env containing KEY=$(rm -rf x) will execute. Only do this with files you control.
exec > >(tee -a log) 2>&1
Redirects everything the script prints from that point on into a log file and the terminal at once.
exec > >(tee -a "/var/log/deploy-$(date +%F).log") 2>&1
echo 'this is captured'
main "$@"
Wraps the body in a function and calls it last, so the whole file parses before anything executes.
main() {
log 'starting'
deploy "$1"
}
main "$@"
Note
Without this, editing a running script can make bash read the changed bytes mid-execution and do something nobody wrote.
Variables & expansion
Parameter expansion is bash's standard library. Prefix and suffix stripping, defaults, and case conversion all happen in the shell with no process launched, which is both faster and less fragile than piping through sed.
VAR=value
Assigns a variable. No spaces around the equals sign, ever.
count=3
name="flight test"
path="$HOME/logs"
Note
count = 3 is bash trying to run a program called count.
"$var"
Quoting stops word-splitting and glob expansion. This is the single highest-value habit in bash.
file="my report.txt"
rm "$file" # removes one file
rm $file # tries to remove 'my' and 'report.txt'
${var:-default}
Uses a fallback when the variable is unset or empty, without assigning it.
PORT="${PORT:-8000}"
echo "listening on ${HOST:-0.0.0.0}:$PORT"
${var:=default}
Same idea, but also assigns the default back to the variable.
: "${LOG_LEVEL:=info}"
echo "$LOG_LEVEL" # info, and LOG_LEVEL is now set
${var:?message}
Aborts with your message if the variable is missing. Cheap required-argument validation.
API_KEY="${API_KEY:?set API_KEY before running}"
${var:+alt}
Expands to the alternate value only when the variable is set AND non-empty. Drop the colon — ${var+alt} — for the set-at-all form. Useful for optional flags.
VERBOSE_FLAG="${DEBUG:+--verbose}"
myapp $VERBOSE_FLAG
${#var}
Length of a string. For an array you must subscript it — ${#arr[@]} is the element count, while a bare ${#arr} gives the length of element zero.
s="HLX-003"
echo "${#s}" # 7
${var#pat} / ${var##pat}
Strips a prefix: # removes the shortest match, ## the longest.
p="/var/log/app/error.log"
echo "${p##*/}" # error.log
echo "${p#*/}" # var/log/app/error.log
${var%pat} / ${var%%pat}
Strips a suffix: % shortest match, %% longest.
f="backup.tar.gz"
echo "${f%.gz}" # backup.tar
echo "${f%%.*}" # backup
Note
Together, ${p##*/} and ${p%/*} are basename and dirname without forking a process.
${var/old/new}
Replaces the first occurrence; double slash replaces all.
s="a-b-c"
echo "${s/-/_}" # a_b-c
echo "${s//-/_}" # a_b_c
${var^^} / ${var,,}
Upper-cases or lower-cases a string in place.
env="Staging"
echo "${env,,}" # staging
echo "${env^^}" # STAGING
Note
Bash 4+ only. macOS ships bash 3.2, so use tr there or install a modern bash.
${var:offset:length}
Substring by position.
sha="9f3c1d8a2b"
echo "${sha:0:7}" # 9f3c1d8
$(( expr ))
Integer arithmetic. No $ needed on variable names inside.
total=$(( count * 2 + 1 ))
pct=$(( done * 100 / total ))
Note
Bash has no float math. Pipe to awk or bc -l when you need decimals.
(( expr ))
Arithmetic as a statement or a test.
(( retries++ ))
if (( retries > 3 )); then die 'giving up'; fi
Note
(( x )) returns exit status 1 when x is 0. Bare on its own line that is fatal under set -e — but on the left of && it is exempt, so (( x )) && cmd is safe.
$(command)
Captures a command's stdout into a value.
branch="$(git rev-parse --abbrev-ref HEAD)"
now="$(date -u +%FT%TZ)"
Note
Trailing newlines are stripped. Prefer $( ) over backticks: it nests cleanly and is readable.
${!var}
Indirect expansion: use the value of one variable as the name of another.
env=PROD_URL
PROD_URL="https://api.example.com"
echo "${!env}"
declare -i / -r / -A
Types a variable as integer, read-only, or associative array.
declare -i attempts=0
attempts+=1 # arithmetic, not string concat
printf -v var
Formats directly into a variable with no subshell.
printf -v padded '%04d' 42
echo "$padded" # 0042
$$ $! $? $0 $LINENO
Current PID, last background PID, last exit status, script name, current line.
long_task &
child=$!
wait "$child" || echo "failed: $?"
Arrays
Arrays exist so that a list of things stays a list of things. The dominant use is building command arguments: appending optional flags to an array and expanding it with "${args[@]}" is the only approach that survives values containing spaces.
arr=(a b c)
Creates an indexed array.
hosts=(hlx-002 hlx-003 hlx-004)
echo "${hosts[0]}" # hlx-002
"${arr[@]}"
Expands to every element as a separate quoted word. This is what you almost always want.
for h in "${hosts[@]}"; do ssh "$h" uptime; done
Note
"${arr[*]}" joins everything into one string using the first character of IFS. Different tool, different job.
arr+=(item)
Appends to an array.
args=()
args+=(--verbose)
[[ -n $CFG ]] && args+=(--config "$CFG")
myapp "${args[@]}"
Note
Building command arguments in an array is the correct way to handle optional flags that may contain spaces.
${#arr[@]}
Number of elements.
if (( ${#hosts[@]} == 0 )); then die 'no hosts'; fi
mapfile -t arr < file
Reads a file into an array, one line per element, without the trailing newlines.
mapfile -t lines < hosts.txt
printf '%s\n' "${lines[@]}"
Note
Also spelled readarray. Use mapfile -t arr < <(cmd) to capture command output.
declare -A map
Associative array, i.e. a string-keyed dictionary.
declare -A ip
ip[hlx-003]=10.0.4.13
for k in "${!ip[@]}"; do echo "$k -> ${ip[$k]}"; done
Note
Must be declared before use. Bash 4+ only.
${arr[@]:1:2}
Slices an array by offset and count.
echo "${hosts[@]:1}" # everything after the first
local -n ref=arr
Nameref: lets a function operate on a caller's array by name.
push() { local -n a="$1"; shift; a+=("$@"); }
push hosts hlx-005
Tests & conditionals
[[ ]] is a bash keyword, not a command, which is why it can skip word splitting and support regex. Use it and treat [ ] as a POSIX compatibility artefact you will meet in other people's scripts.
[[ ... ]]
Bash's conditional. Safer than [ ] because it does not word-split or glob unquoted variables.
if [[ -n $name && $count -gt 0 ]]; then echo ok; fi
Note
Use [[ ]] in bash scripts and forget [ ] exists unless you are targeting POSIX sh.
[[ $a == $b ]]
String equality. The right side is a glob pattern unless you quote it.
if [[ $file == *.log ]]; then echo 'a log'; fi
if [[ $file == "$other" ]]; then echo 'literal match'; fi
[[ $s =~ regex ]]
Regex match, with captures landing in BASH_REMATCH.
if [[ $tag =~ ^v([0-9]+)\.([0-9]+) ]]; then
echo "major=${BASH_REMATCH[1]} minor=${BASH_REMATCH[2]}"
fi
Note
Do not quote the regex; quoting makes it a literal string. Put complex patterns in a variable and use =~ $re.
-f -d -e -s -r -w -x
File tests: regular file, directory, exists, non-empty, readable, writable, executable.
[[ -f $cfg ]] || die "no config at $cfg"
[[ -d $out ]] || mkdir -p "$out"
-z / -n
True when a string is empty / non-empty.
if [[ -z ${API_KEY:-} ]]; then die 'API_KEY is required'; fi
-eq -ne -lt -le -gt -ge
Numeric comparison inside [[ ]]. Inside (( )) use the normal < > == symbols.
if [[ $code -ge 500 ]]; then echo 'server error'; fi
if (( code >= 500 )); then echo 'same thing'; fi
file1 -nt file2
True when file1 is newer than file2. Poor man's make.
if [[ src/app.ts -nt dist/app.js ]]; then npm run build; fi
cmd && cmd || cmd
Runs the second only on success, the third only on failure.
mkdir -p build && cd build || die 'cannot enter build'
Note
[[ x ]] && a || b is not a ternary: if a fails, b runs too. Use a real if/else when both branches matter.
cmd || true
Swallows a non-zero exit so set -e does not kill the script.
grep -q WARN app.log || true
case
Pattern-matching branch. Cleaner than a stack of elifs.
case "$env" in
prod|production) url=$PROD ;;
stag*) url=$STAGING ;;
*) die "unknown env: $env" ;;
esac
Loops
Two rules cover most loop bugs. Read files with while IFS= read -r line, never for line in $(cat file). And feed the loop with < <(cmd) instead of cmd |, so it runs in your shell and variables set inside it survive.
for x in ...
Iterates over a list of words.
for env in dev staging prod; do
echo "deploying $env"
done
for i in {1..10}
Brace expansion generates sequences, with an optional step.
for i in {1..5}; do curl -sf "http://host/$i" || break; done
echo {0..20..5} # 0 5 10 15 20
Note
Brace expansion happens before variable expansion, so {1..$n} does not work. Use a C-style loop or seq.
for ((i=0; i<n; i++))
C-style loop when you need a real counter.
for (( i=0; i<${#hosts[@]}; i++ )); do
echo "$i: ${hosts[i]}"
done
while IFS= read -r line
The correct way to read a file line by line, preserving whitespace and backslashes.
while IFS= read -r line; do
echo "got: $line"
done < hosts.txt
Note
IFS= keeps leading/trailing spaces; -r stops backslashes being eaten. Omit either and you will corrupt data eventually.
done < <(cmd)
Process substitution feeds a command into the loop without a subshell, so variables set inside survive.
count=0
while IFS= read -r l; do (( count++ )); done < <(grep ERROR app.log)
echo "$count" # correct
Note
grep ... | while read runs the loop in a subshell, so count resets to 0 when the pipeline ends. This is the classic bash surprise.
read -r -d ''
Reads null-delimited records, which is the only fully safe way to handle arbitrary filenames.
while IFS= read -r -d '' f; do
echo "$f"
done < <(find . -name '*.log' -print0)
for f in *.log
Globbing beats parsing ls output.
shopt -s nullglob
for f in logs/*.log; do gzip "$f"; done
Note
Without nullglob, a non-matching glob is passed through literally and your loop runs once with logs/*.log as the filename.
shopt -s globstar
Enables ** for recursive matching.
shopt -s globstar nullglob
for f in src/**/*.ts; do echo "$f"; done
break / continue
Exit a loop, or skip to the next iteration. Both take a level count for nested loops.
for h in "${hosts[@]}"; do
ping -c1 -W1 "$h" >/dev/null || continue
echo "$h is up"
done
ssh -n inside a loop
Stops ssh from swallowing the loop's stdin and eating the rest of your input.
while read -r h; do ssh -n "$h" uptime; done < hosts.txt
Note
Without -n, ssh consumes the whole host list on the first iteration and the loop runs once.
Functions & scope
Functions in bash return a status, not a value; anything you want back has to be printed and captured. local is what stops a helper quietly overwriting a caller's variable, and forgetting it is the usual cause of a script that works alone and breaks when sourced.
name() { ...; }
Defines a function. Arguments arrive as $1, $2, "$@".
log() { printf '%s %s\n' "$(date +%T)" "$*" >&2; }
log 'starting deploy'
local
Scopes a variable to the function. Without it, everything is global.
retry() {
local n=0 max=$1; shift
until "$@"; do (( ++n >= max )) && return 1; sleep $(( n * 2 )); done
}
Note
local x=$(cmd) masks the command's exit status, because local itself succeeds. Declare first, assign on the next line if you need to check $?.
return vs echo
return sets an exit status (0-255); to hand back a value, print it and capture with $( ).
current_sha() { git rev-parse --short HEAD; }
sha="$(current_sha)"
"$@" vs "$*"
"$@" preserves each argument separately; "$*" flattens them into one string.
run() { echo "+ $*" >&2; "$@"; }
run rsync -a "my dir/" server:/srv/
shift
Drops $1 and slides the rest down. Standard in argument parsers.
cmd=$1; shift
case $cmd in deploy) deploy "$@" ;; esac
export -f
Makes a function visible to child bash processes, including xargs and find -exec.
process() { echo "handling $1"; }
export -f process
find . -name '*.log' -exec bash -c 'process "$0"' {} \;
Redirection & I/O
Redirection is fd-table manipulation, applied left to right, before the program starts. Heredocs are worth particular attention: whether you write <<EOF or <<'EOF' decides whether variables expand locally or on the remote host, and getting it backwards is a rite of passage.
> and >>
Overwrite or append stdout to a file.
date > run.log
echo 'second line' >> run.log
Note
Use set -o noclobber in interactive shells if you have ever destroyed a file with a stray >.
2>&1
Points stderr at wherever stdout is currently going. Order matters.
cmd > out.log 2>&1 # both to out.log
cmd 2>&1 > out.log # stderr to terminal, stdout to file
Note
Read it right to left: 2>&1 copies stdout's current destination, so it must come after the redirect.
&> file
Bash shorthand for sending stdout and stderr to the same place.
npm run build &> build.log
2>/dev/null
Discards error output only.
find / -name core 2>/dev/null
>/dev/null 2>&1
Silences a command entirely; you only care about its exit status.
if command -v docker >/dev/null 2>&1; then echo present; fi
tee
Writes to a file and passes the stream onward.
make 2>&1 | tee build.log | grep -i error
tee >(cmd)
Forks a stream into another command as well as onward.
generate | tee >(gzip > archive.gz) | wc -l
<<EOF
Heredoc: a multi-line literal on stdin. Variables expand.
cat <<EOF > /tmp/nginx.conf
server { server_name $DOMAIN; }
EOF
<<'EOF'
Quoted heredoc: nothing expands. Use for scripts, awk programs and anything containing $.
ssh host bash <<'EOF'
cd /srv/app && git pull && systemctl restart app
EOF
Note
The difference between <<EOF and <<'EOF' is the most common source of remote-script bugs: unquoted, $HOME expands locally.
<<-EOF
Strips leading tabs so a heredoc can sit at the indentation of its block.
if true; then
cat <<-EOF
indented in source, flush in output
EOF
fi
Note
Tabs only. Spaces are not stripped.
<<< "string"
Herestring: feeds a single string to a command's stdin.
jq -r .name <<< "$json"
read -r a b <<< "$line"
<(cmd)
Process substitution: makes a command's output look like a file.
diff <(ssh prod cat /etc/app.conf) <(cat local.conf)
printf
Predictable formatted output. Unlike echo, it behaves the same everywhere.
printf '%-12s %s\n' "$host" "$status"
printf '%s\n' "${arr[@]}"
Note
echo mangles backslashes and leading dashes differently across shells. Use printf in scripts.
read -p / -s / -t
Prompts for input, hides it, or times out.
read -rp 'Deploy to prod? [y/N] ' ans
[[ $ans == [yY] ]] || exit 0
[[ -t 0 ]]
Detects whether stdin is a terminal, so a script can behave differently when piped.
if [[ -t 0 ]]; then read -rp 'confirm: ' a; else a=yes; fi
Traps, exits & jobs
Traps are the closest bash has to finally. An EXIT trap that removes the temp directory runs on success, on error, and on Ctrl-C, which means cleanup stops depending on the script reaching its last line.
trap ... EXIT
Runs cleanup no matter how the script ends: success, error, or exit.
TMP=$(mktemp -d)
trap 'rm -rf -- "$TMP"' EXIT
trap ... ERR
Fires on any command that would trip set -e. Ideal for a one-line error report.
trap 'echo "failed at line $LINENO: $BASH_COMMAND" >&2' ERR
trap ... INT TERM
Handles Ctrl-C and kill so long jobs shut down cleanly.
trap 'echo interrupted >&2; kill -- $(jobs -p) 2>/dev/null; exit 130' INT TERM
Note
Reach for jobs -p, not kill 0: kill 0 signals the entire process group, which under cron or a parent script takes the parent and its siblings down with you.
exit codes
0 means success. 1 is generic failure, 2 is usage error, 126/127 mean not executable / not found, 128+N means killed by signal N.
exit 2 # bad arguments
# 130 = Ctrl-C (128 + SIGINT 2)
cmd & / wait
Backgrounds a job and waits for it. wait -n returns as soon as any one finishes.
for h in "${hosts[@]}"; do deploy "$h" & done
wait
echo 'all done'
timeout
Kills a command that runs too long.
timeout 30s curl -sf "$url" || echo 'timed out or failed'
timeout -k 5s 30s ./long-job.sh
flock
Stops two copies of the same script running at once.
exec 9>/var/lock/deploy.lock
flock -n 9 || { echo 'already running'; exit 1; }
Arguments & debugging
A script that supports -h and validates its arguments is one someone else can use. When something misbehaves, bash -n catches syntax, set -x with an informative PS4 shows execution, and shellcheck catches the quoting bugs that neither will.
while [[ $# -gt 0 ]]
Hand-rolled parser. The only practical way to support --long-options in bash.
while [[ $# -gt 0 ]]; do
case $1 in
-e|--env) ENV=$2; shift 2 ;;
-n|--dry-run) DRY=1; shift ;;
--) shift; break ;;
-*) die "unknown option: $1" ;;
*) ARGS+=("$1"); shift ;;
esac
done
getopts
Built-in parser for short options. Compact, but no long-option support.
while getopts ':e:nvh' opt; do
case $opt in
e) ENV=$OPTARG ;;
n) DRY=1 ;;
v) VERBOSE=1 ;;
h) usage; exit 0 ;;
:) die "-$OPTARG needs a value" ;;
\?) die "unknown option -$OPTARG" ;;
esac
done
shift $(( OPTIND - 1 ))
Note
The leading colon in ':e:nvh' switches on silent error reporting so your own : and ? cases actually run.
usage() heredoc
Every script deserves a -h. A heredoc keeps it readable.
usage() {
cat <<EOF
${0##*/} - deploy an artifact
Usage: ${0##*/} [-e ENV] [-n] TARGET
EOF
}
bash -n
Parses the script without running it. Catches syntax errors before they hit production.
bash -n deploy.sh && echo 'syntax ok'
bash -x / set -x
Traces every command as it executes, with variables expanded.
bash -x ./deploy.sh staging
# or scope it:
set -x; risky_bit; set +x
PS4
Controls the trace prefix. Adding file and line turns -x output into something you can actually read.
PS4='+ ${BASH_SOURCE##*/}:${LINENO}:${FUNCNAME[0]:-main}: '
set -x
shellcheck
Static analysis for shell. It catches quoting bugs you will otherwise find in production.
shellcheck deploy.sh
shellcheck -S warning -x scripts/*.sh
Note
Worth wiring into CI and your editor. The wiki entry behind each SC#### code explains the reasoning.
declare -p
Prints a variable's real definition, including type and array structure.
declare -p hosts
# declare -a hosts=([0]="hlx-002" [1]="hlx-003")
Text & data
Finding things, then reshaping them. grep, sed, awk and jq cover most of what people reach for Python to do.
This section is one idea applied repeatedly: a stream of lines goes in, a transformed stream comes out, and the tools chain end to end because they all agree on that shape. Once you see the ladder — select with grep, edit with sed, compute with awk — most data wrangling stops being a question of which incantation and becomes a question of which rung.
Two habits separate people who are fast at this from people who are not. The first is composing incrementally: run the first stage, look at the output, add the next stage, look again. A ten-stage pipeline written in one go and debugged as a unit is miserable; the same pipeline grown one stage at a time is easy. Pipe to head while you are building so you are not waiting on the full dataset.
The second is knowing when the text model no longer fits. JSON, YAML and anything with nested structure are not line-oriented, and every regex you write against them is a bet that the formatting will not change. jq and yq parse the real grammar and cost nothing to reach for. The rule is to use them from the first attempt rather than the third.
Finally, filenames deserve their own paranoia. They may contain spaces and, on Linux, newlines. Any pipeline that moves filenames between tools should use null separation — find -print0, xargs -0, read -d '' — and any variable holding one should be quoted. This is the single most common source of scripts that work for a year and then destroy something.
Finding files
find walks the tree and evaluates an expression on each entry; the order of tests matters for speed, since -prune and -maxdepth cut whole subtrees. Preview with -print before you use -delete, every time.
find . -name
Walks a tree matching filenames. -iname is case-insensitive.
find . -name '*.log'
find /srv -iname 'readme*'
Note
Quote the pattern, or the shell expands it against the current directory before find ever sees it.
find -type
Restricts to files (f), directories (d), or symlinks (l).
find . -type d -name node_modules -prune -o -type f -name '*.ts' -print
find -mtime / -mmin
Filters by modification age: days or minutes. + is older than, - is newer than.
find /var/log -type f -mtime +30
find . -type f -mmin -15
find -size
Filters by size. Suffixes: k, M, G.
find /var -type f -size +100M -exec ls -lh {} +
find -exec {} +
Runs a command on the results. The + form batches them into few invocations; ; runs one per file.
find . -name '*.pyc' -exec rm -f {} +
find . -name '*.conf' -exec grep -l listen {} +
find -print0 | xargs -0
Null-delimits results so filenames with spaces or newlines survive.
find . -name '*.log' -print0 | xargs -0 gzip
find -maxdepth
Limits recursion depth. Put it before other tests for speed.
find . -maxdepth 2 -name '*.json'
find -delete
Deletes matches directly. Run the find first without it, every time.
find /tmp/cache -type f -mtime +7 -delete
Note
There is no undo and no confirmation. Preview with -depth ... -print rather than the bare command — -delete turns on -depth by itself, so the traversal order differs. Never combine -prune with -delete.
fd
Modern find: sane defaults, respects .gitignore, much faster to type.
fd '\.log$'
fd -e ts -E node_modules
fd -H -t d cache
Searching content
grep answers 'which lines match'. Learn -r, -n, -C, -l, -q and -F and you have covered most daily use. rg is the same idea with better defaults — recursive, gitignore-aware, and fast enough to change what you bother searching.
grep -rn
Recursive search with line numbers. The workhorse.
grep -rn 'TODO' src/
grep -rni 'api_key' --include='*.py' .
grep -i -w -v
Case-insensitive, whole-word only, or inverted match.
grep -w port config.yml
grep -v '^#' /etc/nginx/nginx.conf | grep -v '^$'
grep -E / -F / -P
Extended regex, fixed strings (fast, no escaping), or Perl-compatible regex.
grep -E 'ERROR|FATAL' app.log
grep -F '[2026-08-20]' app.log
grep -Po '(?<=user_id=)\d+' app.log
Note
-F is dramatically faster when your pattern has no regex in it. -P is GNU-only.
grep -A -B -C
Prints lines after, before, or around each match. Essential for reading stack traces.
grep -C5 'Traceback' app.log
journalctl -u api | grep -A20 'panic'
grep -l / -c / -o
List matching filenames only, count matches, or print just the matched part.
grep -rl 'deprecated' src/ | wc -l
grep -o 'HTTP/1.1" [0-9]*' access.log | sort | uniq -c
grep -q
Silent: exits 0 on first match. Built for if statements.
if grep -q 'ready' /var/log/app.log; then echo up; fi
grep --exclude-dir
Skips directories you never want to search.
grep -rn 'apiKey' . --exclude-dir={node_modules,.git,dist}
rg
ripgrep: recursive by default, gitignore-aware, and fast enough to change how you work.
rg 'useEffect' -t ts
rg -i 'timeout' -g '!*.lock'
rg --files | rg config
sed
A stream editor: it applies commands to each line as it passes. Substitution is ninety percent of what anyone uses it for. Beyond a couple of expressions chained together, switch to awk or a script — long sed programs are write-only.
sed 's/old/new/'
Substitutes the first match per line; add g for every match.
sed 's/localhost/api.internal/g' config.yml
sed -i
Edits the file in place.
sed -i 's/DEBUG/INFO/' app.conf
sed -i.bak 's/8000/8080/' app.conf
Note
GNU sed takes -i with no argument; BSD/macOS sed requires one, so sed -i '' .... Use -i.bak and it works on both.
sed 's|old|new|'
Any character can be the delimiter, which saves escaping slashes in paths.
sed 's|/usr/local|/opt|g' paths.txt
sed -n 'Np'
Prints only selected lines. -n suppresses the default echo.
sed -n '5p' file.txt
sed -n '10,20p' file.txt
sed -n '/START/,/END/p' log.txt
sed 'Nd' / '/re/d'
Deletes lines by number or pattern.
sed '1d' data.csv # drop the header
sed '/^#/d; /^$/d' config.conf # strip comments and blanks
sed -E
Extended regex, so you can use + ? ( ) without backslashes, plus \1 backreferences.
sed -E 's/([a-z]+)-([0-9]+)/\2_\1/' names.txt
sed 'i\' / 'a\'
Inserts a line before or appends after a match.
sed '/^\[server\]/a\ timeout = 30' app.ini
awk
awk splits each line into fields automatically and gives you variables, arrays and arithmetic. That combination makes group-by-and-count a one-liner, which is why it survives despite everything newer. NR, NF, $1, $NF and END will carry you a long way.
awk '{print $1}'
Splits each line into fields and prints one. $0 is the whole line, $NF is the last field.
ps aux | awk '{print $2, $11}'
awk '{print $NF}' access.log
awk -F
Sets the field separator.
awk -F: '{print $1}' /etc/passwd
awk -F, '{print $3}' data.csv
awk '/re/ {action}'
Only acts on lines matching a pattern.
awk '/ERROR/ {print $1, $2, $NF}' app.log
awk '$9 >= 500 {print $7}' access.log
awk BEGIN/END
Runs setup before the first line and a summary after the last.
awk '{sum += $2} END {print "total:", sum}' sizes.txt
awk 'BEGIN {OFS=","} {print $1, $3}' data.tsv
awk NR / NF
NR is the current line number, NF the field count on this line.
awk 'NR > 1' data.csv # skip the header
awk 'NF != 5 {print NR": "$0}' data.tsv # find malformed rows
awk counting with arrays
Awk has associative arrays, which makes group-by a one-liner.
awk '{count[$1]++} END {for (ip in count) print count[ip], ip}' access.log | sort -rn | head
awk printf
Formats numbers and columns properly.
df -h | awk 'NR>1 {printf "%-20s %s\n", $6, $5}'
awk averages
Sum, count and divide in one pass.
awk -F' ' '{t+=$10; n++} END {printf "avg %.1f bytes over %d reqs\n", t/n, n}' access.log
Slicing, sorting, counting
The connective tissue. sort | uniq -c | sort -rn is the most reusable pipeline in Unix — it answers 'what are the most common values' for any column of anything, and it turns up in log analysis constantly.
cut
Pulls out fields or character ranges. Simpler than awk when the delimiter is single-character.
cut -d: -f1,7 /etc/passwd
cut -c1-8 log.txt
Note
cut cannot handle repeated separators. When columns are space-aligned, use awk.
tr
Translates or deletes characters.
tr 'A-Z' 'a-z' < names.txt
tr -d '\r' < win.txt > unix.txt
tr -s ' ' < spaced.txt
sort
Sorts lines. -n numeric, -r reverse, -u unique, -h human sizes, -k by column.
sort -rn sizes.txt
sort -t, -k3 -n data.csv
du -sh * | sort -h
uniq -c / -d
Counts or shows duplicates. Requires sorted input.
awk '{print $1}' access.log | sort | uniq -c | sort -rn | head -20
Note
uniq only collapses adjacent duplicates, which is why sort always comes first.
wc -l
Counts lines, words or bytes.
rg --files -t ts | wc -l
wc -l *.log | sort -n
head / tail
First or last N lines. tail -n +2 skips a header.
head -50 app.log
tail -n 200 app.log
tail -n +2 data.csv
tail -f / -F
Follows a growing file. -F keeps following across log rotation.
tail -F /var/log/nginx/error.log
tail -F app.log | grep --line-buffered ERROR
Note
Add --line-buffered to grep in a live pipeline, or buffering will hold your matches hostage.
column -t
Aligns whitespace-separated output into readable columns.
mount | column -t
cat data.csv | column -t -s,
paste / join / comm
Merge files side by side, join on a key, or compare two sorted lists.
paste a.txt b.txt
comm -23 <(sort a.txt) <(sort b.txt) # only in a
diff -u
Unified diff between two files, the same format git uses.
diff -u old.conf new.conf
diff -ru dirA/ dirB/
xargs
Turns stdin into command arguments.
rg -l 'TODO' | xargs wc -l
cat urls.txt | xargs -n1 -P8 curl -sfO
xargs -I{}
Places each input at a chosen spot, one invocation per item.
cat hosts.txt | xargs -I{} ssh {} 'uptime'
xargs -0 -P
Null-safe input and parallel execution across N processes.
find . -name '*.png' -print0 | xargs -0 -P4 -n1 optipng
JSON & YAML
jq parses JSON properly and applies a filter language to the result. Use -r when feeding other commands, --arg to pass shell variables in safely, and // for defaults. Never build a jq program by string-concatenating shell variables into it.
jq .
Pretty-prints and validates JSON.
curl -s https://api.example.com/status | jq .
jq -r
Raw output: strings without quotes, which is what you want when feeding other commands.
jq -r '.items[].name' data.json | sort
jq .a.b[0]
Navigates nested structures. .[] iterates an array.
jq '.data.results[0].id' resp.json
jq '.[] | .name' list.json
jq select()
Filters by predicate.
jq '.pods[] | select(.status != "Running") | .name' pods.json
jq map / building objects
Reshapes data into exactly the structure you need.
jq '[.items[] | {id, host: .metadata.host}]' data.json
jq --arg
Passes a shell variable in safely, without string-concatenating into the program.
jq --arg h "$HOST" '.hosts[] | select(.name == $h)' inv.json
Note
Never interpolate shell variables directly into a jq program. --arg quotes them correctly.
jq -c / @csv / @tsv
Compact one-line output, or convert to delimited text.
jq -c '.[]' big.json > lines.jsonl
jq -r '.rows[] | [.id, .name] | @csv' data.json
jq // default
Supplies a fallback when a key is missing or null.
jq -r '.config.timeout // 30' app.json
jq -s / -n
Slurps multiple inputs into one array, or starts with no input at all.
jq -s 'add' part*.json
jq -n --arg v "$VER" '{version: $v}'
yq
jq syntax for YAML. Ideal for docker-compose and Kubernetes manifests.
yq '.services.api.image' docker-compose.yml
yq -i '.replicas = 3' deploy.yml
HTTP & encoding
curl in a script needs -fsS: fail on HTTP errors, stay quiet, but still show real errors. Without -f, a 500 response exits 0 and your script carries on with an error page in its variable, which is a bug that can survive for months.
curl -sS
Silent, but still shows errors. The correct default for scripts.
curl -sS https://api.example.com/health
curl -f
Makes curl exit non-zero on HTTP 4xx/5xx instead of cheerfully printing the error page.
curl -fsS "$url" | jq . || die 'request failed'
Note
Without -f, a 500 response is still exit status 0. This single flag prevents a whole class of silent script failures.
curl -H -d -X
Sets headers, sends a body, chooses a method.
curl -fsS -X POST https://api.example.com/jobs \
-H 'Content-Type: application/json' \
-d '{"target":"hlx-003"}'
curl -o / -O / -L
Saves to a named file, keeps the remote name, or follows redirects.
curl -fsSL -o app.tar.gz "$RELEASE_URL"
curl -w
Prints transfer metrics after the request. Great for spotting slow DNS or TLS.
curl -o /dev/null -sS -w 'dns %{time_namelookup}s tls %{time_appconnect}s total %{time_total}s\n' "$url"
curl -I / --retry
Headers only, or automatic retry with backoff.
curl -I https://example.com
curl -fsS --retry 5 --retry-delay 2 "$url"
curl -w '%{http_code}'
Grabs just the status code for a health check.
code=$(curl -o /dev/null -sw '%{http_code}' "$url")
[[ $code == 200 ]] || die "got $code"
base64
Encodes or decodes. -w0 stops GNU base64 wrapping lines.
printf '%s' "$user:$pass" | base64 -w0
base64 -d <<< "$encoded"
sha256sum / md5sum
Checksums a file or verifies one.
sha256sum firmware.bin
sha256sum -c checksums.txt
file / stat
Identifies a file's real type, or shows its metadata.
file mystery.bin
stat -c '%s bytes, modified %y' app.log
xxd / strings
Hex dump a binary, or pull readable text out of one.
xxd -l 64 firmware.bin
strings -n 8 firmware.bin | grep -i version
envsubst
Substitutes environment variables into a template file.
export DOMAIN=api.example.com
envsubst < nginx.conf.tpl > /etc/nginx/conf.d/api.conf
dos2unix / tr -d '\r'
Strips Windows line endings, the cause of many mysterious 'command not found' errors.
tr -d '\r' < script.sh > fixed.sh
Note
A CRLF shebang line produces /usr/bin/env: 'bash\r': No such file or directory. That is always this problem.
Servers
Getting onto a box, working out what it is doing, and fixing it. Ordered roughly by what you reach for first when something is broken.
Operating a server is mostly a diagnostic activity, and the ordering matters more than the tool inventory. The layered approach from the foundations applies directly here: process, port, network path, name resolution, TLS, then application. Each layer has one command that tests it without involving the others, and going in order means you narrow the problem instead of accumulating theories about it.
Underneath that, three subsystems account for most of what you will actually touch. systemd owns process lifetime: what starts at boot, what restarts on failure, what the resource limits are. journald owns the record of what happened, which is why journalctl -u service --since replaces reading four different log files in three formats. And the filesystem is where the surprises live — a full disk that du cannot explain, an inode exhaustion that reports as 'no space' with 40GB free, a permission that is really a group membership from a session that predates the change.
Containers add a layer rather than replacing one. A container is a normal Linux process with a restricted view of the filesystem, network and process table. docker logs is reading a file, docker exec is entering namespaces, and a container killed with exit code 137 was sent SIGKILL (128+9) — often the OOM killer, but just as often docker stop running past its grace period, which docker inspect -f '{{.State.OOMKilled}}' will settle. When Docker behaves strangely, the underlying Linux behaviour is usually the explanation.
One habit worth building before you need it: on a machine you did not set up, spend two minutes running systemctl list-units --failed, df -h, free -h and journalctl -p err -b before you change anything. It is a cheap baseline, and it frequently finds the actual problem before you have finished forming a hypothesis about it.
SSH & transfer
Most SSH friction disappears into ~/.ssh/config. Host aliases, jump hosts and ControlMaster connection reuse turn a paragraph of flags into a word, and make repeated commands to the same host effectively instant.
~/.ssh/config
Stores per-host settings so you type ssh prod instead of a paragraph of flags.
Host prod
HostName 10.0.4.13
User deploy
IdentityFile ~/.ssh/id_ed25519
ServerAliveInterval 30
Note
ServerAliveInterval keeps long sessions from being dropped by NAT timeouts on flaky links.
ssh-keygen -t ed25519
Generates a modern keypair.
ssh-keygen -t ed25519 -C "reuben@$(hostname)"
ssh-copy-id
Installs your public key on a remote host's authorized_keys.
ssh-copy-id -i ~/.ssh/id_ed25519.pub [email protected]
ssh -J jumphost
Hops through a bastion to reach a host you cannot route to directly.
ssh -J bastion.example.com [email protected]
ssh -L local:host:remote
Local port forward: exposes a remote service on your own machine.
ssh -N -L 5432:localhost:5432 prod
# now psql -h localhost hits the remote database
ssh -R
Reverse forward: exposes a local port on the remote host.
ssh -N -R 8080:localhost:3000 prod
ssh -D
SOCKS proxy through the remote host, for reaching an internal network from a browser.
ssh -N -D 1080 bastion
ControlMaster
Reuses one TCP connection for every later ssh to the same host. Makes repeated commands instant.
Host *
ControlMaster auto
ControlPath ~/.ssh/cm-%r@%h:%p
ControlPersist 10m
ssh host 'cmd'
Runs one command and exits. Quote it so it expands on the remote side.
ssh prod 'systemctl is-active app && journalctl -u app -n20 --no-pager'
rsync -avz
Copies only what changed, preserving permissions and timestamps.
rsync -avz --progress ./dist/ deploy@prod:/srv/app/
Note
A trailing slash on the source means 'the contents of'. Without it you nest the directory inside the target.
rsync --dry-run --delete
Mirrors a directory, removing extras on the far side. Preview it first, always.
rsync -avzn --delete ./dist/ prod:/srv/app/ # -n = dry run
rsync -avz --delete ./dist/ prod:/srv/app/
Note
--delete on a wrong path is how people erase production. Run it with -n and read every line before removing the n.
rsync --exclude
Skips paths. Repeatable, or read from a file.
rsync -avz --exclude '.git' --exclude 'node_modules' ./ prod:/srv/app/
scp
Quick one-off copy. rsync is better for anything repeated.
scp prod:/var/log/app.log ./
scp -r ./configs prod:/etc/app/
systemd
systemd owns process lifetime. The four commands that matter are status, restart, daemon-reload after editing a unit, and enable --now to start something and keep it starting. systemctl edit creates overrides that survive package upgrades.
systemctl status
Shows whether a unit is running, its PID, and the last few log lines.
systemctl status app.service
systemctl status nginx --no-pager -l
systemctl start/stop/restart
Controls a service now.
sudo systemctl restart app
sudo systemctl reload nginx # reload config without dropping connections
systemctl enable --now
Starts a service and sets it to start at boot, in one step.
sudo systemctl enable --now app.service
Note
enable alone only affects boot; it does not start anything. --now is what people usually mean.
systemctl daemon-reload
Re-reads unit files after you edit one. Required before a restart picks up changes.
sudo systemctl daemon-reload && sudo systemctl restart app
systemctl cat / edit
Shows a unit's effective definition, or creates a drop-in override that survives package upgrades.
systemctl cat app.service
sudo systemctl edit app.service
systemctl list-units --failed
The first thing to run on a box that is misbehaving.
systemctl list-units --failed
systemctl list-timers --all
a minimal unit file
The shape of a service definition in /etc/systemd/system/.
[Unit]
Description=App API
Wants=network-online.target
After=network-online.target
[Service]
User=app
WorkingDirectory=/srv/app
ExecStart=/srv/app/.venv/bin/uvicorn main:app --port 8000
Restart=always
RestartSec=3
EnvironmentFile=/etc/app/env
[Install]
WantedBy=multi-user.target
systemctl --user
Manages per-user services, no root required.
systemctl --user status syncthing
loginctl enable-linger "$USER" # keep user services alive after logout
systemd-run
Runs a command as a transient unit, so it survives your shell and gets logged.
systemd-run --user --unit=import ./long-import.sh
Logs
journald indexes by unit, time and priority, so the useful queries are filters rather than greps: -u for a service, --since for a window, -p err for severity, -b -1 for the boot before the reboot nobody can explain.
journalctl -u
Logs for a single unit.
journalctl -u app.service -n 200 --no-pager
journalctl -f
Follows the journal live.
journalctl -u app -f
journalctl -f -u nginx -u app # two units at once
journalctl --since
Time windows, in plain English.
journalctl --since '1 hour ago'
journalctl -u app --since '2026-08-20 09:00' --until '09:30'
journalctl -p err
Filters by priority: emerg, alert, crit, err, warning, notice, info, debug.
journalctl -p err -b
journalctl -p warning --since today
journalctl -b
This boot only. -b -1 is the previous boot, which is where you look after an unexplained reboot.
journalctl -b -1 -p err
journalctl -k
Kernel messages, the same content as dmesg but with the journal's filters available.
journalctl -k -b | grep -i 'usb\|oom'
journalctl -o json
Structured output you can pipe into jq.
journalctl -u app -o json --since '10 min ago' | jq -r '.MESSAGE'
journalctl --vacuum-time
Trims the journal when it has eaten the disk.
journalctl --disk-usage
sudo journalctl --vacuum-time=7d
dmesg -T -w
Kernel ring buffer with human timestamps, optionally following. Where hardware and USB problems show up.
sudo dmesg -T | tail -50
sudo dmesg -TW
Processes
Find it, understand it, then signal it. pgrep -af before pkill -f so you can see exactly what you are about to kill, and lsof when the question is which process holds a port or a deleted file.
ps aux
Every process with user, CPU, memory and full command line.
ps aux | grep -v grep | grep uvicorn
ps aux --sort=-%mem | head
pgrep / pkill
Finds or signals processes by name, without the grep dance.
pgrep -af uvicorn
pkill -f 'python -m worker'
Note
pkill -f matches against the whole command line. Run the pgrep -af version first to see exactly what you are about to kill.
kill / kill -9
Sends SIGTERM (polite, default) or SIGKILL (immediate, no cleanup).
kill 4821 # TERM: let it shut down
kill -9 4821 # KILL: last resort
Note
-9 gives the process no chance to flush buffers or release locks. Try TERM, wait, then escalate.
htop / top
Live process view. htop is interactive and sortable.
htop -u app
top -b -n1 | head -20 # batch mode, scriptable
nohup / disown / setsid
Detaches a job so it survives your session closing.
nohup ./import.sh > import.log 2>&1 &
disown
Note
For anything that matters, prefer tmux or a systemd unit. nohup gives you no way to reattach.
lsof -i
Shows what is listening on or connected to a port.
sudo lsof -i :8000
sudo lsof -i -P -n | grep LISTEN
lsof +D / deleted files
Finds open files under a directory, or space held by files that were deleted but not closed.
sudo lsof +D /var/log
sudo lsof -nP | grep '(deleted)'
Note
This is the answer when df shows a full disk but du cannot find the data: a process still holds a deleted log open.
nice / renice
Lowers a job's CPU priority so it does not starve the things that matter.
nice -n 19 ./reindex.sh
sudo renice -n 10 -p 4821
/proc/PID
Live per-process detail from the kernel.
cat /proc/4821/cmdline | tr '\0' ' '; echo
ls -l /proc/4821/cwd
cat /proc/4821/limits
Networking
One tool per layer, used in order: ss for listening sockets, nc for reachability, dig for resolution, openssl s_client for certificates, curl -v for HTTP. Testing them out of order is how a DNS problem becomes an afternoon.
ss -tulpn
Every listening TCP/UDP socket with the owning process. Replaces netstat.
sudo ss -tulpn
sudo ss -tp state established
ip a / ip r
Interface addresses and the routing table.
ip -brief a
ip r get 10.0.4.13
dig
DNS lookup. +short gives you just the answer.
dig +short api.example.com
dig @1.1.1.1 example.com MX
curl -I / -v
Checks response headers, or shows the full TLS and request handshake.
curl -sI https://example.com | head -1
curl -v https://example.com 2>&1 | grep -i 'subject\|issuer'
nc -zv
Tests whether a TCP port is reachable, with no payload.
nc -zv 10.0.4.13 8000
nc -zv -w2 db.internal 5432
mtr / traceroute
Traces the path to a host; mtr keeps updating so you can spot the hop that loses packets.
mtr -rwc 20 api.example.com
tcpdump
Captures packets. -w writes a file you can open in Wireshark.
sudo tcpdump -i any -n port 8000
sudo tcpdump -i any -w /tmp/cap.pcap host 10.0.4.13
ping / arping
Basic reachability. -W sets a short timeout so scripts do not hang.
ping -c3 -W1 10.0.4.13
ufw
Simple firewall front-end on Debian/Ubuntu.
sudo ufw status numbered
sudo ufw allow from 10.0.0.0/8 to any port 8000 proto tcp
Note
Enabling ufw without an allow rule for 22 will lock you out of a remote box. Add the SSH rule first.
hostname -I / getent
Local addresses, and name resolution the way the system actually does it.
hostname -I
getent hosts api.example.com
Disk & filesystem
Space, inodes, and who is holding what. When df says full and du disagrees, look for deleted-but-open files. When there is space but writes fail, check df -i — millions of small files exhaust inodes long before bytes.
df -h
Free space per mounted filesystem.
df -h
df -h --output=source,pcent,target | sort -k2 -r
df -i
Inode usage. A disk can be 'full' with plenty of bytes left if something created millions of tiny files.
df -i
du -sh * | sort -h
What is actually taking the space in this directory.
du -sh -- * | sort -h
sudo du -xh /var --max-depth=2 | sort -h | tail -20
Note
-x keeps du on one filesystem so it does not wander into /proc or a network mount.
ncdu
Interactive disk usage browser. The fastest way to find the culprit.
sudo ncdu -x /
lsblk / findmnt
Block device tree, and what is mounted where.
lsblk -f
findmnt /srv
ln -s / readlink -f
Creates a symlink, or resolves one to its final target.
ln -sfn /srv/releases/2026-08-20 /srv/current
readlink -f /srv/current
Note
-n with -f is what makes re-pointing an existing symlink work instead of creating a link inside the directory.
tar czf / xzf / tf
Create, extract, or list an archive.
tar czf backup.tar.gz -C /srv app
tar tzf backup.tar.gz | head
tar xzf backup.tar.gz -C /tmp/restore
Note
List before extracting. An archive without a top-level directory will spray files across your current directory.
chmod
Sets permissions, numerically or symbolically.
chmod 750 deploy.sh
chmod +x script.sh
chmod -R g+rX /srv/app
Note
Capital X adds execute only to directories and files that already have it somewhere, which is what you want for -R.
chown
Changes owner and group.
sudo chown -R app:app /srv/app
ls -l decoded
drwxr-xr-x is type, then owner/group/other triplets of read, write, execute.
ls -l app.log
# -rw-r----- 1 app app 4096 Aug 20 09:14 app.log
umask / sticky bit
Default permission mask for new files, and the +t bit that stops users deleting each other's files in shared dirs.
umask 027
chmod 1777 /tmp/shared
id / groups / usermod
Who you are, what you are in, and how to add a group membership.
id
sudo usermod -aG docker "$USER" # log out and back in to take effect
Note
Forgetting -a on usermod -G replaces all supplementary groups instead of adding one.
Resources & packages
Read free -h by the available column and load average against nproc. High load with idle CPU means processes are waiting on disk, not compute, and that sends you somewhere completely different.
free -h
Memory usage. Look at the 'available' column, not 'free'.
free -h
Note
Linux uses spare RAM for cache on purpose. Low 'free' with high 'available' is healthy, not a problem.
uptime / load
Load average over 1, 5 and 15 minutes. Compare against your core count.
uptime
nproc
vmstat / iostat
Sampled CPU, memory and disk throughput. Ignore the first row; it is an average since boot.
vmstat 2 5
iostat -xz 2
OOM killer check
Confirms whether the kernel killed your process for using too much memory.
sudo dmesg -T | grep -i 'killed process'
journalctl -k | grep -i oom
ulimit -n
Open file descriptor limit. A busy server hitting it reports 'too many open files'.
ulimit -n
cat /proc/$(pgrep -f uvicorn | head -1)/limits
apt
Install, search and inspect packages on Debian/Ubuntu.
sudo apt update && sudo apt install -y jq ripgrep
apt policy nginx
apt list --installed | grep openssl
dpkg -S / -L
Which package owns this file, and what files did that package install.
dpkg -S /usr/bin/jq
dpkg -L nginx-common | grep conf
crontab -e
Per-user scheduled jobs. Fields: minute hour day-of-month month day-of-week.
*/15 * * * * /srv/app/bin/sync.sh >> /var/log/sync.log 2>&1
0 3 * * * /usr/bin/find /tmp/cache -mtime +7 -delete
@reboot /srv/app/bin/warm-cache.sh
Note
cron runs with a minimal PATH and no shell profile. Use absolute paths and redirect output, or failures vanish silently.
systemd timers
The modern alternative to cron: real logs, dependencies, and missed-run catch-up.
systemctl list-timers --all
systemctl --user enable --now backup.timer
watch
Re-runs a command every couple of seconds and highlights what changed.
watch -n2 -d 'ss -tn state established | wc -l'
watch -n5 'docker ps --format "{{.Names}}\t{{.Status}}"'
Docker
A container is a process with a restricted view. Most debugging is therefore ordinary Linux debugging done through logs, exec and inspect. Two things bite in production: unbounded json-file logs filling the disk, and down -v deleting the volume holding your database.
docker ps -a
Running and stopped containers. --format trims the noise.
docker ps -a --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'
docker logs -f --tail
Follows a container's output from the last N lines.
docker logs -f --tail 100 api
docker logs --since 15m api 2>&1 | grep ERROR
docker exec -it
Opens a shell inside a running container.
docker exec -it api bash
docker exec -it api sh # alpine images have no bash
docker run --rm -it
Runs a throwaway container interactively. --rm cleans up on exit.
docker run --rm -it -v "$PWD:/w" -w /w python:3.12 bash
docker compose
Manages a multi-container stack from a compose file.
docker compose up -d
docker compose logs -f api
docker compose down -v
Note
down -v deletes named volumes, which means your database. Leave off -v unless you mean it.
docker inspect
Full JSON metadata. Pipe to jq to find one field.
docker inspect api | jq -r '.[0].NetworkSettings.IPAddress'
docker inspect -f '{{.State.Health.Status}}' api
docker stats
Live CPU, memory and I/O per container.
docker stats --no-stream
docker system df / prune
Shows what Docker is storing, then reclaims it.
docker system df
docker system prune -a --volumes
Note
prune -a --volumes removes every unused image and volume on the host. On a shared box, check with another human first.
log rotation caps
Docker's default json-file driver grows without limit until it fills the disk.
# /etc/docker/daemon.json
{
"log-driver": "json-file",
"log-opts": { "max-size": "50m", "max-file": "3" }
}
Note
Existing containers keep their old settings; the cap applies to containers created after a daemon restart.
docker cp
Moves files in or out of a container.
docker cp api:/app/config.yml ./
docker cp ./patch.py api:/app/patch.py
nginx & TLS
nginx -t before every reload, without exception. The trailing slash on proxy_pass changes the upstream path, and a 502 means nginx is healthy and the thing behind it is not.
nginx -t
Validates config before you reload. Never reload without it.
sudo nginx -t && sudo systemctl reload nginx
reverse proxy block
The standard shape for putting nginx in front of an app server.
location /api/ {
proxy_pass http://127.0.0.1:8000/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
Note
A trailing slash on proxy_pass strips the location prefix. Its presence or absence changes the upstream path.
access & error logs
Where nginx tells you what actually happened.
sudo tail -F /var/log/nginx/error.log
awk '$9 ~ /^5/ {print $7}' /var/log/nginx/access.log | sort | uniq -c | sort -rn
openssl x509 -dates
Reads a certificate's validity window.
openssl x509 -in cert.pem -noout -subject -dates
openssl s_client
Inspects the certificate a live server is serving, including the chain.
openssl s_client -connect api.example.com:443 -servername api.example.com </dev/null 2>/dev/null | openssl x509 -noout -dates
Dev & agents
Sessions that outlive your connection, git moves that get you out of trouble, and running Claude Code as a component in a pipeline rather than a chat window.
This section is about two things that turn out to be the same thing: keeping work alive longer than a connection, and handing work to something that is not you.
The first is a solved problem with three answers, and the choice is really about ownership. If the work should outlive your SSH session but not your interest in it, use tmux. If it should outlive the machine's next reboot, write a systemd unit. nohup is for the case where you want neither and have thirty seconds.
The second is newer and less settled. Running a model headlessly — prompt in, result out, exit — makes it a component rather than a destination, and components compose. But delegation only works when the results are reviewable, and in practice that means git is doing the heavy lifting: a branch per task, commits small enough to revert individually, and a diff you read yourself rather than a summary you trust. git worktree extends this to parallel work, giving each task its own directory and branch over a single repository.
The git material below is weighted towards recovery for a reason. Once something else is making commits on your behalf, the commands that matter most are the ones that undo — reflog, reset, revert, restore — and the distinction between --force and --force-with-lease stops being pedantry. It is worth being fluent in those before you need them at speed.
tmux
tmux keeps a session alive in a server process, so your programs never learn that your connection dropped. Learn four things — new -s, attach -t, Ctrl-b d, Ctrl-b z — and the rest is optional for a long time.
tmux new -s name
Starts a named session that keeps running after you disconnect.
tmux new -s flighttest
tmux attach -t / ls
Lists sessions and reattaches to one.
tmux ls
tmux attach -t flighttest
tmux a # most recent
Ctrl-b d
Detaches, leaving everything running.
# Ctrl-b then d
tmux new -d -s x 'cmd'
Launches a long job detached, in one line. Ideal for kicking off remote work over ssh.
ssh prod "tmux new -d -s import './import.sh > import.log 2>&1'"
panes & windows
Ctrl-b % splits vertically, " horizontally, c makes a window, o cycles panes, z zooms one pane full-screen.
# Ctrl-b % split right
# Ctrl-b " split down
# Ctrl-b z zoom / unzoom
copy mode
Ctrl-b [ enters scrollback, Ctrl-b ] pastes. The selection keys follow mode-keys, which defaults to emacs unless $EDITOR or $VISUAL contains vi: C-Space then M-w under emacs, Space then Enter under vi.
# Ctrl-b [ then arrows or /search
tmux kill-session
Ends a session and everything in it.
tmux kill-session -t flighttest
tmux kill-server
tmux capture-pane
Dumps a pane's scrollback to a file, which is handy for feeding output to an agent.
tmux capture-pane -pS -2000 -t build > build-scrollback.txt
Git essentials
The daily surface. add -p for commits that say one thing, diff main...HEAD for reviewing a branch against its merge base, and log -S when you need to find the commit that introduced a string — the best archaeology tool git has.
git status -sb
Short status with branch and tracking info.
git status -sb
git add -p
Stages hunk by hunk, so one commit says one thing.
git add -p src/api.ts
git diff variants
Working tree vs index, index vs HEAD, or against another branch.
git diff # unstaged
git diff --staged # what you are about to commit
git diff main...HEAD # your branch's changes only
Note
Three dots compares against the merge base, which is what you want when reviewing a branch.
git log --oneline --graph
Readable history with branch topology.
git log --oneline --graph --decorate -20
git log --oneline main..HEAD
git log -S
Finds the commit that introduced or removed a string. The single best archaeology tool in git.
git log -S 'MAX_RETRIES' --oneline
git log -p -S 'joystick_calibrate' -- firmware/
git blame
Line-by-line authorship. -w ignores whitespace-only changes.
git blame -w -L 40,60 src/parser.ts
git switch / restore
The modern, unambiguous replacements for the many meanings of checkout.
git switch -c feature/graph-nodes
git restore src/app.ts # discard working changes
git restore --staged src/app.ts # unstage
git stash
Parks uncommitted work so you can switch context.
git stash push -m 'wip node layout' -- src/
git stash list
git stash pop
git rebase -i
Rewrites your local commits: squash, reorder, reword, drop.
git rebase -i main
git rebase -i HEAD~5
Note
Only rebase commits you have not shared. Rewriting published history forces everyone else to recover from it.
git push --force-with-lease
Force-pushes only if nobody else has pushed since you last fetched.
git push --force-with-lease origin feature/graph-nodes
Note
Use this instead of --force. Plain --force will silently discard a colleague's commits.
git commit --amend
Folds staged changes into the previous commit, or just fixes the message.
git commit --amend --no-edit
Git recovery
Almost nothing in git is truly lost for about thirty days — the expiry for unreachable entries, which is the case a bad reset or rebase creates. reflog records every position HEAD has held, including the ones no branch points at, so a bad reset or rebase is recoverable if you look before you panic.
git reflog
Every position HEAD has held, including ones no branch points at. This is how you undo almost anything.
git reflog -20
git reset --hard HEAD@{3}
Note
A 'lost' commit after a bad reset or rebase is nearly always still here for ~30 days — that is gc.reflogExpireUnreachable; the 90 days you may have heard is gc.reflogExpire, which covers reachable entries only. Look before you panic.
git reset --soft/--mixed/--hard
Moves the branch pointer, keeping changes staged, unstaged, or discarding them entirely.
git reset --soft HEAD~1 # undo commit, keep changes staged
git reset HEAD~1 # undo commit, keep changes in working tree
git reset --hard HEAD~1 # undo commit, discard changes
Note
--hard throws away uncommitted work with no confirmation. Commit or stash first if you are unsure.
git revert
Creates a new commit that undoes an old one. The safe choice on a shared branch.
git revert a1b2c3d
git revert -m1 <merge-sha>
git cherry-pick
Applies one commit from another branch onto this one.
git cherry-pick a1b2c3d
git cherry-pick a1b2c3d^..e4f5g6h
git bisect
Binary-searches history for the commit that broke something.
git bisect start
git bisect bad
git bisect good v1.4.0
# test, then: git bisect good | git bisect bad
git bisect reset
git bisect run
Automates the search with a test command. Walk away and come back to the answer.
git bisect start HEAD v1.4.0
git bisect run npm test -- parser.test.ts
git clean -nd
Lists untracked files that -fd would delete. Preview first.
git clean -nd
git clean -fd
git clean -fdx # also removes gitignored files
Note
-x removes .env files and build caches too. That is often not what you wanted.
git fsck --lost-found
Finds dangling commits when even the reflog cannot help.
git fsck --lost-found --no-reflogs
Worktrees for parallel agents
One repository, several working directories, one branch each. This is what makes parallel agent work practical: no shared index, no branch switching underneath a running process, and a separate dev server port per task.
git worktree add
Checks out another branch into a second directory sharing one .git. Two agents can then work without fighting over the index.
git worktree add ../app-graph feature/graph-nodes
git worktree add -b fix/auth ../app-auth main
git worktree list / remove
Shows and cleans up worktrees.
git worktree list
git worktree remove ../app-auth
git worktree prune
parallel agent pattern
One worktree per task means each agent gets isolated files, its own branch, and its own dev server port.
for t in graph auth export; do
git worktree add -b "feat/$t" "../app-$t" main
done
# then run one agent per directory
Note
Node and Python dependencies are not shared between worktrees. Each one needs its own install.
Claude Code from the shell
Print mode turns the model into a pipeline stage. Scope tool permissions to specific commands rather than granting Bash wholesale, keep the work on a branch, and read the diff rather than the summary of the diff.
claude
Starts an interactive session in the current directory.
cd ~/projects/app && claude
claude -p "query"
Print mode: runs non-interactively, prints the result, exits. This is what makes it scriptable.
claude -p 'summarise the failing tests and propose a fix'
piping into claude -p
Anything on stdin becomes context.
git diff main...HEAD | claude -p 'review this diff for auth and injection risks'
journalctl -u app -p err --since '1 hour ago' | claude -p 'group these errors by root cause'
--output-format json
Emits structured output you can parse with jq. Options are text, json and stream-json.
claude -p 'list the exported functions in src/api.ts as JSON' --output-format json | jq -r '.result'
--allowedTools
Pre-approves specific tools so an unattended run does not stop at a permission prompt.
claude -p 'run the test suite and fix failures' \
--allowedTools 'Bash(npm test:*)' 'Read' 'Edit'
Note
Scope Bash permissions to specific commands rather than granting Bash wholesale. --disallowedTools is the matching deny list.
claude -c / -r
Continues the most recent conversation, or resumes a specific session by ID.
claude -c
claude -c -p 'now add tests for that'
claude -r abc123 'finish the PR'
--add-dir
Grants access to directories outside the working tree, such as a sibling repo.
claude --add-dir ../shared-types ../firmware
claude mcp
Adds and manages MCP servers for the current project or globally.
claude mcp list
claude mcp add --help
CLAUDE.md
Project memory. Conventions, commands and constraints that get loaded every session.
cat > CLAUDE.md <<'EOF'
## Commands
- test: `pytest -q`
- lint: `ruff check .`
## Conventions
- Pydantic v2 models only; no dict returns from routes.
EOF
--dangerously-skip-permissions
Skips every permission prompt.
# only inside a throwaway container or VM
docker run --rm -it -v "$PWD:/w" -w /w my-agent-image \
claude -p 'refactor the parser' --dangerously-skip-permissions
Note
This removes the confirmation step for every file write and shell command. Use it only where the blast radius is a container you can delete.
headless in a loop
Print mode composes with normal shell tools, so agents become pipeline stages.
for f in src/**/*.py; do
claude -p "add type hints to $f, edit in place" \
--allowedTools 'Read' 'Edit' >/dev/null
done
Note
Run this on a branch, review the whole diff at the end, and keep commits small enough to revert individually.
docs
The CLI reference is the source of truth and changes often.
# https://docs.claude.com/en/docs/claude-code/cli-reference
claude --help
Anthropic API from bash
When you want the model without the agent, curl and jq are enough. Build the request body with jq -n --arg rather than string concatenation — prompts contain quotes and newlines, and hand-built JSON breaks on both.
curl the Messages API
A direct request, no SDK required.
curl -fsS https://api.anthropic.com/v1/messages \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H 'anthropic-version: 2023-06-01' \
-H 'content-type: application/json' \
-d '{"model":"claude-sonnet-4-6","max_tokens":512,"messages":[{"role":"user","content":"hi"}]}' \
| jq -r '.content[0].text'
building the body with jq
Constructs JSON safely instead of concatenating strings that break on quotes and newlines.
body=$(jq -n --arg m 'claude-sonnet-4-6' --arg p "$(cat prompt.txt)" \
'{model:$m, max_tokens:1024, messages:[{role:"user", content:$p}]}')
curl -fsS https://api.anthropic.com/v1/messages -d "$body" ...
Note
This is the fix for prompts containing quotes, backslashes or newlines. Never build request JSON by hand.
keys out of your shell history
Read the key from a file or a keychain rather than exporting it in .bashrc.
export ANTHROPIC_API_KEY="$(cat ~/.config/anthropic/key)"
chmod 600 ~/.config/anthropic/key
Note
A leading space before a command keeps it out of history when HISTCONTROL=ignorespace is set.
Shell speed
Small savings, applied hundreds of times a day. Ctrl-R and !$ are the highest-value pair; fzf is the highest-value single install. Set HISTCONTROL=ignoreboth so secrets typed with a leading space stay out of your history file.
Ctrl-R
Reverse-searches your history. Press again to cycle further back.
# Ctrl-R then type part of the command
!! and !$
The previous command, and its last argument.
mkdir -p /srv/app/releases
cd !$
sudo !!
Ctrl-A / E / U / K / W
Line start, line end, delete to start, delete to end, delete previous word.
# Ctrl-A jump to start, Ctrl-U wipe the line
cd - and pushd/popd
Jump back to the previous directory, or keep a stack of them.
cd /var/log && cd -
pushd /srv/app; popd
alias & functions
Aliases for renaming, functions for anything taking arguments.
alias ll='ls -lah'
mkcd() { mkdir -p -- "$1" && cd -- "$1"; }
port killer
Frees a port something forgot to release.
kill -9 "$(lsof -ti :3000)"
fuser -k 3000/tcp
fzf
Fuzzy finder that turns any list into an interactive picker. Its Ctrl-R replacement alone is worth it.
vim "$(fzf)"
git switch "$(git branch --format='%(refname:short)' | fzf)"
clipboard from the shell
Pipe output straight to the clipboard.
cat id_ed25519.pub | xclip -sel clip # X11
cat id_ed25519.pub | wl-copy # Wayland
cat id_ed25519.pub | pbcopy # macOS
history hygiene
Keeps secrets and duplicates out of your history file.
# ~/.bashrc
HISTCONTROL=ignoreboth
HISTSIZE=50000
shopt -s histappend
Tooling & runners
A project that anyone can start needs one discoverable entry point. A Makefile or justfile with dev, test and deploy targets is documentation that cannot go stale, because it is the thing people actually run.
modern replacements
Drop-in upgrades worth installing on any box you use daily.
rg # grep
fd # find
bat # cat with syntax highlighting
eza # ls
delta # git diff pager
btop # top
dust # du
jless # jq output pager
direnv
Loads and unloads environment variables as you cd in and out of a project.
echo 'export DATABASE_URL=postgres://localhost/app' > .envrc
direnv allow
uv
Fast Python package and environment manager.
uv venv && source .venv/bin/activate
uv pip install -r requirements.txt
uv run pytest -q
pipx
Installs Python CLI tools into isolated environments so they never fight your project deps.
pipx install ruff
pipx list
nvm / pnpm
Node version switching and a faster package manager.
nvm use 22
pnpm install --frozen-lockfile
pnpm dlx create-next-app@latest
make as a task runner
A Makefile is a discoverable, dependency-free front door to your project's commands.
.PHONY: dev test
dev:
uvicorn main:app --reload
test:
pytest -q
Note
Makefile recipes must be indented with real tabs. Spaces produce 'missing separator'.
just
A task runner with make's interface and none of its build semantics.
just --list
just deploy staging
entr / watchexec
Re-runs a command whenever files change.
rg --files -t py | entr -c pytest -q
watchexec -e ts,tsx -- npm run build
ss + curl smoke test
A three-line health check to paste after any deploy.
systemctl is-active app && \
ss -tlpn | grep -q ':8000' && \
curl -fsS localhost:8000/health | jq -e '.status=="ok"'