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.
/proc is the city hall noticeboard, rewritten live by the kernel every time you look at 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
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/nullto discard,/dev/urandomfor random bytes — devices as building blocks- Read
ls -l's first letter fluently:-file,ddir,llink,c/bdevice,ssocket
❌ Don't — anti-patterns
- Editing
/proc/sysby hand for permanence — it resets at boot; that's sysctl.conf (topic 33) - Treating
/procfiles as regular files (they have no size;cpbehaves oddly) — read them, don't manage them
/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.
/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./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?
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/binfor your own scripts — in PATH, survives package upgrades- Separate partition/volume for
/varon 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
/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.
*.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."$ 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
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
lsorechofirst — see exactly what the shell will pass - Single quotes for literals (regexes, passwords with
$), double for controlled expansion
❌ Don't — anti-patterns
rm $fileunquoted — if$fileis empty or has spaces, you delete the wrong things (or everything)- Assuming
*matches hidden files — it doesn't (dotfiles need explicit.*orshopt -s dotglob)
find wants quoted patterns (topic 22), why grep "$x" differs from grep '$x', why filenames with spaces break scripts. One rule, endless clarity.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.
755 = "I hold all keys; everyone else can look and run, not change."$ 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
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
xto be entered (not just read) —755on dirs, never644 chmod 600on 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 missingxchmod -R 755blindly over a tree with secrets — you just made keys world-readable
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).
www-data, postgres) so a breached web server isn't holding the master key.$ 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!)
$ 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 -ltogether — 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 baluwithout-a— that replaces all your groups with justdocker, dropping you from sudo
-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.
$ 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
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 -lito see inodes + link counts when investigating "same file?" questionsreadlink -f <link>to resolve a symlink chain to its real target
❌ Don't — anti-patterns
- Assuming
rmfrees 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
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).
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.$ 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 / 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 cmdwhen the "wrong version" runs — it shows aliases and PATH shadowing at oncecommand -vin scripts to test existence (if command -v jq; then) — cleaner thanwhichlocatefor instant filename lookups;sudo updatedbto refresh its index
❌ Don't — anti-patterns
- Trusting
whichfor shell builtins/functions — it only sees PATH executables;typesees everything - Using
locatefor just-created files — its DB is periodic;findsees the live filesystem
type -a is the single best command for that class of confusion, and it's instant.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.
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.$ 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
$ 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 -hfirst (which tank), thendu -sh dir/* | sort -h(what's heavy) — narrow top-downlsof +L1when deleting a file doesn't free space — a process is holding it (topic 6)df -iwhen "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; usetruncate -s 0or logrotate (topic 30)- Running
du /and waiting forever — scope it (du -sh /var/*) and follow the weight down
df) → weigh (du) → check for held-open (lsof +L1) → check inodes (df -i). Four commands, one calm diagnosis.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.
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.$ 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)
$ 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/-%memto rank hogs;pgrep/pkillto target by name/proc/<pid>/for ground truth:cwd,exe,environ,fd/,limitsps --forestorpstreeto see who spawned whom (orphaned children, runaway forks)
❌ Don't — anti-patterns
- Panicking over zombies (
Zstate) — they hold no memory/CPU, just a slot; the fix is the parent reaping (or restarting), notkillon the zombie kill -9as a first resort (topic 10) — you skip cleanup and can corrupt state
/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.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.
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."$ 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)
$ 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
SIGTERMfirst (plainkill) — let the process flush and close;-9only if it ignores you kill -HUPto 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 -9by 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)
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.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.
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.# 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
$ tmux ls deploy: 1 windows (created Sat Jul 11 14:02) (attached) $ jobs [1]+ Running ./migrate.sh &
✅ Do — good use cases
tmux(orscreen) 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 &orsystemd-runfor 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 whatnohup/tmux fix)
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.
journalctl) instead of every tenant scribbling in their own notebook. systemctl is how you give the manager instructions.# 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?
$ 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 50as your reflex for any service issueenable --nowto 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(onlystart) — works now, vanishes after the next reboot
journalctl. It's the same reconcile-to-desired-state idea as Kubernetes (DevOps playbook topic 11), one machine down.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.
$ 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 coresus 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
wais high — the processes are waiting on I/O, not compute; faster disk/network is the fix
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).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.
$ 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 journalOut 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;
freealone is misleading by design - Check
dmesg -T | grep -i oomwhen 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/cacheis 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
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.
$ 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
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
LimitNOFILEfor high-connection servers (web, proxies, databases) — 1024 is a toy default ls /proc/<pid>/fd | wc -lto 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
ulimitin a login shell and expecting a systemd service to inherit it — services get limits from their unit file, not your shell
/proc/<pid>/fd) to watch, and a clear tell (steady climb = leak) for the bug.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.
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.# 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% 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 openatand read the ENOENTs - Hung process →
strace -p <pid>shows the exact syscall it's blocked on (then check that fd) strace -cfor a syscall profile when a program is mysteriously slow
❌ Don't — anti-patterns
straceon 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/filerather than tracing everything
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.)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.
|) 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.# 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$ 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>&1to capture errors with output;teeto watch AND save- Build pipelines incrementally — add one
| stageat a time and eyeball each step
❌ Don't — anti-patterns
> file 2>&1written as2>&1 > file— order matters; the second redirects stderr to the OLD stdout (terminal), not the file- Useless
cat file | grepwhengrep pattern fileworks — harmless but a tell (the "useless use of cat")
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.
$ 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$ 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
-Cfor context (the lines around an error tell the story),-cto quantify,-vto exclude noise-oto extract just the matched substring — feed it tosort | uniq -c(topic 23) for a tallyrg(ripgrep) as a faster, gitignore-aware grep for codebases
❌ Don't — anti-patterns
- Forgetting
--line-bufferedwithtail -f | grep— output buffers and appears in lumps, not live - Fighting basic-regex escaping (
\(,\+) — use-E(extended) and write normal()and+
-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.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.
$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.# $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$ 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 -Fto 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
-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.
# 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
$ 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/gfor substitution;/pattern/dto delete lines;-n '.,.p'to extract rangessed -i.bakfor in-place edits — the.baksuffix gives you an instant rollback- Config templating in scripts/Dockerfiles — swap placeholders for real values (DevOps playbook does this)
❌ Don't — anti-patterns
sed -iwithout 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.)
find (topic 22) it edits across whole trees in one line.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.
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.# 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$ 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 -0for anything touching filenames — spaces and newlines won't break it-I{}to place the argument mid-command;-n1for one-per-call;-Pto parallelize-P$(nproc)to fan slow per-item work across all cores — a free speedup
❌ Don't — anti-patterns
- Piping filenames to
xargswithout-print0/-0— spaces split one name into many args (andrmdeletes the wrong things) xargswhenfind -execor a shell loop is clearer — for complex per-item logic, a loop reads better
-P flag also makes it a dead-simple parallel executor, turning a slow serial loop into a multi-core one for free.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.
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).# 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)$ 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*.logagainst your current dir before find runs, giving wrong or error results
-exec can't express cleanly.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.
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.# 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$ 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 -rncombo for any "top N by frequency" question sort -n/-hfor numbers and sizes — lexical sort puts "10" before "9" (the classic bug)commanddiffto compare two sorted lists (what's in A not B)
❌ Don't — anti-patterns
uniqwithoutsortfirst — it only collapses adjacent duplicates, so scattered repeats survive uncounted- Default (lexical) sort on numbers —
sortputs100before2; use-n
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.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.
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).#!/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$ 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 pipefailat the top of every non-trivial script — the single best habit in shellshellcheckevery 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
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.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.
journalctl logs, catch-up after downtime).# 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
$ 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
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).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.
update refreshes the catalog, upgrade actually restocks your shelves.# 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)
$ 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 updatebeforeapt install— otherwise you install from a stale catalog dpkg -S/dnf providesto 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) withupgrade(install updates) —apt updatealone changes nothing installed curl … | sudo bashinstalls for everything — you bypass the package manager's tracking, updates, and removal (and trust a URL with root)
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.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.
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.# 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
$ 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 -avfor backups and deploys — re-runs transfer only changes, resumable, preserves metadata--dry-runbefore--delete— see exactly what would be removed on the destinationtar czf - … | ssh …to stream an archive across the network without a temp file
❌ Don't — anti-patterns
rsync --deletewith a wrong trailing slash — you can mirror-delete the wrong side; dry-run first, every timescp -rfor large/repeated syncs — it recopies everything each run; rsync copies only the delta
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.
Esc key.$ 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
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 Escwhenever 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
ifirst to type - Editing critical configs with no backup (
cp file file.bakfirst) — or usesed -i.bak(topic 20) for scripted edits
nano when it's installed and you want simplicity — but vim is the one always there.):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.
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.$ 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?
$ 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 cmdto debug "wrong/missing command" in seconds- Prepend for override, append for fallback — order is everything
- Persist changes in the right dotfile:
.bashrcfor interactive bash,.profile//etc/environmentfor login/GUI
❌ Don't — anti-patterns
- Putting
.(current directory) on PATH — running a maliciouslsfrom a downloaded folder becomes trivial - Editing the wrong dotfile — a change in
.bashrcwon't apply to a login-only or non-bash shell; know which file your shell reads
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..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.
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).# 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$ 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 -fduring incidents;grep+tailfor after-the-fact hunts- logrotate (or journald's
SystemMaxUse) on every log-producing service — unbounded logs WILL fill the disk (topic 8) copytruncateor 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
truncateinstead
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).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.
/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.$ 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)
$ 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 -aafter editing fstab — catches typos now, not at the next reboot (see gotcha)lsblk/findmntto 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")
/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.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.
# 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
$ 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) andsi/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
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).
/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.$ 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
$ 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
-wto test a value live, then persist it in/etc/sysctl.d/*.confonce it's proven- Know the usual suspects:
somaxconn(busy servers),ip_forward(containers),inotifywatches (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
-wonly and expecting them to survive reboot — they don't; use a conf file
/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.-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.
# 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$ 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/andnsenterwhen 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)
/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.Answer 5 per round. Your progress and score are saved in this browser — come back anytime and continue where you left off.