Linux · The Machine Room · Field Guide · 34 Topics

Linux Pro Playbook

The layer under everything you deploy — files, processes, pipes, the shell toolbelt, systemd, cgroups. Each topic with real commands, real output, a plain-English analogy, and the gotcha that pages you. Copy, run, adapt.

Companion to the Networking playbook — this half is the machine; that half is the wire

Part I — Linux · The Machine Room

The Mental Model & Files

01Everything is a file — the one big idea

Scenario: devices, sockets, running processes, kernel settings — Linux exposes them all as files. One idea, and suddenly the same five tools (cat, ls, echo, grep, redirection) operate the entire OS.

🧠
Analogy: a city where every service — post office, power plant, the mayor's live schedule — is reachable through identical mail slots. You don't learn a new protocol per building; you slide paper in, paper comes out. /proc is the city hall noticeboard, rewritten live by the kernel every time you look at it.
Try it
$ cat /proc/cpuinfo | grep "model name" | head -1   # hardware, as a file
$ cat /proc/1/cmdline; echo                          # what is PID 1?
$ cat /sys/class/net/eth0/address                    # NIC's MAC, a file
$ echo 1 | sudo tee /proc/sys/net/ipv4/ip_forward    # WRITE kernel config
$ ls -l /dev/null /dev/sda /dev/random               # devices are files too
Output
model name : AMD EPYC 7571
/sbin/init
02:42:ac:11:00:02
crw-rw-rw- 1 root root 1, 3  /dev/null    ← c = character device
brw-rw---- 1 root disk 8, 0  /dev/sda     ← b = block device

✅ Do — good use cases

  • Explore /proc/<pid>/ — env, open files, limits of any process, live
  • > /dev/null to discard, /dev/urandom for random bytes — devices as building blocks
  • Read ls -l's first letter fluently: - file, d dir, l link, c/b device, s socket

❌ Don't — anti-patterns

  • Editing /proc/sys by hand for permanence — it resets at boot; that's sysctl.conf (topic 33)
  • Treating /proc files as regular files (they have no size; cp behaves oddly) — read them, don't manage them
Pattern & benefit: one interface = infinite composition. The same pipe that filters a log can filter kernel state; the same redirection that writes a file configures the network stack. Docker (your DevOps playbook #1) inherits this worldview wholesale — containers are just these files, namespaced (topic 34).
Gotcha — why it goes wrong: "file" doesn't mean "on disk" — /proc and /sys are kernel RAM wearing a filesystem costume, and writes there take effect instantly, no save button, no undo. echo-ing the wrong value into the wrong sysctl file has taken real servers off the network mid-command. Read twice, write once.
02The filesystem tree — where things live

Scenario: where do configs go? Logs? Binaries? The app you deployed? The FHS (Filesystem Hierarchy Standard) answers once, for every Linux box you'll ever touch.

🧠
Analogy: a well-run hospital's floor plan: reception (/etc — where all configuration is looked up), the archive (/var — data that grows: logs, spools, databases), staff-only equipment (/usr/bin, /usr/lib), the ER whiteboard wiped nightly (/tmp), private offices (/home, /root), and visiting consultants' suites (/opt — third-party apps). Any doctor can work any shift in any hospital because the floors never change.
The map, annotated
/etc      # system config — nginx.conf, ssh/, cron.d/  (text, in git-able form)
/var      # grows: /var/log, /var/lib/postgresql, /var/spool
/usr      # installed software: /usr/bin, /usr/lib, /usr/local/bin (yours!)
/tmp      # scratch — often wiped on reboot, world-writable
/home     # users; /root is root's home (NOT under /home)
/opt      # vendor bundles: /opt/datadog, /opt/your-app
/srv      # served content (rarer)
/proc /sys /dev   # kernel & devices (topic 1)

$ df -h / /var        # are they separate partitions? (matters — topic 8)
$ command -v ruby     # which binary actually runs?
Output
Filesystem      Size  Used Avail Use% Mounted on
/dev/nvme0n1p1   50G   32G   18G  65% /
/dev/nvme1n1     200G  110G  90G  55% /var        ← logs can't kill the root fs
/usr/local/bin/ruby

✅ Do — good use cases

  • Configs in /etc, app data in /var/lib/<app>, logs in /var/log — tools and teammates expect it
  • /usr/local/bin for your own scripts — in PATH, survives package upgrades
  • Separate partition/volume for /var on servers — runaway logs shouldn't brick the OS

❌ Don't — anti-patterns

  • Apps writing into /home/ubuntu/app-live-final2/ — undiscoverable, un-backupable, unpermissioned
  • Precious data in /tmp — many distros wipe it on boot or on a timer
Pattern & benefit: the standard IS the documentation — on a box you've never seen, you already know where nginx's config, the app's logs, and the cron entries live. Containers shrink this world but keep the map: your Dockerfiles (DevOps playbook #2) put things in the same places for the same reasons.
Gotcha — why it goes wrong: /bin vs /usr/bin vs /usr/local/bin — several can hold a program with the same name, and PATH order (topic 29) silently decides which runs. "I upgraded ruby but the old version runs" is almost always two rubies on PATH: command -v -a ruby shows all of them, in order.
03Globs & quoting — the shell rewrites your command★ used daily

Scenario: the single most misunderstood fact of the terminal: rm *.log never sends *.log to rm — the shell expands it first, and the program only ever sees the final list. Every quoting bug flows from here.

🧠
Analogy: the shell is a personal assistant who opens your mail and fills in the blanks before the recipient reads it. Write *.log and the assistant silently rewrites it to the actual filenames first. Single quotes mean "type this literally, don't help"; double quotes mean "expand my $variables but nothing else."
Who expands, and when
$ ls *.log                    # shell expands * → app.log db.log BEFORE ls runs
app.log  db.log
$ echo "$HOME/bin"            # double quotes: $HOME expands
/home/balu/bin
$ echo '$HOME/bin'            # single quotes: literal
$HOME/bin
$ files=*.log; echo $files    # unquoted: glob expands at USE time
app.log db.log
$ echo "$files"               # quoted: the literal pattern, unexpanded
*.log
The rule that prevents disasters
Order of operations: shell expands globs + $vars → THEN runs the command.
Quote variables ALWAYS: "$file" not $file   (spaces/globs in values bite)
Dry-run destructive globs: ls *.log   BEFORE   rm *.log

✅ Do — good use cases

  • Quote every variable expansion: "$var", "${arr[@]}" — spaces and globs in values are landmines
  • Dry-run destructive globs with ls or echo first — see exactly what the shell will pass
  • Single quotes for literals (regexes, passwords with $), double for controlled expansion

❌ Don't — anti-patterns

  • rm $file unquoted — if $file is empty or has spaces, you delete the wrong things (or everything)
  • Assuming * matches hidden files — it doesn't (dotfiles need explicit .* or shopt -s dotglob)
Pattern & benefit: "shell expands first, command runs second" explains a whole class of bugs at once — why find wants quoted patterns (topic 22), why grep "$x" differs from grep '$x', why filenames with spaces break scripts. One rule, endless clarity.
Gotcha — why it goes wrong: an unmatched glob passes through literally by default — ls *.xyz with no matches runs ls '*.xyz' and errors confusingly (bash's nullglob changes this). And a glob matching nothing in for f in *.log iterates once with the literal *.log — guard with [ -e "$f" ].
04Permissions & chmod — the rwx octal★ used daily

Scenario: "Permission denied", a script that won't run, an SSH key SSH refuses to touch — all decoded by the ten characters in ls -l and the octal behind chmod.

🧠
Analogy: every file has three keyrings — one for the owner, one for the group, one for everyone else. Each ring holds up to three keys: read, write, execute. The octal digit is just which keys are on the ring: 7 = all three, 5 = read+execute, 4 = read only. 755 = "I hold all keys; everyone else can look and run, not change."
Decode and set
$ ls -l deploy.sh
-rwxr-xr-x 1 balu devs 214 Jul 11 deploy.sh
 -  rwx r-x r-x    →  owner=7  group=5  other=5  →  755
 └ type (- file, d dir, l link)

# octal: r=4 w=2 x=1, summed per keyring
$ chmod 755 deploy.sh    # rwx r-x r-x   scripts, directories
$ chmod 644 config.yml   # rw- r-- r--   data files, web content
$ chmod 600 id_rsa       # rw- --- ---   SSH keys MUST be this
$ chmod +x deploy.sh     # symbolic: just add execute
The three you'll set most
755  scripts, directories          (enter/run + read)
644  configs, documents, web files (read; owner writes)
600  secrets, SSH keys             (owner only — SSH REFUSES looser)

✅ Do — good use cases

  • Memorize 755 / 644 / 600 — they cover 95% of daily needs
  • Directories need x to be entered (not just read) — 755 on dirs, never 644
  • chmod 600 on private keys — SSH silently refuses keys that are group/world-readable

❌ Don't — anti-patterns

  • chmod 777 "to make it work" — now world-writable; the real bug is usually ownership (topic 5) or a missing x
  • chmod -R 755 blindly over a tree with secrets — you just made keys world-readable
Pattern & benefit: three digits encode a file's entire access policy — fast to read, fast to set, universal across every Unix. Owner/group/other (topic 5) plus rwx is the whole model; once it clicks, "Permission denied" becomes a two-second diagnosis.
Gotcha — why it goes wrong: the directory x bit trips everyone — a dir with r but no x lets you list names but not cd in or read files inside. And 777 is almost never right: when a web server can't read a file it's usually the wrong owner (topic 5), and 777 just hides that insecurely.
05Users, groups & sudo — who you are★ used daily

Scenario: a service can't read its own files, sudo asks for a password, a file is owned by www-data — identity (uid/gid) is the other half of permissions (topic 4).

🧠
Analogy: the system is an office building. Your uid is your badge, groups are the teams you're on (which shared rooms you enter), root (uid 0) is the master key, and sudo is "borrow the master key for one action, written in the logbook." Services get their own badges (www-data, postgres) so a breached web server isn't holding the master key.
Identity & borrowed power
$ id
uid=1000(balu) gid=1000(balu) groups=1000(balu),27(sudo),999(docker)
$ sudo systemctl restart nginx      # borrow root for ONE command (logged)
$ chown www-data:www-data /var/www/app   # give files to the service's badge
$ sudo -u postgres psql             # run AS another user
$ sudo usermod -aG docker balu      # -aG = APPEND (forgetting -a locks you out!)
Output
$ ls -l /var/www/app/index.html
-rw-r--r-- 1 www-data www-data 1240 Jul 11 index.html
                └─ owned by the service, not you — correct & intentional
$ sudo whoami
root

✅ Do — good use cases

  • Run services as dedicated non-root users — least privilege, smaller blast radius
  • sudo -u <user> to act as a service account when debugging (sudo -u postgres psql)
  • id + ls -l together — identity plus ownership is the full "why denied" picture

❌ Don't — anti-patterns

  • Running everything as root "to avoid errors" — one exploit = full box; root is a loaded gun
  • usermod -G docker balu without -a — that replaces all your groups with just docker, dropping you from sudo
Pattern & benefit: uid/gid + groups + sudo is the whole access story beside rwx (topic 4): the file has an owner and group, you have a uid and groups, the kernel checks them against the three keyrings. Service accounts contain damage; sudo gives auditable, temporary elevation instead of permanent root.
Gotcha — why it goes wrong: the -aG vs -G trap is a real lockout — always -aG (append). And group changes need a fresh login (or newgrp) to take effect — "I added myself to docker but still get permission denied" is almost always "you haven't re-logged-in yet."
06Hard links vs symlinks — two kinds of "same file"

Scenario: a symlink points at a moved file and breaks; deleting a file doesn't free space because something still links it. Inodes and the two link types explain both.

🧠
Analogy: a file's real data lives at an inode (a warehouse shelf). A hard link is another label pointing at the same shelf — delete one label, the goods stay until the last label is gone. A symlink is a sticky note saying "see aisle 5" — move the goods and the note now points at nothing (a dangling link).
Code
$ echo data > original.txt
$ ln original.txt hard.txt        # hard link — same inode
$ ln -s original.txt soft.txt     # symlink — points at the NAME
$ ls -li                          # -i shows inode numbers
1839 -rw-r--r-- 2 balu balu  5 original.txt   # link count 2!
1839 -rw-r--r-- 2 balu balu  5 hard.txt       # SAME inode 1839
1902 lrwxrwxrwx 1 balu balu 12 soft.txt -> original.txt

$ rm original.txt
$ cat hard.txt      # still works — inode alive (hard link holds it)
data
$ cat soft.txt      # BROKEN — the name it pointed at is gone
cat: soft.txt: No such file or directory
The distinction that matters
hard link  → same inode; survives renames/deletes of other names; same filesystem only
symlink    → points at a PATH; breaks if target moves; can cross filesystems, link dirs

✅ Do — good use cases

  • Symlinks for "current version" pointers: app-current -> releases/2026-07-11 (atomic deploys)
  • ls -li to see inodes + link counts when investigating "same file?" questions
  • readlink -f <link> to resolve a symlink chain to its real target

❌ Don't — anti-patterns

  • Assuming rm frees space — it removes a name; space frees when link count AND open handles hit zero (topic 8!)
  • Relative symlinks that break when the link itself is moved — know whether you want relative or absolute targets
Pattern & benefit: understanding inodes demystifies deletion, disk usage, and "two files that are really one." The atomic-deploy trick — build a new release dir, then flip one symlink — is instant and instantly reversible, and it's pure inode/symlink mechanics.
Gotcha — why it goes wrong: "I deleted the log but df still shows it full" — a process still has the file open, so the inode (and its disk) survives until that process closes it or restarts (lsof +L1 finds these; topic 8). Deletion removes the directory entry, not necessarily the data.
07Finding things — which, locate, type

Scenario: "which python is actually running? where did this command come from? is ll a real program?" Quick lookups before the heavy find (topic 22).

🧠
Analogy: your shell has a speed-dial list (PATH — topic 29) it checks top-to-bottom when you name a command. which/type ask "who picks up when I dial python?" — and the answer might be an alias, a shell function, or the third entry on the list shadowing the one you meant.
Code
$ which python3           # first match on PATH
/usr/bin/python3
$ type ll                 # is it a program, alias, function, builtin?
ll is aliased to `ls -alF'
$ type -a python3         # ALL matches, in PATH order — shadowing revealed
python3 is /home/balu/.venv/bin/python3
python3 is /usr/bin/python3
$ command -v node         # script-safe "does this exist / where"
/usr/bin/node
$ locate nginx.conf       # instant DB lookup (run updatedb first)
/etc/nginx/nginx.conf
Which tool for which question
which / command -v  → "what runs when I type this?" (PATH lookup)
type -a             → "everything named this" (aliases, funcs, all PATH hits)
locate              → "where on disk is a file named X?" (fast, cached DB)
find                → "search live by any criteria" — topic 22 (slow, precise)

✅ Do — good use cases

  • type -a cmd when the "wrong version" runs — it shows aliases and PATH shadowing at once
  • command -v in scripts to test existence (if command -v jq; then) — cleaner than which
  • locate for instant filename lookups; sudo updatedb to refresh its index

❌ Don't — anti-patterns

  • Trusting which for shell builtins/functions — it only sees PATH executables; type sees everything
  • Using locate for just-created files — its DB is periodic; find sees the live filesystem
Pattern & benefit: these answer "why is the wrong thing running?" in seconds — nearly always an alias, a shell function, or PATH order (topic 29). type -a is the single best command for that class of confusion, and it's instant.
Gotcha — why it goes wrong: which runs a separate program that can't see your shell's aliases or functions — so which ll may say "not found" while ll works fine (it's an alias). Use the shell builtin type for the truth. And locate can list deleted files or miss new ones — it reads a cached database, not the disk.
08Disk full — df, du & the deleted-but-open trap★ used daily

Scenario: "No space left on device" at 3am. Two commands find the hog; a third finds the sneaky case where deleting the file didn't help.

🧠
Analogy: df is checking the fuel gauge (how full is each tank/filesystem); du is weighing the luggage to find what's heavy. And the trap: a bag you "threw away" still weighs the plane down if someone's still holding the strap (a process keeps the deleted file open) — you must make them let go (restart) before the weight lifts.
The 3am runbook
$ df -h                    # which filesystem is full? (the gauge)
Filesystem  Size  Used Avail Use% Mounted on
/dev/root    50G   50G     0 100% /
$ du -sh /var/* | sort -h | tail -5   # what's heavy under the culprit?
1.2G  /var/cache
2.1G  /var/lib
14G   /var/log            # ← found it
$ du -sh /var/log/* | sort -h | tail -3
13G   /var/log/app.log    # one runaway log

# the trap: deleted the file, df STILL shows full?
$ sudo lsof +L1           # files with link-count 0 but still OPEN
COMMAND  PID  USER  FD   ...  SIZE     NLINK  NAME
ruby    8823  app   7w   ...  13000M   0      /var/log/app.log (deleted)
$ sudo systemctl restart app     # make it let go → space returns
Also worth checking — inodes can run out with disk free
$ df -i          # if IUse% = 100% but Use% is low: millions of tiny files
Filesystem  Inodes  IUsed  IFree IUse% Mounted on
/dev/root   3.2M    3.2M   0     100%  /      ← "No space" with GB free!

✅ Do — good use cases

  • df -h first (which tank), then du -sh dir/* | sort -h (what's heavy) — narrow top-down
  • lsof +L1 when deleting a file doesn't free space — a process is holding it (topic 6)
  • df -i when "No space" contradicts free GB — you've run out of inodes, not bytes

❌ Don't — anti-patterns

  • rm-ing a live log to reclaim space — the process keeps writing to the open handle; use truncate -s 0 or logrotate (topic 30)
  • Running du / and waiting forever — scope it (du -sh /var/*) and follow the weight down
Pattern & benefit: disk-full is one of the most common production pages, and this runbook resolves ~all of it: gauge (df) → weigh (du) → check for held-open (lsof +L1) → check inodes (df -i). Four commands, one calm diagnosis.
Gotcha — why it goes wrong: the deleted-but-open trap is the classic — rm huge.log, df unchanged, panic. The space is held by the writing process's open file descriptor (topic 15) until it closes/restarts; lsof +L1 reveals it. And truncate -s 0 file empties a live log without breaking the writer's handle — the safe way to reclaim.

Processes & the Kernel

09Processes & /proc — the running world★ used daily

Scenario: find the process eating the box, see what it's actually doing, understand parent/child trees and the zombie you keep seeing in ps.

🧠
Analogy: processes are a family tree — every process has a parent, all descending from PID 1 (init/systemd, the ancestor). fork is having a child (a copy of yourself), exec is that child putting on a costume to become a different program. A zombie is a finished child whose parent hasn't yet "collected the death certificate" (wait()) — harmless paperwork, not a resource leak.
Code
$ ps aux | sort -rk3 | head -3      # top CPU (col 3); -rk4 for memory
USER  PID  %CPU %MEM  ...  COMMAND
app   8823 98.2  4.1  ...  ruby sidekiq
$ ps -ef --forest | grep -A3 nginx  # the process TREE (parent/child)
$ pgrep -a nginx                    # PIDs by name, with command line
$ ls -l /proc/8823/                 # everything about PID 8823, as files
$ cat /proc/8823/cmdline | tr '\0' ' '   # exact command it launched with
$ ls -l /proc/8823/cwd /proc/8823/exe    # its working dir + binary path
$ cat /proc/8823/limits             # its ulimits (topic 15)
Output
$ pgrep -a nginx
712 nginx: master process /usr/sbin/nginx
713 nginx: worker process
$ sudo ls -l /proc/8823/fd | wc -l     # how many files/sockets it holds open
1024                                    # near a ulimit? topic 15

✅ Do — good use cases

  • ps aux --sort=-%cpu / -%mem to rank hogs; pgrep/pkill to target by name
  • /proc/<pid>/ for ground truth: cwd, exe, environ, fd/, limits
  • ps --forest or pstree to see who spawned whom (orphaned children, runaway forks)

❌ Don't — anti-patterns

  • Panicking over zombies (Z state) — they hold no memory/CPU, just a slot; the fix is the parent reaping (or restarting), not kill on the zombie
  • kill -9 as a first resort (topic 10) — you skip cleanup and can corrupt state
Pattern & benefit: /proc/<pid> makes every process transparent — its command line, environment, open files, working directory, and limits are all readable files (topic 1 in action). No guessing what a mystery process is doing; cat the answer.
Gotcha — why it goes wrong: a pile of zombies isn't the disease — it's a symptom that a parent isn't calling wait() (a bug in that program, or a shell/container without proper init reaping). Killing zombies does nothing (they're already dead); fix or restart the parent. In containers, run a real init (--init) so PID 1 reaps orphans.
10Signals & exit codes — how programs end★ used daily

Scenario: kill a process gracefully vs forcibly, understand why a container exited with 137, and read exit codes like a language — because they are one.

🧠
Analogy: signals are taps on the shoulder. SIGTERM (15) is a polite "please wrap up and leave" — the program can finish writing, close files, then go. SIGKILL (9) is a trapdoor under the chair — instant, no goodbye, no cleanup. The exit code is the note the departing process leaves: 0 = "all good", non-zero = "here's what went wrong."
Code
$ kill 8823           # sends SIGTERM (15) — polite, lets it clean up
$ kill -9 8823        # SIGKILL — trapdoor, no cleanup (last resort)
$ kill -HUP 712       # SIGHUP — many daemons RELOAD config on this (nginx!)
$ pkill -f sidekiq    # by command-line match
$ echo $?             # exit code of the LAST command (0 = success)

# reading exit codes — the vocabulary:
0    success
1-125  the program's own error codes (check its docs)
126   found but not executable   127  command not found (typo/PATH)
130   Ctrl-C (SIGINT, 128+2)     137  SIGKILL/OOM (128+9)   143  SIGTERM (128+15)
Output — the codes you'll actually see
$ docker inspect --format '{{.State.ExitCode}}' mycontainer
137        # 128 + 9 = SIGKILL — almost always the OOM killer (topic 14)
$ ./script.sh; echo $?
127        # "command not found" inside — a typo or PATH issue (topic 29)

✅ Do — good use cases

  • Always SIGTERM first (plain kill) — let the process flush and close; -9 only if it ignores you
  • kill -HUP to reload config on daemons that support it (nginx, many others) — no restart, no downtime
  • Read exit codes: 137 = OOM/killed, 143 = terminated, 127 = not found, 130 = Ctrl-C

❌ Don't — anti-patterns

  • kill -9 by reflex — it skips cleanup: half-written files, unreleased locks, orphaned children
  • Ignoring $? in scripts — an unchecked non-zero exit is a silent failure that corrupts the next step (topic 24)
Pattern & benefit: the 128 + signal convention makes exit codes self-describing — 137 literally spells "killed by signal 9." This one table decodes container crashes, CI failures, and script bugs identically, which is why the DevOps playbook (K8s topic 19) leans on the exact same numbers.
Gotcha — why it goes wrong: a process can catch SIGTERM and ignore it, so a graceful kill seems to "do nothing" — that's usually a program mid-shutdown or a buggy handler, and -9 is the escalation (it can't be caught). But 137/OOM is the famous one: the kernel's OOM killer sent SIGKILL because you ran out of memory (topic 14) — the process didn't crash, it was executed.
11Background jobs & tmux — surviving disconnects

Scenario: you start a 2-hour migration over SSH, your laptop sleeps, the job dies. Job control and terminal multiplexers keep work running when you're not watching.

🧠
Analogy: your SSH session is a phone line to the server; anything you started is on the call and hangs up when the line drops. tmux is renting a permanent office on the server instead — you dial in, do work in the office, hang up the phone, and the office (and everything running in it) stays exactly as you left it until you call back and re-enter.
Code
# shell job control (single session):
$ long_task &            # run in background (& = detach from foreground)
$ jobs                   # list background jobs
$ fg %1                  # bring job 1 back to foreground
# Ctrl-Z suspends the foreground job; bg resumes it in background

# tmux — the real answer for remote long-running work:
$ tmux new -s deploy     # create a named session
  # ... start your migration ...
  # Ctrl-b then d         → DETACH (leaves everything running)
$ ssh server             # reconnect later, even from another machine
$ tmux attach -t deploy  # walk back into the office, work still there
$ tmux ls                # list sessions

# quick one-offs without tmux:
$ nohup ./task.sh &      # immune to hangup; output → nohup.out
Output
$ tmux ls
deploy: 1 windows (created Sat Jul 11 14:02)  (attached)
$ jobs
[1]+  Running    ./migrate.sh &

✅ Do — good use cases

  • tmux (or screen) for ANY long remote task — migrations, builds, big copies
  • Named sessions (tmux new -s name) so you know what's where when you reconnect
  • nohup cmd & or systemd-run for fire-and-forget one-offs

❌ Don't — anti-patterns

  • Running a 2-hour job in a bare SSH session — one dropped connection and it's gone (with no clean shutdown)
  • Relying on & alone over SSH — background jobs still die when the shell that owns them exits (that's what nohup/tmux fix)
Pattern & benefit: tmux decouples your work from your connection — the server keeps running the office regardless of your flaky wifi, and you can even share a session for pairing. It's the difference between "hope the connection holds" and "detach whenever, reattach wherever."
Gotcha — why it goes wrong: a plain cmd & is still a child of your shell — when SSH drops, the shell gets SIGHUP and takes its background jobs down with it. nohup (ignore hangup) or, better, tmux/systemd survive it. And detaching tmux is Ctrl-b then d (two keystrokes, not chorded) — the muscle memory takes a day.
12systemd & journalctl — services & their logs★ used daily

Scenario: start/stop/enable a service, see why it won't boot, tail its logs — systemd is PID 1 on nearly every modern distro, and its two commands run your services.

🧠
Analogy: systemd is the building manager: it opens services in the morning (boot), keeps them running (restarts crashed ones), knows which depend on which (start the DB before the app), and keeps a single unified logbook (journalctl) instead of every tenant scribbling in their own notebook. systemctl is how you give the manager instructions.
Code
# the daily verbs:
$ sudo systemctl status nginx      # running? enabled? recent log lines?
$ sudo systemctl restart nginx     # stop then start
$ sudo systemctl reload nginx      # re-read config, no downtime (SIGHUP-ish)
$ sudo systemctl enable --now app  # start now AND on every boot
$ systemctl is-active app; systemctl is-enabled app

# the unified logbook:
$ journalctl -u nginx -f           # follow ONE service's logs live
$ journalctl -u app --since "10 min ago"
$ journalctl -u app -p err         # only errors and worse
$ journalctl -b                    # everything since THIS boot
$ journalctl --disk-usage          # how big is the journal?
Output — status is a mini-dashboard
$ systemctl status app
● app.service - My App
   Active: activating (auto-restart) (Result: exit-code)   ← crash-looping!
  Process: 9102 ExecStart=/usr/bin/app (code=exited, status=1/FAILURE)
$ journalctl -u app -n 5           # -n = last N lines → the actual error
app[9102]: FATAL: could not connect to database "app_prod"

✅ Do — good use cases

  • systemctl status + journalctl -u <svc> -n 50 as your reflex for any service issue
  • enable --now to both start and persist-across-reboots in one command
  • Run your own apps as unit files — free restart-on-crash, logging, dependencies, resource limits

❌ Don't — anti-patterns

  • Starting apps with bare ./app & on a server — no restart, no logs, gone on reboot; write a unit
  • Forgetting enable (only start) — works now, vanishes after the next reboot
Pattern & benefit: one interface for every service on the box — start, stop, boot-persistence, crash-restart, dependencies, and logs — with structured, filterable, per-service logging via journalctl. It's the same reconcile-to-desired-state idea as Kubernetes (DevOps playbook topic 11), one machine down.
Gotcha — why it goes wrong: Active: activating (auto-restart) means crash-looping — systemd keeps restarting a service that keeps dying; the real error is in journalctl -u <svc>, not the status line. And editing a unit file needs systemctl daemon-reload before the change takes effect — "I edited the service but nothing changed" is almost always a forgotten daemon-reload.
13top & load average — reading the vital signs★ used daily

Scenario: "the server is slow." top and the load average tell you why — but load average is the single most misread number in Linux.

🧠
Analogy: load average is the queue length at a checkout, not how hard the cashier is working. On a 4-lane store (4 CPUs), load 4.0 = every lane busy, no queue (perfect). Load 8.0 = every lane busy AND as many customers waiting again (overloaded). Load 2.0 on 4 lanes = half idle. The magic number to compare against is always your core count.
Code
$ uptime
 14:22:07 up 40 days,  load average: 3.91, 2.10, 1.05
                                      └1min └5min └15min  (trend: rising fast!)
$ nproc          # how many cores? (the number load is relative to)
4                # load 3.91 on 4 cores = ~fully used, not yet overloaded

$ top            # live; press: P=sort CPU, M=sort mem, 1=per-core, q=quit
top - 14:22  load average: 3.91, 2.10, 1.05
%Cpu(s): 71 us, 12 sy,  0 ni,  5 id,  9 wa   ← wa = I/O WAIT (disk/net stall!)
  PID USER  %CPU %MEM  COMMAND
 8823 app   190  4.1   ruby          ← >100% = using multiple cores
Decoding the CPU line
us  user code        sy  kernel/system      wa  WAITING on I/O (not CPU!)
id  idle             ni  niced             high wa + low us = disk/net is the bottleneck,
                                            adding CPU won't help

✅ Do — good use cases

  • Compare load average to nproc — load == cores is fully-used; load ≫ cores is queued/overloaded
  • Read the three numbers as a trend: 1min ≫ 15min = getting worse; 1min ≪ 15min = recovering
  • Watch wa (I/O wait) — high wait means the bottleneck is disk/network, not CPU

❌ Don't — anti-patterns

  • Reading load as a percentage — 1.0 is not "100% busy"; it's relative to core count
  • Adding CPUs when wa is high — the processes are waiting on I/O, not compute; faster disk/network is the fix
Pattern & benefit: load-vs-cores plus the us/sy/wa/id breakdown localizes "slow" to a subsystem in seconds — CPU-bound (high us), kernel-bound (high sy), or I/O-bound (high wa). That single distinction decides whether you scale compute, fix code, or chase disk/network (topic 32 formalizes the triage).
Gotcha — why it goes wrong: high load with low CPU usage baffles people — it's counted as "runnable OR uninterruptible (I/O wait)," so a box stuck on slow disk shows load 20 while CPUs sit idle. The wa figure is the tell. And a single-threaded process maxing one core shows ~100% in top but only raises load by ~1 — per-core view (press 1) reveals it.
14Memory & the OOM killer — free, and who dies★ used daily

Scenario: free -h says almost no memory is "free" — is that bad? (No.) And when memory truly runs out, the kernel picks a process to sacrifice — that's your mysterious exit 137.

🧠
Analogy: Linux treats free RAM like an empty library reading room — wasted if unused, so it fills the space with cached book pages (disk cache) that it instantly clears when a real reader (app) needs a seat. So "low free memory" usually means "efficiently cached," not "full." The OOM killer is the fire marshal: when the room is genuinely over capacity, they evict the person taking the most seats to save everyone else.
Code
$ free -h
              total   used   free   shared  buff/cache   available
Mem:           16Gi  6.2Gi  412Mi     88Mi       9.4Gi      9.1Gi
                                  ↑ tiny!         ↑ cache    ↑ REAL headroom
# read "available", NOT "free": 9.1Gi is what apps can actually still get.
# buff/cache (9.4Gi) is reclaimable — the kernel gives it up on demand.

# did the OOM killer strike?
$ dmesg -T | grep -i "killed process"
[Sat Jul 11 03:14] Out of memory: Killed process 8823 (ruby) total-vm:...
$ journalctl -k | grep -i oom          # same, via the journal
Output — the OOM signature
Out of memory: Killed process 8823 (ruby) score 812
→ that process exited 137 (128+9), "crashed for no reason" in your app logs.
  It didn't crash — the kernel executed it to save the machine.

✅ Do — good use cases

  • Read the available column — that's true headroom; free alone is misleading by design
  • Check dmesg -T | grep -i oom when a process vanishes with 137 and no app-level error
  • Set memory limits (cgroups/containers — topic 34) so one process can't take the whole box down

❌ Don't — anti-patterns

  • Panicking that "free" is low — high buff/cache is the kernel doing its job; it's reclaimable instantly
  • Blaming the app for a 137 crash — check OOM first; it's the kernel's kill, often from a totally different memory hog
Pattern & benefit: understanding cache-vs-used stops a whole genre of false alarms, and knowing the OOM signature turns "the app randomly died" into "the box ran out of RAM at 03:14 and the kernel killed the biggest process." That's the same 137 the DevOps playbook decodes for containers (K8s topic 19) — one mechanism, everywhere.
Gotcha — why it goes wrong: the OOM killer's victim isn't always the culprit — it scores by memory footprint, so a big well-behaved process can be sacrificed for a small leaky one's final straw. And in containers, hitting the cgroup memory limit triggers a local OOM kill even with host RAM free — "137 but the server has plenty of memory" is a container limit, not the host (topic 34).
15File descriptors & ulimits — the "too many open files" wall

Scenario: a busy server suddenly throws "Too many open files" and refuses new connections. Every open file, socket, and pipe is a file descriptor — and there's a per-process cap.

🧠
Analogy: each process gets a numbered coat-check rack with a fixed number of hooks (the fd limit). Every open file, every network socket, every pipe hangs on a hook. Under load a server opens thousands of sockets — run out of hooks and the coat check turns people away ("too many open files"), even though the building is fine. The fix is a bigger rack (raise the limit), or stop leaking coats (close what you open).
Code
$ ulimit -n              # this shell's soft limit on open files
1024                     # default — laughably low for a busy server
$ ulimit -Hn             # the hard ceiling you can raise up to
1048576

# how many is a process actually holding?
$ ls /proc/8823/fd | wc -l
1019                     # nearly at 1024 → about to hit the wall

# raise it — for a systemd service (the right place):
# [Service]
# LimitNOFILE=65535
$ sudo systemctl daemon-reload && sudo systemctl restart app
# or system-wide in /etc/security/limits.conf
Output — the wall, and the confirmation
app: accept4: Too many open files          ← the error under load
$ cat /proc/8823/limits | grep "open files"
Max open files   65535   65535   files      ← after raising it

✅ Do — good use cases

  • Raise LimitNOFILE for high-connection servers (web, proxies, databases) — 1024 is a toy default
  • ls /proc/<pid>/fd | wc -l to see real usage vs the limit before it bites
  • Suspect an fd leak if usage climbs steadily under steady load — the app isn't closing what it opens

❌ Don't — anti-patterns

  • Cranking the limit to hide a leak — a program that never closes sockets will exhaust any ceiling; fix the code
  • Setting ulimit in a login shell and expecting a systemd service to inherit it — services get limits from their unit file, not your shell
Pattern & benefit: because sockets are file descriptors (topic 1's "everything is a file" paying off), the same limit governs open files AND concurrent connections — one number to raise for a server under load, one place (/proc/<pid>/fd) to watch, and a clear tell (steady climb = leak) for the bug.
Gotcha — why it goes wrong: the soft/hard split confuses people — ulimit -n shows the soft limit a process can raise itself up to the hard ceiling, but limits set in your shell don't apply to systemd services (they read LimitNOFILE= from the unit). Set it in the right place or the change silently does nothing to the actual service.
16strace — wiretap on a program's syscalls

Scenario: a program hangs, or fails with a vague error, and the logs say nothing. strace shows every system call it makes — the ground truth of what it's actually asking the kernel to do.

🧠
Analogy: when a suspect's story doesn't add up, you tap their phone. strace taps the only line a program has to the outside world — the syscall interface to the kernel. Every file it opens, every network call, every config it reads (or fails to find) shows up on the wiretap, even when the program itself stays silent about it.
Code
# why can't it find its config? watch the file opens:
$ strace -f -e trace=openat ./app 2>&1 | grep -i config
openat(AT_FDCWD, "/etc/app/config.yml", O_RDONLY) = -1 ENOENT (No such file)
                                                    └─ it's looking HERE, and failing

# attach to an already-running, hung process:
$ sudo strace -p 8823
read(7, ...        ← stuck here: blocked reading fd 7. What's fd 7?
$ ls -l /proc/8823/fd/7
lrwx... 7 -> socket:[48291]     ← waiting on a network socket (a stuck DB call?)

$ strace -c ./app      # -c = summary: which syscalls dominate (counts + time)
$ strace -f -e trace=network ./app   # only network syscalls
Output — the summary view
% time     calls  syscall
 62.4       4021   read       ← where the time actually goes
 21.1       1830   futex      ← lock contention
  8.3        912   openat

✅ Do — good use cases

  • "Where does it look for its config/library?" → strace -e openat and read the ENOENTs
  • Hung process → strace -p <pid> shows the exact syscall it's blocked on (then check that fd)
  • strace -c for a syscall profile when a program is mysteriously slow

❌ Don't — anti-patterns

  • strace on a hot production process casually — it dramatically slows the target (every syscall is intercepted); use briefly
  • Drowning in output — always scope with -e trace=openat / network / file rather than tracing everything
Pattern & benefit: syscalls are the one interface a process can't lie about or hide behind — no matter the language or how bad the logging, strace shows the real file opens, network calls, and blocking points. It's the tool for "the logs say nothing but it's clearly doing something." (For network specifically, tcpdump — topic 52 — is the wiretap one layer out.)
Gotcha — why it goes wrong: strace without -f misses child processes — trace a shell wrapper or a forking server and you see nothing useful until you add -f (follow forks). And the overhead is real: intercepting every syscall can slow a process 10–100×, so a program that "only hangs under strace" may just be crawling — sample briefly, don't leave it attached.

The Shell Toolbelt

17Pipes & redirection — composing tiny tools★ used daily

Scenario: the Unix superpower — small single-purpose programs snapped together with | into exactly the tool you need, right now, that no one shipped.

🧠
Analogy: a factory conveyor belt. Each machine (command) does one job and passes its output down the line (|) to the next. grep is a filter that lets some items through, sort reorders them, wc counts them. You build a custom assembly line in one sentence — no factory redesign, no code.
Code
# the three streams: 0=stdin  1=stdout  2=stderr
$ command > out.txt          # stdout → file (overwrite)
$ command >> out.txt          # stdout → file (append)
$ command 2> err.txt          # stderr → file
$ command > all.txt 2>&1      # BOTH → file (2>&1 = "send 2 where 1 goes")
$ command 2>/dev/null         # discard errors
$ command | tee log.txt       # to screen AND file (a T-junction)

# real pipelines you'll actually write:
$ cat access.log | grep 500 | wc -l              # count 500 errors
$ ps aux | grep -v grep | grep ruby              # ruby procs (minus grep itself)
$ history | awk '{print $2}' | sort | uniq -c | sort -rn | head   # top commands
Output
$ history | awk '{print $2}' | sort | uniq -c | sort -rn | head -3
    412 git
    288 kubectl
    142 cd
# five tools, one line, a report nobody had to build.

✅ Do — good use cases

  • Chain small tools instead of hunting for one big one — the pipeline IS the program
  • 2>&1 to capture errors with output; tee to watch AND save
  • Build pipelines incrementally — add one | stage at a time and eyeball each step

❌ Don't — anti-patterns

  • > file 2>&1 written as 2>&1 > file — order matters; the second redirects stderr to the OLD stdout (terminal), not the file
  • Useless cat file | grep when grep pattern file works — harmless but a tell (the "useless use of cat")
Pattern & benefit: composition beats monoliths — a dozen well-understood tools recombine into thousands of one-off solutions, and each piece is independently testable by eye. This is the same philosophy as everything-is-a-file (topic 1): uniform interfaces (text streams) make everything connectable.
Gotcha — why it goes wrong: redirection order is evaluated left-to-right: cmd > f 2>&1 works (point stdout at f, then stderr at "wherever stdout is" = f), but cmd 2>&1 > f sends stderr to the terminal (stdout's old target) and only stdout to f. And a pipeline's exit code is the last command's by default — a failure mid-pipe hides unless you set pipefail (topic 24).
18grep — find lines that match★ used daily

Scenario: search millions of log lines for the error, the IP, the request-id — with context, counts, and inversion. grep is the tool you'll reach for hourly.

🧠
Analogy: grep is a highlighter with a stencil: run it down a page and it marks only the lines matching the shape you cut (the pattern). The flags are extra powers on the highlighter — mark the surrounding lines too (context), count instead of mark, or highlight everything except the shape.
The flags that matter
$ grep ERROR app.log             # lines containing ERROR
$ grep -i error app.log          # -i = case-insensitive
$ grep -r "TODO" src/            # -r = recursive through a directory
$ grep -n ERROR app.log          # -n = show line numbers
$ grep -c ERROR app.log          # -c = COUNT matches (not the lines)
$ grep -v healthcheck app.log    # -v = INVERT: lines WITHOUT the pattern
$ grep -C3 "OutOfMemory" app.log # -C3 = 3 lines of context around each hit
$ grep -E "5[0-9]{2}" access.log # -E = extended regex (5xx status codes)
$ grep -o "user_id=[0-9]*" app.log  # -o = print only the matched part

# the combo you'll use constantly — errors with surrounding context, live:
$ tail -f app.log | grep --line-buffered -iC2 error
Output
$ grep -C1 "OutOfMemory" app.log
14:22:01 processing batch 4821
14:22:03 FATAL OutOfMemory: heap exhausted     ← the hit
14:22:03 worker 3 exiting                       ← the aftermath (context)

✅ Do — good use cases

  • -C for context (the lines around an error tell the story), -c to quantify, -v to exclude noise
  • -o to extract just the matched substring — feed it to sort | uniq -c (topic 23) for a tally
  • rg (ripgrep) as a faster, gitignore-aware grep for codebases

❌ Don't — anti-patterns

  • Forgetting --line-buffered with tail -f | grep — output buffers and appears in lumps, not live
  • Fighting basic-regex escaping (\(, \+) — use -E (extended) and write normal () and +
Pattern & benefit: a handful of flags (-i -n -r -c -v -C -o -E) cover nearly every search you'll ever run, and grep pipes into everything (topic 17). Combined with -o + sort | uniq -c it becomes an ad-hoc analytics engine over any text.
Gotcha — why it goes wrong: grep ruby ps-style searches match grep's own process — ps aux | grep ruby shows a spurious grep ruby line; the idiom grep [r]uby or grep -v grep excludes it. And plain grep is basic-regex where +, ?, () are literals — reach for -E so your regex means what you think.
19awk — column surgery & quick math★ used daily

Scenario: extract the 4th column, sum a field, filter rows by a value — awk turns structured text (logs, CSVs, command output) into answers without writing a script.

🧠
Analogy: awk is a spreadsheet for streaming text: every line is a row, auto-split into columns ($1, $2, …) at whitespace. You write a tiny rule — "for each row, if column 9 is 500, print column 7" — and it runs down the whole file like dragging a formula down a column.
Code
# $1,$2... = columns; $0 = whole line; NF = column count; NR = row number
$ awk '{print $1}' access.log            # first column (client IPs)
$ awk '{print $1, $NF}' file             # first and LAST column
$ awk -F: '{print $1}' /etc/passwd        # -F: = split on colons, not spaces

# filter + extract in one step:
$ awk '$9 == 500 {print $7}' access.log   # paths that returned 500
$ awk '$3 > 1000' data.txt                 # rows where column 3 exceeds 1000

# math over a column — no scratch variables, no loop:
$ awk '{sum += $5} END {print sum}' sales.txt          # total column 5
$ awk '{sum+=$1; n++} END {print sum/n}' latencies.txt # average

# tally with associative arrays (grouping!):
$ awk '{count[$1]++} END {for (ip in count) print count[ip], ip}' access.log \
    | sort -rn | head       # top client IPs by request count
Output
$ awk '{count[$1]++} END {for(ip in count) print count[ip], ip}' access.log | sort -rn | head -3
 8412 203.0.113.9
 2201 198.51.100.4
  944 192.0.2.17

✅ Do — good use cases

  • Column extraction ($N), field-based filtering ($9==500), and quick sums/averages over logs
  • -F to set the separator for CSVs (-F,) or /etc/passwd (-F:)
  • Associative arrays (count[$1]++) for grouping/tallying — GROUP BY without a database

❌ Don't — anti-patterns

  • Reaching for Python for a one-line column sum — awk does it in the pipe, instantly
  • Parsing real CSV with quoted commas via -F, — it breaks on "Pune, MH"; use a real CSV tool for messy data
Pattern & benefit: awk covers the 80% of text processing between "grep found the lines" and "I need a real program" — columns, filters, math, and grouping, all in a pipe. Its associative arrays make it a GROUP BY engine over any whitespace/delimited text, which is astonishingly powerful for one line.
Gotcha — why it goes wrong: awk splits on runs of whitespace by default, so a log with variable spacing still lines up — but a genuinely tab-or-comma-delimited file with empty fields needs explicit -F'\t'/-F, (otherwise empty columns collapse and your $5 is the wrong field). And awk field numbers are 1-based ($1 first, $0 whole line) — off-by-one is the classic slip.
20sed — stream editing & substitution

Scenario: replace text across files, delete matching lines, edit config in place from a script — sed edits a stream as it flows, no interactive editor needed.

🧠
Analogy: sed is an assembly-line worker with a find-and-replace stamp: each line rolls past, the worker applies the rule (swap this for that, delete lines like this), and the line rolls on — modified. It never sees the whole document, just one line at a time, which is why it handles gigabyte files without breaking a sweat.
Code
# substitute: s/find/replace/  (add g for ALL matches on a line)
$ sed 's/localhost/db.prod/' config.txt        # first match per line → stdout
$ sed 's/localhost/db.prod/g' config.txt       # every match
$ echo "hello world" | sed 's/o/0/g'
hell0 w0rld

$ sed -n '10,20p' file           # -n + p = print ONLY lines 10-20
$ sed '/^#/d' config             # delete comment lines (start with #)
$ sed '/^$/d' file               # delete blank lines
$ sed 's/ *$//' file             # strip trailing whitespace

# edit files IN PLACE — the powerful, dangerous one:
$ sed -i 's/debug/info/g' app.conf          # modifies the file directly
$ sed -i.bak 's/debug/info/g' app.conf      # SAME, but keeps app.conf.bak
Output
$ sed -i.bak 's/8080/3000/g' nginx.conf
$ ls nginx.conf*
nginx.conf   nginx.conf.bak      ← the .bak is your undo button

✅ Do — good use cases

  • s/old/new/g for substitution; /pattern/d to delete lines; -n '.,.p' to extract ranges
  • sed -i.bak for in-place edits — the .bak suffix gives you an instant rollback
  • Config templating in scripts/Dockerfiles — swap placeholders for real values (DevOps playbook does this)

❌ Don't — anti-patterns

  • sed -i without a backup on production configs — one bad regex, no undo
  • Parsing HTML/JSON/nested structures with sed — it's line-oriented; structured data wants a real parser (jq, etc.)
Pattern & benefit: sed edits streams and files at any size with a tiny language, making it the go-to for scripted, non-interactive text changes — CI config templating, bulk find-replace, log cleanup. Paired with find (topic 22) it edits across whole trees in one line.
Gotcha — why it goes wrong: sed -i differs between GNU (Linux) and BSD (macOS): GNU accepts -i alone, BSD requires -i '' — scripts that work on your Mac break on the Linux server (and vice versa). Always use -i.bak (works on both, and gives a backup). And / in your pattern clashes with the s/ delimiter — switch delimiters (s|/old/path|/new/path|) instead of escaping every slash.
21xargs — turn output into arguments

Scenario: you have a list of files (from find or grep) and want to run a command on each. Pipes feed stdin; many commands want arguments. xargs bridges the two.

🧠
Analogy: a pipe delivers items on a conveyor belt (stdin), but some machines (rm, cp, kill) only accept items handed to them by name (arguments), not off a belt. xargs is the worker who takes items off the belt and hands them to the machine — one at a time, or a whole armful at once.
Code
# find files, delete them (rm wants ARGS, not stdin):
$ find . -name "*.tmp" | xargs rm
# but filenames with spaces break that! the SAFE version:
$ find . -name "*.tmp" -print0 | xargs -0 rm        # -print0/-0 = null-separated

$ echo "1 2 3" | xargs -n1 echo        # -n1 = one arg per invocation
echo 1 ; echo 2 ; echo 3

# {} placeholder — put the arg WHERE you need it:
$ ls *.jpg | xargs -I{} cp {} /backup/{}
$ cat pids.txt | xargs -I{} kill {}

# parallelize — run 4 at a time (huge speedup for slow per-item work):
$ cat urls.txt | xargs -P4 -I{} curl -sO {}        # -P4 = 4 in parallel
Output — the space-in-filename trap, shown
$ find . -name "*.tmp" | xargs rm
rm: cannot remove 'my': No such file          ← "my file.tmp" split into two args!
$ find . -name "*.tmp" -print0 | xargs -0 rm  ← -0 handles spaces correctly ✓

✅ Do — good use cases

  • find ... -print0 | xargs -0 for anything touching filenames — spaces and newlines won't break it
  • -I{} to place the argument mid-command; -n1 for one-per-call; -P to parallelize
  • -P$(nproc) to fan slow per-item work across all cores — a free speedup

❌ Don't — anti-patterns

  • Piping filenames to xargs without -print0/-0 — spaces split one name into many args (and rm deletes the wrong things)
  • xargs when find -exec or a shell loop is clearer — for complex per-item logic, a loop reads better
Pattern & benefit: xargs connects "produces a list" tools (find, grep, ls) to "takes arguments" tools (rm, cp, kill, curl) — the missing adapter in the pipe philosophy (topic 17). The -P flag also makes it a dead-simple parallel executor, turning a slow serial loop into a multi-core one for free.
Gotcha — why it goes wrong: the space-in-filenames trap is a genuine data-loss risk — find | xargs rm splits "my file.tmp" into my and file.tmp and removes whatever those match. Always pair find -print0 with xargs -0 (null bytes can't appear in filenames, so the split is safe). Test with echo in place of rm first.
22find — the filesystem power tool★ used daily

Scenario: "find every log over 100MB older than 7 days and delete it", "all .env files under this tree", "files changed in the last hour" — find searches live by any attribute and acts on the results.

🧠
Analogy: locate (topic 7) reads a pre-printed phone book — fast but stale. find is a bloodhound that walks the actual house right now, and you can send it after any scent: by name, size, age, owner, type — then have it act on whatever it finds (delete, move, run a command).
Code
# find PATH  criteria...  [action]
$ find . -name "*.log"                    # by name (quote it — topic 3!)
$ find /var -type f -size +100M           # files over 100MB
$ find . -mtime -1                        # modified in the last 1 day
$ find . -mmin -60                        # ...last 60 minutes
$ find /home -name "*.env" -type f        # secrets audit
$ find . -type d -empty                   # empty directories
$ find . -user www-data                   # by owner (topic 5)

# combine criteria (implicit AND) and ACT on results:
$ find /var/log -name "*.log" -mtime +7 -size +50M -delete
$ find . -name "*.jpg" -exec convert {} {}.webp \;   # run a cmd per file
$ find . -name "*.tmp" -print0 | xargs -0 rm         # or hand off to xargs (topic 21)
Output — always dry-run destructive finds first
$ find /var/log -name "*.log" -mtime +30 -size +100M
/var/log/app.log.1
/var/log/nginx/access.log.2      ← eyeball this list BEFORE adding -delete
$ find /var/log -name "*.log" -mtime +30 -size +100M -delete   ← then act

✅ Do — good use cases

  • Combine criteria (name + size + age + type) — they AND together into precise searches
  • Always run the find first to see the list, THEN append -delete/-exec
  • Quote name patterns (-name "*.log") so the shell doesn't expand the glob first (topic 3)

❌ Don't — anti-patterns

  • find / -delete-style commands typed confidently without a dry run — this is how disasters happen
  • Unquoted -name *.log — the shell expands *.log against your current dir before find runs, giving wrong or error results
Pattern & benefit: find is the search-and-act engine of the filesystem — locate by any attribute, then delete, move, or run a command on each hit. It's the backbone of cleanup scripts, security audits ("where are the .env files?"), and cron jobs, and it composes with xargs (topic 21) for anything -exec can't express cleanly.
Gotcha — why it goes wrong: the quoting trap bites first — find . -name *.log unquoted lets the shell expand *.log against the current directory before find sees it, so you either search for the wrong literal name or get "paths must precede expression." Always quote. And -mtime +7 means "more than 7×24h ago" (not calendar days) — off-by-a-day surprises are common; -mmin for finer control.
23sort | uniq — the tally pipeline★ used daily

Scenario: "which IPs hit us most?", "how many unique users?", "what are the top errors?" — the sort | uniq -c | sort -rn pipeline is the ad-hoc analytics engine every ops engineer runs daily.

🧠
Analogy: counting a jar of mixed coins. First sort them into piles by type (sort — identical items must be adjacent), then count each pile (uniq -c), then rank the piles biggest-first (sort -rn). uniq can only collapse neighbors, which is exactly why the sort must come first.
Code
# the canonical "top N" pipeline:
$ awk '{print $1}' access.log | sort | uniq -c | sort -rn | head
#       └ extract IP        └sort  └count  └rank desc └top 10

$ sort file | uniq              # dedupe (needs sort first!)
$ sort -u file                  # dedupe in one step
$ sort file | uniq -c           # count occurrences of each line
$ sort file | uniq -d           # only DUPLICATED lines
$ sort file | uniq -u           # only UNIQUE (appearing once) lines

# sort flags that matter:
$ sort -n     # numeric (10 after 9, not before)
$ sort -rn    # numeric, reversed (biggest first)
$ sort -h     # human sizes (2K < 1M < 3G) — great with du (topic 8)
$ sort -k3 -t,  # sort by 3rd column, comma-separated
Output
$ awk '{print $9}' access.log | sort | uniq -c | sort -rn
  48210 200
   3102 404
    881 500      ← the errors, ranked — a status-code report in one line
     44 503

✅ Do — good use cases

  • The sort | uniq -c | sort -rn combo for any "top N by frequency" question
  • sort -n / -h for numbers and sizes — lexical sort puts "10" before "9" (the classic bug)
  • comm and diff to compare two sorted lists (what's in A not B)

❌ Don't — anti-patterns

  • uniq without sort first — it only collapses adjacent duplicates, so scattered repeats survive uncounted
  • Default (lexical) sort on numbers — sort puts 100 before 2; use -n
Pattern & benefit: this pipeline is GROUP BY + COUNT + ORDER BY DESC over any text stream — a database-grade report on logs with no database. Paired with grep -o (topic 18) and awk (topic 19) to extract the field first, it answers most "what's happening in these logs?" questions in one line.
Gotcha — why it goes wrong: the number-one uniq mistake is forgetting it needs sorted input — uniq -c on unsorted data reports each run of identical neighbors separately, so a value appearing in three places becomes three "count 1" entries. Sort first, always. And plain sort is locale-dependent — set LC_ALL=C for consistent byte-order sorting in scripts.
24Bash scripting safely — set -euo pipefail★ used daily

Scenario: a deploy script continues after a failed step and does real damage. Three settings and a few habits turn fragile scripts into ones that fail loudly and safely.

🧠
Analogy: a default bash script is a worker who ignores every mistake and keeps going — dropped a step? Shrug, carry on, maybe deploy to an empty variable's worth of "everywhere". set -euo pipefail is the safety training: stop the moment anything fails (-e), never use an undefined tool (-u), and don't let a failure hide mid-pipeline (pipefail).
The safe-script header + habits
#!/usr/bin/env bash
set -euo pipefail          # the three-word seatbelt:
#   -e          exit immediately if any command fails
#   -u          error on undefined variables (catches typos!)
#   -o pipefail a failure ANYWHERE in a pipe fails the whole pipe
IFS=$'\n\t'                # safer word-splitting (spaces in filenames)

# quote everything (topic 3), default carefully:
readonly DIR="${1:?usage: deploy.sh <dir>}"    # :? = fail if $1 unset
cd "$DIR"                                       # quoted!

# the disaster this prevents:
rm -rf "$BUILD/"        # if BUILD is unset → rm -rf "/"  ...
# with set -u, unset $BUILD errors out BEFORE the rm runs.

trap 'echo "failed at line $LINENO"' ERR       # report where it died
$ bash -n script.sh     # syntax-check without running
$ shellcheck script.sh  # LINT it — catches quoting/logic bugs
Output — set -u catching a typo before it hurts
$ BUILD_DIR=/app/build; rm -rf "$BUILDDIR/"      # typo: missing _
bash: BUILDDIR: unbound variable                  ← stopped. Disaster averted.
# without set -u: $BUILDDIR is "" → rm -rf "/" 💀

✅ Do — good use cases

  • set -euo pipefail at the top of every non-trivial script — the single best habit in shell
  • shellcheck every script — it catches quoting, unset-var, and logic bugs before they run
  • "${VAR:?message}" for required inputs — fail with a clear message, not a silent empty string

❌ Don't — anti-patterns

  • Unquoted variables in rm/cd/cp — an empty or spacey value is a catastrophe (topic 3)
  • Long, critical logic in bash — past ~50 lines of real logic, reach for Python/Ruby; bash's error handling is a minefield
Pattern & benefit: set -euo pipefail flips bash from "silently limps past errors" to "stops at the first problem" — the difference between a script that half-deploys and corrupts state versus one that halts safely. Combined with shellcheck and quoting discipline (topic 3), shell becomes trustworthy for automation.
Gotcha — why it goes wrong: set -e has famous blind spots — it doesn't trigger inside if/while conditions, in commands joined by ||/&&, or (without pipefail) for non-final pipe stages. So grep foo file | wc -l "succeeds" even when grep finds nothing. Know that -e is a safety net with holes, not a guarantee — shellcheck flags the gaps.
25cron & systemd timers — scheduled work

Scenario: nightly backups, log cleanup, a health check every 5 minutes — cron is the classic scheduler; systemd timers are the modern upgrade with better logging and dependency handling.

🧠
Analogy: cron is a wall calendar with a very literal butler: you write five numbers (minute, hour, day, month, weekday) and he runs your errand at exactly those times — no memory of whether the last one worked, no notes, just the schedule. systemd timers are the same butler with a proper logbook and the ability to wait for the kitchen to open first (dependencies, journalctl logs, catch-up after downtime).
Code
# the five fields:  minute hour day-of-month month day-of-week  command
$ crontab -e         # edit YOUR crontab
# ┌ min  ┌ hour ┌ dom ┌ mon ┌ dow
  0      2      *     *     *    /opt/backup.sh        # 02:00 every day
  */5    *      *     *     *    /opt/healthcheck.sh   # every 5 minutes
  0      9      *     *     1    /opt/weekly.sh        # 09:00 every Monday

$ crontab -l         # list current jobs
# system-wide: /etc/cron.d/*, /etc/cron.daily/, etc.

# systemd timer equivalent (the modern way) — two files:
# backup.service (what to run) + backup.timer (when):
#   [Timer]
#   OnCalendar=*-*-* 02:00:00
#   Persistent=true          ← runs on next boot if the machine was OFF at 02:00
$ systemctl list-timers      # all timers + next/last run — cron can't show this
Output
$ systemctl list-timers
NEXT                     LEFT     LAST                     UNIT
Sat 2026-07-12 02:00:00  11h left Fri 2026-07-11 02:00:00  backup.timer
# ↑ next run, last run, and (via journalctl -u backup) WHY it failed. cron gives you none of this.

✅ Do — good use cases

  • cron for simple, portable, one-line schedules; systemd timers when you need logs, dependencies, or catch-up
  • Persistent=true (timers) so a job missed during downtime runs on next boot — cron just skips it
  • Absolute paths and explicit env in cron jobs — cron runs with a minimal PATH (see gotcha)

❌ Don't — anti-patterns

  • Assuming cron has your shell's environment — it doesn't; $PATH, aliases, and profile are absent
  • Cron jobs that print output — cron emails stdout/stderr (or drops it); redirect to a log file explicitly
Pattern & benefit: both schedulers turn "someone remembers to run it" into reliable automation. systemd timers add what cron always lacked — journalctl logging per run, list-timers visibility, dependency ordering, and catch-up after downtime — which is why they're the modern default (and the same OnCalendar syntax the DevOps playbook's CronJobs echo, K8s topic 23).
Gotcha — why it goes wrong: the #1 cron bug: it runs with a bare environment — minimal PATH, no profile, no aliases — so a script that works in your shell fails in cron with "command not found." Use absolute paths (/usr/bin/ruby), set PATH at the top of the crontab, and redirect output (>> /var/log/job.log 2>&1) or you'll never see why it failed. And cron times are the server's timezone — usually UTC (the recurring timezone trap).
26Package managers — apt, yum/dnf, and friends

Scenario: install software, update the system, find what provides a missing command — the package manager is how Linux installs, updates, and removes software with dependencies handled.

🧠
Analogy: a package manager is an app store with a dependency-aware delivery service: ask for nginx and it also fetches the libraries nginx needs, tracks what it installed, and can cleanly remove the lot later. The repository is the store's catalog; update refreshes the catalog, upgrade actually restocks your shelves.
Code — Debian/Ubuntu (apt) and RHEL/Fedora (dnf)
# apt (Debian/Ubuntu):
$ sudo apt update                # refresh the catalog (NOT install anything)
$ sudo apt upgrade               # install available updates
$ sudo apt install nginx         # install + dependencies
$ sudo apt remove nginx          # remove (purge also deletes config)
$ apt search postgres            # find packages
$ apt list --installed | grep ssl
$ dpkg -S $(which nginx)         # which PACKAGE owns this file?

# dnf/yum (RHEL/Rocky/Fedora) — same ideas, different verbs:
$ sudo dnf install nginx
$ sudo dnf upgrade
$ dnf provides */nginx           # which package provides this file/command?

# find what provides a MISSING command:
$ command-not-found htop   →   apt install htop     (many distros suggest it)
Output
$ dpkg -S /usr/sbin/nginx
nginx-core: /usr/sbin/nginx      ← the file came from the nginx-core package
$ apt-cache policy nginx          # installed version + what's available
  Installed: 1.24.0-1
  Candidate: 1.26.0-1             ← an upgrade exists

✅ Do — good use cases

  • Always apt update before apt install — otherwise you install from a stale catalog
  • dpkg -S / dnf provides to trace a file back to its package (great for "what installed this?")
  • Prefer distro packages over manual installs — updates and security patches flow automatically

❌ Don't — anti-patterns

  • Confusing update (refresh catalog) with upgrade (install updates) — apt update alone changes nothing installed
  • curl … | sudo bash installs for everything — you bypass the package manager's tracking, updates, and removal (and trust a URL with root)
Pattern & benefit: the package manager tracks every file it installed, resolves dependencies, and delivers security updates — so apt upgrade patches your whole system's known CVEs in one command (the OS-layer version of the DevOps playbook's image scanning, ECR topic 8). Manual installs forfeit all of that.
Gotcha — why it goes wrong: apt update vs apt upgrade trips up newcomers constantly — update only refreshes the list of what's available; nothing on your system changes until upgrade. And mixing package sources (a PPA, a manually-installed .deb, and the distro repo all providing the same lib) causes version conflicts the manager can't cleanly resolve — the Linux echo of dependency hell.
27tar & rsync — archiving & smart copying

Scenario: bundle a directory into one file, back up a tree to another server, sync only what changed — tar packages, rsync copies intelligently over the network.

🧠
Analogy: tar is vacuum-packing a suitcase — many items squeezed into one sealed bundle (optionally compressed) for easy shipping and storage. rsync is a smart moving crew that, on the second trip, only carries the boxes that changed since last time — glancing at each and skipping identical ones, so a re-sync of a huge tree is nearly instant.
Code
# tar — remember "czf = Create Zip File", "xzf = eXtract":
$ tar czf backup.tar.gz /var/www     # create gzipped archive
$ tar xzf backup.tar.gz              # extract it
$ tar tzf backup.tar.gz              # LIST contents without extracting
$ tar czf - /data | ssh host 'cat > /backup/data.tgz'   # stream over ssh

# rsync — the -a (archive) flag preserves perms/times/links:
$ rsync -av /src/ /dst/              # local sync (trailing / matters! see gotcha)
$ rsync -avz /app/ user@host:/app/   # -z = compress over network
$ rsync -av --delete /src/ /dst/     # mirror: delete extras in dst
$ rsync -av --dry-run --delete /src/ /dst/   # PREVIEW what --delete would do
$ rsync -av --progress big.iso host:/tmp/    # resumable-ish, shows progress
Output — rsync only moving the delta
$ rsync -av /app/ backup:/app/       # second run, one file changed:
sending incremental file list
config/settings.yml                  ← ONLY this transferred
sent 4.2K bytes  received 35 bytes    ← not the whole 2GB tree
total size is 2.1G  speedup is 501,203

✅ Do — good use cases

  • rsync -av for backups and deploys — re-runs transfer only changes, resumable, preserves metadata
  • --dry-run before --delete — see exactly what would be removed on the destination
  • tar czf - … | ssh … to stream an archive across the network without a temp file

❌ Don't — anti-patterns

  • rsync --delete with a wrong trailing slash — you can mirror-delete the wrong side; dry-run first, every time
  • scp -r for large/repeated syncs — it recopies everything each run; rsync copies only the delta
Pattern & benefit: rsync's delta algorithm makes repeat backups and deploys cheap — the first sync moves everything, every sync after moves only what changed, and it survives interruption. tar bundles a tree into one portable, compressible artifact. Together they're the backbone of backup scripts and server-to-server moves.
Gotcha — why it goes wrong: the rsync trailing slash is famous: rsync -av /src/ /dst/ copies the contents of src into dst, but rsync -av /src /dst/ (no slash on src) copies src itself into dst as /dst/src/. Add --delete and the wrong slash can wipe files you meant to keep — always --dry-run first. And tar's flag order matters: f must immediately precede the filename (czf name.tgz, not cfz).
28vim survival — edit files on any server

Scenario: you SSH into a box, need to edit one config line, and the only editor guaranteed present is vi/vim. You don't need mastery — you need to get in, make the change, and get out without getting stuck.

🧠
Analogy: vim has two modes like a car with a parking brake: Normal mode (brake on) is for navigating and commanding — every key is a shortcut, not typing; Insert mode (brake off, driving) is for actually typing text. Newcomers panic because they start with the brake on and mash keys that do cryptic things. The escape hatch back to safety is always the Esc key.
The survival kit — this is genuinely enough
$ vim /etc/nginx/nginx.conf

# you start in NORMAL mode. To type text:
i        # enter INSERT mode (now type normally)
Esc      # back to NORMAL mode (do this when confused — always safe)

# save & quit (from Normal mode — press Esc first):
:w       # write (save)
:wq      # write and quit    (or ZZ)
:q       # quit (if no changes)
:q!      # quit WITHOUT saving — the "get me out of here" escape

# navigation (Normal mode) & the few that pay off:
/error   # search for "error" (n = next match)
dd       # delete current line       u = undo       Ctrl-r = redo
gg / G   # jump to top / bottom
:120     # jump to line 120
The panic sequence — memorize this above all
Stuck? Confused? Made a mess?
   Esc   :q!   Enter        ← quit without saving, start over. No damage done.

✅ Do — good use cases

  • Learn the six: i, Esc, :w, :q, :q!, /search — that's a working editor on any server
  • Esc whenever confused — it always returns you to safe Normal mode
  • :q! to bail out of any mess without saving — nothing is committed until :w

❌ Don't — anti-patterns

  • Mashing keys in Normal mode hoping to type — you're issuing commands; press i first to type
  • Editing critical configs with no backup (cp file file.bak first) — or use sed -i.bak (topic 20) for scripted edits
Pattern & benefit: vi/vim is on every Unix box — no install, no exceptions — so the survival kit means you can always edit a config on a bare server, a rescue shell, or a minimal container. Six commands turn the editor everyone fears into a tool you can rely on anywhere. (Prefer nano when it's installed and you want simplicity — but vim is the one always there.)
Gotcha — why it goes wrong: the eternal trap is which mode am I in? — typing :wq while still in Insert mode literally inserts the text ":wq" into your file instead of saving. Always press Esc first, then the colon command. And the "how do I exit vim" meme is real: Esc then :q! is the universal escape — commit it to muscle memory before you ever need it.

Operating the Machine

29PATH & dotfiles — how the shell finds commands

Scenario: "command not found" for something you just installed, or the wrong version runs. Both are PATH — the ordered list of directories your shell searches, configured in your dotfiles.

🧠
Analogy: PATH is your shell's speed-dial list, checked top to bottom. Type python and it dials each directory in order until someone answers — the first match wins, so a directory earlier in the list shadows the same-named program later. Your dotfiles (.bashrc/.zshrc) are where you set that list and it sticks across logins.
Code
$ echo $PATH | tr ':' '\n'        # the search list, one dir per line
/home/balu/.venv/bin              ← checked FIRST (shadows later matches)
/usr/local/bin
/usr/bin
/bin
$ type -a python3                  # every match in order (topic 7)

# add a dir — put it FIRST to win, LAST to be a fallback:
$ export PATH="$HOME/bin:$PATH"    # prepend (yours wins)
# make it permanent — add that line to your shell's dotfile:
#   ~/.bashrc     (bash, interactive)     ~/.zshrc  (zsh)
#   ~/.profile    (login shells, DE)      /etc/environment (system-wide)

$ source ~/.bashrc                # reload dotfile without logging out
$ which -a ruby                   # is there a shadowing second ruby?
Output — shadowing in action
$ type -a python3
python3 is /home/balu/.venv/bin/python3     ← this one runs (first on PATH)
python3 is /usr/bin/python3                  ← shadowed; never reached
# "I installed 3.12 but 3.9 runs" → the 3.9 dir is earlier on PATH.

✅ Do — good use cases

  • echo $PATH | tr ':' '\n' + type -a cmd to debug "wrong/missing command" in seconds
  • Prepend for override, append for fallback — order is everything
  • Persist changes in the right dotfile: .bashrc for interactive bash, .profile//etc/environment for login/GUI

❌ Don't — anti-patterns

  • Putting . (current directory) on PATH — running a malicious ls from a downloaded folder becomes trivial
  • Editing the wrong dotfile — a change in .bashrc won't apply to a login-only or non-bash shell; know which file your shell reads
Pattern & benefit: PATH + type -a explains nearly every "command not found" and "wrong version" mystery — it's always a missing directory or a shadowing earlier match. Dotfiles make your environment reproducible; versioning them (a "dotfiles repo") means a new machine feels like home in minutes.
Gotcha — why it goes wrong: the dotfile maze bites everyone — bash reads .bashrc for interactive non-login shells but .bash_profile/.profile for login shells, so "my PATH works in the terminal but not over SSH / in cron / in the GUI" is a wrong-file problem. And after editing a dotfile you must source it (or open a new shell) — the running shell won't notice the change on its own.
30Logs & logrotate — reading & taming /var/log

Scenario: find the error in the logs, follow them live during an incident, and stop them from filling the disk (topic 8) — the daily reality of /var/log and journald.

🧠
Analogy: logs are the ship's ongoing logbook — the record of what happened, when, and why, that you read after the bump in the night. logrotate is the quartermaster who starts a fresh logbook each week and archives the old ones (compressed), tossing volumes older than the retention policy — so the logbook shelf never overflows and sinks the ship (fills the disk).
Code
# reading & following:
$ tail -f /var/log/nginx/error.log         # follow live (incident mode)
$ tail -n 200 /var/log/syslog              # last 200 lines
$ less +F /var/log/app.log                 # follow, Ctrl-C to scroll back
$ grep -i error /var/log/app.log | tail    # topic 18 + tail

# modern systems: journald (topic 12) holds much of it:
$ journalctl -u nginx --since "1 hour ago" -p warning
$ journalctl -f                            # follow ALL system logs live

# taming size — logrotate config (/etc/logrotate.d/app):
#   /var/log/app/*.log {
#     daily
#     rotate 14           ← keep 14 days
#     compress            ← gzip old ones
#     copytruncate        ← don't break the app's open file handle (topic 6!)
#   }
$ sudo logrotate -f /etc/logrotate.d/app   # force a rotation to test
Output — rotated logs on disk
$ ls /var/log/nginx/
access.log            ← current
access.log.1          ← yesterday
access.log.2.gz       ← older, compressed
access.log.3.gz       ← logrotate keeps N, compresses, deletes the rest

✅ Do — good use cases

  • tail -f / journalctl -f during incidents; grep + tail for after-the-fact hunts
  • logrotate (or journald's SystemMaxUse) on every log-producing service — unbounded logs WILL fill the disk (topic 8)
  • copytruncate or a post-rotate signal so the app keeps writing to the fresh file (topic 6's open-handle trap)

❌ Don't — anti-patterns

  • Leaving app logs unrotated "for now" — the 3am disk-full page (topic 8) is almost always an ungoverned log
  • Deleting a live log to reclaim space — the process keeps the handle open (topic 6); rotate or truncate instead
Pattern & benefit: logs are your incident timeline — tail -f to watch it unfold, grep to reconstruct it after. logrotate makes that sustainable by capping disk use automatically, turning "logs fill the disk and crash the box" from an inevitability into a solved config. It's the single-machine version of the DevOps playbook's log pipeline (EKS topic 45).
Gotcha — why it goes wrong: naive rotation breaks the writer — if logrotate just renames app.log, the app keeps writing to the same open inode (now named app.log.1), and the new app.log stays empty (topic 6 again). The fix is copytruncate (copy-then-empty-in-place) or a post-rotate signal telling the app to reopen. And journald has its own limits (journalctl --vacuum-size=500M) separate from /var/log files.
31Mounts & fstab — attaching storage

Scenario: attach a new disk, mount a network share, understand why a directory's contents "disappeared" (something mounted over it) — the filesystem is a tree of mounted devices.

🧠
Analogy: the filesystem is one big tree, and mounting grafts a new branch onto a chosen twig (the mount point). Plug in a disk and graft it at /data — now walking into /data walks onto the disk. /etc/fstab is the gardener's standing instructions for which branches to graft every morning (boot), so the tree looks the same after every reboot.
Code
$ df -h                    # what's mounted where, and how full (topic 8)
$ mount | column -t        # every mount, with type & options
$ lsblk                    # block devices as a tree — disks & partitions
NAME   SIZE TYPE MOUNTPOINT
nvme0n1 50G disk
└─nvme0n1p1 50G part /
nvme1n1 100G disk          ← a new disk, not yet mounted

# mount it (manually — gone on reboot):
$ sudo mkdir /data
$ sudo mount /dev/nvme1n1 /data

# make it permanent — /etc/fstab (mounted every boot):
# UUID=abc-123  /data  ext4  defaults,noatime  0  2
$ blkid /dev/nvme1n1       # get the UUID (stable across reboots, unlike names)
$ sudo mount -a            # test fstab NOW without rebooting (catch errors!)
$ umount /data             # detach (fails if something's using it → lsof)
Output
$ findmnt /data
TARGET SOURCE          FSTYPE OPTIONS
/data  /dev/nvme1n1    ext4   rw,noatime      ← grafted successfully

✅ Do — good use cases

  • Reference disks by UUID in fstab, not /dev/sdb — device names can reshuffle between boots
  • sudo mount -a after editing fstab — catches typos now, not at the next reboot (see gotcha)
  • lsblk / findmnt to see the storage tree and what's mounted where

❌ Don't — anti-patterns

  • Editing fstab and rebooting without mount -a — a bad line can drop the box into emergency mode on boot
  • Mounting over a non-empty directory by accident — the existing files aren't deleted, but they're hidden until you unmount (a confusing "my files vanished")
Pattern & benefit: the single-tree, mount-anywhere model means storage is composable — a slow archive disk at /backup, fast NVMe at /var/lib/postgresql, a network share at /shared, all in one namespace. fstab makes the layout survive reboots, and UUIDs make it survive hardware reshuffles.
Gotcha — why it goes wrong: a broken fstab line can make the machine fail to boot into a rescue/emergency shell — which is why sudo mount -a (test all fstab entries against the running system) before rebooting is non-negotiable. Add the nofail option for non-critical/removable mounts so a missing disk doesn't block boot. And "my files disappeared" is often something mounted over a populated directory — umount reveals them again.
32Performance triage — the USE method in practice★ used daily

Scenario: "the server is slow" — the vaguest page there is. A repeatable triage walks the four resources (CPU, memory, disk, network) instead of guessing, and localizes the bottleneck in minutes.

🧠
Analogy: a doctor doesn't guess — they check vitals in order: pulse, breathing, temperature, blood pressure. Performance triage is the same disciplined round over the four bodily resources of a server. Each has a "utilization / saturation / errors" reading, and the abnormal one names your bottleneck — no hunches, just the chart.
The four-resource round
# CPU — busy? queued? (topic 13)
$ uptime; mpstat 1 3        # load vs nproc; %usr %sys %iowait per core

# MEMORY — out? swapping? (topic 14)
$ free -h; vmstat 1 5       # 'si/so' columns nonzero = SWAPPING = pain

# DISK I/O — the silent killer
$ iostat -xz 1 3            # %util near 100 + high await = disk saturated
$ iotop -o                  # WHICH process is doing the I/O

# NETWORK
$ ss -s                     # socket summary
$ sar -n DEV 1 3            # per-interface throughput (or nload/iftop)

# the all-in-one modern dashboards:
$ htop      # CPU/mem/procs, interactive     btop  # prettier, same idea
$ dstat -tcmnd    # cpu+mem+net+disk in one scrolling table
Output — reading the tell
$ vmstat 1
 r  b   swpd   free   si   so    bi    bo   us sy id wa
 2  3      0   210M    0    0  8192  4096   12  4  8 76   ← wa=76! disk-bound
# high 'b' (blocked) + high 'wa' + iostat %util~100 → it's DISK, not CPU.

✅ Do — good use cases

  • Walk all four resources in order — don't fixate on CPU; "slow" is often disk or memory-pressure
  • Watch wa (iowait) and si/so (swap) — they redirect you off the CPU red herring
  • Learn one all-in-one (htop/btop/dstat) for the glance, the specific tools for the diagnosis

❌ Don't — anti-patterns

  • Adding CPU/replicas before finding the bottleneck — if it's disk or swap, more compute does nothing
  • Trusting a single snapshot — sample over a few seconds (1 3); transient spikes lie
Pattern & benefit: a fixed round over CPU/memory/disk/network turns "it's slow" into "disk is saturated at 100% util with 200ms await" — an actionable, specific diagnosis. The same discipline scales up to the DevOps playbook's cluster triage (EKS topic 45/46): find the constrained resource first, fix that, re-measure.
Gotcha — why it goes wrong: the seductive trap is stopping at CPU — top shows low CPU, you conclude "not resource-bound," and miss that the box is swapping (memory) or iowait-bound (disk) with CPUs idle by definition (they're waiting). High load + low CPU (topic 13) is the flag to check memory and disk. Always finish the round before concluding.
33sysctl — tuning the kernel's knobs

Scenario: a high-traffic server drops connections, or a container needs ip_forward, or file-watching apps hit a limit — many fixes are one kernel parameter, set via sysctl (which is just writing to /proc/sys, topic 1).

🧠
Analogy: the kernel ships with hundreds of tuning dials behind a panel (/proc/sys) set to safe, general-purpose defaults. sysctl is the labeled control panel for those dials — turn up the connection backlog for a busy web server, enable packet forwarding for a router/container host, raise the inotify watch limit for a dev machine. Same dials, different workloads.
Code
$ sysctl vm.swappiness              # read one knob
vm.swappiness = 60
$ sudo sysctl -w vm.swappiness=10   # set it NOW (temporary — lost on reboot)
$ sysctl -a | grep somaxconn        # search all knobs

# the knobs you'll actually touch:
net.core.somaxconn=1024             # bigger connection accept queue (busy servers)
net.ipv4.ip_forward=1               # route packets between interfaces (containers/routers)
fs.inotify.max_user_watches=524288  # file-watchers (webpack/IDE "ENOSPC" fix)
vm.max_map_count=262144             # Elasticsearch and friends require this

# make it PERMANENT — /etc/sysctl.d/99-tuning.conf:
#   net.core.somaxconn = 1024
$ sudo sysctl -p /etc/sysctl.d/99-tuning.conf   # apply the file now
Output — a temp change vs a real error it fixes
$ sudo sysctl -w net.ipv4.ip_forward=1
net.ipv4.ip_forward = 1
# the classic dev error this fixes:
#   Error: ENOSPC: System limit for number of file watchers reached
#   → raise fs.inotify.max_user_watches (nothing to do with disk space!)

✅ Do — good use cases

  • -w to test a value live, then persist it in /etc/sysctl.d/*.conf once it's proven
  • Know the usual suspects: somaxconn (busy servers), ip_forward (containers), inotify watches (dev tooling)
  • Document why in the conf file — future-you needs to know what workload each knob serves

❌ Don't — anti-patterns

  • Cargo-culting a wall of sysctl "tuning" from a blog without understanding each line — some defaults are safe for good reasons
  • Setting values with -w only and expecting them to survive reboot — they don't; use a conf file
Pattern & benefit: because kernel parameters are just files under /proc/sys (topic 1), tuning is transparent and scriptable — read a knob, test a change live, persist the good ones. A handful of well-chosen values (connection queues, forwarding, watch limits) resolve a large fraction of "server can't keep up" and "app hits a system limit" issues.
Gotcha — why it goes wrong: the most common facepalm is a runtime -w change that vanishes on reboot — the server behaves for weeks, reboots for a patch, and the "fixed" problem returns; persist in /etc/sysctl.d/. Also, some parameters are namespaced (per-container) and some are host-global — inside a container you may not be able to change a global knob, which surprises people tuning containerized apps (topic 34).
34Namespaces & cgroups — how containers actually work

Scenario: the payoff topic — a container isn't a mini-VM, it's a normal Linux process wearing two kernel features: namespaces (what it can see) and cgroups (what it can use). Understanding them demystifies Docker and the entire DevOps playbook.

🧠
Analogy: a container is an ordinary employee (process) the building treats specially. Namespaces are one-way mirrors: the employee sees only their own office (their processes, their network, their filesystem) and believes it's the whole building. cgroups are the utility meter with a cap: "this office may draw at most 2 cores and 512MB" — exceed the memory cap and the fire marshal (OOM killer, topic 14) evicts them. Two kernel features, and you've built a container.
Code — see the machinery a container is made of
# a container is just a process — find it on the HOST:
$ docker run -d --name web --memory 512m --cpus 2 nginx
$ docker inspect -f '{{.State.Pid}}' web
9102
$ ps -o pid,cmd 9102          # it's a normal nginx process on the host!

# its NAMESPACES (what it can see) — under /proc:
$ sudo ls -l /proc/9102/ns/
lrwxrwxrwx net -> net:[4026532...]   ← its own network stack
lrwxrwxrwx pid -> pid:[4026532...]   ← its own PID numbering (it thinks it's PID 1)
lrwxrwxrwx mnt -> mnt:[4026532...]   ← its own filesystem view

# its CGROUP limits (what it can use):
$ cat /sys/fs/cgroup/.../memory.max     # 536870912 = the 512m cap
$ systemd-cgtop                          # live cgroup resource usage
Output — the reveal
$ docker exec web ps aux
PID   COMMAND
  1   nginx: master        ← INSIDE, it's PID 1 (pid namespace)
$ ps aux | grep nginx        # OUTSIDE, on the host, same process:
9102  nginx: master        ← PID 9102. One process, two views. That's a container.

✅ Do — good use cases

  • Set cgroup limits (--memory, --cpus) on every container — an unbounded container can starve the host (topic 14)
  • Debug containers from the host via /proc/<pid>/ns/ and nsenter when tools inside are missing
  • Read Docker/Kubernetes as "namespaces + cgroups orchestrated" — the magic is standard kernel features

❌ Don't — anti-patterns

  • Treating containers as VMs — they share the host kernel; a kernel exploit or unbounded cgroup affects everyone
  • Running containers without memory limits on a shared host — one leak triggers a host-wide OOM (topic 14)
Pattern & benefit: once you see that a container is a process with a restricted view (namespaces) and a resource cap (cgroups), the whole container world stops being magic: the 137 OOM exits (topic 10/14), the shared-kernel security model, the pod-as-shared-namespace (DevOps playbook K8s topic 12) — all follow directly. This is the topic that connects this playbook to the last one.
Gotcha — why it goes wrong: the shared-kernel reality surprises people who expect VM-grade isolation — containers see the host's kernel version, and some /proc/sysctl values (topic 33) are the host's, not theirs (an app reading host CPU count instead of its cgroup limit is a classic over-provisioning bug). And a container hitting its cgroup memory cap gets OOM-killed with the host RAM still free — "137 but the server has memory" is the container limit, not the host.
🎯 Quiz — Test Yourself

Answer 5 per round. Your progress and score are saved in this browser — come back anytime and continue where you left off.

Score: 0 / 0 answered · 0 total