This is a rebuilt replacement for a 326-page trainer PDF covering Linux, virtualization, system administration and Docker. It is not a summary of that PDF. The PDF was used only as a syllabus — a list of what must be covered — and then every explanation was rewritten from scratch, at greater depth, with the missing prerequisites filled in, the errors corrected, and the diagrams redrawn.
Three things make it different from the notes it replaces.
It teaches, rather than listing. The original gives ls two options and one example. Here, every command carries its purpose, full option table, several worked examples, real output explained field by field, the way professionals actually use it, the mistakes people make, and how it differs on BSD or macOS.
It covers the hidden syllabus. The source PDF ends with 495 multiple-choice questions spanning filesystems, LVM, RAID, NFS, SSH hardening, ACLs, sudoers, LDAP, cron and the boot process — none of which its prose ever teaches. Roughly 40% of that question bank was unanswerable from the notes themselves. Those topics are taught here as full chapters.
It corrects the source. Where the original is wrong, this handbook says so in a bordered callout rather than silently repeating the error. The corrections so far include the claim that macOS and PlayStation OS derive from Linux (they derive from BSD and Mach), the release year of Linux 6.0, a section on rmdir that duplicates mkdir’s text verbatim, the meaning of file -k, du -M, and the description of kill -9 as an “option” rather than a signal.
Press / anywhere, then type. Arrow keys to move, Enter to jump
Next / previous chapter
] and [
Jump to a section
The outline rail on the right tracks where you are
Copy a command
The copy button on any code panel
Switch light / dark
The dial in the top-right corner
Link to a passage
Hover any heading and click the #
Print or export to PDF
Ctrl+P — every chapter prints, expanded, in order
Four kinds of panel appear throughout, and they mean different things:
bash
# A command panel. This is something you type.
uname-r
terminal
$ uname-r
6.8.0-45-generic
A terminal panel is darker and shows a transcript — the command and its real output. The $ marks what you typed; everything else came back from the machine.
A diagram panel
┌──────────────┐ ┌──────────────┐
│ monospace │────────▶│ ASCII art │
└──────────────┘ └──────────────┘
Mermaid panels render as real diagrams — flowcharts, sequence diagrams, timelines and mind maps.
Callouts are graded, and the grading is meaningful:
The handbook is built in seven parts, each readable independently. Chapters that are still being written are marked; the rest are complete.
Part I — Foundations
01 Introduction to Linux · what an operating system does, kernel vs distribution, the Unix lineage, POSIX, GPL, and how to fingerprint any machine you are dropped onto. 02 Virtualization & Virtual Machines · hypervisor types, full vs paravirtualization, VM networking modes, VirtualBox/VMware/KVM from the command line, and building the throwaway lab machine that every later chapter assumes.
Part II — Working in the Shell
03 Shells & Terminals · terminal vs shell, TTYs and PTYs, startup file order, aliases, functions, environment variables, history, prompts, redirection and quoting. 04 Files & Directories · the filesystem tree, and ls pwd cd mkdir rmdir rm cp mv touch file ln locate find in full. 05 Text Processing & Searching · streams and pipes, then cat less head tail grep sed awk sort uniq cut tr wc diff tee echo printf xargs, plus survivable vi. 06 Archiving & Compression · archiving vs compression, tar in depth, zip/unzip, and gzip vs bzip2 vs xz vs zstd.
Part III — System Internals
07 Filesystems, Mounting & the FHS · inodes, journalling, filesystem types, mkfs, mount, /etc/fstab, fsck, df vs du, swap and NFS. 08 Disks, Partitioning, LVM & RAID · the storage stack, MBR vs GPT, fdisk/parted, LVM end to end, and every RAID level. 09 Processes, Signals & Services · fork and exec, process states, zombies, the full signal table, ps/top, nice, jobs, and writing your own systemd unit. 10 The Boot Process · firmware to login prompt, GRUB, initramfs, targets and runlevels. In progress.
Part IV — Networking & Remote Access
11 Linux Networking · addressing, subnets, routing, DNS, ports, and the ip/ss/dig/curl toolkit. In progress. 12 SSH & Secure Transfer · key authentication, ssh-agent, hardening sshd_config, tunnels, scp and rsync. In progress.
Part V — System Administration
13 Users, Permissions, ACLs & sudo · the permission model, umask, setuid/setgid/sticky, ACLs and sudoers. In progress. 14 Package Management · apt/dpkg, dnf/yum/rpm, repositories and building from source. In progress. 15 Logs, Monitoring & Scheduling · journalctl, rsyslog, logrotate, cron, at, systemd timers and the monitoring toolkit. In progress. 16 Directory Services with LDAP · DNs, LDIF, objectClasses, ports 389 and 636. In progress.
Part VI — Automation
17 Bash Scripting · from shebang to set -euo pipefail, with the quoting and exit-code discipline that separates working scripts from dangerous ones. In progress. 18 The Automation Script Library · the source PDF's 25 production scripts, rebuilt, explained line by line and hardened. In progress.
Part VII — Containers
19 Containers & Docker · namespaces and cgroups, images and layers, Dockerfiles, networking, volumes, Compose, Podman and production practice. In progress.
You cannot learn this material by reading it. Almost every chapter ends in a hands-on lab, and several of them are destructive on purpose — you will fill a disk, break /etc/fstab, kill the wrong process and recover from it.
So the first practical task is a virtual machine you are free to ruin:
Install VirtualBox on your laptop (Chapter 2 covers hypervisor choice, and why a Type 2 hypervisor is right for a learning lab).
Create a VM with 2 vCPUs, 4 GB RAM and a 25 GB disk, and install Ubuntu Server LTS — no desktop.
Set networking to NAT and forward host port 2222 to guest port 22, so you can ssh -p 2222 user@127.0.0.1 from a real terminal instead of the VirtualBox console window.
Take a snapshot immediately and call it clean-install. Every time a lab wrecks the machine, roll back to it in seconds.
Chapter 2 gives the exact commands for all four steps.
Pick the route that matches why you are here. All three assume you do the labs.
For a university exam. Read Chapters 1, 2, 3 and 7 closely — definitions, comparison tables and the > [!EXAM] callouts are written for you. Then work the Practice section of every chapter: the MCQs, fill-in-the-blanks and true/false questions are modelled on the source PDF’s own 495-question bank. Memorise the cheat sheets last, the week before the paper.
For an interview. Go straight to the Interview Corner of every chapter and answer each question out loud before opening the model answer. Prioritise Chapters 9 (processes and signals), 7 (filesystems), 13 (permissions), 11 (networking) and 19 (Docker) — in practice that is where the questions come from. The scenario questions matter more than the definitions: being handed an unfamiliar server and knowing your first five commands is the thing that gets people hired.
For real work. Read Part II properly, then use the rest as reference — search is there for exactly this. Read Chapter 17 before you write any script that runs unattended, and Chapter 9’s systemd section before you deploy anything that must survive a reboot.
The source PDF was described as covering Kubernetes, Cloud and DevOps across hundreds of pages. It does not. Kubernetes appears six times, AWS three, and Jenkins thirteen — all as short shell snippets inside its automation-scripts chapter. There is no Terraform, Ansible, Helm, Prometheus, Git or CI/CD content in it, and no cloud-architecture material. Docker is the only container technology it teaches properly.
This handbook covers that syllabus faithfully and improves it. Where a topic is genuinely absent from the source, it says so rather than pretending otherwise, and points you at what to study next.
I — Foundations
01Introduction to Linux
What an operating system actually does, where Linux came from, and why every cloud server you will ever touch is running it.
You already know how to program. You can write a function, call an API, loop over an array. But every line of code you have ever written was executed by something, on something, and that something made thousands of decisions on your behalf: which CPU core ran your loop, where in physical memory your array lives, how your print reached a screen, how your HTTP request reached a network card.
That something is the operating system. For roughly 96% of the world’s servers, ~85% of smartphones, all 500 of the world’s fastest supercomputers, and effectively 100% of cloud compute, that operating system is Linux.
Learning Linux is not learning “another OS.” It is learning the environment in which all professional software actually runs.
Imagine you had no operating system. To write a program that saves a file, you would need to know:
the exact model of disk controller in this machine, and its register layout
which physical sectors are free
how to avoid two programs writing the same sector simultaneously
how to keep a malicious program from reading another program’s data
Now ship that program to a laptop with a different disk. It breaks. An operating system solves this by inserting one layer that does two jobs, forever:
The two jobs of an operating system
Your program says: open("/etc/passwd")
│
┌─────────────────────┴─────────────────────┐
│ │
1. ABSTRACTION 2. ARBITRATION
Turn thousands of different Decide who gets the CPU,
hardware devices into a few the memory, the disk, and
uniform ideas: "file", the network — and stop
"process", "socket". anyone taking it all or
reading someone else's data.
│ │
└─────────────────────┬─────────────────────┘
│
One stable interface: the system call
Every operating system feature you will ever learn — permissions, processes, mounts, cgroups, namespaces, containers — is a variation on abstraction or arbitration.
Cost — no per-socket licence. A startup can run 200 servers for the price of the hardware.
Control — source is available, so a vendor cannot end-of-life your platform out from under you.
Automatability — everything is a file or a command, so everything can be scripted. This is the precondition for DevOps, Infrastructure as Code, and immutable infrastructure.
Talent & tooling — the entire cloud-native ecosystem (Docker, Kubernetes, Terraform, Prometheus) is written for Linux first.
You rent an office in a large building. You do not negotiate with the electricity grid, you do not install your own plumbing, and you cannot walk into another tenant’s office.
The building is the hardware: power, water, lifts, floor space.
The building manager is the kernel. Small team, enormous authority.
The tenants are your programs.
Requesting a service desk visit is a system call: you fill a specific form (read, write, open), hand it over, and wait.
Your keycard only opens your floor — that is memory protection and file permissions.
The manager decides lift priority at 9 a.m. — that is the CPU scheduler.
Notice what the analogy predicts correctly: tenants cannot see each other’s rooms (process isolation), a tenant hogging the lift is a management failure (scheduler), and if the building manager crashes, everything stops (kernel panic).
This confusion costs people interview points constantly.
Engine vs. car
THE LINUX KERNEL A LINUX DISTRIBUTION
(one program, ~40M lines) (kernel + several thousand programs)
┌───────────────┐ ┌──────────────────────────────────────┐
│ │ │ ╔════════════╗ installer, desktop, │
│ ENGINE │ → │ ║ ENGINE ║ package manager, │
│ │ │ ╚════════════╝ shell, coreutils, │
└───────────────┘ │ wheels, seats, dashboard, paint │
└──────────────────────────────────────┘
You cannot drive an engine. Ubuntu, Debian, Fedora, RHEL, Arch —
Linus Torvalds ships this. different cars, same kind of engine.
Linux is the engine. Ubuntu is a car. Both use the word “Linux” in conversation, and that is fine — but in an exam or interview, say precisely which one you mean.
In 1983 Richard Stallman started building a complete free operating system called GNU. By 1991 GNU had the compiler, the shell, the text editor, the libraries, the utilities — everything except a working kernel. In 1991 a Finnish student published a kernel and no userland.
Two half-systems, perfectly complementary. That is why purists insist on the name GNU/Linux: the commands you will type in Chapter 4 (ls, cp, grep) are GNU programs, not Linux ones.
Operating system. A program that manages hardware resources and provides services to application software through a defined interface. It consists of a kernel (privileged, always resident) plus userspace components (shell, libraries, daemons, utilities).
Kernel. The core of the OS. It is the only code that runs with full hardware privilege (on x86-64, CPU privilege ring 0). It owns memory management, process scheduling, device drivers, filesystems, and the network stack. Application code runs in ring 3 and must ask the kernel for anything privileged.
Linux. Specifically, a monolithic, modular, preemptive, multi-user, multitasking Unix-like kernel, first released by Linus Torvalds in 1991, licensed under GPLv2. Unpack that:
Term
Meaning
Why it matters
Monolithic
Drivers and filesystems run inside kernel address space
Fast (no message passing), but a bad driver can crash the machine
Modular
Code can be loaded/unloaded at runtime as .ko modules
You do not recompile the kernel to add a driver — modprobe
Preemptive
The kernel can interrupt a running task
Desktop responsiveness; real-time capability
Multi-user
Multiple users with separate privileges concurrently
Basis of the entire permission model (Chapter 17)
Unix-like
Follows Unix design and largely POSIX
Skills transfer to macOS, BSD, Solaris, AIX
POSIX (IEEE 1003.1). A standard from the 1980s defining the interface between applications and a Unix-like OS: the C library, system interfaces and headers, plus a set of commands and utilities. POSIX is why a script written on macOS mostly runs on Linux.
Linux distribution (“distro”). A packaged, tested, installable operating system built around the Linux kernel. Every distribution ships:
the Linux kernel — hardware, processes, memory, peripherals
system daemons — background services started at boot: logging, task scheduling, networking, sshd
development and packaging tools — compilers and the package manager (dpkg/apt, rpm/dnf)
life-cycle management utilities — updates, configuration, health monitoring
Crucially, these components are tested together for compatibility and interoperability before release. That integration testing is the actual product a distribution sells.
Free / open-source software (the prerequisite the PDF assumes you know). “Free” as in freedom, not price. The GNU project defines four freedoms: to run the program, to study it, to modify it, and to redistribute it (modified or not). The GPL enforces these with copyleft: if you distribute a modified version, you must also distribute the source under the same licence.
The source notes list these; here is what each one actually does.
flowchart TB
subgraph US["USER SPACE — ring 3, unprivileged"]
A["Your app<br/>python, nginx, bash"]
L["Libraries<br/>glibc"]
end
SC{{"SYSTEM CALL BOUNDARY<br/>open · read · write · fork · execve · socket · mmap"}}
subgraph KS["KERNEL SPACE — ring 0, full privilege"]
P["Process management<br/>fork, scheduling, signals"]
M["Memory management<br/>MMU, virtual memory, paging"]
V["Storage / VFS<br/>ext4, XFS, Btrfs"]
D["Device drivers<br/>block, char, net"]
N["Network stack<br/>TCP/IP, netfilter"]
S["CPU scheduler<br/>CFS / EEVDF"]
end
HW["HARDWARE — CPU · RAM · disks · NIC · GPU"]
A --> L --> SC
SC --> P & M & V & N & S
P --> S
V --> D
N --> D
D --> HW
M --> HW
S --> HW
Memory (MMU)
Gives every process its own private illusion of a full address space. The Memory Management Unit in the CPU translates a virtual address your program uses into a physical RAM address, using page tables the kernel maintains. This is why one process cannot read another's memory — the translation simply does not exist.
Processes
Creates, tracks and destroys running programs. Each has a PID, a parent, an owner, an address space and a set of open file descriptors. Linux creates processes by cloning (fork) and then replacing the program image (execve).
Devices (drivers)
Translates uniform kernel requests into device-specific register writes. A driver is why cat /dev/sda works the same whether the disk is NVMe, SATA or virtual.
Storage
The Virtual File System (VFS) layer presents one API (open/read/write/close) over ext4, XFS, Btrfs, NFS, tmpfs and even /proc. Chapter 11 goes deep on this.
CPU (scheduling)
Decides which runnable thread gets a core, and for how long. Linux used the Completely Fair Scheduler (CFS) for years; kernel 6.6 replaced it with EEVDF. You influence it with nice, chrt and cgroup CPU shares.
Networking
Implements TCP/IP, routing, firewalling (netfilter/nftables) and sockets. Chapter 15 covers the packet path end to end.
This single walkthrough explains more about Linux than any definition.
sequenceDiagram
autonumber
participant U as You
participant B as bash (PID 2410)
participant K as Kernel
participant D as Disk driver
U->>B: type "ls" + Enter
B->>B: parse line, split words
B->>K: search $PATH, stat("/usr/bin/ls")
B->>K: fork() — clone myself
K-->>B: child PID 2411
Note over B,K: parent waits with wait4()
B->>K: child calls execve("/usr/bin/ls")
K->>K: build new address space, load ELF, map glibc
K-->>B: child now runs ls code
B->>K: ls calls openat(".") then getdents64()
K->>D: read directory blocks
D-->>K: raw bytes
K-->>B: directory entries
B->>K: write(1, "file1 file2\n", 12)
K-->>U: bytes appear on terminal
B->>K: exit(0) → parent reaped, prompt returns
Six kernel concepts are visible in that trace: PATH lookup, fork, execve, file descriptors (1 = stdout), system calls, and exit codes. You will meet all six again.
┌────────────────────────────────────────────────────────────┐
│ USER SPACE can NOT: touch hardware directly, │
│ ring 3 read other processes' RAM, │
│ bash, nginx, python disable interrupts │
└───────────────────────────┬────────────────────────────────┘
│ syscall instruction
│ (a deliberate, checked trap)
┌───────────────────────────┴────────────────────────────────┐
│ KERNEL SPACE can do everything │
│ ring 0 Crash here = kernel panic │
└────────────────────────────────────────────────────────────┘
A crash in user space kills one process. A crash in kernel space kills the machine. This is the entire security argument for VMs vs containers in Chapter 2 and Chapter 22 — containers share this kernel box; VMs each get their own.
Interviewers ask about Unix history because the design decisions of 1970 explain the commands you type in 2026.
timeline
title From Multics to the cloud
1965 : Multics begins — MIT, Bell Labs, GE
1969 : Thompson and Ritchie write the first Unix on a PDP-7
1971 : First Edition Unix manual published
1973 : Unix rewritten in C — becomes portable
1983 : Richard Stallman announces the GNU project
1987 : Tanenbaum releases MINIX for teaching
1988 : POSIX.1 (IEEE 1003.1) standardised
1991 : Linus Torvalds posts Linux 0.01
1992 : Linux relicensed under GPLv2
1994 : Linux 1.0 · Red Hat Linux · Debian 0.91
2003 : Linux 2.6 — scalable to big SMP servers
2007 : The Linux Foundation is formed
2008 : Android ships on the Linux kernel
2011 : Linux 3.0
2015 : Linux 4.0 — live kernel patching
2017 : Linux 4.14 LTS — security and hardware breadth
2018 : Ubuntu 18.04 LTS — GNOME replaces Unity
2020 : Linux 5.10 LTS
2022 : Linux 6.0
2023 : Rust merged as a second kernel language (6.1+)
I. Multics (1965–1969) — “Multiplexed Information and Computer Services.” An ambitious MIT/Bell Labs/GE time-sharing system: many users on one machine, each believing they had it to themselves. It worked, but it was enormous, late and slow. Bell Labs withdrew.
II. Unix (1969–1971). Ken Thompson and Dennis Ritchie built a deliberately small alternative on a spare PDP-7 — the name UNICS (“UNiplexed Information and Computing Service”) was a pun on Multics. It contributed the ideas you will use every day:
a hierarchical file system — one tree from /
process management — cheap processes you can compose
a command-line interface — a shell that is itself a program
a wide range of small utilities — each doing one thing, chained by pipes
(1973) rewritten in C, making Unix the first portable OS
III. POSIX (1980s). As Unix forked into many incompatible commercial variants (AIX, HP-UX, SunOS, Xenix — the “Unix wars”), the IEEE 1003.1 standard defined the language interface between application programs and the Unix OS to ensure portability: the C library, system interfaces and headers, plus commands and utilities.
IV. GNU (1983) — “GNU’s Not Unix.” Stallman’s response to increasingly proprietary Unix. It promoted the Free Software concept — the freedoms to run, study, modify and redistribute — protected by the GNU General Public License (GPL), and set out to build a complete free OS: the shell (bash), core utilities (ls, cp, grep), compilers (gcc) and the C library (glibc).
V. The Linux kernel (1991). Torvalds, a 21-year-old at the University of Helsinki, wanted a Unix-like system on his 386 and found MINIX too restrictively licensed. He posted:
Hello everybody out there using minix — I’m doing a (free) operating system (just a hobby, won’t be big and professional like gnu) for 386(486) AT clones.
Licensed under GPLv2 from 1992, compiled with GNU GCC, it became exactly the kernel GNU was missing — giving a Unix-like OS with low cost, full control and strong community support.
The source notes give seven reasons. Here they are with the evidence a hiring manager would want.
Three decades of consistent growth. Linux is not a legacy skill or a fad; it has been the default server OS for over 20 years, and cloud made that dominance near-total.
Extraordinary versatility. Web servers, supercomputers (all of the TOP500), IoT devices, in-car systems including Tesla, and Android phones. Many other systems are Unix-like relatives, which makes the skills transferable.
Vast hardware support. One kernel spans a €4 microcontroller board to a 256-core server, thanks to a huge driver tree and a stable driver model.
Rich software availability. A deep native ecosystem, plus most major Windows/macOS applications either ported or replaced by equivalents.
Customisability. Open source and modular: strip it to 8 MB for an embedded device, or tune it for 10-million-packet-per-second networking.
A strong community and ecosystem. Forums, documentation, tools, conferences, and a genuinely searchable body of knowledge — when something breaks at 2 a.m., someone has already written it down.
Cost-effectiveness — especially for startups. Run websites, databases and applications with no licence fees, and easy installation, upgrade, deployment and maintenance.
To that list, add the two reasons that matter most for your career:
Linux is the substrate of DevOps. Docker, Kubernetes, Terraform, Ansible, Prometheus and every CI runner assume Linux. You cannot debug a CrashLoopBackOff without understanding processes, exit codes and file permissions.
It is the only environment you can fully inspect./proc, /sys, strace, perf, bpftrace — you can watch the OS think. That builds the mental model that makes you senior.
✔ Preferred. A standard, machine-parseable file present on every systemd-era distro
. /etc/os-release; echo "$ID $VERSION_ID"
✔ Best in scripts — it is valid shell, so you can source it
lsb_release -a
⚠ Works, but lsb_release is often not installed (it drags in Python)
cat /etc/issue
⚠ Cosmetic login banner; may be edited or contain escape codes
cat /etc/redhat-release, /etc/debian_version
⚠ Family-specific; fine as a fallback, not as your first choice
hostnamectl
✔ Nice human summary: hostname, OS, kernel, architecture, virtualisation
bash
# The idiom to memorise for scripts that must behave differently per family
./etc/os-release
case"$ID_LIKE$ID"in*debian*)sudoapt-getinstall-y"$1";;*rhel*|*fedora*)sudodnfinstall-y"$1";;*)echo"unsupported distro: $ID">&2;exit1;;esac
Virtualization: kvm is the giveaway. Other quick checks:
bash
systemd-detect-virt# prints kvm, vmware, oracle, docker, lxc — or "none" on bare metal
cat/sys/class/dmi/id/product_name# "VirtualBox", "VMware Virtual Platform", "Standard PC (Q35 ...)"
grep-c^processor/proc/cpuinfo# how many logical CPUs the OS can see
up 43 days — uptime. A very long uptime on a server is not a badge of honour; it usually means unpatched kernels.
3 users — logged-in sessions.
load average: 0.42, 0.55, 0.61 — runnable+waiting tasks averaged over 1, 5 and 15 minutes. Compare it to your core count: load 4.0 on 4 cores is fully busy; load 4.0 on 32 cores is idle. Get the core count from nproc.
terminal
$ free-h
total used free shared buff/cache availableMem: 7.7Gi 2.1Gi 0.9Gi 84Mi 4.7Gi 5.3GiSwap: 2.0Gi 0B 2.0Gi
bash
lscpu|head-15# architecture, cores, sockets, model, virtualization flags
nproc# just the number of usable logical CPUs
cat/proc/version# kernel + compiler used to build it
ls/boot/vmlinuz-*# the actual kernel images installed
$ cat/proc/uptime
3729055.34 8912304.11$ cat/proc/sys/kernel/hostname
web-prod-01$ ls-l/proc/self/fd
lrwx------ 1 dev dev 64 Jul 31 21:09 0 -> /dev/pts/3lrwx------ 1 dev dev 64 Jul 31 21:09 1 -> /dev/pts/3lrwx------ 1 dev dev 64 Jul 31 21:09 2 -> /dev/pts/3
/proc is not a real directory on a disk — it is a virtual filesystem the kernel generates on read. You just read live kernel state with cat. That is the Unix philosophy made concrete, and it is why shell scripts can monitor a machine without any special API.
The single most common opening interview question in this syllabus.
Dimension
UNIX
Linux
What it is
A family of proprietary OSes descended from AT&T Bell Labs code, plus a trademark/certification
A kernel (1991) plus GNU userland; a Unix-like reimplementation sharing no AT&T code
Origin
Thompson & Ritchie, Bell Labs, 1969–71
Linus Torvalds, Helsinki, 1991
Licence
Proprietary, per-vendor (some BSD-derived variants are open)
GPLv2 — free and copyleft
Examples
AIX (IBM), HP-UX, Solaris, macOS (certified UNIX)
Ubuntu, RHEL, Debian, Android, Alpine
Hardware
Typically tied to vendor hardware (POWER, SPARC, PA-RISC)
Runs on almost anything, from microcontrollers to supercomputers
Development
Closed, vendor-controlled
Open, ~15,000 contributors per release cycle
Cost
Licence + support contract
Free; you may pay for support (RHEL, Ubuntu Pro)
Standard
Often formally POSIX/SUS-certified
Largely POSIX-compliant, rarely certified
Interview answer in one line
“Unix is the ancestor and a certification; Linux is an independent, GPL-licensed, Unix-like kernel that behaves like Unix without containing its code.”
Beginner — What is an operating system, in one sentence?
Software that manages hardware resources and exposes them to applications through a stable interface — concretely, it manages memory, processes, devices, storage, CPU scheduling and networking, and it enforces isolation between users and programs.
Beginner — What is the difference between Linux and a Linux distribution?
Linux is the kernel: one program that talks to hardware and schedules work. A distribution is a complete, installable operating system built around that kernel — adding GNU libraries and utilities, system daemons, a package manager, an installer and life-cycle tools, all tested together. Analogy: Linux is the engine, Ubuntu is the car.
Beginner — Which command tells you the kernel version, and which tells you the distribution?
`uname -r` gives the kernel release (e.g. `6.8.0-45-generic`). The distribution comes from userspace: `cat /etc/os-release` (or `hostnamectl` for a human summary). This is a deliberate trap — the kernel genuinely does not know it is "Ubuntu."
Intermediate — Is Linux the same as Unix? Explain the relationship.
No. Unix is a family of proprietary operating systems descending from AT&T Bell Labs code (1969), and also a certification/trademark. Linux is an independent kernel written from scratch by Linus Torvalds in 1991 that *behaves* like Unix — it follows Unix design principles and is largely POSIX-compliant — but contains no AT&T source code. This distinction was legally significant: it is why Linux survived the SCO litigation and why "Unix-like" is the accurate term. Interestingly, macOS *is* certified UNIX while Linux is not.
Intermediate — Why is Linux called a monolithic kernel, and what would the alternative be?
Monolithic means device drivers, filesystems and the network stack all run inside the kernel's address space at full privilege. The upside is speed: a filesystem calling a driver is a function call, not an inter-process message. The downside is blast radius — a buggy driver can panic the machine. The alternative is a microkernel (MINIX, QNX, seL4, and Mach in the hybrid XNU), which runs drivers as user-space servers: more robust and more isolated, but slower due to message passing. Linux is monolithic *and modular*: `.ko` modules can be loaded and unloaded at runtime, giving some of the flexibility without the IPC cost.
Intermediate — Are all Linux distributions free?
The software is nearly always free and open source, but the *product* need not be. Red Hat Enterprise Linux requires a paid subscription for supported binaries, certification and a 10-year lifecycle — you pay for support, QA, certified hardware/software compatibility and legal indemnity, not for the code. SUSE Linux Enterprise is the same model. Rocky Linux and AlmaLinux exist precisely to offer RHEL-compatible binaries free of charge. Ubuntu is free with optional paid Ubuntu Pro. The key distinction to state: **free as in freedom, not necessarily free as in support.**
Advanced — Walk me through what happens between pressing Enter on ls and seeing output.
The shell parses the line into words, resolves `ls` by searching `$PATH` and `stat`ing candidates, then calls `fork()` to clone itself. The child calls `execve("/usr/bin/ls")`; the kernel tears down the child's address space, maps the ELF binary and its shared libraries (`glibc`) via the dynamic loader, and jumps to the entry point. `ls` calls `openat()` on the directory and `getdents64()` to read entries, which goes through the VFS layer to the concrete filesystem (ext4), which asks the block layer, which asks the driver, which talks to the device. Results come back up, `ls` formats them and calls `write(1, ...)` to file descriptor 1, which the terminal driver renders. `ls` calls `exit_group(0)`; the parent, blocked in `wait4()`, reaps the child, stores the exit status in `$?`, and prints the prompt.
Advanced — What is the practical difference between user space and kernel space, and why do you care as a DevOps engineer?
Kernel space runs at CPU ring 0 with unrestricted access to hardware and all memory; user space runs at ring 3 and must request privileged operations via system calls, which the kernel validates. Practically: a segfault in user space kills one process, while a bug in kernel space panics the host. That boundary is the entire basis of the container-vs-VM security argument — containers on one host share a single kernel, so a kernel-level escape affects every container, whereas each VM has its own kernel behind a hypervisor. It is also why "unprivileged containers", `seccomp` and dropping capabilities matter: they narrow which system calls a workload may make.
Advanced — Why did Linux succeed where GNU Hurd and commercial Unix did not?
Three reasons. Technically, Torvalds chose a pragmatic monolithic design that worked in 1991, while GNU's Hurd pursued a more elegant microkernel that never stabilised. Legally, GPLv2 forced improvements back into the commons, so competing vendors' work compounded instead of fragmenting — the opposite of the Unix wars. Economically, commodity x86 plus a zero-cost OS enabled scale-out architectures (Google, Amazon) that per-socket Unix licensing made unaffordable. Distribution mattered too: Red Hat and Debian turned a kernel into something an enterprise could actually buy and support.
Scenario — You SSH into a server you have never seen. Name the first five commands you run and why.
1. `hostnamectl` or `cat /etc/os-release` + `uname -r` — which distro, which kernel, is it virtualised. Everything else depends on this.
2. `uptime` and `nproc` — load average interpreted against core count; how long since the last reboot.
3. `df -hT` — is any filesystem full? A full `/` or `/var` explains an enormous share of "the app is broken" tickets.
4. `free -h` — memory pressure and whether swap is in use, reading the `available` column.
5. `systemctl --failed` then `journalctl -p err -b` — which units failed and what the kernel/services complained about this boot.
Follow-up: `ss -tulpn` for listening ports, and `ps aux --sort=-%mem | head` for the heaviest processes.
Scenario — A developer says "it works on my machine but not on the server." How does your Linux knowledge help?
Enumerate the differences the OS makes visible: distribution and version (`/etc/os-release`), kernel (`uname -r`), architecture (`uname -m` — an x86-64 binary or image will not run on Graviton), library versions (`ldd ./app`), locale and timezone, filesystem case sensitivity (macOS is case-insensitive, Linux is not, so `require('./Utils')` breaks), file permissions and ownership after a `git clone` or `COPY`, environment variables, and whether the process runs as root locally but as an unprivileged user in production. The structural fix is to remove the difference: containerise, so the same image runs in both places (Chapter 22).
Company style — Why do cloud providers standardise on Linux?
Cost at scale (no per-instance licence across millions of hosts), modifiability (AWS ships its own kernel patches and builds Amazon Linux; Google runs a heavily customised kernel), automatability (the entire control plane provisions machines by script and API, which requires a headless, text-configurable OS), the kernel features cloud is built on (KVM for virtualisation, namespaces and cgroups for containers, eBPF for observability and networking), and the ecosystem — every cloud-native tool targets Linux first.
HR style — How do you keep up with Linux and DevOps?
Answer with specifics and a system, not enthusiasm: release notes for the tools you actually run (kernel, distro LTS announcements, Kubernetes changelogs), one or two high-signal newsletters, and — most convincingly — a home lab. "I run a three-VM cluster on my laptop with VirtualBox, I break it deliberately and practise recovery, and I keep my notes and scripts in a Git repo" is a far stronger answer than naming five websites. Mention how you verify: you read `man` pages and official docs before Stack Overflow answers.
HR style — Tell me about a time you broke something.
Use a real, small, honest story with a systems lesson: you ran `rm -rf` with a variable that was empty, or `chmod -R 777 /`, or filled `/var` with logs and the database stopped accepting writes. Then tell the recovery and the *change you made afterwards* — snapshot before risky operations, `set -u` in scripts, `--dry-run` first, log rotation configured, alerting on disk usage at 80%. Interviewers are testing whether you learn systematically, not whether you have a spotless record.
IDENTIFY THE SYSTEM READ ITS STATE
uname -r kernel release uptime load + how long up
uname -m architecture nproc usable CPU count
uname -a everything free -h memory (read AVAILABLE)
cat /etc/os-release distro (script) df -hT disk usage + fs type
hostnamectl human summary lscpu CPU detail
systemd-detect-virt vm? container? ps aux process snapshot
cat /proc/version kernel + gcc systemctl --failed broken units
THE SIX THINGS AN OS MANAGES Memory (MMU) · Processes · Devices (drivers)
Storage (VFS) · CPU (scheduling) · Networking
PRIVILEGE user space = ring 3 → syscall → kernel = ring 0
user crash = one process ends
kernel crash = the machine ends (panic)
TIMELINE Multics 1965 → Unix 1969/71 → C 1973 → GNU 1983 → MINIX 1987
→ POSIX 1988 → Linux 0.01 1991 → GPLv2 1992 → 1.0 + Red Hat 1994
→ 2.6 in 2003 → Foundation 2007 → Android 2008 → 3.0 2011
→ 4.0 live patching 2015 → 5.10 LTS 2020 → 6.0 in 2022 → Rust 6.1
FIVE PARTS OF A DISTRO kernel · libraries · system daemons ·
dev + packaging tools · lifecycle utilities
...all TESTED TOGETHER (classic exam answer)
FAMILIES Debian/Ubuntu → apt, .deb Red Hat/Fedora/RHEL → dnf, .rpm
SUSE → zypper, .rpm Arch → pacman Alpine → apk
ONE-LINERS THAT WIN INTERVIEWS
"Linux is the engine; Ubuntu is the car."
"Unix is the ancestor and a certification; Linux is a GPL-licensed
Unix-like kernel that shares its behaviour but not its code."
"The kernel doesn't know it's Ubuntu — that's a userspace fact."
Which is not one of the six resources an OS manages, as listed in the syllabus? (a) Memory (b) Processes (c) Compilation (d) Networking
uname -r prints: (a) the distribution name (b) the kernel release (c) the CPU architecture (d) the hostname
The Linux kernel is licensed under: (a) MIT (b) Apache 2.0 (c) GPLv2 (d) GPLv3
Which is a Debian-family distribution? (a) Fedora (b) openSUSE (c) Ubuntu (d) Rocky Linux
Which statement is true? (a) macOS is based on the Linux kernel (b) Android is based on the Linux kernel (c) PlayStation OS is based on Linux (d) Windows uses a Linux kernel
POSIX exists to: (a) speed up the kernel (b) standardise the application/OS interface for portability (c) manage packages (d) license free software
A crash in kernel space: (a) kills one process (b) kills the machine (c) is caught by the shell (d) restarts the process
Which command shows whether you are inside a VM? (a)uname -p(b)systemd-detect-virt(c)nproc(d)history
Load average 4.00 is healthy on a host with: (a) 1 core (b) 2 cores (c) 32 cores (d) load average is unrelated to cores
“Monolithic kernel” means: (a) it cannot be extended (b) drivers run in kernel address space (c) it is one single file on disk (d) it supports one user
Answers
1. (c) — compilation is a userspace tool's job.
2. (b).
3. (c) GPLv2 — deliberately *not* v3, a detail interviewers like.
4. (c) Ubuntu.
5. (b) Android.
6. (b).
7. (b) — a kernel panic.
8. (b).
9. (c) 32 cores — load must always be read against core count.
10. (b) — and Linux is monolithic *and* modular.
uname can tell you which distribution you are running.
Every Linux distribution is free of charge.
Android is built on the Linux kernel.
A distribution’s main value is its integration and testing of components.
/proc is stored on your disk.
macOS is a certified UNIX; Linux generally is not.
Adding a driver to Linux always requires recompiling the kernel.
Answers
1. **False** — it is an independent, Unix-*like* reimplementation.
2. **False** — the distro is a userspace fact; read `/etc/os-release`.
3. **False** — RHEL and SLES require paid subscriptions for supported binaries; the *software* is free, the *product* need not be.
4. **True**.
5. **True**.
6. **False** — it is a virtual filesystem generated by the kernel on read.
7. **True** — a satisfying piece of trivia.
8. **False** — loadable kernel modules (`modprobe`, `.ko`) exist precisely to avoid that.
Do these on a throwaway VM (build one in Chapter 2 first if you have not).
Fingerprint your machine. Record kernel release, architecture, distribution and version, virtualisation type, core count, total RAM and root filesystem type — one command each. Put the results in a file called fingerprint.txt.
Read live kernel state as files.cat five different files under /proc and explain in one line each what they told you. Include /proc/uptime, /proc/meminfo, /proc/cpuinfo, /proc/loadavg and /proc/self/status.
Watch a program talk to the kernel. Install strace, then run strace -c ls and strace -f -e trace=openat ls. Identify the execve, the openat calls for shared libraries, and the final write to fd 1.
Prove case sensitivity. Create file.txt and File.txt in one directory, put different text in each, and cat both. Explain why this breaks code ported from macOS or Windows.
Compare two package families. Without installing anything, work out and write down the command to install nginx, remove it, search for it, and list installed packages — once for apt, once for dnf.
Read a distribution’s identity in a script. Write a five-line shell script that sources /etc/os-release and prints Detected: <NAME> <VERSION_ID> (family: <ID_LIKE>).
Explain, without notes, the difference between a Linux distribution and the Linux kernel, and how they interact within the overall system.
Find three trustworthy sources for downloading Linux distributions, and explain how you would verify an ISO you downloaded is genuine (research sha256sum and GPG signature verification).
Investigate whether Linux is the same as UNIX: histories, similarities, differences, and the reason Linux was created rather than a Unix being adopted.
Determine whether all Linux distributions are free. Compare pricing models, professional support, additional services and enterprise features for Ubuntu, RHEL and Rocky Linux.
Investigate how well Linux runs on varied hardware, and list the key considerations before installing it on an unfamiliar device (firmware type, drivers, architecture, secure boot).
Map the Linux community and ecosystem: where the resources, forums, tutorials and documentation you would actually rely on live, ranked by trustworthiness.
Explain the significance of the GPL and how it affects using, modifying and redistributing Linux. Contrast it with a permissive licence like MIT.
Enumerate Linux’s security features and how each maintains system security: user/group permissions, sudo, file ownership, SELinux/AppArmor, seccomp, capabilities, namespaces, ASLR, signed kernel modules.
List the basic commands every Linux user should know and group them by purpose. Compare your list against Chapters 4–9 afterwards and note what you missed.
Compare Ubuntu, Fedora and Arch Linux on installation ease, package management, community support and target audience. Recommend one for a beginner and defend the choice.
I — Foundations
02Virtualization & Virtual Machines
How one physical machine becomes many, why every cloud instance you will ever launch is a virtual machine, and how to build the throwaway lab the rest of this handbook depends on.
In 2001, a typical corporate data centre looked like this: a rack of servers, each one running exactly one application, each one averaging 5 to 15 percent CPU utilisation. The mail server was idle at night. The payroll server was idle for 29 days a month. The web server needed twelve cores at 11 a.m. and none at 3 a.m. Every one of those machines drew power, occupied rack space, required cooling, and had to be bought, patched and eventually replaced.
The reason for one-app-per-server was not stupidity. It was blast radius. If the mail server and the payroll database shared a machine, a memory leak in one starved the other; a security compromise in one exposed the other; a required reboot for one took down both. Operations teams bought isolation the only way they knew how — with separate hardware.
Virtualization broke that trade-off. It lets you keep the isolation and stop wasting the hardware, by inserting a layer that hands each workload a convincing, private, complete-looking computer that does not actually exist.
That is the whole idea. Everything else in this chapter is mechanism.
BEFORE AFTER
┌────────┐ ┌────────┐ ┌────────┐ ┌───────────────────────────┐
│ mail │ │ payroll│ │ web │ │ ┌─────┐ ┌─────┐ ┌─────┐ │
│ 8% │ │ 3% │ │ 11% │ │ │mail │ │pay │ │ web │ │
│ used │ │ used │ │ used │ │ └─────┘ └─────┘ └─────┘ │
├────────┤ ├────────┤ ├────────┤ │ ┌─────┐ ┌─────┐ ┌─────┐ │
│ 1 CPU │ │ 1 CPU │ │ 1 CPU │ │ │ ci │ │test │ │stage│ │
│ 1 PSU │ │ 1 PSU │ │ 1 PSU │ │ └─────┘ └─────┘ └─────┘ │
│ 1 rack │ │ 1 rack │ │ 1 rack │ ├───────────────────────────┤
│ slot │ │ slot │ │ slot │ │ HYPERVISOR │
└────────┘ └────────┘ └────────┘ ├───────────────────────────┤
│ ONE machine · 78% used │
3 machines · 92% of the money └───────────────────────────┘
spent on idle silicon
Same isolation. One third of
the power, space and capital.
Three separate wins fall out of that single move, and it is worth naming them separately because interviewers ask for them separately:
Consolidation — many logical machines on one physical machine, so you stop paying for idle silicon.
Decoupling — the operating system no longer knows or cares what hardware it is on, so a machine becomes a file you can copy, snapshot, move and version.
Isolation — a crash, a kernel panic, a rogue process or a compromised service is contained inside one VM.
The second one is the quiet revolution. Once a server is a file, everything DevOps does becomes possible: reproducible environments, immutable infrastructure, golden images, automated provisioning, disaster recovery that is a restore rather than a purchase order. Amazon Web Services is, at its foundation, a very large business built on the observation that a computer can be an API call.
Capital and operating cost. Consolidation ratios of 10:1 to 30:1 are routine. Fewer machines means less power, less cooling, less rack space, fewer support contracts.
Speed of provisioning. A physical server takes weeks — quote, purchase, ship, rack, cable, install. A VM takes 90 seconds. That difference is why “just spin up a test environment” is a sentence that exists.
Disaster recovery. A VM is a directory of files plus a description. Replicate those to another site and you have a recovery plan that can be tested without buying a second data centre.
Hardware independence. A VM configured with a virtual virtio disk and a virtual NIC boots on any host, regardless of whether the physical disk is SAS, SATA or NVMe. You can replace a whole generation of hardware underneath a running estate.
Consolidated maintenance. Live migration moves running VMs off a host so you can patch its firmware at 2 p.m. on a Tuesday instead of 3 a.m. on a Sunday.
Chapter 1 used a building manager for the operating system. Extend the same building.
You own a large warehouse. You could let one tenant have all of it — that is a bare-metal server. Instead, you build internal walls, run separate electricity meters, fit separate locks, and let out six units. Each tenant:
believes they have “a building” — their own front door, their own meter, their own space
cannot walk into another unit
shares the roof, the foundations and the electricity supply, whether they know it or not
can be evicted, and their unit re-let, without touching anyone else
The hypervisor is the landlord: it decides how much floor space and power each unit gets, enforces the locks, and is the only party who can see the whole floor plan. The VM’s operating system is a tenant who has never seen the outside of the building.
The analogy predicts real behaviour. If the landlord promises 8 units of power each but the supply only delivers 30, everyone is fine until they all switch on the kettle at once — that is memory and CPU overcommitment, and section 9 covers what happens next. If someone drills through a wall, the isolation was never as absolute as the tenants believed — that is a VM escape, and section 17 covers it.
A flight simulator presents a pilot with a cockpit: throttle, yoke, instruments, the view out of the window. Every control is real to the touch, and every instrument reacts correctly. Nothing behind the panel is an aircraft.
A full virtualization hypervisor does exactly this to an operating system. When Windows asks the hard disk controller for sector 40,000, something answers, and it answers the way an Intel AHCI controller would — but it is software, and the sector lives inside a .vdi file on your laptop’s SSD. Windows never finds out.
Now imagine a simulator where the pilot knows it is a simulator, and instead of moving a physical throttle they type set_thrust(0.8). Far less machinery to build, far more efficient, but it only works with a pilot trained for that interface. That is paravirtualization: the guest knows, cooperates, and calls the hypervisor directly.
This one settles the VM-versus-container question that Chapter 19 will formalise.
Three degrees of sharing
DETACHED HOUSE BLOCK OF FLATS HOT-DESK OFFICE
= physical server = virtual machines = containers
Your own foundations Shared foundations, Shared everything:
Your own plumbing shared roof one kitchen, one
Your own roof YOUR OWN kitchen, bathroom, one set
Your own everything bathroom, front door of plumbing.
You get a desk and
Expensive. Each flat has its a locker.
Total isolation. OWN KERNEL.
Every container
A fire in flat 3 SHARES THE HOST
is contained by KERNEL.
concrete.
Cheap, instant,
Boots in 30 s, dense. Boots in
costs ~1 GB RAM 0.1 s, costs ~10 MB.
of overhead.
A plumbing failure
affects everyone.
The single sentence to remember: a VM virtualizes hardware, a container virtualizes an operating system. A VM has its own kernel; a container borrows the host’s.
A software-based representation of a physical computer. It emulates the hardware components a real machine has — CPU, memory, storage devices and network interfaces — presenting them to a guest operating system that runs unmodified and, in general, unaware. A VM is defined by two things on disk: a configuration (how much RAM, which devices) and one or more virtual disk images.
Virtualization
The technique of creating those software-based representations of physical resources, so that one physical resource can be presented as many logical ones (or, less commonly, many as one — that is aggregation, as in a storage pool).
Hypervisor (Virtual Machine Monitor, VMM)
The software layer that creates, runs and manages virtual machines. It allocates physical resources to VMs, schedules their virtual CPUs onto real cores, mediates their I/O, and enforces isolation between them. It is the only component that sees all VMs.
Host
The physical machine, and (for a Type 2 hypervisor) the operating system running directly on it.
Guest
The operating system running inside a VM. "Guest OS", "guest kernel", "guest additions" all refer to this side of the boundary.
vCPU
A virtual CPU: from the guest's point of view a processor core, from the host's point of view a thread that the hypervisor schedules onto a real logical CPU. vCPUs are time-shared, which is why 40 vCPUs can exist on a 16-thread host.
Virtual disk image
A file (or set of files, or a block device) that the guest sees as a hard disk. Formats: .vdi (VirtualBox), .vmdk (VMware), .qcow2 and raw (QEMU/KVM), .vhdx (Hyper-V).
Guest additions / guest tools
Drivers and daemons installed inside the guest that let it cooperate with the hypervisor: paravirtualized device drivers, clipboard and drag-and-drop, shared folders, dynamic screen resize, time synchronisation, graceful-shutdown handling, and reporting the guest's IP address back to the host.
Snapshot
A captured point-in-time state of a VM — disk contents, and optionally RAM and CPU state — that you can revert to. Implemented by freezing the current disk read-only and writing all subsequent changes to a new delta or differencing file.
The sentence “a hypervisor multiplexes physical resources among isolated guests while retaining control” hides five distinct jobs. Interviewers like to pull them apart.
Job
What it actually means
Mechanism
CPU multiplexing
Many vCPUs time-share fewer physical threads
Hypervisor scheduler; VMCS/VMCB state save-restore on every switch
Memory partitioning
Each guest gets a private “physical” address space
A second layer of page tables: guest-physical → host-physical, in hardware (EPT/NPT)
Device mediation
Guests must not touch real hardware directly
Emulated devices, paravirtualized devices, or assigned devices via IOMMU
Interrupt routing
Real interrupts must reach the right guest
Virtual APIC, posted interrupts
Retaining control
The guest must never be able to seize the machine
Guest runs in a de-privileged mode; every sensitive operation causes an exit to the hypervisor
Virtualization is not emulation, and not simulation#
These three get used interchangeably in conversation and must not be in an exam.
Emulation
Virtualization
Simulation
What it does
Reproduces a different instruction set in software
Partitions the same instruction set, running guest code natively
Models behaviour without reproducing mechanism
Guest CPU code
Interpreted or recompiled instruction by instruction
Executes directly on the physical CPU
Not executed at all
Speed
10–100× slower than native
2–10% slower than native
Irrelevant — different purpose
Example
QEMU running an ARM Raspberry Pi image on an x86 laptop; a SNES emulator
KVM running Ubuntu on an x86 host; VMware ESXi
A network simulator predicting latency; a flight training model
Can run a different architecture?
✔ Yes — that is the point
✘ No (guest and host share an ISA)
n/a
QEMU is the confusing one, because it does both: qemu-system-aarch64 on an x86 host is emulation, while qemu-system-x86_64 -enable-kvm on an x86 host is virtualization with QEMU acting only as the device model. Apple’s Rosetta 2 and Microsoft’s x86-on-ARM layer are emulation (specifically, binary translation) at the application rather than machine level.
Virtualization comes in several forms, each solving a different scope of problem. The three the syllabus names are the three you must be able to distinguish instantly, because the boundary between the first two is the VM-versus-container question.
Emulating an entire physical machine’s hardware: CPU, memory, storage, firmware, buses, network interfaces. Because the illusion is complete, an unmodified operating system runs on it. The guest boots its own bootloader, loads its own kernel, initialises its own drivers, and manages its own memory — all against virtual hardware.
This is what “a VM” means in ordinary usage, and it is what the rest of this chapter is mostly about.
Its defining use case is running software that requires a specific hardware or OS environment you do not have. If you have an application that only runs on Windows but your machine runs Linux, hardware-level virtualization lets you create a VM presenting a complete PC, install Windows in it, and run the application — on the same laptop, at the same time, with no dual-boot and no reboot.
A popular example of hardware-level virtualization is KVM — the Kernel-based Virtual Machine — which transforms the Linux kernel itself into a hypervisor, allowing multiple virtual machines to run unmodified Linux or Windows images. Section 13 goes through it properly; note the phrasing now, because “name an example of hardware-level virtualization” is a one-mark question and “KVM” is the expected answer.
Instead of faking hardware, fake the operating system. Multiple isolated user-space instances — containers — run on a single shared host kernel. Each container gets its own view of the filesystem, process table, network stack, hostname and user IDs, but there is exactly one kernel on the machine, and every container’s system calls go into it.
On Linux this is built from two kernel features you will meet again in Chapter 19:
namespaces — per-container views of the filesystem mount table, PIDs, network interfaces, hostname, users and IPC. Isolation of what you can see.
cgroups — per-container limits and accounting for CPU, memory, block I/O and process count. Isolation of what you can use.
Because there is no second kernel, no firmware, no boot process and no duplicated OS in memory, containers are dramatically lighter than VMs: tens of megabytes rather than gigabytes, and start times measured in tens of milliseconds. That efficiency and density is precisely why containers won for microservices and horizontally scalable applications, where you may want 300 instances of a small service on one host.
The cost is the shared kernel. Containers isolate at the application level, not the hardware level. A kernel vulnerability is a shared vulnerability, and you cannot run a Windows container on a Linux kernel — or a different kernel version — no matter how much you want to.
Separate a single application from the operating system beneath it, so that it runs in a self-contained environment carrying its own dependencies.
The problem it solves is dependency conflict. Application A needs version 1.8 of a runtime; application B needs 2.4; the OS ships 2.0. Rather than virtualizing a machine or an OS, encapsulate each application with the exact libraries and runtime it expects, so it neither affects nor is affected by the rest of the system.
Real examples, most of which you have used without labelling them this way:
Technology
Platform
How it isolates
Snap, Flatpak, AppImage
Linux
Bundled runtimes; Snap and Flatpak add namespace-based sandboxing
Microsoft App-V, MSIX
Windows
Virtualized registry and filesystem layers per application
Citrix Virtual Apps
Windows, remote
The application runs on a server; only screen and input travel
Switches, routers, whole L2/L3 topologies in software
VLANs, VXLAN, Open vSwitch, VMware NSX, AWS VPC
Storage virtualization
Pools of physical disks presented as logical volumes
LVM (Chapter 12), SAN LUNs, Ceph, ZFS zvols, EBS
GPU virtualization
A physical GPU shared or partitioned
NVIDIA vGPU, MIG on A100/H100, SR-IOV
flowchart TB
V["Virtualization"] --> HW["Hardware level<br/>emulate a whole machine"]
V --> OS["OS level<br/>share one kernel"]
V --> APP["Application level<br/>encapsulate one app"]
V --> OTH["Also: desktop · network<br/>storage · GPU"]
HW --> HWE["KVM · VMware ESXi · Hyper-V<br/>Xen · VirtualBox · QEMU"]
OS --> OSE["Docker · Podman · LXC<br/>containerd · Windows containers"]
APP --> APE["Snap · Flatpak · AppImage<br/>App-V · venv · JVM"]
HWE --> HWR["Own kernel · GB of RAM<br/>seconds to boot · strong isolation"]
OSE --> OSR["Shared kernel · MB of RAM<br/>ms to start · process isolation"]
APE --> APR["Shared kernel and OS<br/>dependency isolation only"]
5 · Internal Working — how a hypervisor actually does it#
This section is the one the source notes skip entirely, and it is the one that separates someone who has used VirtualBox from someone who can be trusted with a hypervisor estate. It answers a single question: if the guest kernel thinks it owns the machine, what stops it?
Recall the privilege rings from Chapter 1. A normal OS kernel runs in ring 0 and applications in ring 3. Ring 0 code may load page tables, mask interrupts, halt the CPU and talk to devices.
Now put two kernels on one machine. Both were written expecting ring 0. Only one thing can actually be in charge. So the hypervisor takes ring 0 and pushes the guest kernel down — historically into ring 1 or ring 3, a trick called ring deprivileging. The guest kernel is now running at a privilege level it was never compiled for.
For a classically virtualizable architecture, that is fine: every privileged instruction the guest attempts would trap — raise an exception the hypervisor catches — and the hypervisor emulates the intended effect. That is trap-and-emulate, and it is the 1974 Popek–Goldberg model.
x86 broke it. In 2000, Robin and Irvine catalogued 17 sensitive but unprivileged instructions on x86: instructions whose behaviour depends on the current privilege level but which fail silently instead of trapping when executed with insufficient privilege. The textbook case is POPF: it pops flags off the stack, and one of those flags is the interrupt-enable flag IF. Executed in ring 0, it changes interrupt masking. Executed in ring 1 or 3, it silently ignores that bit and carries on. No trap, no error — the guest kernel believes it has disabled interrupts and it has not.
Other symptoms of the same disease: PUSHF reveals the real flags, so the guest can see it is deprivileged; SGDT/SIDT/SLDT read descriptor-table registers and leak the hypervisor’s values; LAR/LSL and PUSH CS reveal the true current ring. Collectively: ring aliasing, address-space compression and non-faulting access to privileged state.
So x86 offered three escape routes, and all three matter historically.
VMware’s answer in 1999, and the reason VMware existed as a company before Intel and AMD caught up.
The hypervisor does not run guest kernel code directly. It reads guest kernel instructions, translates them into a safe equivalent sequence, caches the translation, and runs the translation instead. Guest user-space code (ring 3) still runs natively at full speed, because it is already unprivileged and harmless. Only ring-0 guest code goes through the translator.
Binary translation (dynamic recompilation)
GUEST RING 3 code ────────────────────────► runs NATIVELY, untouched
(your app, ~99% of cycles)
GUEST RING 0 code
┌──────────────┐ ┌──────────────────┐ ┌───────────────────────┐
│ cli │ │ TRANSLATOR │ │ TRANSLATION CACHE │
│ popf │───►│ scan basic block│───►│ vcpu->flags.if = 0 │
│ mov cr3, edx │ │ rewrite unsafe │ │ vcpu->flags = ... │
│ ret │ │ instrs into │ │ call shadow_pt_set │
└──────────────┘ │ hypervisor calls│ │ ret │
└──────────────────┘ └───────────┬───────────┘
translate ONCE │ execute
execute MANY times ◄──────────┘ many times
Because a basic block is translated once and executed thousands of times, the amortised cost is far lower than interpretation. It works on any x86 CPU, including pre-2005 hardware, with an unmodified guest — the guest genuinely cannot tell. The cost is enormous engineering complexity, plus real overhead on system-call-heavy and page-table-heavy workloads.
Technique 3 — hardware-assisted virtualization (the one you actually use)#
In 2005–2006 Intel shipped VT-x (project name Vanderpool) and AMD shipped AMD-V (project name Pacifica). Both solve the problem the same way: rather than squeezing two kernels into four rings, add a whole new dimension of privilege.
The CPU gains two modes of operation:
VMX root mode — where the hypervisor runs. It has rings 0–3 of its own.
VMX non-root mode — where guests run. Also has rings 0–3 of its own.
So the guest kernel runs in ring 0 of non-root mode. It is genuinely in ring 0; POPF behaves; PUSHF shows what the guest expects; no deprivileging, no translation, no lying. But the CPU is configured so that a list of specified operations cause a VM exit: an atomic, hardware-assisted transition into the hypervisor with all guest state saved.
The informal name for the hypervisor’s new privilege level is “ring −1”. It is not an architectural term — Intel’s manuals say VMX root operation and AMD’s say host mode — but it is the phrase interviewers use, and it captures the idea exactly: a level of privilege beneath ring 0.
Ring -1: hardware-assisted virtualization
┌─────────────────────────────────────────────────────────────┐
│ VMX NON-ROOT OPERATION (the guest's own private world) │
│ │
│ ring 3 guest applications nginx, bash, notepad │
│ ring 0 GUEST KERNEL — genuinely ring 0, unmodified │
└───────────────────────────┬─────────────────────────────────┘
│ VM EXIT ▲ VM ENTRY
│ (hardware, │ (VMLAUNCH /
│ atomic, │ VMRESUME)
▼ ~1-2 µs) │
┌───────────────────────────┴─────────────────────────────────┐
│ VMX ROOT OPERATION — informally "RING -1" │
│ ring 0 HYPERVISOR (KVM module / ESXi vmkernel / Hyper-V) │
│ Owns the VMCS: which events exit, and the saved guest state│
└───────────────────────────┬─────────────────────────────────┘
┌───────────────────────────┴─────────────────────────────────┐
│ PHYSICAL CPU · RAM · devices │
└─────────────────────────────────────────────────────────────┘
The control block that stores all of this is the VMCS (Virtual Machine Control Structure) on Intel, or the VMCB (Virtual Machine Control Block) on AMD. It is a 4 KB region per vCPU holding the guest’s saved register state, the host’s saved state, and — critically — the exit controls: bitmaps saying which instructions, exceptions, I/O ports and MSR accesses should cause an exit. Tuning those bitmaps to exit as rarely as possible is what hypervisor performance engineering largely consists of.
New instructions came with it: VMXON/VMXOFF to enter and leave root operation, VMLAUNCH/VMRESUME to run a guest, VMREAD/VMWRITE to manipulate the VMCS, and VMCALL — the hypercall instruction a cooperating guest uses to deliberately call the hypervisor. AMD’s equivalents are VMRUN, VMLOAD/VMSAVE and VMMCALL.
The second half: virtualizing memory with EPT and NPT#
Solving the CPU was only half the problem. Memory was arguably worse.
A normal OS maintains page tables mapping virtual → physical. A guest OS does the same, but its idea of “physical” is a fiction — guest-physical address 0 is not host-physical address 0. So there are two translations needed:
Before hardware support, hypervisors used shadow page tables: the hypervisor secretly built a third set of page tables collapsing both steps into one guest-virtual → host-physical map, and pointed the real CR3 at that. It works, but the hypervisor must intercept every guest page-table modification to keep the shadow in sync. On a fork-heavy or memory-mapping-heavy workload, that is thousands of VM exits per second, and shadow tables consume real memory per process per VM.
The second generation of hardware assist fixed it:
The MMU now walks two page-table hierarchies in hardware. The guest’s own CR3 and page tables handle guest-virtual → guest-physical; a second, hypervisor-owned hierarchy handles guest-physical → host-physical. The guest can rewrite its page tables as often as it likes with no exits at all.
Shadow page tables
EPT / NPT
Who translates guest-physical → host-physical
Hypervisor software
The MMU, in hardware
Exit on guest page-table write
✔ Every time
✘ Never
Memory overhead
One shadow table per guest process per VM
One nested table per VM
TLB miss cost
Normal walk
Longer walk (up to 24 memory refs) — mitigated by large pages
Verdict for 2026
Legacy fallback only
✔ Always on; use 2 MB/1 GB huge pages to shorten the nested walk
Two more hardware pieces complete the picture, both worth naming:
IOMMU — Intel VT-d, AMD-Vi. Translates device DMA addresses the way the MMU translates CPU addresses. It is what makes PCI passthrough safe: without it, giving a VM direct control of a NIC or GPU would let that VM’s device DMA anywhere in host memory. Required for GPU passthrough, SR-IOV and secure device assignment.
SR-IOV — Single Root I/O Virtualization. A physical NIC advertises multiple lightweight virtual functions, each assignable straight to a VM. Near-native network performance with no hypervisor in the data path. This is a large part of how AWS delivers 100 Gbit networking to instances.
Trace one disk read from a guest to physical hardware. This single walkthrough explains more about hypervisors than any definition.
sequenceDiagram
autonumber
participant A as App in guest
participant G as Guest kernel
participant C as CPU
participant K as KVM (ring -1)
participant Q as QEMU device model
participant H as Host kernel + SSD
A->>G: read() syscall
Note over A,G: ordinary syscall, no exit — stays inside the VM
G->>C: write to virtio queue, then notify the device
C->>K: VM EXIT (reason: EPT violation on the MMIO page)
Note over C,K: guest state saved to the VMCS, ~1-2 microseconds
K->>Q: hand off: guest wants I/O on this virtio device
Q->>H: pread() on MyVM.qcow2
H-->>Q: 4 KB of data
Q->>Q: place data in the guest's DMA buffer
Q->>K: raise a virtual interrupt for the guest
K->>C: VM ENTRY (VMRESUME) with interrupt injected
C->>G: guest sees IRQ, virtio driver completes the request
G-->>A: read() returns
Three lessons live in that trace, and each is a real interview answer:
Compute is nearly free; I/O is where the overhead is. Steps for arithmetic, branches and even system calls inside the guest never leave non-root mode. The exit happens at the device boundary. This is why CPU-bound workloads run at 97–99% of native speed while naive I/O can be far worse.
The hypervisor is often two pieces. KVM does the CPU and memory virtualization inside the kernel; QEMU, a userspace process, emulates the devices. That split is why ps aux on a KVM host shows one qemu-system-x86_64 process per VM.
Reducing exits is the whole game. VirtIO, vhost-net (moving the network datapath into the host kernel), vhost-user with DPDK, SR-IOV and device passthrough are all strategies for making step 3 happen less often, or not at all.
Everything above requires hardware support. Any x86 CPU sold since roughly 2008 has it, but it is frequently disabled in firmware, and this is the single most common reason a beginner’s first VM refuses to start or crawls.
bash
# 1. Does the CPU report the extension at all?# vmx = Intel VT-x svm = AMD-V
grep-Eoc'(vmx|svm)'/proc/cpuinfo
terminal
$ grep-Eoc'(vmx|svm)'/proc/cpuinfo
16
That 16 is the number of lines in /proc/cpuinfo whose CPU flags contain vmx or svm — in other words, one per logical CPU on a 16-thread machine. The number itself does not matter. Any value greater than 0 means the extension is present and enabled; 0 means it is absent or switched off in firmware.
bash
# Variants worth knowing
grep-E--color'(vmx|svm)'/proc/cpuinfo|head-1# see the whole flags line
lscpu|grep-ivirtual# cleaner, summarised
terminal
$ lscpu|grep-ivirtual
Virtualization: VT-xVirtualization type: full
Line
Meaning
Virtualization: VT-x
Intel hardware assist is available. AMD-V on AMD parts
Virtualization type: full
You are on bare metal, offering full virtualization to guests
Hypervisor vendor: KVM (appears instead)
You are already inside a VM.lscpu reports the hypervisor it detected
Virtualization type: para
You are a paravirtualized guest — classic Xen PV
bash
# 2. The friendliest check on Debian/Ubuntu
sudoaptinstallcpu-checker
sudokvm-ok
terminal
$ sudokvm-ok
INFO: /dev/kvm existsKVM acceleration can be used
Versus the failure you must be able to diagnose:
terminal
$ sudokvm-ok
INFO: /dev/kvm does not existHINT: sudo modprobe kvm_intelINFO: Your CPU supports KVM extensionsINFO: KVM (vmx) is disabled by your BIOSHINT: Enter your BIOS setup and enable Virtualization Technology (VT), and then hard poweroff/poweron your systemKVM acceleration can NOT be used
Read those two outputs carefully — they are different failures. “CPU does not support KVM extensions” means the silicon lacks it (very old, or a low-end Atom/Celeron). “Disabled by your BIOS” means you can fix it.
bash
# 3. Are the kernel modules loaded, and does the device node exist?
lsmod|grep-E'^kvm'
ls-l/dev/kvm
cat/sys/module/kvm_intel/parameters/nested# Y if nested virtualisation is on
/dev/kvm is a character device owned by group kvm. If your user is not in that group, libvirt and QEMU will fall back to slow emulation or refuse outright — sudo usermod -aG kvm,libvirt $USER, then log out and back in.
With the mechanisms understood, the two named technologies in the syllabus make sense.
Full virtualization completely emulates the underlying hardware, allowing unmodified guest operating systems to run in isolation. The hypervisor traps and emulates privileged instructions from the guest OS, ensuring complete isolation and security. The guest operates as if it were running on actual hardware, entirely unaware that it is in a virtual environment.
Full virtualization — the guest does not know
┌────────────────┐ ┌────────────────┐ ┌────────────────┐
│ GUEST OS A │ │ GUEST OS B │ │ GUEST OS C │
│ Windows 11 │ │ Ubuntu 24.04 │ │ RHEL 9 │
│ UNMODIFIED │ │ UNMODIFIED │ │ UNMODIFIED │
│ │ │ │ │ │
│ sees: Intel │ │ sees: Intel │ │ sees: Intel │
│ AHCI disk, │ │ AHCI disk, │ │ AHCI disk, │
│ e1000 NIC, │ │ e1000 NIC, │ │ e1000 NIC, │
│ PS/2 mouse, │ │ PS/2 mouse, │ │ PS/2 mouse, │
│ PIIX3 chipset │ │ PIIX3 chipset │ │ PIIX3 chipset │
└───────┬────────┘ └───────┬────────┘ └───────┬────────┘
│ │ │
privileged instruction / device access
is TRAPPED and EMULATED — the guest is never told
│ │ │
┌───────┴───────────────────┴───────────────────┴────────┐
│ HYPERVISOR + device model │
│ Presents a complete, believable virtual machine: │
│ vCPU · vRAM · virtual BIOS/UEFI · virtual PCI bus · │
│ virtual disk controller · virtual NIC · virtual VGA │
└────────────────────────┬───────────────────────────────┘
┌────────────────────────┴───────────────────────────────┐
│ PHYSICAL HARDWARE CPU · RAM · SSD · NIC · GPU │
└────────────────────────────────────────────────────────┘
✔ Maximum compatibility — install any OS from its normal ISO
⚠ Emulation layer costs performance, mostly on I/O
Provides maximum compatibility, but can incur performance overhead due to the emulation layer. That is the trade-off in one line. Examples: VMware ESXi and Workstation, KVM/QEMU, VirtualBox, Hyper-V, Xen in HVM mode.
Paravirtualization provides a software interface similar to the underlying hardware but not identical. The guest operating system is modified to be aware it is virtualized, and interacts with the hypervisor through special hypercalls rather than by pretending to poke hardware. Reducing the complexity of hardware emulation reduces overhead — but it requires modifying the guest OS, which is not always feasible.
Paravirtualization — the guest cooperates
┌──────────────────────────┐ ┌──────────────────────────┐
│ GUEST OS A │ │ GUEST OS B │
│ MODIFIED kernel │ │ MODIFIED kernel │
│ + paravirtual drivers │ │ + paravirtual drivers │
│ │ │ │
│ knows it is a VM. │ │ knows it is a VM. │
│ Does NOT pretend to │ │ Does NOT pretend to │
│ write to a disk │ │ write to a disk │
│ controller register. │ │ controller register. │
└────────────┬─────────────┘ └────────────┬─────────────┘
│ │
HYPERCALL: a deliberate, direct call into the hypervisor
"map this page" "here is a ring buffer of packets"
"yield my timeslice" "block me until this event fires"
│ │
No trap. No instruction decoding. No fake hardware to emulate.
│ │
┌────────────┴──────────────────────────────┴─────────────┐
│ HYPERVISOR — exposes an API, not imitation hardware │
│ Shared memory ring buffers · event channels · grant │
│ tables (Xen) or virtqueues (VirtIO) │
└────────────────────────┬────────────────────────────────┘
┌────────────────────────┴────────────────────────────────┐
│ PHYSICAL HARDWARE │
└─────────────────────────────────────────────────────────┘
✔ Much less overhead — batching replaces per-register traps
⚠ Guest kernel must be ported. Closed-source guests need vendor buy-in
Why paravirtualization mattered — and where it went#
This is the piece the source notes leave out, and it is the piece that makes the two technologies stop feeling arbitrary.
Before VT-x, paravirtualization was the only way to get good x86 performance without VMware’s binary translator. Xen, released from Cambridge in 2003, took exactly that route: it modified the Linux kernel to run as a Xen PV guest, replacing every privileged operation with a hypercall. The results were startling for the time — within a few percent of native, at a moment when trap-and-emulate was impossible and binary translation was proprietary. Amazon EC2 launched in 2006 on Xen, and ran on it for over a decade.
The catch was structural: you cannot paravirtualize a guest you cannot modify. Xen PV could run Linux, NetBSD and Solaris. It could not run Windows, because Microsoft was not going to port the NT kernel to Xen’s hypercall interface.
VT-x and AMD-V removed the reason for whole-kernel paravirtualization. With hardware assist, an unmodified guest runs in real ring 0 at near-native speed, so the enormous effort of porting a kernel bought you very little on the CPU side. Xen added HVM mode; the industry moved to hardware-assisted full virtualization; Amazon migrated EC2 from Xen PV to Xen HVM and then to the KVM-derived Nitro hypervisor.
But paravirtualization did not die — it retreated to exactly where it still wins: I/O. Hardware assist made privileged instructions cheap. It did nothing to make emulating an Intel e1000 network card cheap, and emulating a NIC is genuinely awful: for every packet, the guest driver writes several device registers, each write is an exit, and the hypervisor must reconstruct the intent from a sequence of register pokes designed for a chip that does not exist.
So the modern arrangement is a hybrid, and this is the sentence to give in an interview:
Modern virtualization is hardware-assisted full virtualization for CPU and memory, with paravirtualized drivers for I/O.
Those paravirtualized drivers are VirtIO — designed by Rusty Russell in 2007 and now the standard across KVM, and supported by Xen, VirtualBox, Hyper-V guests on Linux and cloud providers generally. A VirtIO device is not an imitation of any real chip. It is a documented, minimal contract: shared-memory virtqueues into which the guest places descriptors, and a single notification to say “there is work”. One exit can carry hundreds of packets or many disk requests.
At the heart of virtualization lies the hypervisor: the software layer that enables the creation and management of virtual machines, allocates physical resources to them, and ensures isolation between them. Hypervisors are traditionally sorted into two types by what sits underneath them.
A Type 1 hypervisor runs directly on the host’s hardware. There is no general-purpose operating system beneath it; the hypervisor is the operating system, specialised for one job — running VMs. It boots from firmware, initialises the hardware itself, and everything else on the machine is a guest.
Type 1 — bare-metal hypervisor
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ VM 1 │ │ VM 2 │ │ VM 3 │ │ VM 4 │ │ VM 5 │
│ ┌─────┐ │ │ ┌─────┐ │ │ ┌─────┐ │ │ ┌─────┐ │ │ ┌─────┐ │
│ │ app │ │ │ │ app │ │ │ │ app │ │ │ │ app │ │ │ │ app │ │
│ ├─────┤ │ │ ├─────┤ │ │ ├─────┤ │ │ ├─────┤ │ │ ├─────┤ │
│ │guest│ │ │ │guest│ │ │ │guest│ │ │ │guest│ │ │ │guest│ │
│ │ OS │ │ │ │ OS │ │ │ │ OS │ │ │ │ OS │ │ │ │ OS │ │
│ └─────┘ │ │ └─────┘ │ │ └─────┘ │ │ └─────┘ │ │ └─────┘ │
└────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘
│ │ │ │ │
┌────┴───────────┴───────────┴───────────┴───────────┴─────┐
│ H Y P E R V I S O R │
│ VMware ESXi · Microsoft Hyper-V · Xen · KVM/Proxmox │
│ Nutanix AHV · Citrix Hypervisor · AWS Nitro │
│ │
│ ← THERE IS NO HOST OS. This layer boots from firmware. │
│ It owns the hardware, the drivers and the scheduler. │
└───────────────────────────┬───────────────────────────────┘
┌───────────────────────────┴───────────────────────────────┐
│ P H Y S I C A L H A R D W A R E │
│ CPU (VT-x/AMD-V) · RAM · NVMe · NIC · IOMMU │
└───────────────────────────────────────────────────────────┘
Thin privileged layer → high efficiency, small attack surface.
No desktop, no browser, no package manager, nothing to distract it.
Because they interact directly with the hardware, Type 1 hypervisors offer better performance and are considered more secure, due to the minimal software layer between the hardware and the VMs. They are what enterprise environments and data centres use, where performance, density and scalability are critical.
Concretely, what makes them enterprise-grade is not just speed:
NUMA awareness — pinning a VM’s vCPUs and memory to the same CPU socket so memory access stays local.
Live migration and high availability — moving running VMs, and restarting them elsewhere automatically when a host dies.
Distributed resource scheduling — automatically rebalancing VMs across a cluster.
Clustered storage — shared datastores (vSAN, Ceph, NFS, iSCSI) so any host can run any VM.
A tiny footprint — ESXi installs in about 150 MB and can boot from an SD card or USB stick.
A Type 2 hypervisor runs on top of a host operating system, functioning as an application. You install it the way you install a browser. It asks the host OS for memory, for file access, for network access, and for CPU time, like any other process — with the addition of a kernel driver (vboxdrv, vmmon) that lets it enter VMX root mode.
Type 2 — hosted hypervisor
┌─────────┐ ┌─────────┐ ┌──────────────────────┐
│ VM 1 │ │ VM 2 │ │ ORDINARY HOST APPS │
│ ┌─────┐ │ │ ┌─────┐ │ │ │
│ │ app │ │ │ │ app │ │ │ Firefox │
│ ├─────┤ │ │ ├─────┤ │ │ VS Code │
│ │guest│ │ │ │guest│ │ │ Slack │
│ │ OS │ │ │ │ OS │ │ │ Spotify │
│ └─────┘ │ │ └─────┘ │ │ │
└────┬────┘ └────┬────┘ └──────────┬───────────┘
│ │ │
┌────┴───────────┴──────────────┐ │
│ H Y P E R V I S O R │ │
│ as an ORDINARY APPLICATION │ ← same │
│ VirtualBox · VMware │ privilege │
│ Workstation/Fusion · │ level as │
│ Parallels Desktop · QEMU │ Spotify │
│ (+ a kernel driver for VT-x) │ │
└───────────────┬───────────────┘ │
┌───────────────┴─────────────────────────────────┴─────────┐
│ H O S T O P E R A T I N G S Y S T E M │
│ Windows · macOS · Linux — with its own scheduler, │
│ its own memory manager, its own drivers, its own updates │
└───────────────────────────┬───────────────────────────────┘
┌───────────────────────────┴───────────────────────────────┐
│ P H Y S I C A L H A R D W A R E │
└───────────────────────────────────────────────────────────┘
Two schedulers stacked, two memory managers stacked.
Convenient to install; extra latency; host OS can swap your VM out.
Type 2 hypervisors are generally easier to set up and suitable for desktop virtualization and development environments. Their weakness is structural: the extra layer of the host OS introduces additional latency, making them less suitable for performance-intensive applications.
The reason is worth understanding rather than memorising. There are now two schedulers in series. The guest kernel decides which of its processes should run, then the host kernel decides whether the hypervisor process should run at all. If you open forty browser tabs, the host may preempt your VM’s vCPU thread mid-timeslice — the guest experiences that as a stalled CPU, and its own scheduler has no idea why. The same doubling applies to memory: the host can page your VM’s “physical” RAM out to swap, so a guest memory access that should take 80 nanoseconds takes 8 milliseconds.
The Type 1 / Type 2 dichotomy is a teaching device from the 1970s. It survives because it is useful, but KVM breaks it, and asking about that is a favourite interview move.
KVM is a Linux kernel module (kvm.ko plus kvm_intel.ko or kvm_amd.ko). Loading it does not create a new operating system — it converts the running Linux kernel into a hypervisor. Afterwards, that Linux kernel is simultaneously:
a Type 1 hypervisor: it runs on bare metal, it owns the hardware directly, guests execute in VMX non-root mode with no OS layer between them and the silicon, and every VM exit lands in kernel code
a general-purpose operating system: still running sshd, bash, nginx, cron, apt and your text editor as ordinary processes, with all its usual drivers, filesystems and scheduler
Where does KVM sit?
┌──────────┐ ┌──────────┐ ┌────────────────────────┐
│ VM 1 │ │ VM 2 │ │ nginx · sshd · bash │
│ guest OS │ │ guest OS │ │ cron · your editor │
└────┬─────┘ └────┬─────┘ └───────────┬────────────┘
│ │ │
┌────┴────┐ ┌────┴────┐ │ ordinary
│ qemu- │ │ qemu- │ ← userspace │ syscalls
│ system │ │ system │ processes! │
│ (device │ │ (device │ ioctl(/dev/kvm) │
│ model) │ │ model) │ │
└────┬────┘ └────┬────┘ │
┌────┴────────────┴──────────────────────────┴────────────┐
│ L I N U X K E R N E L │
│ ┌───────────────────┐ │
│ │ kvm.ko │ ← the hypervisor lives INSIDE │
│ │ kvm_intel.ko │ a general-purpose kernel │
│ └───────────────────┘ │
│ scheduler · MM · VFS · ext4 · TCP/IP · drivers │
└────────────────────────┬────────────────────────────────┘
┌────────────────────────┴────────────────────────────────┐
│ P H Y S I C A L H A R D W A R E │
└─────────────────────────────────────────────────────────┘
Type 1? Yes — no OS between guest and hardware; exits land in ring 0.
Type 2? Yes — it is a module inside an OS that also runs normal apps.
Correct answer: "Type 1 with a Type 2 heritage — the taxonomy predates it."
The same fuzziness catches other products, and knowing this is a genuine differentiator:
Product
Usually called
The complication
KVM
Type 1
It is a module inside a general-purpose kernel
Microsoft Hyper-V
Type 1
It looks like a Windows feature you tick a box for, but enabling it inserts the hypervisor beneath Windows — your Windows install becomes the privileged “root partition”, i.e. a guest. This is exactly why it fights VirtualBox
Xen
Type 1
Requires a privileged Linux guest (dom0) to provide drivers and management. The hypervisor alone cannot talk to your disk
VMware ESXi
Type 1
The cleanest example — vmkernel is a purpose-built OS
VirtualBox
Type 2
Uninterestingly clear-cut
AWS Nitro
Type 1
KVM-derived, with device emulation offloaded to dedicated hardware cards rather than software
WSL2
—
A managed Hyper-V VM running a real Linux kernel, presented as a Windows feature
Networking in virtualization is pivotal: it is what lets VMs talk to each other, to the host, and to the outside world. The hypervisor gives the guest a virtual NIC and then attaches that NIC to a virtual switch whose wiring you choose. Different wiring gives you very different degrees of connectivity and isolation — and picking wrongly is the single most common cause of “my VM has no internet” and “I can’t SSH into my VM”.
Four modes matter. Learn them as a spectrum from connected to sealed.
NAT allows VMs to access external networks using the host’s IP address. The host acts as a middleman, translating requests from the VM to the outside world and vice versa. It is simple to set up — it is the default in VirtualBox, VMware and libvirt — and it provides a layer of security by hiding the VM’s IP address from external networks.
NAT — the VM borrows the host's identity
┌───────────────┐
┌───────────────────────────────────────┐ │ INTERNET │
│ HOST LAN address 192.168.1.10 │ │ │
│ │ └───────▲───────┘
│ ┌────────────────────────────────┐ │ │
│ │ NAT engine inside the │ │ packets leave with
│ │ hypervisor: │ │ SOURCE = 192.168.1.10
│ │ · gateway 10.0.2.2 │──┼─────────────┘
│ │ · DNS proxy 10.0.2.3 │ │ (the host's address —
│ │ · DHCP server 10.0.2.x │ │ the world never sees
│ │ · rewrites src IP + port │ │ 10.0.2.15)
│ └───────────────▲────────────────┘ │
│ │ │
│ ┌──────────┴──────────┐ │
│ │ VM 10.0.2.15 │ │
│ │ gw 10.0.2.2 │ │
│ └─────────────────────┘ │
└───────────────────────────────────────┘
OUTBOUND ✔ apt update, git clone, curl — always works, zero config
INBOUND ✘ nothing on the LAN can initiate a connection to the VM
…unless you add a PORT FORWARD on the host
VM ↔ HOST ✔ (reach the host at 10.0.2.2)
VM ↔ VM ⚠ each VM gets its OWN private NAT — they cannot see each
other. VirtualBox's separate "NAT Network" mode fixes this
Exactly as with your home router, NAT complicates incoming connections, because port forwarding must be configured to allow external access. That is the price of the isolation, and section 15 sets up the one port forward you will actually use: host port 2222 to guest port 22, so you can SSH in.
Bridged networking connects the VM directly to the host’s physical network. The virtual NIC is joined to a software bridge that also contains the host’s physical interface, so guest frames go out on the wire with the guest’s own MAC address. The VM obtains its own IP address from the network’s DHCP server, or via static configuration, making it appear as a separate physical device on the network.
Bridged — the VM is a real machine on your LAN
┌──────────── LAN / switch — 192.168.1.0/24 ─────────────┐
│ │ │ │
┌──┴────┐ ┌────┴─────┐ ┌──────┴───────┐ ┌──────┴───────┐
│ROUTER │ │ HOST │ │ VM (bridged) │ │ Colleague's │
│ .1 │ │ .10 │ │ .21 │ │ laptop .30 │
│ DHCP │ │ 6a:1f:.. │ │ 08:00:27:.. │ │ b4:2e:99:.. │
└───────┘ └────┬─────┘ └──────▲───────┘ └──────────────┘
│ │
└── software bridge inside the host ──┘
(host NIC in promiscuous mode)
The VM has its OWN MAC and its OWN DHCP lease from the router.
VM ↔ HOST ✔ VM ↔ VM ✔
VM ↔ INTERNET ✔ LAN → VM ✔ ssh 192.168.1.21 just works
This method allows full network functionality but exposes the VM to the same security risks as any other network device. That sentence deserves emphasis: a bridged VM is on your office or home network with no firewall between it and everything else. If you bridge a deliberately vulnerable practice VM, you have put a vulnerable machine on a network you do not control. Bridge for realism; do not bridge for malware analysis.
Host-only networking creates a private network between the host and the VMs. The hypervisor creates a virtual interface on the host (vboxnet0, vmnet1, virbr1) and a virtual switch that only VMs and the host are attached to. The VMs cannot access external networks, nor can external devices access the VMs. It is ideal for testing and development environments where internet access is unnecessary.
Host-only — a private wire between host and VMs
┌───────────────┐
│ INTERNET │ ✘ no route out
└───────▲───────┘
│ ╳ blocked
┌─────────────────────────┴───────────────────────────────┐
│ HOST │
│ eth0 192.168.1.10 ──── to the real LAN │
│ │
│ vboxnet0 192.168.56.1 ──┐ a virtual interface │
└──────────────────────────────┼───────────────────────────┘
│ virtual switch
┌─────────────────────┼─────────────────────┐
│ │ │
┌───────┴────────┐ ┌────────┴───────┐ ┌─────────┴──────┐
│ VM 1 │ │ VM 2 │ │ VM 3 │
│ 192.168.56.101 │ │ 192.168.56.102 │ │ 192.168.56.103 │
└────────────────┘ └────────────────┘ └────────────────┘
VM ↔ HOST ✔ ssh 192.168.56.101 straight from the host
VM ↔ VM ✔ build a cluster
VM ↔ INTERNET ✘ no apt update
LAN → VM ✘ invisible to everything else
This configuration enhances security by isolating VMs, but limits connectivity.
Internal networking allows VMs to communicate with each other on a private network, but not with the host or external networks. It is the strictest mode: a virtual switch that the host itself has no interface on. Useful when you need VMs to interact without exposing them externally at all.
Internal — VMs only, host excluded
┌───────────────────────────────────────────────────────────┐
│ HOST — has NO interface on this switch. │
│ Cannot ping the VMs. Cannot be pinged by them. │
└───────────────────────────────────────────────────────────┘
┌──────── virtual switch "intnet-lab" ────────┐
│ │ │
┌────┴──────┐ ┌──────┴─────┐ ┌──────┴──────┐
│ VM app │ │ VM db │ │ VM target │
│ 10.10.0.2 │ │ 10.10.0.3 │ │ 10.10.0.4 │
└───────────┘ └────────────┘ └─────────────┘
VM ↔ VM ✔ everything else ✘
No DHCP server exists on this switch unless you run one yourself,
so configure static addresses (or put a DHCP server in one VM).
Use for: malware analysis · penetration-testing ranges ·
air-gapped multi-tier application labs ·
reproducing a network fault with zero risk
This is the source table, corrected in one place and expanded with the rows that matter in practice.
Feature
NAT
Bridged
Host-Only
Internal
VM → Host communication
✔ Yes (host is the gateway, 10.0.2.2)
✔ Yes
✔ Yes
✘ No
VM → VM communication
⚠ Yes through NAT only if VMs share a NAT Network; plain per-VM NAT isolates them
✔ Yes
✔ Yes
✔ Yes
VM → External network
✔ Yes (via NAT, using the host’s IP)
✔ Yes (its own IP)
✘ No
✘ No
External → VM communication
⚠ No, without port forwarding
✔ Yes
✘ No
✘ No
Isolation level
Moderate
Low
High
Very high
Use case
Safe internet access — the default
Full network integration
Secure testing
VM interaction only
VM gets its own LAN IP
✘ No — private 10.0.2.x, invisible outside
✔ Yes, from your LAN’s DHCP
✘ No — private 192.168.56.x
✘ No — whatever you set
Needs a DHCP server
✔ Built into the hypervisor
Your existing router provides it
✔ Built in (usually)
✘ You provide one, or use static IPs
Works reliably over Wi-Fi
✔ Yes
⚠ Often not — see the warning above
✔ Yes
✔ Yes
Survives moving to a different network
✔ Yes — addresses never change
✘ No — new subnet, new lease
✔ Yes
✔ Yes
Guest visible to LAN security scans
✘ No
✔ Yes
✘ No
✘ No
Typical host-side interface
vboxnet/NAT engine (no bridge)
br0, bridge0, vmnet0
vboxnet0, vmnet1, virbr1
none
Cloud analogue
A private-subnet instance behind a NAT gateway
An instance with a public IP in a public subnet
A private subnet with no NAT gateway
An isolated VPC with no gateways at all
Choose it when
You just want the VM online — 90% of labs
The VM must be a server others reach
Host↔VM only; clean, stable addressing
Malware, exploit practice, air-gapped labs
flowchart TD
Q1{"Does the VM need<br/>internet access?"}
Q1 -->|"No"| Q2{"Does the host need<br/>to reach the VM?"}
Q2 -->|"Yes"| HO["HOST-ONLY"]
Q2 -->|"No"| IN["INTERNAL"]
Q1 -->|"Yes"| Q3{"Must other machines<br/>on the LAN reach<br/>the VM directly?"}
Q3 -->|"No"| NAT["NAT<br/>+ port forward if you<br/>need SSH from the host"]
Q3 -->|"Yes"| Q4{"Wired Ethernet?"}
Q4 -->|"Yes"| BR["BRIDGED"]
Q4 -->|"No, Wi-Fi"| NAT2["NAT + port forwarding<br/>bridging is unreliable<br/>over 802.11"]
Choose and install a hypervisor compatible with your host operating system and hardware. For beginners, Type 2 hypervisors like VirtualBox or VMware Workstation are user-friendly and widely supported.
Your host
Recommended
Why
Windows or Linux laptop, x86-64
VirtualBox
Free, cross-platform, identical UI everywhere, excellent CLI. What this chapter assumes
Linux laptop or workstation
KVM + virt-manager
Native, faster than VirtualBox, and it is what production uses
Windows with Docker/WSL2 already
Hyper-V
You already have the hypervisor; adding a second one causes the conflict in section 5
Apple silicon Mac
UTM (free) or Parallels/VMware Fusion
No VT-x on ARM; VirtualBox is unsupported. ARM64 guests only
A spare machine you can dedicate
Proxmox VE or ESXi
Type 1, a real lab, live migration and clustering
Before you install anything, run the checks in section 5. Hardware virtualization must be enabled.
Use the hypervisor’s interface to create a new VM. You typically provide a name, select the guest operating system type, and choose the version.
That OS-type selection is not cosmetic. It sets defaults for the whole machine: chipset, firmware (BIOS or UEFI), default disk controller, default NIC model, whether the RTC runs in UTC or local time, pointing-device type, and how much RAM and disk to suggest. Choosing “Other/Unknown” when you meant “Ubuntu (64-bit)” produces a VM that boots to a black screen or fails to see its disk, and the error message will not tell you why.
Mount the installation media — an ISO file or physical disk — and boot the VM. Follow the standard installation process of the chosen operating system. Practical notes the PDF omits:
Download the ISO from the vendor and verify its checksum: sha256sum ubuntu-24.04.1-live-server-amd64.iso compared against the published SHA256SUMS. A corrupted ISO produces installer failures that look like hardware problems.
Choose Ubuntu Server, not Desktop, for Linux practice. No GUI means less RAM, faster boot, and you learn the shell rather than avoiding it.
Install and enable OpenSSH during setup. On the Ubuntu Server installer this is one checkbox, and skipping it means your first task is enabling SSH from a console you find awkward.
Keep the username and password simple and memorable. This machine is disposable.
After installing the OS, install the hypervisor’s guest additions or tools. These enhance performance and enable features like shared folders, clipboard sharing and better graphics support.
What they actually install, so you know what you lose without them:
Component
Effect
Paravirtualized device drivers
The performance jump — VirtIO/VMware SVGA/vboxvideo rather than emulated hardware
Dynamic screen resizing
The guest desktop follows the window size
Shared clipboard, drag and drop
Copy text and files between host and guest
Shared folders
Mount a host directory inside the guest
Seamless mouse integration
No more “capture/release the pointer”
Time synchronisation
Guests drift badly when paused or migrated; the tools fix the clock
Graceful shutdown handling
The host’s “power button” reaches the guest’s init system, so acpipowerbutton works
Guest property reporting
The host can query the guest’s IP address — required for VBoxManage guestproperty get
bash
# VirtualBox on an Ubuntu guest — the reliable route
sudoaptupdate
sudoaptinstall-ybuild-essentialdkmslinux-headers-$(uname-r)# then: Devices → Insert Guest Additions CD image…
sudomount/dev/cdrom/mnt
sudo/mnt/VBoxLinuxAdditions.run
sudoreboot
bash
# Or, from the distro packages — simpler, slightly older
sudoaptinstall-yvirtualbox-guest-utils# headless server
sudoaptinstall-yvirtualbox-guest-x11# graphical desktop# VMware
sudoaptinstall-yopen-vm-tools# server
sudoaptinstall-yopen-vm-tools-desktop# desktop# KVM — VirtIO drivers are already in the Linux kernel; add the agent
sudoaptinstall-yqemu-guest-agent
sudosystemctlenable--nowqemu-guest-agent
flowchart LR
A["1 · Install<br/>hypervisor"] --> B["2 · Create VM<br/>name, OS type,<br/>version"]
B --> C["3 · Allocate<br/>CPU · RAM ·<br/>disk · network"]
C --> D["4 · Mount ISO,<br/>install guest OS"]
D --> E["5 · Install guest<br/>additions / tools"]
E --> F["6 · SNAPSHOT<br/>immediately"]
F --> G["Now break it<br/>freely"]
Step 6 is not in the source notes and is the most valuable step in the list. Section 15 explains why.
Snapshots capture the VM’s state at a specific point in time. They are invaluable for testing or before making significant changes. Before installing new software, take a snapshot; if something goes wrong, revert to the previous state.
How they work is the part that predicts every problem they cause:
What a snapshot actually does
BEFORE the snapshot
┌────────────────────────────┐
│ MyVM.vdi (read-write) │ ← guest writes go here
└────────────────────────────┘
AFTER taking snapshot "clean-install"
┌────────────────────────────┐
│ MyVM.vdi READ-ONLY │ ← frozen. This IS the snapshot.
│ (the base image) │
└─────────────┬──────────────┘
│ backing / parent
┌─────────────┴──────────────┐
│ {uuid}.vdi (read-write) │ ← every NEW write goes here.
│ DELTA / differencing disk │ Reads check here first, then
│ starts at 0 bytes │ fall back to the parent.
└────────────────────────────┘
AFTER two more snapshots — a CHAIN
base (RO) ──► delta1 (RO) ──► delta2 (RO) ──► delta3 (RW, live)
Reading one block may now traverse FOUR files.
Reverting = throw away the deltas after the chosen point.
Consequences of that design, all of which you will meet:
Taking a snapshot is instant. Nothing is copied; a file is frozen and a new empty one created.
Deleting a snapshot is slow. The delta must be merged back into its parent — gigabytes of I/O.
Reverting discards everything after it, silently and permanently.
Performance degrades with chain depth. Each read may walk the chain. Four or five snapshots deep, a VM feels noticeably sluggish; twenty deep, it is unusable.
Deltas grow without limit. A snapshot on a busy database VM can grow larger than the original disk within days, because every changed block is stored again.
Cloning creates an exact copy of a VM. It is useful when deploying multiple VMs with the same configuration. Two kinds, and picking wrongly costs either disk space or reliability:
Full clone
Linked clone (VMware) / linked clone (VirtualBox)
Disk usage
Full copy — 25 GB VM costs 25 GB
A delta on top of the original — starts near zero
Creation time
Minutes
Seconds
Independent of the source?
✔ Yes — delete the original freely
✘ No. Deleting or altering the source destroys the clone
Performance
Native
Chain-walk penalty, like a snapshot
Use for
Anything you will keep; production templates
Twenty short-lived test VMs from one base
Whichever you choose, regenerate the machine’s identity afterwards — the step that catches everyone:
bash
# VirtualBox: --mode machine copies the current state only,# and this flag is essential
VBoxManageclonevm"MyVM"--name"MyVM-clone"--register\--modemachine--optionskeepdisknames=off
# The MAC address must be new, or two clones fight on the network.# VirtualBox reassigns MACs on clone by default; verify:
VBoxManageshowvminfo"MyVM-clone"|grep-i"NIC 1"
bash
# Inside a cloned Linux guest, reset the things that must be unique
sudohostnamectlset-hostnameweb-02
sudotruncate-s0/etc/machine-id&&sudosystemd-machine-id-setup
sudorm-f/etc/ssh/ssh_host_*# then:
sudodpkg-reconfigureopenssh-server# regenerate host keys
sudorm-f/etc/netplan/*.yaml.orig
VMs can be moved between hosts, sometimes even while running — live migration. This facilitates load balancing and hardware maintenance without downtime.
Type
VM state during the move
Downtime
Requirements
Cold migration
Powered off
The whole move
Almost none
Warm / suspend migration
Suspended, state file moved, resumed
Seconds to minutes
Compatible CPUs
Live migration
Running throughout
Milliseconds — often unnoticeable
Shared storage (or storage migration too), a fast dedicated network, compatible CPU features
Storage migration
Running; the disk moves
None
Hypervisor support (vMotion Storage, virsh migrate --copy-storage-all)
Live migration is worth understanding because it sounds impossible:
The destination host creates an empty VM shell with the same configuration.
The hypervisor copies the source VM’s memory pages to the destination while the VM keeps running.
Pages the guest modifies during the copy are marked dirty and re-sent. This repeats, converging as the dirty rate falls below the transfer rate.
When the remaining dirty set is small enough to move in a few milliseconds, the source VM is paused, the final pages, CPU registers and device state are transferred, and the destination is resumed.
An ARP/RARP announcement tells the network switch that this MAC address now lives on a different port. Existing TCP connections survive; a ping may lose one packet.
bash
# KVM/libvirt live migration, one command
virshmigrate--live--persistent--undefinesource\MyVMqemu+ssh://host02.example.com/system
Increase memory — allocate more RAM if the guest OS needs it.
Add storage — expand the virtual disk or add new ones.
Change networking — switch between NAT, bridged or host-only.
Almost all of these require the VM to be powered off in a Type 2 hypervisor. Enterprise hypervisors support hot-add of CPU and memory (and hot-plug of disks and NICs) if the guest OS supports it — Linux does, and Windows Server does for memory. Note that hot-removal is much harder than hot-add and is often unsupported.
Growing a disk is a two-stage operation and forgetting the second stage is a classic:
bash
# 1. Grow the virtual disk (hypervisor side)
VBoxManagemodifymediumdisk"/home/user/VirtualBox VMs/MyVM/MyVM.vdi"--resize40000# or: qemu-img resize /var/lib/libvirt/images/MyVM.qcow2 +20G# 2. THEN, inside the guest, grow the partition and the filesystem
sudogrowpart/dev/sda3# extend partition 3 to fill the disk
sudoresize2fs/dev/sda3# ext4 — or: xfs_growfs /# with LVM (the Ubuntu Server default):
sudopvresize/dev/sda3
sudolvextend-l+100%FREE/dev/ubuntu-vg/ubuntu-lv
sudoresize2fs/dev/ubuntu-vg/ubuntu-lv
Regularly monitor VM performance using tools provided by the hypervisor. Keep an eye on CPU usage, memory consumption and disk I/O to detect and resolve bottlenecks.
The crucial idea, and the one the source notes miss: the guest’s own metrics lie about the host. A guest at “100% CPU” may be waiting on a contended physical core; a guest reporting plenty of free RAM may be having its pages swapped out by the host. You must look from both sides.
From inside the guest
From the host / hypervisor
top, htop — including the %st steal column
virt-top, virsh domstats MyVM
vmstat 1, iostat -x 1
esxtop (ESXi), virsh cpu-stats
free -h, /proc/pressure/* (PSI)
Host free -h, vmstat — is the host swapping?
systemd-detect-virt — confirm where you are
VBoxManage metrics query
dmesg for balloon-driver and virtio messages
Datastore free space — see the snapshot warning
Resource overcommitment: the thing that actually bites#
You may allocate more resources to VMs than the host physically has. That is not a bug — it is the point, because most VMs are idle most of the time. It is also how you turn a working host into a stalled one.
Memory overcommit and ballooning. The hypervisor promises 4 GB each to ten VMs on a 32 GB host. As long as the guests do not all use their full allocation, everyone is fine. When they do, the hypervisor must reclaim, and it has four increasingly unpleasant tools:
Ballooning — a virtio-balloon (or VMware Tools balloon) driver inside the guest is asked to inflate: it allocates guest memory and pins it, which makes the guest’s own kernel evict its caches and page out its least-used data through its own, well-informed reclaim logic. The hypervisor then takes those physical pages back. This is the good option, because the guest chooses what to give up.
Page sharing / KSM — Kernel Samepage Merging scans for identical pages across VMs and collapses them to one copy-on-write page. Excellent when you run twenty identical Ubuntu VMs; costs CPU to scan, and has known side-channel implications, so it is off by default in some distributions.
Compression — compress cold guest pages rather than writing them out.
Host swapping — the hypervisor pages guest “physical” memory to host disk. This is the disaster case. The guest has no idea; from inside, memory access latency jumps from ~80 ns to milliseconds at random, and the guest’s own tuning is worthless because it cannot see what is happening. A guest whose memory is being host-swapped looks inexplicably slow.
bash
# Balloon a running KVM guest down from 4 GB to 2 GB
virshsetmemMyVM2G--live
virshdommemstatMyVM
Current balloon target in KiB — what the guest is allowed
available
Total memory the guest kernel sees
unused / usable
Free, and reclaimable-without-pain, from the guest’s own view
rss
How much host physical memory this VM is actually consuming — the number the host cares about
swap_in/swap_out
Guest-side swapping. Non-zero and rising means the balloon is too tight
major_fault
Faults requiring disk I/O — the guest is thrashing
CPU overcommit and steal time. vCPUs are time-shared, so 60 vCPUs on 16 threads is legal. What the guest experiences when it loses that race is steal time: the %st column in top.
terminal
$ top
top - 14:22:09 up 12 days, 4:31, 1 user, load average: 3.91, 3.72, 3.55Tasks: 148 total, 4 running, 144 sleeping, 0 stopped, 0 zombie%Cpu(s):41.2us,6.1sy,0.0ni,24.7id,0.3wa,0.0hi,0.4si,27.3st
MiB Mem : 3908.4 total, 204.1 free, 2611.7 used, 1092.6 buff/cache
27.3 st means 27% of the time this vCPU was ready to run and the hypervisor gave the physical CPU to somebody else. Read that carefully, because of what it implies:
It is not your workload’s fault, and no amount of application tuning will fix it.
It is measured from inside the guest, and it is your only window onto host contention.
The other 27% of your CPU is being spent by someone else — a noisy neighbour on a shared cloud host, or your own overcommitted hypervisor.
%st
Interpretation
Action
0%
No contention, or you are on a dedicated/bare-metal instance
None
1–5%
Normal on shared cloud instances
None
5–15%
Meaningful contention
Watch it; consider a larger or dedicated instance type
>15% sustained
Serious. Your application is being starved
Move the VM, stop the overcommit, or change instance type/family
Rising sawtooth on a t3/burstable instance
CPU credits exhausted — you are being throttled
Switch to unlimited mode, or a non-burstable family
Nested virtualization is running a hypervisor inside a VM — an L1 guest that is itself a hypervisor for L2 guests. It requires the physical CPU’s virtualization extensions to be exposed to the L1 guest, which the hypervisor must explicitly permit.
Nested virtualization
┌──────────────────────────────────────────────────────────┐
│ L0 — PHYSICAL HOST KVM / ESXi / Nitro│
│ ┌────────────────────────────────────────────────────┐ │
│ │ L1 — GUEST that is ALSO a hypervisor │ │
│ │ sees vmx/svm in /proc/cpuinfo │ │
│ │ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ L2 guest │ │ L2 guest │ │ │
│ │ │ (a container │ │ (a Windows │ │ │
│ │ │ runtime VM, │ │ test VM) │ │ │
│ │ │ a K8s node) │ │ │ │ │
│ │ └──────────────┘ └──────────────┘ │ │
│ └────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
Every L2 VM exit must be handled by L1, which itself exits
to L0. Overhead compounds — expect a real performance cost.
When you actually need it:
Running Docker Desktop, WSL2 or Hyper-V inside a Windows VM — because those are hypervisors.
CI runners in the cloud that build or test VM images, run minikube/kind with a VM driver, or execute integration tests against a full VM.
Kubernetes on VMs where the container runtime uses microVMs — Kata Containers, Firecracker, gVisor with a VM platform.
Training and demonstration environments — teaching virtualization on cloud instances.
Android emulators inside a cloud VM, which need KVM to be usable.
How to enable it:
bash
# --- Check whether you have it (inside the L1 guest) ---
grep-Eoc'(vmx|svm)'/proc/cpuinfo# non-zero → nesting is exposed
systemd-detect-virt# confirms you are in a VM at all# --- KVM host (L0): expose VMX/SVM to guests ---
cat/sys/module/kvm_intel/parameters/nested# want: Y or 1echo"options kvm_intel nested=1"|sudotee/etc/modprobe.d/kvm-nested.conf
# AMD: options kvm_amd nested=1
sudomodprobe-rkvm_intel&&sudomodprobekvm_intel
# Then give the guest a host-passthrough CPU model:
virsheditMyVM# <cpu mode='host-passthrough'/># or with virt-install: --cpu host-passthrough
bash
# --- VirtualBox (6.1+) ---
VBoxManagemodifyvm"MyVM"--nested-hw-virton
# --- VMware Workstation/Fusion ---# VM settings → Processors → "Virtualize Intel VT-x/EPT or AMD-V/RVI"# or add to the .vmx: vhv.enable = "TRUE"
Platform
Nested virtualization support
KVM on Intel/AMD
✔ nested=1, default-on in recent kernels
VirtualBox 6.1+
✔ --nested-hw-virt on (AMD from 6.0, Intel from 6.1)
Virtual machines offer numerous advantages that make them indispensable in modern computing. The five the syllabus names, each with the mechanism that delivers it and the caveat nobody mentions.
Each VM operates independently, ensuring that crashes or security breaches in one VM do not affect others or the host system. The mechanism is real and hardware-enforced: separate kernels, separate address spaces enforced by EPT/NPT, and separate virtual device sets. A kernel panic in one VM is a panic in that VM; the host and its siblings never notice.
Caveat: isolation of fault, not of resource. A VM that saturates the shared disk or the shared NIC degrades every VM on the host — the “noisy neighbour” problem. And isolation is not absolute; see section 17.
By running multiple VMs on a single physical machine, you maximise hardware usage, reducing costs and energy consumption. A host at 70% utilisation instead of 8% is roughly nine machines’ worth of work done by one. Overcommit (section 9) pushes this further by exploiting the fact that VMs are idle at different times.
Caveat: virtualization has real overhead — roughly 2–5% CPU, more on I/O-heavy workloads — and each guest duplicates a full OS in memory. You trade a little efficiency per workload for an enormous gain in aggregate.
VMs can be easily cloned, moved or backed up. This flexibility simplifies testing, development and disaster recovery processes. This is the decoupling win: because a VM is a file plus a description, everything you can do to a file you can do to a server.
Caveat: flexibility invites sprawl. “VM sprawl” — hundreds of forgotten VMs consuming licences, patching effort and storage — is a genuine operational disease. Tag everything with an owner and an expiry date.
Adding more VMs to handle increased workloads is straightforward. Cloud providers leverage this to offer scalable services. Autoscaling groups, managed instance groups and Kubernetes node pools are all “create more VMs from a template, automatically”.
Caveat: scalability is bounded by the host. Vertical scaling stops at one machine’s capacity, and horizontal scaling requires the application to tolerate it.
Run outdated or unsupported software within a VM without affecting the host system. This is one of virtualization’s most valuable and least glamorous uses: the 2004 manufacturing control application whose vendor no longer exists, running on Windows Server 2003 in a VM, on hardware that did not exist when it was written, because the virtual hardware it sees has not changed.
Caveat: an unpatched legacy OS is an unpatched legacy OS. Put it on an isolated network segment (host-only or internal, or a locked-down VLAN) and treat it as compromised by default.
VirtualBox is a free and open-source hypervisor developed by Oracle. It is popular for its ease of use and cross-platform support — the same product, the same interface and the same command line on Windows, macOS, Linux and Solaris hosts. It is the right first hypervisor, and it is what section 15 builds your lab with.
VBoxManage — a complete command-line interface. Every GUI action has a CLI equivalent, which means every VM operation is scriptable.
Headless mode — run VMs with no window at all, controlled entirely over SSH or RDP.
Guest Additions — the driver and integration package described in section 8.
Seamless mode and shared clipboard — genuine desktop integration.
The Extension Pack — USB 2.0/3.0 passthrough, VirtualBox RDP, disk encryption, PXE boot. Note the licensing: the base product is GPLv2, but the Extension Pack is under the PUEL licence and is free only for personal, educational and evaluation use. Commercial use requires a licence. This has caught real companies out in audits.
VirtualBox offers flexible networking options to suit different needs, and by default VMs use NAT networking, allowing internet access through the host.
To reach a service inside a NAT’d VM from the host, add a port-forwarding rule:
Open VM settings.
Go to the Network section.
Click on Advanced.
Set up port forwarding rules.
In the Port Forwarding dialog, each rule has five fields, and understanding them removes all the guesswork:
Field
What to enter
Example
Name
Any label
ssh
Protocol
TCP or UDP
TCP
Host IP
Leave blank to listen on all host interfaces; 127.0.0.1 to accept only local connections
127.0.0.1
Host Port
The port on your host you will connect to. Must be unused, and >1024 to avoid needing root
2222
Guest IP
Leave blank — VirtualBox resolves it
(blank)
Guest Port
The port the service listens on inside the VM
22
Everything in that dialog is one CLI command, which is why professionals never open it:
bash
VBoxManagemodifyvm"MyVM"--natpf1"ssh,tcp,127.0.0.1,2222,,22"# ^ ^ ^ ^ ^ ^# name proto hostIP hostPort│guestPort# guestIP (blank)
bash
# List the rules currently configured
VBoxManageshowvminfo"MyVM"--machinereadable|grep-inatpf
# Remove a rule
VBoxManagemodifyvm"MyVM"--natpf1delete"ssh"
VBoxManage is the command-line utility for controlling VirtualBox. Everything the GUI does, it does — and it is how you script, automate and work over SSH. What follows builds a complete VM from nothing, in the order you would actually run it.
$ VBoxManagecreatevm--name"MyVM"--register
Virtual machine 'MyVM' is created and registered.UUID: 12345678-1234-1234-1234-123456789abcSettings file: '/home/user/VirtualBox VMs/MyVM/MyVM.vbox'
A VM named “MyVM” is created and ready for configuration. Three things in that output, each worth understanding:
Output line
Meaning
is created and registered
Two separate things happened. The VM was created (a directory and .vbox file), and registered — added to VirtualBox’s inventory so the GUI and other commands can see it. Without --register the VM exists on disk but VirtualBox does not know about it, and every later command fails with “Could not find a registered machine named ‘MyVM’”
UUID: 12345678-…
The machine’s permanent unique identifier. You may use it in place of the name in any command — useful in scripts, because names can be changed and can collide
Settings file: '…/MyVM/MyVM.vbox'
The VM’s definition: an XML file listing every device, setting and attached disk. This file plus the .vdi disk beside it is the virtual machine. Copy both to another host and the VM moves. It also means you can diff a VM’s configuration in Git
bash
# Better practice: declare the OS type at creation so sane defaults apply
VBoxManagelistostypes|grep-iubuntu
VBoxManagecreatevm--name"MyVM"--ostype"Ubuntu_64"--register
No output is returned for this command, indicating success. That silence is the Unix convention — Chapter 1’s “no news is good news”. A failure would print a VBoxManage: error: line and set a non-zero exit code, which is what you check in scripts (if ! VBoxManage modifyvm …; then).
--memory is in megabytes, so 2048 is 2 GB. Writing --memory 2 gives the VM 2 MB and it will not boot.
A 20 GB virtual disk is created for “MyVM”. Two details:
--size is in megabytes, so 20000 is roughly 20 GB (19.5 GiB, precisely). A common error is --size 20, producing a 20 MB disk.
The 0%...100% progress indicator appears even for a dynamically allocated disk, because VirtualBox still writes the image header and metadata. For a fixed-size disk (--variant Fixed) that progress bar is the full 20 GB being zeroed, and it takes minutes.
bash
# Explicitly dynamic (the default) — grows on demand
VBoxManagecreatehd--filename".../MyVM.vdi"--size20000--variantStandard
# In VirtualBox 6+ the modern spelling is `createmedium`;# `createhd` remains as a supported alias
VBoxManagecreatemediumdisk--filename".../MyVM.vdi"--size20000
Attach the disk to the VM. This is two steps, because a disk needs a controller to plug into:
Adds a virtual SATA controller to the VM’s virtual PCI bus, named "SATA Controller". The name is arbitrary but you must reuse it exactly in storageattach
--controller IntelAHCI
Which chip to emulate. IntelAHCI is standard SATA and every modern OS has the driver
storageattach --port 0 --device 0
Which connector on that controller. Think of port as the SATA cable number. SATA uses --device 0 always; IDE uses device 0/1 for master/slave
Note the two differences from the disk attachment: --type dvddrive instead of hdd, and --port 1 so it does not collide with the hard disk on port 0.
bash
# Eject the ISO after installation — otherwise the VM may boot the# installer again instead of the system you just installed
VBoxManagestorageattach"MyVM"--storagectl"SATA Controller"\--port1--device0--typedvddrive--mediumnone
$ VBoxManagestartvm"MyVM"--typeheadless
Waiting for VM "MyVM" to power on...VM "MyVM" has been successfully started.
“MyVM” is now running in headless mode (without a GUI). Reading it:
Waiting for VM "MyVM" to power on... — VBoxManage has asked the VirtualBox service to start the VM and is blocking until the VM process reports that it reached the running state. If this line appears and then hangs or errors, the failure is in the VM’s configuration or in VT-x availability, not in your command.
has been successfully started — the VM process is alive and the virtual power button has been pressed. It does not mean the guest OS has booted; that takes another 10–30 seconds.
--type values:
Value
Behaviour
gui
Opens a window with the VM’s console. The default
headless
No window at all. The VM runs as a background process. This is how servers run VMs, and how you run a lab you access over SSH
sdl
A minimal window with no VirtualBox chrome
separate
GUI in a separate process from the VM
bash
# Headless does not mean inaccessible. Options for getting in:
ssh-p2222user@127.0.0.1# via the NAT port forward
VBoxManagecontrolvm"MyVM"screenshotpng/tmp/vm.png# look at the console
VBoxManagestartvm"MyVM"--typeseparate# attach a window later
No output is returned; the VM will begin a graceful shutdown. What actually happens: VirtualBox signals an ACPI power-button event to the guest’s virtual chipset. The guest’s ACPI daemon (systemd-logind on modern Linux, or the Windows power service) receives it and runs a normal shutdown — unmounting filesystems, stopping services, flushing buffers. It is the software equivalent of briefly pressing the power button on a physical machine.
This only works if the guest is listening for ACPI events. A guest with no ACPI support, sitting at a BIOS prompt, or hung in a kernel panic will ignore it entirely and stay running.
Every controlvm power state, in order of increasing violence:
Command
Effect
Data safe?
pause
Freeze the vCPUs. RAM stays in host memory
✔
resume
Unfreeze
✔
savestate
Write RAM and CPU state to disk, stop the VM. Restart resumes exactly where it was
✔
acpipowerbutton
Graceful shutdown request — the correct way to stop a VM
✔
acpisleepbutton
Suspend request to the guest
✔
reset
Hard reset — the reset button
⚠ Unflushed writes lost
poweroff
Pull the plug. Instant, no guest involvement
⚠⚠ Corruption risk
bash
# Wait for a graceful shutdown to complete, with a fallback.# The pattern to use in scripts.
VBoxManagecontrolvm"MyVM"acpipowerbutton
foriin$(seq130);doVBoxManageshowvminfo"MyVM"--machinereadable|grep-q'VMState="poweroff"'&&breaksleep2done
VBoxManageshowvminfo"MyVM"--machinereadable|grep-q'VMState="poweroff"'\||VBoxManagecontrolvm"MyVM"poweroff
# --- Inventory ---
VBoxManagelistvms# all registered VMs, with UUIDs
VBoxManagelistrunningvms# only the running ones
VBoxManagelistostypes# valid --ostype identifiers
VBoxManagelisthostonlyifs# host-only networks available
VBoxManagelistbridgedifs# host interfaces you can bridge to
VBoxManageshowvminfo"MyVM"# everything about one VM
VBoxManageshowvminfo"MyVM"--machinereadable# the same, greppable# --- Snapshots ---
VBoxManagesnapshot"MyVM"take"clean-install"--description"OS + additions, nothing else"
VBoxManagesnapshot"MyVM"list--details
VBoxManagesnapshot"MyVM"restore"clean-install"# VM must be powered off
VBoxManagesnapshot"MyVM"restorecurrent
VBoxManagesnapshot"MyVM"delete"clean-install"# merges the delta back# --- Cloning ---
VBoxManageclonevm"MyVM"--name"MyVM-clone"--register--modemachine
# --- Import / export (OVF/OVA — the portable, cross-hypervisor format) ---
VBoxManageexport"MyVM"-oMyVM.ova
VBoxManageimportMyVM.ova
# --- Disks ---
VBoxManagelisthdds
VBoxManagemodifymediumdisk"/path/MyVM.vdi"--resize40000# in MB
VBoxManageclosemediumdisk"/path/old.vdi"--delete
# --- Guest control (needs Guest Additions) ---
VBoxManageguestcontrol"MyVM"run--exe/bin/systemctl\--usernameuser--passwordsecret--systemctlis-system-running
VMware provides robust virtualization solutions suitable for both desktop and enterprise environments. It is worth being precise about the product family, because they are very different things sharing a brand:
Product
Type
What it is
VMware Workstation Pro (Windows/Linux)
Type 2
The desktop hypervisor. What vmrun drives
VMware Fusion (macOS)
Type 2
The same product for Mac, including Apple silicon
VMware ESXi
Type 1
The bare-metal hypervisor. vmkernel, managed by esxcli
In practice, what VMware is genuinely best at: mature and reliable snapshot management, the best 3D/graphics acceleration among desktop hypervisors, Unity/seamless mode, and — at the enterprise tier — vMotion, the live migration implementation that made the technique mainstream.
$ vmrunstart"/path/to/MyVM.vmx"Started VM successfully
“MyVM” has started successfully.
Note what vmrun is addressed by: the path to the .vmx file, not a VM name. The .vmxis the VM’s identity as far as VMware is concerned — a plain text file listing every setting. This differs fundamentally from VirtualBox (which keeps a registry of named VMs) and libvirt (which keeps named domains), and it is why vmrun commands are always long.
bash
# You will often need -T to say which VMware product to talk to
vmrun-Twsstart"/path/to/MyVM.vmx"# Workstation on Linux/Windows
vmrun-Tfusionstart"/path/to/MyVM.vmx"# Fusion on macOS
vmrun-Tplayerstart"/path/to/MyVM.vmx"# Player# Start with no window — the equivalent of VirtualBox's --type headless
vmrun-Twsstart"/path/to/MyVM.vmx"nogui
# What is running right now?
vmrunlist
terminal
$ vmrunlist
Total running VMs: 2/home/user/vmware/ubuntu-24.04/ubuntu-24.04.vmx/home/user/vmware/win11-test/win11-test.vmx
$ vmrunstop"/path/to/MyVM.vmx"soft
Stopped VM successfully
“MyVM” is shutting down gracefully.
The soft argument is doing the important work here, and the source notes do not explain it:
Argument
Effect
Requirement
soft
Asks the guest OS to shut down cleanly, via VMware Tools
VMware Tools / open-vm-tools must be installed and running in the guest
hard
Immediate power off — pulls the virtual plug
None
(omitted)
Defaults to hard on most versions
None
bash
# The rest of the vmrun vocabulary
vmrunsuspend"/path/to/MyVM.vmx"soft# save state to disk
vmrunreset"/path/to/MyVM.vmx"soft# restart
vmrunpause"/path/to/MyVM.vmx"
vmrununpause"/path/to/MyVM.vmx"
vmrunsnapshot"/path/to/MyVM.vmx""clean-install"
vmrunlistSnapshots"/path/to/MyVM.vmx"
vmrunrevertToSnapshot"/path/to/MyVM.vmx""clean-install"
vmrundeleteSnapshot"/path/to/MyVM.vmx""clean-install"
vmrunclone"/path/to/MyVM.vmx""/path/to/Clone.vmx"full-cloneName="Clone"
vmrunclone"/path/to/MyVM.vmx""/path/to/Linked.vmx"linked-snapshot="clean-install"
vmrungetGuestIPAddress"/path/to/MyVM.vmx"-wait# ← the good one
vmrunrunProgramInGuest"/path/to/MyVM.vmx"/bin/uname-a
vmruncopyFileFromHostToGuest"/path/to/MyVM.vmx"./setup.sh/tmp/setup.sh
The VM’s IP address is 192.168.1.20. Read the whole output field by field, because you will see it constantly:
Field
Value
Meaning
eth0
—
Interface name. On a modern distro this would more likely be ens33 (VMware) or enp0s3 (VirtualBox)
flags=4163<UP,BROADCAST,RUNNING,MULTICAST>
—
UP = administratively enabled; RUNNING = the link is actually up. An interface that is UP but not RUNNING has no carrier — in a VM that usually means the virtual cable is “unplugged” in the hypervisor settings
mtu 1500
—
Maximum transmission unit. Standard Ethernet
inet 192.168.1.20
✔
The IPv4 address.192.168.1.20 is a normal LAN address, so this VM is on bridged networking — a NAT’d VMware guest would show 192.168.x.x from vmnet8’s own subnet, and a NAT’d VirtualBox guest would show 10.0.2.15
netmask 255.255.255.0
—
/24 — 254 usable addresses on this subnet
broadcast 192.168.1.255
—
The subnet’s broadcast address
inet6 fe80::…
—
Link-local IPv6, auto-generated. Not routable off the link
ether 00:0c:29:4b:8a:1c
—
The MAC address. 00:0c:29 is a VMware OUI — a dead giveaway that this is a VMware VM. 08:00:27 is VirtualBox; 52:54:00 is QEMU/KVM
RX/TX packets
—
Counters since boot. Zero RX on a “working” interface means nothing is reaching it
bash
# Better ways to find a VMware guest's address
vmrungetGuestIPAddress"/path/to/MyVM.vmx"-wait# from the HOST, via VMware Tools
ip-briefaddrshow# from inside the guest, modern
hostname-I# from inside, quickest
Virtualization is the foundational technology of modern infrastructure. It solves one core problem: isolation without waste. Before virtualization, you bought physical hardware isolation at massive cost. After virtualization, you buy logical isolation at a tenth the price, while still running the same hardware at high utilization.
The three types of virtualization each solve a different scope problem:
Hardware-level virtualization — complete emulation of a machine, allowing unmodified guest OSes to run in isolation. The foundation of cloud computing. Examples: KVM, ESXi, Hyper-V, Xen HVM.
OS-level virtualization — sharing one kernel with isolated user-space views via namespaces and cgroups. Lightweight, dense, fast. Examples: Docker, Podman, LXC.
Application virtualization — bundling a single app with its dependencies. Solves dependency conflicts. Examples: venv, Snap, Flatpak, the JVM.
The key distinction that predicts everything else: VMs have their own kernel; containers share the host’s. This is why containers boot in milliseconds and VMs in seconds, why a container escape compromises the host while a VM escape does not, and why you cannot run Windows containers on a Linux kernel.
At the technical level, virtualization on x86 required hardware assistance after 2005 (Intel VT-x, AMD-V). Without it, the CPU was fundamentally broken for virtualization — 17 sensitive but unprivileged instructions would fail silently instead of trapping to the hypervisor. VT-x and AMD-V introduced a whole new privilege level — “ring −1” — where the hypervisor sits, while guests run in a de-privileged ring 0. This solved the CPU problem. Memory was solved separately via EPT (Extended Page Tables) and NPT (Nested Page Tables), allowing the MMU to walk two page-table hierarchies in hardware instead of the hypervisor doing it in software.
The production pattern is hybrid: hardware-assisted full virtualization for CPU and memory, with paravirtualized I/O drivers (VirtIO) for performance. That split exists because emulating a network card or disk controller is expensive — every register write traps to the hypervisor. A paravirtualized device is a simpler contract: a shared-memory ring buffer, a single notification, and the hypervisor handles the I/O. That one change moves network throughput from ~1 Gbit/s to 10–100 Gbit/s.
Every cloud instance you will ever launch is a virtual machine. Every Docker container on macOS or Windows runs inside a Linux VM. Kubernetes nodes are almost always VMs. CI/CD runners are VMs created on demand and destroyed when the job finishes. Understanding virtualization is understanding the foundation that everything else in this handbook sits on.
The practitioner’s insight: virtualization is nearly free in the CPU and memory sense, but I/O is where you feel it. CPU-bound workloads run at 97–99% of native speed inside a VM. Naive disk I/O can be an order of magnitude slower because every operation exits the VM to the hypervisor. That is why paravirtualized drivers matter so much, why virtio-blk and virtio-net are standard on every cloud image, and why a production hypervisor estate spends more time optimizing I/O paths than anything else.
The rule: use acpipowerbutton for normal shutdown; use poweroff only if the guest is hung. Use pause when you need to freeze a VM instantly; use savestate when you want to release host memory.
Does the VM need internet access?
├─ NO
│ └─ Does the HOST need to reach the VM?
│ ├─ YES → HOST-ONLY (clean addressing, 192.168.56.x)
│ └─ NO → INTERNAL (maximum isolation, VMs only)
│
└─ YES
└─ Must other machines on the LAN reach it directly?
├─ NO (it reaches them, not vice versa)
│ └─ NAT (default, simple, + port fwd if needed)
│
└─ YES (it must be a "real" server)
└─ Wired Ethernet?
├─ YES → BRIDGED (gets own LAN IP)
└─ NO (Wi-Fi) → NAT + port forward (bridging unreliable)
Always use dynamically allocated disks on a laptop. A “20 GB” dynamic disk costs ~3 GB on your SSD until you fill it. Fixed disks pre-allocate the full size and are slow to create.
The rule: snapshots are undo buttons for the next few hours. Delete them after. Snapshot chains deeper than 5 layers degrade performance visibly. Snapshots older than 72 hours in production trigger alert pages for a reason.
# Requires: shared storage, compatible CPU features, fast network# Single command:
virshmigrate--live--persistent--undefinesource\MyVMqemu+ssh://dest-host.example.com/system
# Gotchas:# 1. CPU features must be compatible (EVC / <cpu mode='custom'>)# 2. If guest dirties memory faster than the link can carry,# it won't converge — use:
virshmigrate-setmaxdowntimeMyVM30000# 3. Watch for "post-copy" mode if available — trades# a small downtime for guaranteed convergence
# === Inventory ===
VBoxManagelistvms# all registered VMs
virshlist--all# all libvirt domains
vmrunlist# running VMware VMs# === Snapshots ===
VBoxManagesnapshot"MyVM"take"name"# create snapshot
VBoxManagesnapshot"MyVM"restore"name"# revert (power off first)
virshsnapshot-create-asMyVMname--disk-only# KVM disk snapshot# === Networking ===
VBoxManageshowvminfo"MyVM"--machinereadable|grep-inatpf# show port forwards
virshdomiflistMyVM# guest interfaces in libvirt
VBoxManageguestpropertyenumerate"MyVM"# all guest properties (requires additions)# === Disk management ===
VBoxManagemodifymediumdisk"MyVM.vdi"--resize40000# resize in MB
VBoxManagelisthdds# all registered disks
qemu-imginfo/var/lib/libvirt/images/MyVM.qcow2# QEMU/KVM disk info# === Graceful shutdown (always prefer over poweroff) ===
VBoxManagecontrolvm"MyVM"acpipowerbutton
vmrunstop"/path/MyVM.vmx"soft
virshshutdownMyVM
# === Performance check ===
top# look for `%st` (steal time) ≤5%
virshdommemstatMyVM# memory balloon state
VBoxManagemetricsquery"MyVM"CPU/Load/Average# CPU metrics
Remember: the first place to troubleshoot a slow VM is the host, not the guest. Check top for steal time, free -h for host swapping, and datastore space for snapshot decay. The VM’s top output lies about what the hypervisor is doing to it.
II — Working in the Shell
03Shells & Terminals
The terminal multiplexes your keyboard; the shell interprets your commands — understanding both separately is the key to scripting, debugging and building systems that do not break.
You can type ls and see files. Nothing mysterious about that. But between your keystroke and the output, at least three layers of software have made decisions: the terminal caught your key and sent it somewhere, a shell interpreted the command you typed, and the shell started a program and displayed its output. If any one of those layers is misconfigured, the experience breaks in ways that seem like magic.
Understanding the shell is not about memorising commands. It is about understanding what happens between the cursor and the filesystem — how environment variables propagate, why aliases work in the interactive shell but not in scripts, why your .bashrc runs 100 times a day but you only edited it once, and how to read a terminal that has 40 panes because someone is using tmux.
This is also the chapter where most people lock themselves out of their machines. Reach the end, and you will know the recovery steps.
Without a shell, you would invoke a program like this:
c
fork();// Create a new processexecve("/bin/ls",args,env);// Replace it with /bin/ls// wait for it to finish// read its output from... where?// read its exit code
A shell is the abstraction layer that lets you do this:
bash
ls|grephosts
The shell parses the line, creates two processes, attaches a pipe between them, waits for both, and gives you the result — all from one line of text. It also handles the messy details: storing your password in memory so you don’t type it 50 times, expanding *.txt to a list of files without the program knowing about wildcards, providing history so you can re-run the command you typed yesterday, and letting you define functions that work like programs but are written in the shell’s own language.
Analogy 1: the terminal and shell are not the same#
Purpose
Analogy
Terminal
Multiplexes your keyboard and display
A telephone switchboard — routes your keystrokes to the shell and routes the shell’s output back to your screen
Shell
Reads commands and runs programs
A personal assistant — takes your English-like requests and translates them into exact actions the OS understands
You can use a shell without a terminal (over SSH, in a container, in a script). You can use a terminal without a shell (opening a telnet connection, writing bytes to /dev/ttyUSB0). But together, in the interactive login session on your laptop, they create the illusion that you are directly commanding the machine.
Imagine you get up every morning and do the same tasks: put on glasses, drink coffee, check email. You don’t want to think about this every time, so you write them down as a checklist.
A shell’s startup files are that checklist. When you open a terminal:
If you are logging in (like ssh user@host), the shell reads /etc/profile, then your ~/.bash_profile — these are the “getting ready for the day” tasks.
If you are opening a sub-shell (like running a script), the shell skips the login steps and just reads ~/.bashrc — these are the “every time I’m here” tasks.
Most people never touch their login startup files; they live in ~/.bashrc, adding aliases and functions that they use every day.
Analogy 3: environment variables as inherited traits#
When you start a program, the shell passes it a list of variables — things like PATH (where to find programs), HOME (your home directory), USER (your username). These are environment variables — every program born from this shell inherits them.
If you then run a command that starts another process, that grandchild inherits the same environment, unless you explicitly unset a variable. This is why knowing which variables are exported matters: set a wrong PATH, and every program run from that shell onwards fails silently.
Terminal. The user interface that multiplexes your keyboard and screen to one or more shell sessions. In the past, a physical piece of hardware (VT100 terminal); now, a software application (Terminal.app on macOS, GNOME Terminal on Linux, Windows Terminal on Windows 11). The terminal speaks to the kernel via a TTY (teletypewriter) device — a character device at /dev/tty* or /dev/pts/* that handles the low-level protocol.
Shell. A command-line interpreter: a program that reads a command, parses it into words and operators, expands variable references and globs, and executes the resulting program(s). Examples: bash (Bourne-Again Shell), sh (the POSIX minimal shell), zsh (Z shell), fish (Friendly Interactive Shell), ksh (Korn shell).
TTY and PTY. A TTY (teletypewriter, or terminal) is a character device representing a physical or virtual terminal. A PTY (pseudo-terminal) is a software pair: a master half (held by the terminal emulator) and a slave half (given to the shell as file descriptors 0/1/2, stdin/stdout/stderr). When you type in the terminal, the characters go to the slave; when the shell writes to file descriptor 1, the bytes come out of the master to your screen.
Line discipline. The kernel module between the terminal driver and the PTY that handles backspace, Ctrl+C, Ctrl+Z, and other raw terminal features — the reason you can hit backspace to erase a character even though the shell has not seen it yet. There are different line disciplines for raw vs. canonical mode, which is why the screen goes weird when you cat a binary file and you have to reset it afterwards.
Login shell. A shell that starts when you log in (SSH, su -, physical login). It reads startup files designed for setting up your environment once: /etc/profile, /etc/profile.d/*, ~/.bash_profile, ~/.bash_login, or ~/.profile.
Interactive shell. A shell connected to a terminal (you can type commands). It reads startup files designed for interactive use: /etc/bashrc, ~/.bashrc. A non-interactive shell (running a script) skips these.
Environment variable. A key-value pair passed from parent to child process. Defined with export NAME=value; inherited by all subshells unless unset. Examples: PATH, HOME, USER, SHELL, PWD.
Shell variable. A variable that exists only in the current shell, not passed to subprocesses. Defined with NAME=value (no export). Examples: loop counters, temporary strings.
Bytes flow through the PTY slave back to the master, to the terminal emulator, to your screen.
This is why a shell always runs as the controlling process of the PTY: it is the program responsible for reading from that device. If the shell exits, the PTY becomes orphaned and the terminal window closes.
Login vs non-login, interactive vs non-interactive#
Not all shells are the same. The shell’s startup sequence depends on how it was invoked:
Startup file decision tree
Start shell
│
├─ Invoked with -i flag (interactive)?
│ If no: non-interactive
│ → skip all startup files
│ → read only BASH_ENV (if sh compatibility mode)
│ → execute script and exit
│
└─ If yes: interactive shell
│
├─ Invoked as login shell?
│ (SSH, su -, /bin/login, --login flag)
│
├─ If yes: LOGIN INTERACTIVE
│ → read /etc/profile
│ → read /etc/profile.d/* (if it exists)
│ → read ~/.bash_profile (or ~/.bash_login, or ~/.profile)
│ → read ~/.bashrc (if bash and interactive)
│ → prompt appears
│
└─ If no: NON-LOGIN INTERACTIVE
(running bash again inside bash)
→ skip /etc/profile and ~/.bash_profile
→ read ~/.bashrc only
→ prompt appears
When you SSH in or run su - user, this is what actually happens:
flowchart TD
A["Shell invoked<br/>(login + interactive)"] --> B["Read /etc/profile"]
B --> C["Loop: read each file in /etc/profile.d/*"]
C --> D["Read ~/.bash_profile"]
D --> E["If ~/.bash_profile doesn't exist,<br/>try ~/.bash_login"]
E --> F["If that doesn't exist,<br/>try ~/.profile"]
F --> G["(bash also reads ~/.bashrc<br/>from within ~/.bash_profile)"]
G --> H["Prompt appears"]
H --> I["You type command"]
I --> J["Shell parses, expands, executes"]
J --> K["Before exit: read ~/.bash_logout"]
K --> L["Shell exits"]
The typical ~/.bash_profile contains:
bash
# ~/.bash_profile: login-shell setup# Sourced by login shells (SSH, su -)# Load system-wide defaults and bash login scriptsif[-f/etc/profile];thensource/etc/profile
fi# NOW source ~/.bashrc# This is the key idiom: login shells source the interactive setupif[-f~/.bashrc];thensource~/.bashrc
fi
Why? Because you almost certainly have interactive setup (aliases, functions, prompt) in ~/.bashrc. If ~/.bash_profile did not source it, those would not exist on login. This is the idiom to memorise.
When you SSH and provide a password, this happens:
sequenceDiagram
participant S as sshd
participant K as Kernel
participant L as Login process
participant B as bash (your shell)
S->>K: fork + execve(/etc/passwd entry)
K->>L: load login/su/whatever
L->>L: authenticate user
L->>K: fork
K->>B: execve shell from /etc/passwd
Note over B: /etc/passwd has argv[0] = "-bash"
Note over B: dash prefix = login shell
B->>B: read /etc/profile
B->>B: read ~/.bash_profile
B->>B: source ~/.bashrc (idiom)
B-->>S: send prompt
5 · A Shell is a Language — the Operators and Expansions#
One reason people find the shell confusing is that it is a full language with operators, conditionals, loops, and variable expansion — but the syntax is terse and full of special characters.
# Method 1: check the SHELL variableecho$SHELL# Method 2: use the ps command — which process are you inside
ps-p$$# Method 3: read the tty
tty
terminal
$ echo$SHELL/bin/bash$ ps-p$$ PID TTY STAT TIME COMMAND 2410 pts/0 Ss 0:00 bash$ tty
/dev/pts/0$ who
user pts/0 Aug 2 15:23 (192.168.1.50)
The ps -p $$ line tells you the process ID of the running shell ($$), and the COMMAND field shows /bin/bash (or /bin/sh, or /bin/zsh). The tty command shows which device file your terminal is connected to; if you run it over SSH, it says /dev/pts/0 or similar (a pseudo-terminal, because it is software). If you were on a physical machine with a physical serial cable, it might say /dev/ttyS0.
# Just type the new shell
zsh
# You are now in zsh; exit to go backexit
Permanent switch (becomes your login shell):
bash
# List available shells
cat/etc/shells
# Change your login shell (in /etc/passwd)
chsh-s/bin/zsh
# You must log out and back in for this to take effect# Verify it worked (on next login)echo$SHELL
terminal
$ cat/etc/shells
# /etc/shells:validloginshells.
/bin/sh/bin/bash/bin/zsh/usr/bin/zsh/bin/ksh/bin/fish$ chsh-s/bin/zsh
Password:Changing the login shell for userEnter the new value, or press ENTER for the default Login Shell [/bin/bash]: /bin/zsh
After chsh, SSH into a fresh session to confirm your login shell is now in the SHELL variable.
# /etc/profile — read by all login shells (SSH, su -)# Run by: sh, bash, zsh (if invoked as sh or login)# Set umask, PATH, timezoneexportPATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
exportLANG=en_US.UTF-8
# Source all files in /etc/profile.d/if[-d/etc/profile.d];thenforiin/etc/profile.d/*.sh;doif[-r"$i"];then."$i"fidoneunseti
fi
~/.bash_profile — your login shell setup (SSH, console login):
bash
# ~/.bash_profile — read by login bash shells# Load interactive setup from ~/.bashrcif[-f~/.bashrc];thensource~/.bashrc
fi# Add anything login-specific belowexportEDITOR=vim
~/.bashrc — your interactive shell setup (every shell, login or not):
bash
# ~/.bashrc — read by all interactive bash shells# Run by: every shell you open on this system# Aliasesaliasll='ls -lh'aliasgrep='grep --color=auto'# Functions
extract(){case"$1"in*.tar.bz2)tarxvjf"$1";;*.tar.gz)tarxvzf"$1";;*.tar)tarxvf"$1";;*.zip)unzip"$1";;*.rar)unrarx"$1";;*)echo"Usage: extract <file>";;esac}# PromptPS1='[\u@\h \W]\$ '# HistoryHISTSIZE=1000HISTFILESIZE=2000# ColorsexportCLICOLOR=1exportLS_COLORS='di=34:ln=31'
~/.bash_logout — cleanup on shell exit:
bash
# ~/.bash_logout — run when login shell exits# Use for cleanup: clearing screen, backing up history, etc.
clear
echo"Goodbye $USER!"
# Shell variable (not inherited by subprocesses)USERNAME=alice
# Environment variable (inherited)exportUSERNAME=alice
# Both at onceexportPATH=/usr/bin:/bin
# View all environment variables
printenv
# View all variables (shell + environment)set# View one variableecho$USERNAME
printenvUSERNAME
# Check if a variable is exporteddeclare-pUSERNAME
# Re-run the last command
!!
# Re-run the last command that started with "grep"
!grep
# Re-run command number 145
!145
# Use the last argument of the previous commandechofile.txt
cat!$# expands to: cat file.txt# Use all arguments from the last command
!*
# Search history interactively
Ctrl+R
# Type letters; shell searches backwards# Press Enter to run, Ctrl+G to cancel
When you are typing at the bash prompt, these keybindings let you edit without leaving the line:
Key
Action
Ctrl+A
Move cursor to start of line
Ctrl+E
Move cursor to end of line
Ctrl+F
Move cursor forward one character (→)
Ctrl+B
Move cursor back one character (←)
Ctrl+R
Search history backwards
Ctrl+S
Search history forwards (if not XON/XOFF)
Ctrl+U
Delete from cursor to start of line (kill line)
Ctrl+K
Delete from cursor to end of line (kill to end)
Ctrl+W
Delete word backwards
Meta+D (Alt+D)
Delete word forwards
Meta+B (Alt+B)
Move back one word
Meta+F (Alt+F)
Move forward one word
Ctrl+L
Clear screen (same as clear)
Ctrl+X Ctrl+E
Open the command in your $EDITOR
Example of editing a long command:
bash
# You typed this long line, with a typo:
tar-xzvf/path/to/archive/my-backup-20240802.tar.gz/path/to/destnation
# Press Ctrl+A to go to the start# Press Ctrl+F Ctrl+F Ctrl+F to move to "destnation"# Press Ctrl+R to search for "destination" in history# Find it, press Enter, and it replaces the word# Or: Ctrl+U to delete from cursor to line start, retype correctly
Here is a production-quality configuration suitable for development work, condensed from the PDF example on p135–137:
bash
# ~/.bashrc — user interactive shell setup# This file is sourced by non-login shells (e.g., when you open a new tab in an editor)# It is also sourced by login shells via ~/.bash_profile# Skip if not interactivecase$-in*i*);;*)return;;esac# ============ HISTORY ============HISTSIZE=10000HISTFILESIZE=20000HISTCONTROL=ignoredups:ignorespace
# Append to history file instead of overwritingshopt-shistappend
# ============ ALIASES ============aliasls='ls --color=auto'aliasll='ls -lh'aliasla='ls -la'aliasgrep='grep --color=auto'aliasmkdir='mkdir -pv'aliasless='less -R'# ============ PROMPT ============PS1='[\u@\h \W]\$ '# ============ FUNCTIONS ============# extract: unarchive any compressed file format
extract(){if[[$#-eq0]];thenecho"Usage: extract <file> [destination]"echo"Extracts common archive formats"return1filocalfile="$1"localdest="${2:-.}"case"$file"in*.tar.bz2|*.tbz2)tarxvjf"$file"-C"$dest";;*.tar.gz|*.tgz)tarxvzf"$file"-C"$dest";;*.tar.xz|*.txz)tarxvJf"$file"-C"$dest";;*.tar)tarxvf"$file"-C"$dest";;*.zip)unzip"$file"-d"$dest";;*.rar)unrarx"$file""$dest";;*.7z)7zx"$file"-o"$dest";;*)echo"Unknown format: $file";return1;;esacecho"Extracted to $dest"}# backup: create a dated backup of a file
backup(){if[[$#-eq0]];thenecho"Usage: backup <file>"return1filocalfile="$1"if[[!-e"$file"]];thenecho"Error: $file not found"return1filocalbackup_file="${file}.backup.$(date+%Y%m%d-%H%M%S)"cp-v"$file""$backup_file"echo"Backed up to $backup_file"}# find_in_files: grep recursively with proper escaping
find_in_files(){if[[$#-lt1]];thenecho"Usage: find_in_files <pattern> [directory]"return1filocalpattern="$1"localdir="${2:-.}"grep-r"$pattern""$dir"--include="*.txt"--include="*.sh"--include="*.md"}# ============ ENVIRONMENT ============exportEDITOR=vim
exportPAGER=less
exportLANG=en_US.UTF-8
# Add local bin to PATH if it existsif[[-d~/.local/bin]];thenexportPATH=~/.local/bin:$PATHfi# ============ SHELL OPTIONS ============shopt-scheckwinsize# update LINES and COLUMNS after each commandshopt-sglobstar# enable ** for recursive globshopt-sdotglob# include files starting with .
A terminal emulator is software that emulates a physical VT100 terminal. You use it to connect to a shell. Common ones:
OS
Terminal
Alternatives
macOS
Terminal.app
iTerm2, Alacritty, Kitty
Linux
GNOME Terminal, Konsole
Xfce4-terminal, LXTerminal, Alacritty, Kitty
Windows
Windows Terminal
PuTTY, ConEmu
Container/VM
/dev/ttyN (physical) or /dev/pts/N (pseudo)
socat, screen, tmux
When you open a terminal emulator on your laptop, the OS creates a PTY pair, passes the slave end to your shell, and the terminal emulator holds the master end. Everything you type flows through that pipe.
Finding your TTY:
terminal
$ tty
/dev/pts/0$ who
user pts/0 Aug 2 15:23 (192.168.1.50)user pts/1 Aug 2 15:30 (192.168.1.60)
If you SSH into a server and open multiple terminals, each one gets a different /dev/pts/N. If you are on a physical console (not SSH, not a GUI terminal — a real physical login on a server with a monitor and keyboard), it would be /dev/tty1, /dev/tty2, etc.
The shell language is not standard. Different shells behave differently, and the differences matter for portability:
Feature
Bash
Zsh
Fish
sh/POSIX
Portable?
Mostly (default on macOS < 10.15, Ubuntu)
Not standard; less common in scripts
No; uncommon in production
Yes — the baseline
Arrays
arr=(1 2 3)
arr=(1 2 3)
set arr 1 2 3
No
Associative arrays
Yes (Bash 4+)
Yes
No
No
[[ conditional]]
Yes
Yes
Uses if and test
No ([[ is not POSIX)
${var/a/b} (string replacement)
Yes (Bash 4+)
Yes
Yes
No
Process substitution <()
Yes
Yes
Workaround
No
Script shebang
#!/bin/bash
#!/bin/zsh
#!/usr/bin/fish
#!/bin/sh
Where scripts run
Any Bash system
Any Zsh system
Fish-enabled systems
Every Unix
Line editing
Readline
Zline
Custom
Basic
Default prompt customization
PS1 variable
PROMPT variable
functions fish_prompt
Minimal
The practical rule: If you are writing a script that must run on any Linux system (cloud VMs, containers, CI runners), use #!/bin/sh (POSIX shell). If you need Bash features and can guarantee Bash is present, use #!/bin/bash. Never use Zsh or Fish for scripts you will ship — they are for interactive use on your laptop.
Beginner — What is the difference between a terminal and a shell?
A terminal is the software or hardware that handles your keyboard input and screen output — it is a multiplexer. A shell is a command interpreter that reads what you type, parses it, and runs programs. You can have a shell without a terminal (e.g. a script running in the background), or a terminal without a shell (e.g. a serial console running `getty` waiting for login). Together, they create the interactive experience you think of as "the command line."
Beginner — How do you find out which shell you are currently using?
Three ways: (1) `echo $SHELL` — prints your login shell from the environment, (2) `ps -p $$` — shows the process running as PID $$ (the current shell), (3) `tty` — shows the terminal device you are connected to. The second is most reliable because `$SHELL` might be outdated if you ran `chsh` recently without logging out.
Beginner — What does `chsh` do, and what file does it modify?
`chsh` (change shell) modifies the login shell for your user. It edits the `/etc/passwd` file, specifically the last field of your user line (normally `/bin/bash` or `/bin/sh`). The change takes effect on the next login; it does not affect your current shell session. You need to know the full path to the new shell (e.g. `/bin/zsh`); `chsh -l` lists valid shells from `/etc/shells`.
Intermediate — Explain the difference between `~/.bash_profile`, `~/.bashrc`, and `~/.bash_logout`.
`~/.bash_profile` is read by login shells (SSH, physical console login, `su -`) and is where you set up environment variables and load `~/.bashrc` via the `source ~/.bashrc` idiom. `~/.bashrc` is read by all interactive shells — login or not — and contains aliases, functions, and prompt settings. `~/.bash_logout` is read when a login shell exits, typically for cleanup (e.g. clearing history or the screen). If you only edit `~/.bashrc` and never source it from `~/.bash_profile`, login shells will not have your aliases and functions.
Intermediate — Why don't aliases work in shell scripts?
Aliases only expand in interactive shells (when bash is invoked with the `-i` flag). Scripts run in non-interactive mode (no `-i`), so the shell skips alias expansion. The shell simply does not expand `ll` to `ls -lh`; it tries to find a command named `ll` and fails. Use functions instead of aliases if you need the code to work in both interactive and script contexts, because functions are expanded in all shells.
Intermediate — What does `source ~/.bashrc` do, and why would you run it?
`source` (or `.`) re-reads a file and executes it in the current shell. Running `source ~/.bashrc` applies changes you made to the file without logging out — new aliases, functions, environment variables become available immediately. Without this, you would have to log out and back in for changes to take effect. This is very common when debugging shell configuration: edit the file, source it, test it.
Intermediate — Compare environment variables and shell variables. Give an example where the difference matters.
A shell variable exists only in the current shell; `NAME=value` creates one. An environment variable is exported: `export NAME=value`. When the shell starts a subprocess (e.g. `python`, `bash`, `ls`), the subprocess inherits all environment variables but not shell variables. Example: `MYVAR=hello` is a shell variable. If you run `bash -c 'echo $MYVAR'`, the subshell sees nothing (empty). But `export MYVAR=hello` followed by `bash -c 'echo $MYVAR'` prints `hello`. This is why setting `export PATH=...` is critical — without `export`, subshells do not inherit the modified PATH.
Intermediate — What is the purpose of the line discipline in a TTY, and why do you care?
The line discipline is the kernel module between the terminal and the shell that handles raw terminal features: echoing characters back to the screen, processing backspace and Ctrl+C, buffering input until you press Enter, and switching between raw and canonical (cooked) modes. You care because when a program reads binary data and does not put the terminal back into canonical mode, you get garbled output (the terminal stops echoing, weird characters appear, backspace doesn't work). This is why `reset` or `stty sane` exists — they re-enable canonical mode.
Advanced — Trace the startup sequence from SSH login to interactive shell prompt. What files are read, in what order?
SSH runs your login shell (typically `bash`). The shell recognizes it is a login shell (argv[0] starts with `-bash`) and reads in order: `/etc/profile` → `/etc/profile.d/*` → `~/.bash_profile` (or `~/.bash_login` if profile missing, or `~/.profile` as last resort). Inside `~/.bash_profile` is the idiom `source ~/.bashrc`, which reads `~/.bashrc` — where aliases, functions, and prompt settings live. The sequence is: system-wide login setup, user-specific login setup, then user-specific interactive setup. On logout, `~/.bash_logout` is read.
Advanced — You log into a server and discover aliases are not working. What is the most likely cause, and how do you fix it?
Most likely: the login shell did not source `~/.bashrc`. This happens if `~/.bash_profile` does not have the `source ~/.bashrc` idiom. Check: (1) `cat ~/.bash_profile` and look for `source ~/.bashrc` or `. ~/.bashrc`. If it is missing, add it. (2) Run `source ~/.bashrc` to activate aliases in the current session. (3) Alternatively, if `~/.bashrc` exists but `~/.bash_profile` does not, login shells skip both and read only `~/.profile`, which typically does not define aliases. Create a minimal `~/.bash_profile` that sources `~/.bashrc`. Verify with `alias ll` (should print the alias definition) and `which ll` (should say `alias`).
Advanced — Explain the decision tree for when `~/.bashrc` is sourced vs. when it is not.
`~/.bashrc` is sourced when: (1) the shell is interactive (flag `-i` is set), AND (2) the shell is bash. Login shells are interactive, so they source it (usually via the `source ~/.bashrc` idiom inside `~/.bash_profile`). Non-login interactive shells (opening bash again inside bash) also source it. Non-interactive shells (scripts, `bash -c`, commands piped to bash) do NOT source it — they only read `BASH_ENV` if that variable is set. Zsh and other shells have different rules: Zsh sources `~/.zshrc` for all interactive shells, login or not, and does not have a separate `.profile` for login.
Scenario — You modify `~/.bashrc` to add an alias. Your coworker, on the same server, types the alias and it works; yours doesn't. Why?
Your coworker logged out and back in (or ran `source ~/.bashrc`), while you just edited the file in the current shell. Your shell has not re-read `~/.bashrc`, so the alias does not exist in your current session. Run `source ~/.bashrc` to apply the changes. Alternatively, close and reopen your terminal (triggering a login shell, which sources the file).
Scenario — You write a script that uses an alias you defined in `~/.bashrc`. It works when you run it interactively but fails in cron. What is happening?
Cron runs the script in a non-interactive shell (no `-i` flag), which does not expand aliases. Your interactive shell expands `ll` to `ls -lh`, but the cron shell sees a command literally named `ll` and it does not exist. Fix: (1) Use the full command in the script instead of the alias, (2) Or define a shell function instead of an alias and source the file in the script, (3) Or manually source `~/.bashrc` in the script with `source ~/.bashrc`, though this is fragile.
Company style — How would you provide a standard shell environment to all users on a shared server?
Create a system-wide setup in `/etc/profile.d/custom.sh` that all login shells source. Define common variables, paths, aliases and functions that apply to everyone. Then, provide a template `~/.bashrc` for users to copy or extend. Document the paths and conventions: "All production tools are in `/opt/tools/bin`; add this to your PATH in `~/.bashrc`." For consistency, you might also use a *dotfiles* repository — a Git repo containing `~/.bashrc`, `~/.vimrc`, `~/.gitconfig`, etc. — that developers clone and symlink. This ensures everyone has the same setup.
HR style — Describe a time your shell configuration saved you time or prevented a mistake.
A strong answer: "I defined a backup function in `~/.bashrc` that uses `date` to create timestamped copies: `backup filename` creates `filename.backup.20240802-153042`. This saved me when I accidentally overwrote a config file — I had a backup. Later, I added `mkdir -pv` as an alias, which creates directories recursively without errors if they exist, so scripts are more robust. Most recently, I wrapped `rm` in a function that prompts for confirmation. These small investments in the shell configuration have prevented several mistakes in production."
HR style — Tell me about a time you had to debug something you could not see directly.
Strong approach: "A colleague's script was failing in CI but passing locally. I added `set -x` to enable debug output (showing every command before it runs) and ran the script in the CI environment (via SSH to the runner). I saw that `$PATH` was different — the CI environment's `PATH` did not include the directory where a tool was installed. Fixed it by having the CI pipeline export the correct `PATH`. This taught me to always check environment variables first when debugging cross-environment failures."
A terminal is software that multiplexes your keyboard and display; a shell is a command interpreter — they work together but are not the same thing.
TTY is a character device; PTY is a software pair (master/slave) that lets a terminal emulator talk to a shell.
The line discipline handles terminal features like backspace and Ctrl+C; it sits between the TTY driver and the shell.
Login shells read /etc/profile, then ~/.bash_profile, then (via the idiom) ~/.bashrc; non-login interactive shells read only ~/.bashrc.
The key idiom is: ~/.bash_profile sources ~/.bashrc, so login shells have aliases and functions too.
Aliases are text replacement and do not work in scripts; functions are code with variables and do work everywhere.
Environment variables are exported to subprocesses; shell variables are not — use export NAME=value for anything a subprocess needs to see.
Readline keybindings (Ctrl+A/E/U/K, Alt+B/F) work in bash, zsh, and many CLI tools; they are not unique to the shell.
History expansion (!!, !$, !145) re-runs commands; Ctrl+R searches interactively.
source ~/.bashrc applies configuration changes without logging out.
The three ways to find your current shell: echo $SHELL, ps -p $$, tty.
chsh -s /bin/zsh permanently changes your login shell (takes effect on next login); just typing zsh temporarily switches.
Bash, Zsh, and Fish are different languages; scripts must use #!/bin/sh for portability or #!/bin/bash if Bash-specific.
Configuration mistakes (syntax errors, bad PATH) can lock you out — recovery is ssh user@host bash --noprofile --norc, or use absolute paths to fix the file.
Which startup file is read by all login shells, not just bash? (a)~/.bash_profile(b)/etc/profile(c)~/.bashrc(d)~/.profile
An alias defined in ~/.bashrc works in: (a) interactive shells (b) scripts (c) both (d) neither
To make an environment variable visible to subprocesses, use: (a)VAR=value(b)export VAR=value(c)declare VAR=value(d)local VAR=value
Which command temporarily switches shells? (a)chsh(b) Just typing the shell name (c)bash --login(d)su - user
A script’s shebang line must be: (a)#!/bin/bash(b)#!/usr/bin/env bash(c)#!/bin/sh for portability (d) any of the above
Ctrl+U in the shell deletes: (a) the entire line (b) from cursor to line start (c) the previous word (d) nothing (unsupported)
A login shell reads: (a) only ~/.bashrc(b) only ~/.bash_profile(c)/etc/profile, ~/.bash_profile, then (via idiom) ~/.bashrc(d)~/.bash_logout
What is the purpose of /etc/profile? (a) User login setup (b) System-wide login setup (c) Per-interactive setup (d) Shell language definition
The command tty shows: (a) which shell you are in (b) which terminal device you are connected to (c) the time (d) your username
Functions differ from aliases in that: (a) functions work in scripts (b) functions support arguments and variables (c) functions are not text replacement (d) all of the above
Answers
1. (b) — `/etc/profile` is standard for POSIX shells; `~/.bash_profile` is bash-specific.
2. (a) — only interactive shells; scripts skip alias expansion.
3. (b) — `export` makes the variable available to subprocesses.
4. (b) — just type `zsh`, `fish`, or `bash` to switch temporarily; `chsh` is permanent.
5. (d) — any are valid; (c) is the most portable standard.
6. (b) — Ctrl+U deletes from cursor to line start (helpful for long lines).
7. (c) — this is the login shell startup sequence.
8. (b) — `/etc/profile` is read by all login shells for system-wide setup.
9. (b) — `tty` shows the terminal device (e.g., `/dev/pts/0`).
10. (d) — all are correct.
Environment variables are automatically inherited by child processes.
source ~/.bashrc applies configuration changes immediately without logging out.
The line discipline is responsible for parsing shell commands.
$SHELL always reflects the currently running shell.
A script with #!/bin/sh shebang can be run with bash script.sh and still use Bash features.
Answers
1. **False** — a terminal emulator can run any program, not just a shell (though a shell is typical).
2. **False** — aliases expand only in interactive shells.
3. **False** — `/etc/profile` is read by all POSIX login shells (sh, bash, zsh if they are login).
4. **True** — assuming they are exported; non-exported shell variables are not inherited.
5. **True** — `source` re-reads the file in the current shell.
6. **False** — the line discipline handles terminal features (echo, backspace). Shell parsing is the shell's job.
7. **False** — `$SHELL` is your login shell from `/etc/passwd`; it is not updated if you switch shells with `bash` or `zsh`.
8. **True** — `bash script.sh` overrides the shebang and runs it under bash, so Bash-specific features work (though they should not be used in a script with `#!/bin/sh`).
Identify your terminal: Run tty and ps -p $$ and who. Explain what each command told you.
Inspect startup files: List and read /etc/profile, ~/.bash_profile, ~/.bashrc, and ~/.bash_logout if they exist. Document what each does on your system.
Create an alias and function: Add alias myecho='echo "Hello from alias"' and myfunc() { echo "Hello from function"; } to ~/.bashrc. Source it. Test both in the interactive shell and in a script (./script.sh). Document which works where.
Modify your prompt: Add PS1='[\t] \u@\h:\W\$ ' to ~/.bashrc, source it, and verify the prompt changes. Explain each escape sequence.
Test history expansion: In the shell, type echo first, then echo second, then !! (re-run second), then !echo (re-run first), then !$ (reuse “second” argument). Document the output.
Practice Readline shortcuts: Type a long command, then use Ctrl+A, Ctrl+E, Ctrl+U, Ctrl+K to navigate and edit without using arrow keys. Feel the muscle memory forming.
Write a shell function countdown() that takes a number and counts down to zero, printing each number with a 1-second delay. Add it to ~/.bashrc and test it. Does it work in a script if you source ~/.bashrc first?
Create a backup of your ~/.bashrc using cp and timestamp it with date. Then intentionally introduce a syntax error into ~/.bashrc (e.g., unclosed quote). Log out and back in. When login fails, use ssh user@host bash --noprofile --norc to recover and fix it. Document the steps.
Write a script that outputs a different prompt based on whether it is interactive ([ -t 0 ] checks if stdin is a terminal). Test it by running directly and by piping input.
Investigate the difference between export PATH=$PATH:/new/dir and export PATH=/new/dir:$PATH. Write a script that lists the order of $PATH before and after each change. Why does order matter?
Create a ~/.bashrc snippet that detects whether the shell is running over SSH (check SSH_CONNECTION environment variable) and prints a warning if you are root and over SSH. Add it and test it.
Examine /etc/shells and /etc/passwd on your system. Identify which shells are available and which is your login shell. Use grep to find lines matching your username.
Write a shell function that wraps rm with a confirmation prompt and logs all deletions to a file (~/.rm-log). Test it and verify the log. How would you make this global for all users on a system?
Compare the output of env, set, declare -p, and printenv. Explain what each shows and why they differ.
Use strace to trace a shell command (e.g., strace -e trace=open,openat bash -c 'echo hi') and identify which startup files the shell tries to open.
Create a test: write two functions with the same name in ~/.bashrc, then source it. Which one does the shell use? Now do the same with aliases. Explain the behavior.
From the original PDF pages 114–144, these are the challenges that must be preserved:
Find all available shells on your system and research which one each is designed for.
Determine your current shell using three different methods.
Safely switch your login shell to a different shell using chsh, then verify the change took effect.
Create a ~/.bashrc with at least three aliases and two functions; test both in an interactive shell and in a script.
Modify your prompt (PS1) to display the time, username, hostname, and working directory.
Create a function that automatically extracts any archive format (.zip, .tar.gz, .rar, .7z, etc.) based on file extension.
Add environment variables to ~/.bash_profile that customise the shell experience (e.g., EDITOR, PAGER, custom PATH), then verify they are inherited by subprocesses.
Use Readline shortcuts to edit a complex command without arrow keys or using other editing methods.
Demonstrate history expansion (!!, !$, !cmd, !145, Ctrl+R`) with concrete examples.
Intentionally break your ~/.bashrc with a syntax error, then recover using bash --noprofile --norc or similar recovery technique, and fix the file.
II — Working in the Shell
04Files & Directories
The filesystem is one tree; these commands are how you navigate it, list it, create in it, copy through it, and find anything inside it.
Everything in Unix — and therefore Linux — is arranged in a single hierarchical filesystem tree. There is no such thing as a “D: drive” or separate drive letters. Instead, every file, directory, and device is reachable as a path starting from the root /. A USB drive, a disk partition, even /dev/null, all hang from that same tree, mounted at a point you choose.
This design was radical in 1970 and remains one of Unix’s greatest gifts to the world. A script that works on your laptop — navigating by paths, redirecting to files, composing commands — will work identically on a 1,000-node cloud cluster, because the tree is always there, always the same shape.
That tree has two ways to address a file:
Absolute paths start with / and describe a route from the root to your target: /home/alice/documents/budget.txt.
Relative paths describe a route from where you are now: documents/budget.txt if you are in /home/alice, or ../bob/notes.txt to reach a sibling’s directory.
These 13 commands are your hands in that tree. You will run them hundreds of times a day.
Without these commands, you cannot:
- navigate the filesystem at all
- understand where your application’s files live
- script bulk operations on hundreds of files
- diagnose why a disk is full (find by size)
- manage symlinks for configuration
- avoid accidentally destroying production data (rm -rf / is a keystroke away)
Master these commands and you can operate any Unix system confidently. Botch them — especially rm — and you can destroy a system before SSH saves your keystrokes.
The Unix filesystem is one tree, not a forest of drives.
The single filesystem tree
/
│
┌───────────┼───────────┐
│ │ │
bin home var
│ │ │
├─ ls ├─ alice ├─ log
├─ cp └─ bob └─ lib
│
(/bin/ls means "ls in the bin folder in the root")
A USB stick or new disk MOUNTS INTO a path:
/
│
┌───────────┼───────────┐
│ │ │
bin home mnt
│ │ │
│ │ ┌──┴──┐
│ │ │ usb ←── Mount point
│ │ │ (one of the tree)
│ │ [files on the USB are here]
│ │
When you unmount, /mnt/usb vanishes from the tree,
but the files on the USB are unharmed.
There are no drive letters. A path is always a tree traversal from some point (/, ., or ~).
Every directory contains two hidden entries that are not real files:
. means “this directory” — the spot you are standing in.
.. means “one level up” — the parent directory.
These are not unique to this chapter; they appear in every path you type. cd .. means “go to the parent and make it current.” find . -name "*.log" means “in this directory and below, find files ending in .log.”
Filesystem. A hierarchical tree of files, directories, and device special files, rooted at /. Every object (file, directory, socket, pipe, device) is a path reachable from root.
Path. A string describing a location in the tree, made of segments separated by /. An absolute path (starting with /) is unambiguous anywhere; a relative path (starting with ., .., or a name) is interpreted from the current working directory.
Current working directory (pwd). The directory the shell is currently in. When you type a relative path or a command name without a directory, the shell resolves it from here.
Hidden file. A file or directory whose name starts with . (dot). Not special to the kernel; the convention is simply to hide them from default directory listings. Pronounced “dotfile.”
Inode. The kernel’s internal record of a file: its owner, permissions, size, timestamps, and where its data blocks live on disk. Every file has one. The inode number uniquely identifies it within a filesystem. Hard links share one inode; symlinks do not.
Hard link. A directory entry pointing directly to an inode. Multiple names can point to the same inode; deleting one leaves the others intact as long as the reference count does not drop to zero.
Symbolic link (symlink). A special file containing a path (text) rather than data. When you open a symlink, the kernel follows the path inside and opens that target instead. Breaking the link does not delete the target. A symlink can point across filesystems and to targets that do not exist yet.
FHS (Filesystem Hierarchy Standard). A standard defining what conventionally lives where in the tree: /bin for userspace executables, /etc for configuration, /home for user home directories, etc. Not enforced by the kernel; followed by all distributions.
When you create a file, the kernel does not allocate one huge block. It allocates many small chunks called data blocks (usually 4 KB), and it creates an inode to track them:
Hard links work by creating a new directory entry pointing to the same inode. Symlinks work differently — they are small files containing a path as text:
flowchart LR
A["Symlink inode<br/>(small file)"] --> B["Content:<br/>/path/to/real/file<br/>(just text)"]
C["Directory entry<br/>name: link.txt<br/>inode: 99999"] --> A
D["When you open link.txt,<br/>kernel reads the path<br/>and opens /path/to/real/file"]
The critical difference: deleting a symlink breaks the link but leaves the target. Deleting a hard link decrements the reference count; when it hits zero, the inode is freed.
When you run ls /home/alice/documents, the kernel:
Looks up the inode for /home/alice/documents by walking the tree: start at /, find home inode, look inside that, find alice inode, and so on.
Reads the inode and checks permissions — does your user have r (read) permission on this directory?
Reads the directory’s data blocks to find all entries (documents, notes.txt, etc.) and their inode numbers.
For each entry, reads its inode to get size, permissions, timestamps, and displays them.
That walk-the-tree lookup — called a directory traversal — is why ls /very/long/deep/path can be slow on a filesystem with millions of files and deep trees.
A filename in Linux can contain almost any byte except / and the null byte (\0). This means:
Spaces, tabs, newlines are legal — but they wreak havoc in scripts because the shell interprets them as delimiters. Never name a file my document.txt in production; use my_document.txt or my-document.txt.
Case-sensitive — File.txt, file.txt, and FILE.TXT are three different files. A port from macOS or Windows often breaks because of this.
Newlines in filenames — legal, bizarre, and present in real-world broken systems. find handles them with -print0 piping to xargs -0.
Leading dashes — ls -l is a flag; ls -- -my-file treats -my-file as a filename (the -- stops flag parsing). Always be careful when passing user input to commands.
You need to rename all .jpg files to .jpeg, back them up first.
terminal
$ pwd/home/alice/photos$ ls*.jpg
vacation-001.jpgvacation-002.jpgvacation-003.jpg$ # Back them all up first$ mkdir-pbackup
$ cp-v*.jpgbackup/
vacation-001.jpg -> backup/vacation-001.jpgvacation-002.jpg -> backup/vacation-002.jpgvacation-003.jpg -> backup/vacation-003.jpg$ # Now rename them using find + move$ find.-maxdepth1-name"*.jpg"-typef|whilereadf;do mv "$f" "${f%.jpg}.jpeg" done$ ls*.jpeg
vacation-001.jpegvacation-002.jpegvacation-003.jpeg
Scenario 3: find all modified files in production and back them up#
You suspect someone modified application files. You want to find anything changed in the last hour.
terminal
$ find/var/www/app-typef-mmin-60-print0|xargs-0cp-v--parents-t/backup/app/
/var/www/app/config/database.yml -> /backup/app/var/www/app/config/database.yml/var/www/app/app.js -> /backup/app/var/www/app/app.js$ # Check what changed$ find/var/www/app-typef-mmin-60-execls-lh{}\;-rw-r--r-- 1 www-data www-data 2.3K Aug 2 15:22 /var/www/app/config/database.yml-rw-r--r-- 1 www-data www-data 18K Aug 2 15:11 /var/www/app/app.js
Everything below is essential to typing competently in a shell. Do each one; do not read passively. Changes in this section are your production-safety practice.
pwd — Print Working Directory. Shows where you are.
bash
pwd
terminal
$ pwd/home/alice
Every option:
Option
Meaning
-L
Logical: show the path as you navigated to it, even if symlinks were involved. This is the default.
-P
Physical: resolve all symlinks and show the real path.
Practical use: Symlinks can confuse you. If you are in /var/www (which is a symlink to /srv/www), pwd shows /var/www by default, but pwd -P shows /srv/www. On production systems with many symlinks, use -P to know the real location.
terminal
$ # Example: /home/alice/current is a symlink to /home/alice/project-2024-08$ cd/home/alice/current
$ pwd/home/alice/current$ pwd-P
/home/alice/project-2024-08
cd — Change Directory. Move to a different directory. Does not take input from stdin; you must provide a path on the command line.
bash
cd<path>
Forms of <path>:
Path
Meaning
Example
<dir>
Relative to current directory
cd documents (if you are in /home/alice, you go to /home/alice/documents)
/dir
Absolute path
cd /var/log (you go to /var/log from anywhere)
~
Your home directory (from /etc/passwd or $HOME)
cd ~ → /home/alice
~user
Another user’s home directory
cd ~bob → /home/bob
-
The previous directory you were in (tracked in $OLDPWD)
cd /var/log, then cd /tmp, then cd - → back to /var/log
..
Parent directory
cd .. goes up one level
.
Current directory
cd . does nothing (rarely useful)
CDPATH. An environment variable (like $PATH) that lists directories to search when you type cd dirname. If dirname is not in the current directory and not an absolute path, the shell searches $CDPATH in order. Setting CDPATH="/home:/var" means cd log from anywhere will find /var/log if it exists.
terminal
$ CDPATH=/home:/var
$ cdlog
$ pwd/var/log
pushd and popd. Directory stack navigation.
pushd <dir> — change to <dir>and push the current directory onto a stack.
popd — pop a directory off the stack and go there.
When you exit the shell, the stack is forgotten. Use pushd and popd in interactive shells to avoid losing your place; use explicit paths in scripts.
ls — List Directory. The command you will type thousands of times. Show the contents of a directory.
bash
ls[options][path...]
All critical options:
Option
Meaning
-l
Long format. Shows inode entry count in the first column (total), then for each file: permissions, link count, owner, group, size, mtime, name. This is what you usually want.
-a
Show all entries, including dotfiles (hidden files starting with .). By default, . and .. and dotfiles are hidden.
-A
Show all entries except . and .. (the “dot dot” is usually not useful in listings).
-h
Human-readable sizes: use K, M, G instead of bytes. Only meaningful with -l.
-R
Recursive. List directories and all files inside them, recursively. Can be verbose; usually you want find instead.
-t
Sort by modification time (newest first). Combine with -r to sort oldest first.
-S
Sort by file size (largest first).
-r
Reverse sort order (applies to -t, -S, or alphabetical).
-i
Show inode numbers. Useful when debugging hard links.
-d
List the directory itself, not its contents. ls -d /tmp shows /tmp’s metadata, not what is inside.
-1
One entry per line (always; ls uses multiple columns if the terminal is wide).
The ls -l line, field by field:
terminal
$ ls-l/home/alice/document.txt
-rw-r--r-- 1 alice wheel 8192 Aug 2 14:22 document.txt
Field
Value
Meaning
File type + perms
-rw-r--r--
First character: - = regular file, d = directory, l = symlink, b = block device, c = character device, s = socket, p = pipe. Then 9 characters of permissions (Chapter 17).
Link count
1
Number of hard links to this inode. A file starts at 1. A directory starts at 2 (one for its entry in the parent, one for . inside itself). When you hard-link a file, this count increases.
Owner
alice
User (UID) that owns the file.
Group
wheel
Group (GID) that owns the file.
Size
8192
Bytes. Note: this is the logical size, not the on-disk size (which may be larger due to fragmentation or block size).
Mtime
Aug 2 14:22
Last modification time. If the file is > 6 months old, shows year instead of time.
Name
document.txt
The filename.
The total line:
terminal
$ ls-l/home/alice
total 48drwxr-xr-x 2 alice wheel 4096 Aug 2 14:01 documents/-rw-r--r-- 1 alice wheel 8192 Aug 2 14:22 document.txt
The total 48 line shows the number of 512-byte blocks used by the directory inode itself, not the files inside. It is informational and often misread as “total size of files.” Do not use it to calculate disk usage; use du -sh for that.
When ls is lying about hidden files:
terminal
$ ls-l/home/alice
-rw-r--r-- 1 alice wheel 8192 Aug 2 14:22 document.txt$ ls-la/home/alice
-rw-r--r-- 1 alice wheel 8192 Aug 2 14:22 document.txt-rw------- 1 alice wheel 512 Aug 2 14:01 .ssh/drwxr-xr-x 5 alice wheel 4096 Aug 2 13:54 .config/drwxr-xr-x 2 alice wheel 4096 Aug 2 12:00 .local/
Scripts often break because ls hides dotfiles by default. Always use ls -a or ls -A when scripting.
Create parent directories if needed. mkdir -p /home/alice/documents/work/2024 creates the entire path even if /home/alice/documents does not exist. Use this in scripts.
-m <mode>
Set permissions on the new directory. mkdir -m 700 /home/alice/.ssh creates it with owner-only read/write/execute, preventing others from listing it.
-v
Verbose: print each directory as it is created.
Brace expansion — a shell feature, not a mkdir feature — lets you create many directories at once:
rmdir — Remove Directory. Delete an empty directory.
bash
rmdir[options]<dirname>[<dirname2>...]
Options:
Option
Meaning
-p
Remove parent directories if they become empty. rmdir -p /home/alice/documents/work/2024 removes 2024, then work (if empty), then documents (if empty), and so on up.
-v
Verbose: print each directory as it is removed.
Critical gotcha:rmdir only works on empty directories. If you try rmdir /var/log when it contains files, it fails.
terminal
$ mkdirempty_dir
$ rmdirempty_dir
$ rmdirempty_dir
rmdir: failed to remove 'empty_dir': No such file or directory$ mkdirhas_content
$ touchhas_content/file.txt
$ rmdirhas_content
rmdir: failed to remove 'has_content': Directory not empty$ rmhas_content/file.txt
$ rmdirhas_content
rm — Remove (Delete). Permanently delete files and directories. No undo. No recycle bin. Use with extreme caution.
bash
rm[options]<file>[<file2>...]
All critical options:
Option
Meaning
-f
Force: do not prompt for confirmation, and do not fail if a file does not exist. Silently deletes anything you have permission to delete. Dangerous with wildcards or unset variables.
-i
Interactive: ask for confirmation before deleting each file. Good for learning or risky scripts.
-I
Prompt once before deleting more than 3 files or a directory recursively. A middle ground between -i and no option.
-r
Recursive: delete directories and everything inside them. The nuclear option.rm -rf / with accidentally expanded / will destroy the system.
-v
Verbose: print each file as it is deleted.
-d
Remove empty directories (like rmdir). Without -r, -d fails on non-empty directories.
The deadliest command in Unix:
bash
rm-rf/
This recursively deletes everything starting from /, the root. A typo like rm -rf / tmp (with a space) instead of rm -rf /tmp has ended careers. Every production system has a war story about this command.
Safer patterns:
bash
# Always use quotes around variables
rm-f"$file"# Do not use unset variablesset-u# bash: exit if any undefined variable is used
rm-rf$VAR/
# Preview what will be deleted before actually deleting
find.-name"*.log"-typef
# if satisfied:
find.-name"*.log"-typef-delete
# Use a separate step to check: list, then deletefiles=$(find.-name"*.log")echo"Will delete: $files"read-p"Press enter to continue..."echo"$files"|xargsrm-f
Recovering deleted files: If a process still has the file open, you can sometimes recover it.
terminal
$ rm/var/log/app.log
$ # But the app is still writing to it$ lsof+L1# Show deleted files still openCOMMAND PID USER FD TYPE DEVICE SIZE/OFF NLINK NAMEjava 1234 appuser 23w REG 10,1 8388608 0 /var/log/app.log (deleted)$ # Copy from the file descriptor$ cp/proc/1234/fd/23/var/log/app.log.recovered
This works because Linux keeps the file’s data blocks alive as long as at least one process holds them open.
mv — Move or Rename. Move a file to a new location or rename it. Both operations use the same command because renaming is just moving within the same directory.
Update: only move if source is newer or destination missing.
Within the same filesystem:mv is instant — the kernel just updates directory entries (inodes stay in place).
Across filesystems:mv must copy the file (slow) and then delete the source. This is automatic; mv detects the filesystem boundary.
terminal
$ # Within /home: instant (same filesystem)$ mv/home/alice/file.txt/home/bob/file.txt
$ # /home to /tmp: copy + delete (different filesystems; slow for large files)$ timemv/home/alice/large-video.mp4/tmp/
real 0m12.456s # Had to copy 8 GB
Glob handling:mv behaves differently when the shell expands globs:
terminal
$ # Single destination — renames$ mvfile1.txtfile1.txt.backup
$ # Multiple sources, directory destination — moves all into the directory$ mv*.log/var/log/archive/
$ # Dangerous: if glob expands to nothing, fails silently in some shells$ shopt-snullglob# bash: prevent empty glob expansion$ mv*.xyz/tmp/# If no .xyz files exist, does nothing
In production scripts, use find with -exec or xargs instead of globbing:
bash
# Do not do this:
mv$LOG_DIR/*.log/archive/
# Do this instead:
find"$LOG_DIR"-maxdepth1-name"*.log"-typef-print0|\xargs-0mv-t/archive/
touch — Update File Timestamps (or Create Empty File). Change access time, modification time, or creation-related times. If the file does not exist, creates an empty file.
bash
touch[options]<file>[<file2>...]
All important options:
Option
Meaning
-a
Update only access time (atime).
-m
Update only modification time (mtime).
-c
Do not create the file if it does not exist; only update timestamps if it does.
-t <timestamp>
Set to a specific time. Format: [[CC]YY]MMDDhhmm[.ss]. Example: touch -t 202408021422 file.txt sets to 2024-08-02 14:22.
-d <date>
Set to a date string the system can parse. touch -d "2024-08-02 14:22:00" file.txt.
-r <ref_file>
Set timestamps to match another file. touch -r template.txt newfile.txt.
The three timestamps on every file:
Every file has three timestamps:
Timestamp
Command to view
Command to update
Meaning
mtime (modification time)
ls -l, stat
touch -m
When the file’s contents were last changed. What ls -l shows by default.
atime (access time)
ls -u, stat
touch -a
When the file was last read. Often disabled for performance (reading should not require a disk write).
ctime (change time)
stat
Cannot be set
When the file’s inode was last changed (permissions, ownership, size). Not the same as creation time.
file — Determine File Type. Reads the first few bytes of a file and guesses its type based on “magic numbers” — standard byte sequences at the start of known formats.
bash
file[options]<file>[<file2>...]
Options:
Option
Meaning
-b
Brief: omit the filename; just print the type.
-i
MIME type: print the application/octet-stream-style type instead of English.
-L
Follow symlinks: if argument is a symlink, inspect the target, not the link itself.
-k
Keep going: continue scanning even if a match is found. Prints all matched types. Not “additional info” as some docs claim — it is specifically “keep scanning on match.”
terminal
$ file/bin/bash
/bin/bash: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, for GNU/Linux 3.2.0, BuildID[sha1]=..., stripped$ file/etc/passwd
/etc/passwd: ASCII text$ file/dev/sda
/dev/sda: block special (8/0)$ file-i/bin/bash
/bin/bash: application/x-executable; charset=binary$ file/home/alice/docs/contract.pdf
/home/alice/docs/contract.pdf: PDF document, version 1.4
The magic number database is in /usr/share/misc/magic. You can add custom types; see man magic.
Symbolic link. Without this, creates a hard link (a second directory entry pointing to the same inode).
-f
Force: unlink the destination if it exists, then create the link.
-v
Verbose: print the result.
-r
Relative symlinks: instead of storing the absolute path in the symlink, store a relative path. ln -sr /var/log/app.log /home/alice/applog creates a symlink containing ../../var/log/app.log instead of an absolute path. Useful for portable backups.
Hard links vs symbolic links — the crucial difference:
Inode diagram: hard link vs symlink
HARD LINK:
directory entry inode data
file1.txt ──→ [12345] ──→ "contents"
file1.bak ──→ [12345] ──→ (same)
Deleting file1.txt leaves file1.bak intact
Modifying file1.bak changes file1.txt too
(same file, two names)
SYMBOLIC LINK:
directory entry inode inode data
link.txt ──→ [99999] ──→ path text ──→
(small) "/path/to/file" (real inode)
Deleting the target breaks the symlink
Modifying through link.txt modifies the target
(different files, link contains a path)
Examples:
terminal
$ # Hard link: two names, one inode$ echo"original">file.txt
$ lnfile.txtfile.bak
$ ls-i
12345 file.txt12345 file.bak$ catfile.bak
original$ rmfile.txt
$ catfile.bak
original$ # Symlink: separate inodes, link contains path$ ln-s/var/log/app.log./applog
$ ls-liapplog
54321 applog -> /var/log/app.log$ catapplog
(contents of /var/log/app.log)$ rm/var/log/app.log
$ catapplog
cat: applog: No such file or directory$ # Link still exists; it just points nowhere$ ls-liapplog
54321 applog -> /var/log/app.log (dangling)
Hard links: key limits.
Cannot cross filesystems (hard links must point to the same inode, which is filesystem-local).
Cannot link directories (would create loops in the tree).
If the source is a symlink, ln creates a link to the symlink, not the target (unless you use ln -L to follow the symlink first).
Symlinks: practical uses.
Configuration management: /etc/nginx/sites-enabled/default is often a symlink to /etc/nginx/sites-available/default-real, so you enable/disable sites by creating/removing symlinks.
Version management: /opt/java is a symlink to /opt/java-11.0.12; upgrading means updating the symlink, leaving old versions in place for rollback.
Personal shortcuts: ~/.bashrc often sources ~/.bashrc-work or ~/.bashrc-personal for role-based configuration.
locate — Find a File by Name (Fast). Searches a pre-built database of filenames instead of walking the tree. Extremely fast but only as current as the database.
bash
locate[options]<pattern>
Options:
Option
Meaning
-i
Case-insensitive search.
-r
Treat pattern as a regular expression (POSIX ERE).
-n <count>
Limit results to count matches.
-c
Count: print number of matches, not the filenames.
-e
Existing files only: exclude results where the file no longer exists (the database is stale).
The database is updated by updatedb, usually via a cron job nightly. On some systems, locate may not know about files created in the last 24 hours.
terminal
$ # Create a file$ touch/tmp/myfile.txt
$ # locate might not find it yet$ locatemyfile.txt
(no result)$ # Force an update$ sudoupdatedb
$ locatemyfile.txt
/tmp/myfile.txt$ # Case-insensitive$ locate-iMYFILE
/tmp/myfile.txt$ # Count$ locate-c"*.log"1247
When to use locate vs find:
locate for quick interactive searches on whole system: locate nginx.conf.
find for precise, complex searches with filters: find /var/www -name "*.php" -type f -newer /tmp/marker.
find — Search for Files. Walk a directory tree and filter by name, type, size, age, permissions, owner, and more. Execute commands on matches. The most powerful and widely-used file search in Unix.
bash
find[path][options][expression]
If path is omitted, defaults to . (current directory).
Essential options and predicates:
Predicate
Meaning
-name <pattern>
Match by filename, shell glob pattern: *, ?, [abc].
-iname <pattern>
Case-insensitive -name.
-path <pattern>
Match by full path (from the starting point). find /home -path "*/.git/*" -prune skips .git directories.
-regex <regex>
Match by full path using POSIX ERE regex.
-type <type>
Filter by file type: f (regular file), d (directory), l (symlink), b (block device), c (character device), s (socket), p (pipe).
-size <size>
Match by size: +100k (larger than 100 KiB), -10M (smaller than 10 MiB), 5G (exactly 5 GiB). Suffix: c (bytes), k (KiB), M (MiB), G (GiB).
-mtime <days>
Modified time: +30 (older than 30 days), -7 (modified in the last 7 days), 0 (modified today).
-mmin <minutes>
Modified time in minutes: -60 (modified in the last 60 minutes).
-atime <days>
Access time (last read): +7 (not accessed for 7+ days).
-ctime <days>
Change time (inode modified).
-newer <file>
Files modified more recently than <file>.
-user <user>
Match by owner. find /home -user alice.
-group <group>
Match by group.
-uid <uid>
Match by numeric UID. Useful for finding files owned by deleted users (UID exists, name does not).
-perm <perms>
Match by permissions. -perm 644 (exactly 644), -perm -644 (has all these bits set), -perm /644 (has any of these bits set).
-empty
Match empty files or directories.
-maxdepth <depth>
Do not search deeper than depth levels. find /home -maxdepth 1 lists only files directly in /home, not subdirectories.
-mindepth <depth>
Do not process anything shallower than depth levels. Rarely needed.
-prune
Skip this directory; do not descend into it. find / -path /proc -prune -o -name "pattern" -print.
Actions:
Action
Meaning
-print
Print matching path (the default).
-print0
Print paths null-separated, not newline-separated. For piping to xargs -0 (safe with spaces/newlines in filenames).
-delete
Delete matching files. Use with caution. Equivalent to rm -f.
-exec <command> {} \;
Execute command on each match. {} is replaced by the filename. Must end with ; (escaped as \; in the shell). find /var/log -name "*.old" -exec rm {} \;.
-exec <command> {} +
Execute command with many filenames at once (more efficient than \;). find /tmp -type f -exec ls -lh {} +.
-execdir <command> {} \;
Like -exec, but changes to the directory containing the file first. Prevents {} from containing paths; useful for avoiding argument-too-long errors.
Production one-liners:
bash
# 1. Find and delete old logs (older than 30 days)
find/var/log-name"*.log"-typef-mtime+30-delete
# 2. Find files larger than 1 GB and list them
find/-typef-size+1G-execls-lh{}\;2>/dev/null|awk'{print $5, $9}'# 3. Find files modified in the last hour, safe for newlines in names
find/var/www-typef-mmin-60-print0|xargs-0ls-lh
# 4. Find all world-writable files (security risk)
find/-typef-perm/0022>/dev/null
# 5. Find and tar a directory structure, excluding certain paths
find/home/alice-path"*/.git"-prune-o-path"*/node_modules"-prune-o-typef-print0|\xargs-0tarczfbackup.tar.gz
# 6. Find files owned by a deleted user (UID 1000, but user gone)
find/-uid1000-typef2>/dev/null
Beginner — What does `pwd` do?
Print the current working directory — the absolute path you are in. Useful when you are lost in a deeply nested directory tree and need to see where you are. `pwd -P` resolves symlinks and shows the physical location; `pwd -L` (the default) shows the path as you navigated to it.
Beginner — Explain the difference between `cd ~` and `cd /root`.
`cd ~` takes you to the user's home directory, which comes from the shell's `$HOME` environment variable or the user's entry in `/etc/passwd`. `cd /root` is the literal directory `/root`. For a regular user, `~` is `/home/username`; for the root user, `~` is `/root`. They are the same if you are root, but different if you are not.
Beginner — What does `ls -l` show you, field by field?
From left to right: file type + permissions (10 characters), link count, owner, group, size (in bytes), modification time, and filename. The first character of the permissions field tells you the file type: `-` is a regular file, `d` is a directory, `l` is a symlink, `b` or `c` is a device.
Beginner — When would you use `mkdir -p`?
When you want to create a directory and its parents if needed. Without `-p`, `mkdir /home/alice/documents/work` fails if `/home/alice/documents` does not exist. With `-p`, it succeeds. Always use `-p` in scripts because you cannot assume the parent exists.
Beginner — What is the difference between `cp` and `mv`?
`cp` creates a copy; the original remains. `mv` moves (or renames) the file; the original is gone. Within the same filesystem, `mv` is instant. Across filesystems, `mv` must copy (slow) and then delete (so it is safe — if the copy fails, the original stays).
Intermediate — Explain hard links vs symbolic links.
A hard link is another directory entry pointing to the same inode. Deleting one hard link does not affect the others; the file is deleted only when the link count drops to zero. A symbolic link is a special file containing a path (text). Deleting the symlink does not touch the target, but if the target is deleted, the symlink becomes broken (dangling). Hard links cannot cross filesystems or point to directories; symlinks can do both.
Intermediate — What does `find . -name "*.log" -type f -mtime +7 -delete` do?
Finds all regular files (not directories) in the current directory and below whose name ends in `.log` and which were modified more than 7 days ago, then deletes them. The `-type f` ensures only files are deleted, not directories. The `-delete` action runs `unlink()` on each match, so they are gone immediately — no recovery without a backup.
Intermediate — When should you use `touch`?
To create an empty file (if it does not exist) or update its modification time to now. Commonly used in scripts to mark a milestone: `touch /tmp/app_started` sets the mtime to now, which downstream scripts can check with `find ... -newer /tmp/app_started`. Also used to test whether a directory is writable: `touch /var/log/test.tmp && rm /var/log/test.tmp`.
Intermediate — Why is `CDPATH` rarely used in production?
Because it breaks portability and predictability. A script that does `cd log` on your machine (where `CDPATH=/home:/var`) will go to `/var/log`, but on another machine without `CDPATH` set, it will fail. Best practice: always use explicit paths in scripts.
Advanced — You need to find and delete all files in `/tmp` owned by user `nobody` and not accessed in the last 30 days. Write the `find` command.bash
find/tmp-usernobody-atime+30-typef-delete
Explanation: `-user nobody` matches files owned by the user `nobody`; `-atime +30` matches files not accessed in the last 30 days (older than 30 days); `-type f` ensures only regular files, not directories; `-delete` removes them. Test first without `-delete` to preview: `find /tmp -user nobody -atime +30 -type f`.
Advanced — What is the difference between `atime`, `mtime`, and `ctime`? How would you use them to detect tampering?
`mtime` is modification time (when the file's *contents* changed); `atime` is access time (when it was last read); `ctime` is change time (when the inode changed — permissions, ownership, size). To detect tampering: check `mtime` and `ctime` against a known baseline. If `mtime` is recent but the file should not have changed, or if `ctime` is recent but `mtime` is old (inode changed without contents changing), the file may have been tampered with. `ctime` cannot be set; it is maintained automatically.
Advanced — You have a directory with millions of files. `ls` is very slow. What are three ways to get a quick count?
1. **`find /path -type f -printf '.' | wc -c`** — find outputs one dot per file and wc counts them.
2. **`find /path -type f | wc -l`** — count lines of output from find.
3. **`locale | grep "^LC_ALL"`** — no, that is wrong. Instead: **`ls -q /path | wc -l`** — `ls -q` lists one per line and suppresses special chars.
The fastest is usually `find /path -type f | wc -l` because find can be optimized for counting, and piping to `wc` is fast.
Scenario — You suspect someone deleted critical logs from `/var/log`. The process that writes them is still running. How do you recover the file?
The kernel keeps file data alive as long as a process has it open. Use `lsof +L1` to find deleted files still held open:
bash
The process has the file open on file descriptor 3, so we copy `/proc/1234/fd/3` to recover it.
Company style — How would you script a bulk backup of all `.conf` files on a system, preserving structure?bash
The `-print0` ensures newlines in filenames do not break the pipeline; `xargs -0` (or `tar --null`) reads null-separated input.
HR style — Tell me about a time you misused `rm` or `find` and what you learned.
An honest story: "I once ran `rm -rf $BACKUP_DIR/*` when the script intended to clean up a temporary directory, but `$BACKUP_DIR` was empty (unset). The command became `rm -rf /*`, deleting from the root. I did not run it as root, so it failed quickly with permission denied, but I learned three lessons: (1) Always `set -u` in scripts to error on undefined variables; (2) Always `set -e` to exit on errors; (3) Always run dangerous operations with `--dry-run` first or `find` without `-delete` to preview. Now I always check with `find ... -type f` before adding `-delete`."
HR style — You are managing a large system with many symlinks. How do you ensure they are correct?
A systematic approach: (1) Document the symlink structure in a config file or script; (2) Regularly audit with `find -type l -exec test ! -e {} \; -print` to find dangling symlinks; (3) Use `ls -L` to follow symlinks and check that targets are where expected; (4) In deployment, recreate symlinks rather than trusting they exist — `rm -f /etc/nginx/sites-enabled/default && ln -s /etc/nginx/sites-available/default-real /etc/nginx/sites-enabled/default`. This makes deployments idempotent.
What does cd ~ do? (a) Go to /root(b) Go to the user’s home directory from $HOME(c) Go to /home(d) Go to the previous directory
The first character of ls -l output tells you: (a) file size (b) file type (file, directory, symlink, etc.) (c) owner name (d) permissions
What does mkdir -m 700 /tmp/secret do? (a) Creates a directory readable by all (b) Creates a directory readable/writable only by the owner (c) Creates a directory in /tmp and /secret(d) Creates a directory with 700 hard links
Which command copies a file and its directory structure? (a)cp(b)cp -r(c)cp -a(d) both (b) and (c)
What happens when you rm a file that is still open by a process? (a) The process crashes (b) The file is immediately deleted (c) The file’s inode is marked for deletion but data kept until the process closes it (d) Permission denied
A hard link to a file means: (a) a copy of the file (b) another name for the same inode (c) a symlink (d) a backup
What does find /var -size +1G -type f -print0 | xargs -0 ls -lh do? (a) Lists all files larger than 1 GB with human-readable sizes (b) Searches only in /var(c) Handles filenames with spaces correctly (d) all of the above
When you run rm -rf /var/log/*, the * is expanded by: (a)rm itself (b) the kernel (c) the shell (bash/zsh) (d) the filesystem
What is the risk with touch -t 202401010000 myfile? (a) It deletes the file (b) It makes the file read-only (c) It changes the modification time, which may break build systems relying on file age (d) It creates a symlink
locate nginx.conf is fast because: (a)locate uses a pre-built database (b) it is a shell builtin (c) it only searches /usr/bin(d) it uses find internally but caches results
Answers
1. (b) — `cd ~` expands to `$HOME` (e.g., `/home/alice`).
2. (b) — the first character: `-` for regular file, `d` for directory, `l` for symlink, `b`/`c` for devices.
3. (b) — `-m 700` sets permissions to 700 (owner only, no group/other access).
4. (d) — both `cp -r` and `cp -a` copy recursively; `-a` also preserves attributes.
5. (c) — the inode is marked for deletion but kept alive by the kernel while the process holds it open.
6. (b) — a hard link is another directory entry pointing to the same inode.
7. (d) — it finds large files, prints them null-separated (safe for spaces), and lists them in long format.
8. (c) — the shell expands `*` to a list of matching files before `rm` sees them.
9. (c) — changing the mtime retroactively may fool build systems into thinking nothing changed.
10. (a) — `locate` searches a pre-built database (usually `/var/lib/mlocate/mlocate.db`), not the live filesystem.
pwd always returns an absolute path starting with /.
cd .. is the same as cd ../.
ls hides dotfiles by default.
Hard links can span filesystems.
touch creates a file with size 0 if it does not exist.
ctime can be manually set with touch -t.
locate searches the live filesystem.
find -type l matches symbolic links.
rm -rf can be recovered with undo if you are fast.
A symlink is slower to follow than a hard link.
Answers
1. **True** — `pwd` returns an absolute path (unless using `-L` with symlinks navigated, but the path is still absolute).
2. **False** — `cd ..` and `cd ../` are the same destination but the shell syntax is different; both work.
3. **True** — by default, `ls` hides files starting with `.` unless you use `-a` or `-A`.
4. **False** — hard links must point to an inode in the same filesystem. Use symlinks to cross filesystems.
5. **True** — `touch` creates a zero-byte file (unless it exists, then updates timestamps).
6. **False** — ctime is set by the kernel automatically; you cannot manually change it.
7. **False** — `locate` searches a pre-built database (updated nightly); it is not live.
8. **True** — `-type l` in `find` matches symbolic links.
9. **False** — `rm` does not have an undo. Data may be recoverable from disk backups, but there is no Linux-level recovery once the inode is freed.
10. **True** — following a symlink requires reading the link file (which contains the path) and then opening the target; a hard link is a direct inode reference.
Do these on a throwaway VM or in a dedicated /tmp directory.
Navigate the tree. Create /tmp/lab/deep/nested/path with mkdir -p, then use cd, pwd, cd .., and cd - to navigate between levels and prove each one works.
List and inspect. Create five files with touch: file1.txt, file2.txt, .hidden, file_large (write 1 MB to it), and symlink_to_file1 -> file1.txt. Run ls, ls -a, ls -i, ls -lh, ls -t, and explain each output field.
Hard vs symbolic links. Create a file, make a hard link and a symlink to it, modify the original, delete it, and show that the hard link still works but the symlink is broken. Use ls -i to show inode numbers.
Copy and move. Create a directory backup/, copy some files into it with cp -a to preserve timestamps, then move them out with mv.
Find one-liners. Write and test these:
- Find all files ending in .txt in the current directory and below.
- Find all files owned by your user modified in the last hour.
- Find all empty files and directories.
- Find all world-writable files (security risk).
- Find all files larger than 100 MB and list them by size.
Touch and timestamps. Create a file, use touch -t to set its mtime to a week ago, then use find -mtime +7 to confirm it is found.
Recover deleted file. (Advanced) Open a file for writing in one terminal, delete it in another, and use lsof +L1 to find and recover it using /proc/PID/fd/N.
Write a bash script that safely backs up all .conf files in /etc, preserving directory structure, excluding certain paths, and handling filenames with spaces and newlines. Use find, -print0, and tar.
Investigate the FHS on your system. Create a map showing which directories are on which filesystems (use df /etc, df /var, etc.) and which are symlinks (use ls -ld /bin, ls -ld /sbin). Many systems now symlink /bin to /usr/bin.
Profile file access on your system. Use find with -atime, -mtime, and -ctime to categorize files by age. Identify candidates for archival or deletion (e.g., logs older than 90 days, cache files not accessed in a year).
Create a scenario with hard links, symlinks, and regular copies of a file. For each, show: storage used (du), whether modifications affect the other, and the behavior when the original is deleted.
Write a find command (or series of commands) to identify security issues on your system:
- World-writable files (-perm /002).
- Setuid binaries (-perm /4000).
- Files owned by a deleted user (UID exists, user does not).
- Symlinks pointing to /etc/passwd or other sensitive files.
Compare locate, find, and which performance on your system. Search for a known file using each and time them. Explain why find is slower but more flexible, and why locate can be wrong.
Build a script that monitors a directory for changes using find and mtime. Run it hourly via cron to detect unexpected modifications to production config files. Log the changes to a file.
Demonstrate how cd - (with $OLDPWD) saves time in workflows. Write a shell session (typed commands) showing you toggle between two directories repeatedly using cd -.
Explore inode exhaustion. On a filesystem with many small files (e.g., Node modules), use find | wc -l to count files and df -i to check inode usage. Show what happens when you hit 100% inode usage (even if space remains).
Reverse-engineer a legacy directory structure. Given an existing tree with many symlinks and hard links, write a report describing:
Which files are actually duplicated vs linked.
Which symlinks are dangling.
Which directories could be cleaned up or reorganised.
Recommendations for improving FHS compliance.
II — Working in the Shell
05Text Processing & Searching
The Unix philosophy made practical — small tools that do one thing, chained together to transform and understand any stream of text.
Every line of text you will ever process on Linux — a log file, a CSV row, a configuration option, an API response, the output of another command — flows through the same pipeline. You do not write programs to transform text; you compose filters — small, single-purpose tools — using pipes.
This is the Unix philosophy, and it is not nostalgia. When you have 100 GB of logs and need the top 10 IP addresses, you do not open an IDE. You write a one-liner. When you need to find all TODO comments in your codebase before a release, you grep. When you need to reformat 50,000 configuration lines at 2 a.m., you sed.
The tools in this chapter are the ones that make that possible. They are also the ones you will use most frequently in production, because text is how Unix speaks. Logs are text, configs are text, input from users is text, the output of every command is text.
The shell’s redirection operators connect these streams:
bash
command>file# redirect stdout to a file (overwrite)command>>file# redirect stdout to a file (append)command2>errors.txt# redirect stderr onlycommand2>&1# redirect stderr to wherever stdout goescommand&>file# redirect both stdout and stderr (bash shorthand)command<input.txt# read stdin from a filecommand<<<"text"# here-string: treat the string as stdin
cat/dev/null# redirect stdout to nowhere; /dev/null is a black hole
The pipe| chains commands — the stdout of the left side becomes the stdin of the right side:
You have a web server access log. You need the 10 IP addresses that generated the most requests.
terminal
$ cataccess.log|grep"200 OK"|awk'{print $1}'|sort|uniq-c|sort-rn|head-10
│ │ │ │ │ │ │ produce raw lines filter success extract group count sort take codes IP like each by top 10 address IPs group count
This works because:
Each tool accepts a stream on stdin
Each tool produces a stream on stdout
The next tool consumes that stdout as its stdin
No intermediate files touch the disk — all in RAM
This is why pipelines scale to billions of lines. This is the Unix philosophy.
Stream. An ordered sequence of bytes, usually organised into lines separated by newline characters (\n). Can come from a file, stdin, or another process.
Filter. A program that reads from stdin, transforms data, and writes to stdout. Designed to be the middle of a pipeline.
File descriptor. A non-negative integer (0, 1, 2, 3 …) that represents an open file, pipe or socket from a process’s perspective. POSIX reserves 0, 1, 2 for stdin, stdout, stderr. A process can have up to (typically) 1024 open descriptors at once.
Buffering. The OS or program holds data temporarily before sending it. Line buffering flushes on \n; full buffering waits until a buffer fills; unbuffered sends immediately. This matters in pipes — see tail’s -F flag.
Regular expression (regex). A pattern that matches strings. Supports literals (cat), wildcards (. means any char, * means zero or more), character classes ([a-z]), anchors (^ start, $ end), and more. There are two dialects: BRE (Basic Regular Expression, used by grep, sed by default) and ERE (Extended, used by grep -E, egrep). Modern PCRE (Perl-Compatible Regular Expressions) is a superset, used by ripgrep and some tools.
Exit code (return value). A number (typically 0–255) a program returns when it exits. 0 means success; any other value signals failure. Commands in pipes can check this — grep returns 0 if a match was found, 1 if not, 2 if there was an error.
4 · Internal Working: The Pipe Buffer and Buffering Gotchas#
Why:grep sees stdin is not a terminal, so it switches to full buffering. It holds 64 KB of data before flushing. Meanwhile, tail -F is publishing lines one by one, but they sit in the buffer.
Purpose: Display file contents, concatenate files, stream data through pipes, produce text for redirection.
bash
catfile.txt# display one file
catfile1.txtfile2.txt# concatenate two files
cat<< 'EOF' # here-document: read until EOFmarker
Thisisamulti-linestring.
EOF
cat/dev/null# common: redirect stdout to nowhere
All important options:
Flag
Effect
Example
-n
Number all lines
cat -n file.txt → 1 line1, 2 line2
-b
Number non-blank lines only
cat -b file.txt → skips blank lines in numbering
-s
Squeeze multiple blank lines into one
cat -s file.txt → \n\n\n becomes \n
-A
Show non-printing characters: ^I tab, $ EOL, ^M CR
cat -A file.txt → reveals hidden whitespace
-v
Show non-printing characters, except tabs and newlines
cat -v file.txt
-e
Same as -v except tabs shown, line ends marked with $
cat -e file.txt
-t
Same as -A except non-printing chars not shown, tabs as ^I
cat -t file.txt
CRLF (Windows line endings) demonstration:
Files created on Windows have \r\n (carriage return + line feed) instead of \n:
terminal
$ printf"line1\r\nline2\r\n">windows.txt
$ catwindows.txt|od-c# od shows raw bytes0000000 l i n e 1 \r \n l i n e 2 \r \n$ cat-Awindows.txt# -A shows non-printing charsline1^M$line2^M$
Convert with dos2unix or sed 's/\r$//':
bash
sed's/\r$//'windows.txt>unix.txt
Useless cat — an anti-pattern:
bash
catfile.txt|greppattern# WRONG: unnecessary cat
greppatternfile.txt# RIGHT: grep can read files directly
catfile.txt|wc-l# WRONG: cat then pipe to wc
wc-lfile.txt# RIGHT: wc can read files
Use cat only when you actually need to concatenate or when the input is stdin (e.g. docker logs | cat).
Related commands:
tac — reverse cat, print lines in reverse order
less / more — paged viewing (next)
bat — cat with syntax highlighting (modern alternative, outside the core)
zcat — cat for gzip files without decompressing first: zcat file.gz
Purpose: Display the first lines of a file or stream. Default: 10 lines.
bash
head-n20file.txt# first 20 lines
head-c100file.txt# first 100 bytes (characters)
head-n-10file.txt# all except the last 10 lines (negative form)
catfile.txt|head# first 10 lines of stdin
head-qfile1file2# no header separating files
head-vfile.txt# print filename header even if one file
Purpose: Display the final lines of a file or stream. Default: 10 lines.
bash
tail-n20file.txt# last 20 lines
tail-c100file.txt# last 100 bytes
tail-ffile.txt# follow: print new lines as they are appended
tail-Ffile.txt# follow even if file is rotated (robust)
tail+Nfile.txt# start from line N onward (old syntax)
tail--pid=<PID>-ffile.txt# stop following when PID exits
All options and their purposes:
Option
Purpose
Example
Production use
-n N
Print last N lines (default 10)
tail -n 50 file.txt
Review recent logs
-c N
Print last N bytes
tail -c 1024 file.txt
Get tail of a binary or large file
-f
Follow: keep showing new lines
tail -f /var/log/app.log
Monitor logs (stops when file is deleted)
-F
Follow file by name, reopen if rotated
tail -F /var/log/app.log
Better for production (handles logrotate)
+N
Start from line N (not -n, just +N)
tail +100 file.txt
Skip first 99 lines
--pid=<PID>
Stop -f/-F when a process exits
tail -f file.txt --pid=$$
Debugging: follow a log while a script runs
Why -F is better than -f in production:
Log rotation is standard: logrotate renames /var/log/app.log to /var/log/app.log.1, and the app starts writing to a new /var/log/app.log.
tail -f keeps the file descriptor open to the old inode, so you stop seeing new logs
tail -F re-opens by name, so it follows the rotation correctly
bash
tail-f/var/log/syslog&# [PID 1234]
sleep5;kill1234# This unblocks tail, but could be in the middle of reading# Better:
tail-f/var/log/syslog--pid=$$&# tail stops gracefully when this shell exits
The + quantifier — “one or more” (ERE only, or BRE \+):
ca+t matches: "cat", "caat", "caaat"
does NOT: "ct"
The ? quantifier — “zero or one”:
colou?r matches: "color", "colour"
does NOT: "colouur"
Character classes — [abc] means “a or b or c”:
[aeiou] matches: "a", "e", "i", "o", "u"
[0-9] matches: any single digit
[a-zA-Z] matches: any single letter (lower or upper)
[^aeiou] matches: any character EXCEPT vowels (^ inside [] means NOT)
Anchors:
^ERROR matches: "ERROR" only at the start of a line
ERROR$ matches: "ERROR" only at the end of a line
^ERROR$ matches: a line containing ONLY "ERROR"
Word boundaries (ERE or with -E):
\berror\b matches: "error" as a whole word, not "errorfield"
(BRE: `\berror\b`, ERE: `\berror\b`)
Groups and alternation:
(cat|dog) matches: "cat" or "dog"
(gr[ae]y) matches: "gray" or "grey"
Greedy vs lazy matching:
.*txt matches: "anything.txt" (greedy, takes as much as possible)
.*?txt matches: the shortest string ending in "txt" (lazy, needs PCRE)
Escaping metacharacters:
\. matches: a literal dot, not "any character"
\$ matches: a literal dollar sign
\[ matches: a literal [
BRE vs ERE — the difference:
In BRE (Basic Regular Expression, default grep):
grep "a\+b" file # \+ means one or more (escaped)
grep "a*b" file # * means zero or more (unescaped)
grep "(abc)" file # () are literal parens; \(\) are groups
In ERE (Extended, grep -E or egrep):
grep -E "a+b" file # + means one or more (unescaped)
grep -E "a*b" file # * means zero or more
grep -E "(abc)" file # () are groups (unescaped)
Regex reference table:
Pattern
Meaning
Example
BRE / ERE
.
Any single character
c.t → cat, cot, c1t
both
*
Zero or more
ca*t → ct, cat, caat
both
+
One or more
ca+t → cat, caat
ERE: a+, BRE: a\+
?
Zero or one
colou?r → color, colour
ERE: u?, BRE: u\?
[abc]
Any of a, b, c
[aeiou] → vowels
both
[^abc]
Not a, b, c
[^0-9] → non-digit
both
^
Start of line
^ERROR
both
$
End of line
ERROR$
both
\b / \<\>
Word boundary
\bcat\b
both (in BRE, may need -E in some implementations)
Purpose: Edit streams line by line. Transform text without opening an editor or storing in memory.
bash
sed's/old/new/'file.txt# substitute: first match per line
sed's/old/new/g'file.txt# substitute: all matches per line
sed-i.bak's/old/new/g'file.txt# edit file in place (backup as .bak)
sed-n'/pattern/p'file.txt# print only lines matching pattern
sed'/pattern/d'file.txt# delete lines matching pattern
sed'2,5d'file.txt# delete lines 2–5
sed-e's/old/new/'-e'/pattern/d'file.txt# chain multiple edits
sed's/red/blue/'file.txt# replace first "red" on each line
sed's/red/blue/g'file.txt# replace all "red" on each line
sed's/red/blue/i'file.txt# case-insensitive
sed's/red/blue/p'file.txt# print matching lines twice (with `-n`, print once)
sed's/red/blue/w out.txt'file.txt# write replacements to file
Flags for s:
Flag
Meaning
Example
g
Global — all occurrences on the line
s/x/y/g
p
Print the matched line (use with -n for single output)
Purpose: Process structured data (fields in rows), extract columns, compute sums/averages, build reports.
Mental model first: awk sees a file as rows of records, each split into fields. By default, fields are separated by whitespace (spaces or tabs), and each row is a line. You define actions to run on matching patterns.
bash
awk'{print $1, $3}'data.txt# print fields 1 and 3 of each line
awk'$3 > 100 {print $1}'data.txt# print field 1 where field 3 > 100
awk'BEGIN {sum=0} {sum+=$1} END {print sum}'data.txt# sum column 1
awk-F,'{print $2}'data.csv# CSV: use comma as field separator
Imagine this is in the PDF: count HTTP status codes from a web log, sorted by frequency.
bash
awk'{print $9}'access.log|sort|uniq-c|sort-rn
Breaking it down:
- awk '{print $9}' → extract field 9 (HTTP status code) from each line of the log
- sort → group identical status codes together
- uniq -c → count consecutive groups
- sort -rn → sort by count, descending
Even better with awk directly:
bash
awk'{status[$9]++} END {for (s in status) print status[s], s}'access.log|sort-rn
awk '{status[$9]++}' → build a “status” array, incrementing the count for each status code
END {for (s in status) ...} → after all input, loop through the array and print
Purpose: Remove (or count) consecutive duplicate lines. Important: uniq only sees adjacent duplicates.
bash
uniqfile.txt# remove consecutive duplicate lines
uniq-cfile.txt# count consecutive groups
uniq-dfile.txt# show only duplicates
uniq-ufile.txt# show only unique lines (appear once)
uniq-ifile.txt# case-insensitive
tr'a-z''A-Z'<file.txt# lowercase to uppercase
tr-d'0-9'<file.txt# delete all digits
tr-s' '<file.txt# squeeze multiple spaces into one
tr-c-d'[:alpha:]'<file.txt# delete all non-alphabetic
Purpose: Capture output while also passing it through a pipeline.
bash
catdata.txt|teeoutput.txt|wc-l# save and count in one pipelinecommand|tee-alog.txt|greppattern# append to log, then filter
sudotee/etc/config.txt<temp.txt# write to root-owned file
All options:
Option
Effect
Example
-a
Append (don’t overwrite)
tee -a log.txt
-i
Ignore SIGINT
tee -i output.txt
-p
Diagnose write errors
tee -p file.txt
The sudo tee pattern:
To write to a file you don’t own (e.g., /etc/config):
bash
echo"new config"|sudotee/etc/config>/dev/null
Why:
- echo runs as your user
- sudo elevates tee’s privilege
- tee writes to the protected file
- > /dev/null suppresses the stdout (we only care about the file write)
Purpose: Edit files with powerful modal editing. Universal on all Unix systems.
bash
vifile.txt# open file
Mental model: vi has two modes.
Normal mode (when you start): navigation and commands
Insert mode (to edit): type text
Escape from insert mode back to normal: press Esc.
Entering insert mode:
Key
Action
i
Insert before cursor
a
Append after cursor
o
Open new line below
O
Open new line above
In normal mode — navigation:
Key
Action
h j k l
Left, down, up, right (or arrow keys)
gg
Go to start of file
G
Go to end of file
5G
Go to line 5
/pattern
Search forward
?pattern
Search backward
n
Next search match
N
Previous search match
In normal mode — editing:
Key
Action
dd
Delete line
5dd
Delete 5 lines
yy
Yank (copy) line
p
Paste below
P
Paste above
u
Undo
Ctrl+R
Redo
x
Delete character
r
Replace character
J
Join line with next
Colon commands (type : to enter command mode):
Command
Action
:w
Write (save)
:q
Quit
:wq or :x
Write and quit
:q!
Quit without saving
:set number
Show line numbers
:set nonumber
Hide line numbers
:%s/old/new/g
Replace all
:10,20s/old/new/
Replace in lines 10–20
:e file
Open another file
Getting unstuck (the essential tip):
If you are stuck, confused, or typed something weird:
1. Press Escape repeatedly to return to normal mode
2. Type :q! and press Enter to quit without saving
3. You are safe; no data is lost
flowchart LR
A["cat access.log<br/>(produces raw lines)"] -->|stdout| B["grep 200<br/>(filters success)"]
B -->|stdout| C["awk print $1<br/>(extracts IP)"]
C -->|stdout| D["sort<br/>(groups)"]
D -->|stdout| E["uniq -c<br/>(counts)"]
E -->|stdout| F["sort -rn<br/>(ranks)"]
F -->|stdout| G["head -10<br/>(top 10)"]
H["error output"] -.->|stderr| I["terminal"]
G -->|final output| J["terminal/file"]
Each box is a process. Each arrow is a pipe (or stdout redirect).
Purpose: Read lines from stdin, turn them into arguments for a command. Handles bulk operations.
bash
find.-name"*.py"|xargsls-l# list all Python files
find.-name"*.log"|xargsrm# delete all log filesecho"arg1 arg2 arg3"|xargs-n1echo# run echo once per argument
catfilelist.txt|xargs-I{}cp{}/backup/# copy files listed in a file
All important options:
Option
Effect
Example
-n N
Pass N arguments per command invocation
xargs -n 1 echo — one at a time
-I {}
Use {} as placeholder for each argument
xargs -I {} cp {} /dest
-0
Null-delimited input (from find -print0)
find . -name "*.txt" -print0 | xargs -0 wc
-P N
Run N commands in parallel
xargs -P 4 -n 1 process — 4 at a time
-t
Print command before executing
xargs -t rm — show what it’s deleting
-r
Do not run if input is empty
xargs -r rm — avoid rm with no args
Example: deleting many files safely
bash
find/tmp-name"*.old"-print0|xargs-0-t-rrm
-print0 — null-terminated (safe for spaces/special chars)
Beginner — What is a pipe, and how do you use it?
A pipe connects the stdout of one command to the stdin of another, using the `|` operator. It allows you to chain commands without intermediate files. Example: `cat data.txt | grep ERROR | wc -l` — the output of `cat` feeds into `grep`, whose output feeds into `wc`.
Beginner — What does `grep` do, and what is the most common flag?
`grep` selects (filters) lines matching a pattern. The most common flag is `-v`, which inverts the match — select lines that do NOT match. E.g. `grep -v DEBUG app.log` shows all lines except debug output.
Beginner — What is the difference between `cut` and `awk`?
`cut` is simpler and faster for extracting columns: `cut -f 2 -d: /etc/passwd`. `awk` is more powerful — it can compute, build arrays, and apply conditions: `awk -F: '$3 > 1000 {print $1}' /etc/passwd` (users with UID > 1000). Use `cut` for simple slicing, `awk` for computed selections.
Beginner — What does `sort | uniq -c | sort -rn` do?
This canonical pattern counts and ranks items. `sort` groups identical lines, `uniq -c` counts each group, and `sort -rn` orders by count descending. Example: `cat IPs.txt | sort | uniq -c | sort -rn | head -5` shows the top 5 most common IPs.
Intermediate — What is the difference between `sed 's/old/new/'` and `sed 's/old/new/g'`?
The first replaces only the first occurrence on each line. The `g` flag (global) replaces all occurrences on each line. Example: `echo "a b a" | sed 's/a/A/'` outputs `A b a`, while `sed 's/a/A/g'` outputs `A b A`.
Intermediate — What does `awk 'BEGIN {sum=0} {sum+=$1} END {print sum}' file` do?
It sums the first column of a file. `BEGIN` runs before input, so `sum=0` initializes. Then for each line, `$1` is added to sum. `END` runs after all input, printing the total. If the file has numbers in column 1, this outputs their sum.
Intermediate — Explain the gotcha with `tail -F /var/log/app.log | grep ERROR`. Why is it slow?
`grep`, seeing stdin is not a terminal, switches to full buffering. It holds 64 KB of data before flushing, so lines sit in the buffer for seconds. `tail -F` publishes one line at a time, but you do not see them until the buffer fills. Fix: `tail -F /var/log/app.log | grep --line-buffered ERROR` or use `sed -n '/ERROR/p'` (line-buffered by default).
Intermediate — What is a regular expression, and what is the difference between BRE and ERE?
A regex is a pattern template for matching strings. BRE (Basic Regular Expression) requires escaping for special characters: `grep "a\+b"` means one or more `a` followed by `b`. ERE (Extended) does not escape: `grep -E "a+b"`. ERE is more readable; always use `grep -E` or `egrep` unless you need portability to ancient systems.
Advanced — Design a one-liner to find the top 10 most-accessed HTTP endpoints from an access log, ignoring 404s.bash
Or more precisely: `awk '$9 == 200 {print $7}' access.log | sort | uniq -c | sort -rn | head -10` (assuming status is field 9). This filters for status 200, extracts the endpoint (field 7), counts, ranks, and takes the top 10.
Advanced — What does `find . -name "*.txt" -print0 | xargs -0 wc -l` do, and why use `-print0` and `-0`?
It counts lines in all `.txt` files. `-print0` makes `find` output null-delimited filenames (instead of newline-delimited), and `xargs -0` reads null-delimited input. Why: filenames with spaces or special characters break the ordinary `find . -name "*.txt" | xargs wc -l`. The null delimiter is safe for any filename.
Advanced — Explain the difference between `grep -r` and `grep -R`.
`grep -r` recurses into directories but does not follow symlinks. `grep -R` recurses and follows symlinks. In production searching `/etc` or large codebases, `-r` is typically safer (avoids infinite loops if there are circular symlinks), while `-R` is useful when symlinks intentionally point to shared code. The difference matters on systems with symlinked `include` directories.
Scenario — You have a 500 MB CSV file. You need to extract rows where column 3 (status) is "active" and column 5 (age) is > 30, then output columns 1, 2, 5 sorted by column 2. Write the pipeline.bash
Breakdown:
- `-F,` — CSV delimiter
- `$3 == "active" && $5 > 30` — filter rows
- `{print $1 FS $2 FS $5}` — output columns 1, 2, 5 with same delimiter
- `sort -t, -k2` — sort by column 2
This never loads the whole file into memory — it streams.
Scenario — A developer says "grep is too slow." They want to search 1 million log lines for a pattern. What faster tool would you recommend, and why?
**ripgrep** (`rg`). It is 10–100x faster than `grep` because it:
1. Uses SIMD (Single Instruction Multiple Data) for pattern matching
2. Runs in parallel on multi-core machines
3. Skips binary files automatically
4. Respects `.gitignore`
Example: `rg "ERROR" /var/log` vs `grep -r "ERROR" /var/log`. Ripgrep is instant; grep takes seconds on very large datasets. If `rg` is not available, `grep -F` (fixed string, no regex) is faster than `grep` (regex is slower).
Company style — You are on call and a database query is slow. Your team mentions "the app writes to a CSV log." How would you investigate?
1. First: `ls -lh /path/to/log.csv` — how big? Slower if it's 50 GB.
2. Head/tail: `head /path/to/log.csv` and `tail /path/to/log.csv` — current data, format check.
3. Line count: `wc -l /path/to/log.csv` — how many rows?
4. Search: `grep "error\|null" /path/to/log.csv | wc -l` — how many errors?
5. Sample analysis: `head -1000 /path/to/log.csv | awk -F, '{print $3}' | sort | uniq -c | sort -rn` — what values are common in column 3?
These grep, awk, and sort commands run in seconds and answer the question without loading the file into a database. This is DevOps thinking: use text processing before complex tools.
HR style — Tell me about a time you used text processing to solve a problem.
Good answer includes: a real problem, the tools used (grep/awk/sed), and the result. Example: "We had 10 GB of API logs. I needed to find all failed requests and group them by error code to identify the most common failure. I used `grep "FAILED" | awk '{print $NF}' | sort | uniq -c | sort -rn` to find that 80% were timeout errors. That led the team to increase the timeout threshold." This shows you think operationally and reach for text processing before complex tools.
Which command would you use to show lines matching a pattern? (a)sed 's/x/y/'(b)grep(c)awk(d)sort
What does sort -n do? (a) Ignores numbers (b) Sorts alphabetically (c) Sorts numerically (d) Shuffles
The regex . matches: (a) end of line (b) any single character (c) zero or more (d) one or more
What is the correct way to count lines matching a pattern? (a)grep PATTERN FILE | wc -l(b)grep -c PATTERN FILE(c) both are equivalent (d) neither
How do you delete lines matching a pattern with sed? (a)sed 's/PATTERN//g'(b)sed '/PATTERN/d'(c)sed -d PATTERN(d)sed 'PATTERN~d'
Which is faster on a 1 GB file: grep or sort? (a)grep(b)sort(c) same speed (d) depends on patterns
What does awk '{print $1, $NF}' do? (a) prints all fields (b) prints first and last fields (c) prints first and number-of-fields (d) error
sed -i.bak is better than sed -i because: (a) it is faster (b) it creates a backup (c) it works on all systems (d) it is more portable
When should you use tail -F instead of tail -f? (a) never (b) on production logs that rotate (c) only in scripts (d) when following stdin
What does xargs -0 mean? (a) run zero times (b) read null-delimited input (c) no timeout (d) run in parallel
Answers
1. (b) — `grep` is the filtering tool.
2. (c) — `-n` sorts numerically (1, 2, 10; not 1, 10, 2).
3. (b) — `.` matches any single character.
4. (c) — Both work, but `grep -c` is more direct.
5. (b) — `/PATTERN/d` deletes matching lines.
6. (a) — `grep` is O(n) scan; `sort` is O(n log n).
7. (b) — first and last fields.
8. (b) — creates a backup.
9. (b) — on production logs that rotate (logrotate).
10. (b) — null-delimited input (from `find -print0`).
sort | uniq removes all duplicates; uniq alone removes only adjacent duplicates.
tail -f always works for logs that rotate.
sed can edit files in place with the -i flag.
awk can compute sums and averages without loading the entire file into memory.
wc -l always counts lines accurately, even if the file has no final newline.
cut -f 2 -d: will correctly extract the 2nd field from a line with multiple colons.
The pipe | connects stderr to the next command by default.
Answers
1. **False** — `.` is a metacharacter meaning any character. It matches "a1b", "aXb", etc.
2. **True** — `sort` groups identical lines, then `uniq` removes adjacent duplicates.
3. **False** — `tail -f` keeps the inode open, so it misses rotated files. Use `tail -F`.
4. **True** — `sed -i file.txt` edits in place.
5. **True** — `awk` processes line by line, streaming.
6. **False** — if there is no final newline, `wc -l` counts one less.
7. **True** — as long as fields are consistently delimited by `:`.
8. **False** — the pipe connects stdout only. To include stderr: `cmd 2>&1 | next` or `cmd |& next`.
Do these on a system where you can safely create files.
Text pipeline practice. Create a file with 10 random words, one per line. Use sort, uniq -c, and sort -rn to “rank” them (which will be silly, but you learn the pattern).
grep and regex. Create a file with 5 email addresses. Use grep to select only Gmail addresses. Use grep -E with alternation to select Gmail or Outlook.
sed substitution. Create a config file with colour names (e.g., color=red, color=blue). Use sed 's/color/colour/g' to change British spelling, and verify with diff.
awk computed fields. Create a file with 3 columns: name, score1, score2. Use awk to print the name and average of the two scores.
tail following. In one terminal, run tail -f /tmp/test.log. In another, echo "line1" >> /tmp/test.log and watch it appear. Then use sed -n '/line/p' /tmp/test.log to filter logged lines.
xargs and find. Create a directory with 5 .txt files. Use find . -name "*.txt" | xargs wc -l to count lines in all of them.
Log analysis. Given an Apache or Nginx access log (or simulate one), extract the 10 most-accessed URLs and their hit counts. Show your pipeline step by step.
CSV processing. Create a CSV file with columns: id, name, age, salary. Use awk to compute the average salary and print employees over 30 years old.
Regex mastery. Write a grep command that finds all IPv4 addresses in a file. Then write one that finds all email addresses.
File sorting and ranking. Create a file listing system processes (or use ps aux). Rank them by memory usage and show the top 5 CPU consumers separately.
Diff and patch. Create two versions of a config file. Generate a unified diff. Show a colleague how to apply the patch to the original, achieving the new version.
Stream editing with sed. Create a Dockerfile or shell script with hardcoded values. Use sed to replace staging values with production values in place.
Multi-file grep. Write a one-liner that finds all Python files in a directory tree and extracts lines containing “TODO” with their filenames and line numbers.
Buffering and real-time. Set up a simple log file and tail -f it in one terminal. In another, simulate writing to it. Notice the delay without --line-buffered. Then fix it.
Pipeline optimisation. Design a pipeline that processes 1 GB of structured data. Measure performance: compare grep alone, awk alone, and a full pipeline. Which is fastest?
Error handling in pipelines. Write a script that safely processes a list of filenames (possibly with spaces) using find -print0 and xargs -0. Demonstrate why this is safer than plain xargs.
II — Working in the Shell
06Archiving & Compression
Bundling many files into one and making that one file smaller are two different jobs, and Unix keeps them deliberately separate.
You have 14,000 files in a project directory. You need to get them onto another machine, or into a backup, or up to an S3 bucket. Copying 14,000 files one at a time is slow — not because of the bytes, but because every single file costs a round trip: an open, a write, a close, and over a network, a handshake. Ten thousand small files can take longer to transfer than one large file of the same total size, by an order of magnitude.
So you want to turn many files into one file. That is archiving.
Separately, those bytes are compressible. Text, source code, JSON, logs and configuration files are enormously redundant — the word function appears 4,000 times in your JavaScript, 2026-07-31 appears on every line of the log. Squeezing that redundancy out means fewer bytes to store and fewer to ship. That is compression.
These are two different problems, solved by two different kinds of program, and the single most useful thing this chapter can teach you is to stop thinking of them as one thing. The Windows world taught everybody that “zipping” is one operation. On Unix it is two, and knowing that is the difference between using tar confidently and copying incantations off Stack Overflow forever.
PROBLEM 1: many files → one file PROBLEM 2: one file → smaller file
───────────────────────────────── ─────────────────────────────────
Also must remember, for each file: Must be reversible bit for bit:
· its path and name · no data may be lost
· its permissions (rwx) · must work on any byte stream,
· its owner and group not just on files
· its timestamps · knows nothing about names,
· whether it is a symlink, permissions or directories
a hard link, a device node,
a FIFO, a sparse file
SOLVED BY: tar, cpio, ar SOLVED BY: gzip, bzip2,
(archivers) xz, zstd (compressors)
└────────── compose with a pipe ──────────┘
tar -cf - dir | gzip > dir.tar.gz
Windows conflated the two: a .zip file is an archive and each member inside it is compressed, by one program, in one format, defined by one specification. That is genuinely convenient, and it buys a real technical advantage we will come to. But it also means that when a better compression algorithm arrives, the archive format itself has to change, every implementation has to be updated, and old tools cannot read new files.
Unix’s separation means zstd — invented in 2016, twenty-something years after tar’s format was standardised — worked with tar on day one. Nothing about tar had to change. That is what “small tools composed by pipes” buys you.
Egress is billed. Cloud providers charge for bytes leaving their network. Compressing a 400 GB nightly database dump at 5:1 is a direct line-item saving, every night, forever.
Storage is billed. Backup retention of 90 days at 4:1 compression is 90 days at a quarter of the price.
Backup windows are finite. A backup that takes nine hours does not fit in a six-hour window. Which compressor you choose is a real capacity-planning decision, not a preference.
Restore time is an SLA. Recovery Time Objective is measured in minutes. bzip2 decompresses at roughly a tenth the speed of zstd; on a 200 GB restore that is the difference between a coffee and an afternoon.
Image pull latency multiplies. A Kubernetes cluster scaling from 10 to 400 pods pulls the same image 390 times. This is exactly why the container ecosystem moved from gzip to zstd layers.
You pack boxes. Objects that were spread across shelves and drawers go into a small number of boxes. On the side of each box you write what is inside and — crucially — which room it came from, because when you arrive you need the kitchen things back in the kitchen. You also note “fragile”, “this end up”, and who owns it.
That is archiving. The box is the archive. The writing on the side is the metadata: path, permissions, owner, timestamps. An archiver that forgot which room a thing came from would be useless.
You vacuum-seal the duvet. Same duvet, same fibres, a third of the volume, and it springs back to full size when you open the bag. Nothing was thrown away.
That is lossless compression. Notice that the vacuum bag knows nothing about duvets. It would work on jumpers. It works on anything squashy.
You can vacuum-seal a box — compress the archive — and that is exactly what .tar.gz is. But the box and the vacuum bag remain two separate inventions.
This one is not decoration. It is precisely the technical difference, and it will let you answer the hardest interview question in this chapter.
Sequential media vs indexed media
A .tar ARCHIVE = A CASSETTE TAPE A .zip ARCHIVE = A CD
──────────────────────────────── ─────────────────────
Songs recorded one after another. Tracks recorded anywhere on
To reach track 7 you must physically the disc, plus a TABLE OF
wind past tracks 1-6. CONTENTS that says exactly
where each track starts.
There is no table of contents. To
know what is on the tape you play Want track 7? Read the index,
the whole thing. jump straight to it.
Cheap to record: just start recording, Needs to write the index, so
you never need to know how long needs to know where things
the album will be. ended up. Harder to stream.
And now the practical consequence, which is the thing people get wrong: to pull one small file out of the middle of a 40 GB .tar.gz, the machine must decompress everything in front of it. There is no shortcut. Whereas unzip reads the index at the end of the .zip, seeks directly to that one member, and decompresses that member alone.
Say you archive 500 nearly identical HTML files. Each one shares the same <head>, the same navigation bar, the same footer.
tar glues them into one continuous stream, and then gzip compresses that stream as a whole. When gzip reaches file 300 and sees the navigation bar again, it can say “the same 800 bytes I saw 4 KB ago” — because as far as gzip is concerned, this is one long document. Compressors exploiting redundancy across members like this produce what is called a solid archive.
zip compresses each member independently, because that is the price of the index: each member must be decompressible on its own, so it cannot refer back to a neighbour. File 300 starts from a blank slate.
On many small similar files, .tar.gz routinely beats .zip substantially. On one large file, the difference vanishes. That trade — ratio versus random access — is not a flaw in either format. It is the choice each one made.
Archiving. Combining multiple files and directories into a single file (an archive), together with the metadata needed to restore them faithfully: relative path, permission bits, numeric and symbolic owner and group, modification time, file type (regular, directory, symbolic link, hard link, character device, block device, FIFO), and link targets. Archiving does not necessarily reduce size — a plain .tar is usually slightly larger than the sum of its inputs, because of headers and padding.
Compression. Encoding a byte stream so it occupies fewer bytes, in a way that a matching decoder can reverse.
Lossless
The output of decompression is bit for bit identical to the input. Mandatory for code, documents, databases and archives. Everything in this chapter is lossless.
Lossy
Information is deliberately discarded because a human will not notice — JPEG, MP3, H.264. Never appropriate for a tarball. Mentioned only so you can say the word "lossless" and mean something by it.
Compression ratio. Original size ÷ compressed size. A ratio of 4:1 means the output is 25% of the input. Some tools report the inverse as a percentage saved (gzip -l says -0.0%), so always check which convention you are reading.
Filter. A program that reads a byte stream on standard input and writes a transformed byte stream on standard output. gzip, bzip2, xz and zstd are all filters first and file-manglers second, which is why tar can invoke them without any special integration.
tar. A utility that creates, lists, appends to and extracts from archives in the tar format. Unpack that definition:
Term in the definition
What it actually means
“the tar format”
A sequence of 512-byte blocks: one header block per member, followed by that member’s data padded up to a 512-byte boundary
“creates”
Mode -c. Reads files from disk, writes an archive
“lists”
Mode -t. Walks the headers and prints them — the archive’s table of contents
“appends to”
Mode -r (add at the end) and -u (add only if newer). Only possible on an uncompressed archive
“extracts”
Mode -x. Reads the archive, recreates files and metadata on disk
what it is not
A compressor. tar compresses nothing by itself; -z, -j, -J and --zstd shell out to gzip, bzip2, xz and zstd
zip / unzip. Implementations (the Info-ZIP ones on Linux) of the PKZIP .zip format, which is simultaneously an archive format and a compression container: each member is individually compressed — normally with DEFLATE, the same algorithm gzip uses — and an index called the central directory is written at the end of the file.
Solid vs non-solid archive. In a solid archive the compressor sees all members as one continuous stream and can exploit redundancy between them (.tar.gz, .7z by default). In a non-solid archive each member is compressed independently (.zip). Solid compresses better; non-solid allows random access and localises corruption.
A tar archive is nothing but 512-byte blocks. There is no header at the front of the file, no index at the back, no magic number at offset 0 identifying the whole file. There is only: header, data, header, data, …, and then two blocks of zeros to say “that is the end”.
A tar stream, block by block (every block is exactly 512 bytes)
Two immediate consequences you can now reason about instead of memorising:
A tar archive is append-friendly. “End of archive” is just two zero blocks; tar -r seeks to them, overwrites them, and carries on. That is why -r exists and why it cannot work on a compressed archive — you cannot seek into the middle of a gzip stream and resume writing valid output.
A tar archive is streamable. Nothing in a header refers to anything later in the file, so tar can start emitting bytes before it knows how big the archive will be. This is the property that makes tar -cf - dir | ssh host … possible, and it is why Docker layers are tar streams.
The header itself is the POSIX ustar layout, 500 used bytes of the 512:
Offset
Length
Field
Notes
0
100
name
Path, as stored. Historically limited to 100 characters
100
8
mode
Permission bits, in ASCII octal
108
8
uid
Numeric user ID, octal
116
8
gid
Numeric group ID, octal
124
12
size
Size in bytes, octal. Classic limit: 8 GiB
136
12
mtime
Modification time as an octal Unix timestamp
148
8
chksum
Simple sum of the header’s bytes — an integrity check on the header, not the data
ustar\0 then 00 — how a tool recognises a tar header at all
265
32
uname
Owner name as text, e.g. deploy
297
32
gname
Group name as text
329
16
devmajor, devminor
Device numbers, for device nodes
345
155
prefix
Prepended to name, raising the practical path limit to ~255
Two details in that table earn their keep in interviews. First, there is a checksum per header but none over the file data — tar has no way of telling you a member’s contents were corrupted, which is why you keep a sha256sum alongside the tarball. Second, ownership is stored twice: numerically and by name. On extraction tar prefers the name, looks it up in /etc/passwd on the restoring machine, and uses whatever UID that maps to. If you are restoring onto a different host where deploy happens to be UID 1004 instead of 1001, that is what you want. If you are restoring a container root filesystem, it is emphatically not — which is exactly what --numeric-owner is for.
Modern GNU tar escapes the 100-character and 8 GiB limits with extra pseudo-members: GNU L/K blocks for long names, or POSIX pax extended headers (typeflagx), which carry arbitrary key=value metadata — sub-second timestamps, huge sizes, extended attributes, SELinux labels. Check with tar --show-defaults; GNU tar 1.35 still defaults to --format=gnu.
A zip file: per-member headers, plus a real index at the end
┌──────────────────────────────────────────────────────┐
│ PK\x03\x04 LOCAL FILE HEADER "dist/index.html" │
│ method=8 (deflate) · crc32 · csize · usize · name │
├──────────────────────────────────────────────────────┤
│ <deflate-compressed bytes of index.html> │ compressed
├──────────────────────────────────────────────────────┤ ON ITS OWN
│ PK\x03\x04 LOCAL FILE HEADER "dist/app.js" │
├──────────────────────────────────────────────────────┤
│ <deflate-compressed bytes of app.js> │
├══════════════════════════════════════════════════════┤
│ PK\x01\x02 CENTRAL DIRECTORY ENTRY "dist/index…" │
│ …everything above, PLUS: external attributes │
│ (Unix mode lives here) and, critically, │
│ OFFSET OF THE LOCAL HEADER ────────────────┐ │
├──────────────────────────────────────────────────┼───┤
│ PK\x01\x02 CENTRAL DIRECTORY ENTRY "dist/app…" │ │
├──────────────────────────────────────────────────┼───┤
│ PK\x05\x06 END OF CENTRAL DIRECTORY (EOCD) │ │
│ entry count · size of central dir · │ │
│ OFFSET WHERE THE CENTRAL DIRECTORY STARTS ─────┘ │
└──────────────────────────────────────────────────────┘
↑ unzip reads the file BACKWARDS: find EOCD at the tail,
jump to the central directory, jump to one member. Done.
Because a zip is located from its tail, you can stick a zip on the end of any other file and it still parses. That is not a curiosity: it is how self-extracting .exe archives work, and it is why .jar, .apk, .docx, .xlsx, .odt and .epub are all zip files — the format tolerates a prologue.
It is also, for the same reason, why the zip format has an awkward relationship with streaming: to write a local header you must state the compressed size and CRC-32 before the data, which you cannot know until you have compressed it. The format’s workaround is a “data descriptor” written afterwards with a flag bit set, but support for it has always been patchy. Tar has no such problem.
What actually happens when you run tar -xzf backup.tar.gz#
sequenceDiagram
autonumber
participant U as You
participant T as "tar (PID 4102)"
participant G as "gzip -d (PID 4103)"
participant K as Kernel
U->>T: tar -xzf backup.tar.gz
T->>T: parse options: mode=extract, filter=gzip, archive=backup.tar.gz
T->>K: pipe() then fork()
K-->>T: child PID 4103
T->>G: child execve("gzip", "-d")<br/>stdin = the .gz file, stdout = the pipe
loop for every member
G->>T: inflate the next chunk into the pipe
T->>T: read 512 bytes, validate header checksum
T->>K: creat(name, mode) / mkdir / symlink
T->>K: write(fd, data blocks)
T->>K: fchown if root, then utimensat(mtime)
T->>K: close(fd)
end
T->>T: sees two zero blocks → end of archive
T->>K: waitpid(4103) — reap gzip
T-->>U: exit 0, prompt returns
Six things in that trace are worth holding on to. tar runs the compressor as a separate process connected by a pipe — which is why a corrupt .gz produces a gzip: error message and tar: Child returned status 1, two programs complaining in sequence. Metadata is applied after the data is written, and mtime is applied last, because writing to a file would otherwise update it. chown only happens if you are root, since an ordinary user cannot give a file away. And the whole thing is a single forward pass — no seeking, no index, exactly as the tape demanded.
flowchart LR
subgraph UNIX["The Unix way — two tools, one pipe"]
F1["files on disk"] --> T1["tar -c<br/>archive"] --> S1["one .tar stream"] --> G1["gzip<br/>compress"] --> O1["archive.tar.gz"]
end
subgraph WIN["The zip way — one tool, both jobs"]
F2["files on disk"] --> Z1["zip<br/>archive AND compress<br/>member by member"] --> O2["archive.zip"]
end
tar deserves the most space in this chapter because it is the one you will type every week for the rest of your career, and because its option syntax is genuinely peculiar.
tar creates, extracts and manages archive files that bundle multiple files and directories together with their metadata.
bash
tar[options]tar_file_namefile1file2
That is the syntax the source notes give, and it is worth pausing on because it is misleading in a way that has destroyed real people’s work. tar does not take the archive name positionally. The archive name is the argument to -f. Everything else is a member. The syntax as usually written is:
The three syntaxes, and why a leading dash is optional#
bash
tarczfarchive.tar.gzmydir# old style ("key" argument) — no dash
tar-czfarchive.tar.gzmydir# short options, bundled
tar-c-z-farchive.tar.gzmydir# short options, separate
tar--create--gzip--file=archive.tar.gzmydir# long options
All four do the same thing. tar is one of a tiny handful of Unix commands where the first argument may omit its leading dash — a survival from 1979, before getopt existed, when the first argument was called the key and was simply a string of mode letters. ps aux and ar rcs are the other two you will meet.
So omitting -f does not error — it writes to, or reads from, your terminal. Creating without -f at an interactive prompt is caught for you:
terminal
$ tar-czmydir
tar: Refusing to write archive contents to terminal (missing -f option?)tar: Error is not recoverable: exiting now
But in a pipeline the guard does not fire, and you get raw binary sprayed into the pipe. And extracting without -f is worse: tar -xz silently reads standard input, so it just hangs, and every beginner concludes the archive is enormous.
Default choice. Universally readable, fast enough, everywhere
-j
--bzip2
bzip2
.tar.bz2, .tbz2, .tbz
Legacy archives. Do not choose it for new work
-J
--xz
xz
.tar.xz, .txz
Distribution artefacts: compress once, download a million times
--zstd
—
zstd
.tar.zst
Backups and CI caches at scale — best speed-to-ratio by a wide margin
--lzma
—
lzma
.tar.lzma
Legacy pre-xz format. Read-only in practice
-Z
--compress
compress
.tar.Z
1980s archives only
-a
--auto-compress
inferred
any
Create mode: pick the filter from the name you gave -f
(none)
—
detected
any
Extract/list mode: GNU tar sniffs the format for you
Two of those rows change how you should type tar day to day.
bash
tar-acfartefacts.tar.zstdist/# -a reads ".zst" and calls zstd. No flag to remember
tar-acfartefacts.tar.xzdist/# same command, different suffix, different compressor
And on the way back out:
bash
tar-xfanything.tar.gz# works
tar-xfanything.tar.bz2# works
tar-xfanything.tar.zst# works
tar-tfanything.tar.xz# works
Since GNU tar 1.15, extract and list auto-detect compression. You never need -z to unpack anything. Worse, specifying the wrong one breaks a command that would otherwise have worked:
terminal
$ tar-xzflab.tbz2
gzip: stdin: not in gzip formattar: Child returned status 1tar: Error is not recoverable: exiting now
-C DIR makes tarchdir() into DIR. What people miss is that it applies positionally: it affects only the operands that come after it, and you may use it more than once.
Consider archiving /var/www/html. The naive command:
terminal
$ tar-czfsite.tar.gz/var/www/html
tar: Removing leading `/' from member names$ tar-tzfsite.tar.gz|head-3
var/www/html/var/www/html/index.htmlvar/www/html/style.css
Every member is now var/www/html/…. Extract that anywhere and you get a four-level directory tree you did not ask for. With -C the archive is rooted where you want it:
Read it as: “go to /var/www, then archive the thing called html.” Now the archive is portable — tar -xzf site.tar.gz -C /srv/newsite drops a clean html/ wherever you like.
Used more than once, -C lets you gather files from several places into one flat archive:
A plain, uncompressed archive. A “tarball” strictly speaking
.tar.gz
.tgz
tar, gzip-compressed. The default of the Unix world
.tar.bz2
.tbz2, .tbz, .tb2
tar, bzip2-compressed
.tar.xz
.txz
tar, xz-compressed. Kernel and distribution source releases
.tar.zst
.tzst
tar, zstd-compressed. Backups, CI caches, modern container layers
.tar.lz4
—
tar, lz4-compressed. Speed above all else
.tar.Z
.taz
tar, compress-compressed. Historical
The short forms exist because MS-DOS allowed only three characters after the dot. They mean exactly the same thing as the long forms, and tar -a understands both.
This is the command you run before every extraction, so learn to read it properly. It deliberately looks like ls -l, because it is showing you the same metadata out of the tar headers.
The typeflag from the header: directory, regular file, symlink, hard link, char device, block device, FIFO
Rest of field 1
rwxr-xr-x
The mode field, rendered as owner/group/other. html/logs/ at drwxr-x--- means group members can read it, nobody else can enter it
Field 2
deploy/deploy
uname/gname — owner name slash group name, not numbers. With --numeric-owner you would see 1001/1001
Field 3
5242880
Size in bytes, from the header. Uncompressed size. Directories and symlinks are 0
Field 4
2026-07-28 11:04
mtime, the modification time as stored
Field 5
html/logs/access.log
The member name, exactly as stored. Trailing / marks a directory
Suffix
-> releases/2026-07-20
For a symlink, the linkname field: where it points
Suffix
link to html/index.html
For a hard link, the earlier member it shares an inode with
Three things this listing tells you before you extract anything, which is precisely why you run it:
Every path is relative and every path starts with html/. This is not a tarbomb. Extracting it creates exactly one directory.
html/current is a symlink into releases/, which is not in the archive. The restore will produce a dangling link, and your web server will 404. Worth knowing at 3 a.m.
access.log is 5 MiB and mode 0640 owned by www-data. If you extract as an ordinary user, that ownership is silently dropped and the log ends up owned by you.
No -z, so no compression: archive.tar will be larger than docs by the size of the headers and padding. That is normal and correct.
Create a gzip-compressed archive — the source example, exactly as given:
bash
tar-czfarchive.tar.gzfile1.txtfile2.txt
Creates a compressed archive archive.tar.gz containing file1.txt and file2.txt, bundling them and reducing size in a single command. -c creates, -z filters through gzip, -f names the output.
Create a bzip2-compressed archive of a directory:
bash
tar-cjfarchive.tar.bz2mydir
-j is bzip2. Note the pattern: the flag determines the compression, and the filename is a promise you are making to whoever receives it. tar -czf archive.tar.bz2 mydir will happily produce a gzip file with a lying name, and the person who receives it will curse you. This is what -a exists to prevent.
List a compressed archive without extracting:
bash
tar-tzvfbackup.tar.gz# or, better, just: tar -tvf backup.tar.gz
Note what happened: the file arrived at ./dir/subfile.txt, not./subfile.txt, because tar recreates the stored path. If you want it bare in the current directory, strip the leading component:
One dot per 2,000 blocks (20 MB of input), then a throughput summary. --checkpoint-action=echo prints a line instead; --checkpoint-action=exec=/usr/local/bin/notify runs a command, which is how you drive a progress bar from a cron job.
Verify what you wrote:
bash
tar-cWf/backup/data.tar/srv/data# note: no -z; -W cannot be used with a compressor
-W re-reads the finished archive and compares every member against the filesystem. It roughly doubles the I/O, so it is for tape drives and dubious USB disks, not for routine backups. For a compressed archive the equivalent is a checksum, covered in section 8.
date +%F is ISO YYYY-MM-DD, so the files sort chronologically in ls and lexicographic sort order equals date order. Use +%F-%H%M if you take more than one a day. Never use %d-%m-%Y; it sorts by day of the month.
Exclude caches and version control from an application backup:
--one-file-system is the quiet hero: it stops the backup descending into anything mounted underneath, which on a real server means /proc, /sys, /dev, tmpfs mounts and NFS shares. Without it, a backup of / tries to archive /proc/kcore — a file the kernel reports as the size of your address space — and never finishes.
Read it as a pipeline: -f - means “write the archive to stdout”, | hands the bytes to ssh, and cat > on the far side writes them to a file. No temporary file is created on either machine, which matters enormously when /data is 400 GB and neither host has 400 GB spare.
The variations are all the same shape:
bash
# Pull instead of push
sshweb-01'tar -czf - /var/www'>web-01-www.tgz
# Copy a tree between hosts, unpacking on arrival, preserving all metadata
tar-czf--C/var/wwwhtml|sshweb-02'tar -xzf - -C /var/www'# Copy locally, preserving ownership, permissions, links and sparseness
tar-cf--C/src.|sudotar-xpf--C/dst
# Compress on the far side instead, if the source host is CPU-starved
tar-cf-/data|sshbackup-host'zstd -T0 -3 > /backup/data.tar.zst'# Straight into object storage, never touching local disk
tar-czf--C/var/wwwhtml|awss3cp-s3://acme-backups/www-$(date+%F).tgz
Incremental backups with --listed-incremental. GNU tar can keep a snapshot file recording the state of every file it archived. On the next run it compares against that snapshot and archives only what changed.
bash
SNAR=/var/backups/www.snar
# Sunday: force a full (level 0) backup and start a fresh snapshot
tar-czf/backup/www-full-$(date+%F).tar.gz\--listed-incremental="$SNAR"--level=0\-C/var/wwwhtml
# Monday to Saturday: only what changed since the snapshot was last updated
tar-czf/backup/www-incr-$(date+%F).tar.gz\--listed-incremental="$SNAR"\-C/var/wwwhtml
6 · Practical Demonstration II — the Compression Tools#
Every tool in this section is a filter. Each one reads bytes and writes smaller bytes, knows nothing about directories, and can be used with or without tar. They differ in exactly three ways: how much they shrink, how long they take, and how much memory they need.
$ ls
report.csv$ gzipreport.csv
$ ls
report.csv.gz
report.csv is gone. gzip, bzip2 and xz all replace the input file with the compressed version by default — they are not “make a copy”, they are “convert in place”. Everyone loses a file to this once.
terminal
$ gzipfile1file2
$ ls
file1.gz file2.gz
Both originals replaced, both .gz files created. That is the quiz-bank question, and the answer is file1.gz, file2.gz.
The cure is -k / --keep:
bash
gzip-klog.txt# produces log.txt.gz AND keeps log.txt
bzip2-kdata.sql
xz-kfirmware.bin
Or use the tool as a pure filter, which never touches the input at all:
gzip implements DEFLATE — LZ77 sliding-window matching (a 32 KiB window) followed by Huffman coding. It is the universal default: every language, every OS, every HTTP client and every tar on earth can read a .gz.
bash
gzip[options]file...
gunzip[options]file.gz...# identical to gzip -d
zcatfile.gz# identical to gzip -dc
Write to stdout, leave the input alone. The filter mode
-l
--list
Show compressed size, uncompressed size and ratio without decompressing
-t
--test
Verify integrity (checks the stored CRC-32) and say nothing if fine
-r
--recursive
Walk a directory and compress each file individually
-f
--force
Overwrite an existing output file; compress links and terminals
-v
--verbose
Print the name and ratio of each file
-n
--no-name
Do not store the original name and timestamp — needed for reproducible output
-N
--name
Do store and restore them (default when compressing)
-S .suf
--suffix
Use a suffix other than .gz
-# on a .gz
—
Recompressing: gzip -9 data.gz decompresses and recompresses harder
terminal
$ gzip-v-9access.log
access.log: 88.2% -- replaced with access.log.gz$ gzip-lstyle.css.gz
compressed uncompressed ratio uncompressed_name 20513 20480 -0.0% style.css
Read that second output carefully, because it is teaching you something real: style.css was random bytes, so gzip made it bigger — 20,513 from 20,480 — and reports a negative ratio. Compression cannot shrink incompressible data; it can only add its own header and framing. Never gzip a JPEG, an MP4, a .gz or an encrypted blob.
Recompressing harder — the quiz-bank question. gzip -9 data.gz works: gzip notices the input is already compressed, decompresses it and recompresses at level 9. gzip data.gz without a level gives you data.gz already has .gz suffix -- unchanged.
bzip2 uses a completely different approach: the Burrows–Wheeler transform (sort all rotations of a block, which clusters similar characters together), then move-to-front coding, then Huffman. It works on blocks of 100–900 KB, set by the level.
bash
bzip2-9dump.sql# → dump.sql.bz2, original gone
bunzip2dump.sql.bz2
bzcatdump.sql.bz2|head
Options mirror gzip’s: -1…-9 (default -9, unlike gzip — and here the number selects the block size, 100 KB × N, not just effort), -d, -k, -c, -t, -f, -v, plus -s for a small-memory mode.
xz implements LZMA2: LZ77 with an enormous dictionary (64 MiB at -9) plus range coding. It is the ratio champion of the traditional tools, and the reason the Linux kernel, most distributions’ source packages and many .rpm/.deb payloads use it.
Zstandard, from Facebook/Meta in 2016: LZ77 matching paired with finite-state-entropy and Huffman coding. Its achievement is not a better ratio but a fundamentally better curve — at any given speed it compresses better than the alternatives, and at any given ratio it is faster.
bash
zstd-3dump.sql# → dump.sql.zst, and dump.sql is KEPT
zstd-19-T0--rmdump.sql# maximum practical level, all cores, delete the input
unzstddump.sql.zst
zstdcatdump.sql.zst|head
Option
What it does
-1 … -19
Level. Default -3.-1 is astonishingly fast; -19 rivals xz
--ultra -20 … -22
Levels beyond 19, unlocked only with --ultra. Large memory cost
--fast[=N]
Levels below 1, for when throughput is everything
-T N, -T0
Threads; -T0 uses every core. Multi-threading is built in, no separate binary
--long[=windowLog]
Long-range matching, up to a 2 GiB window. Excellent on VM images and repeated data
--rm
Delete the input after success. Not the default
-k, -c, -d, -t, -f, -v, -q
As gzip
--adapt
Adjust the level on the fly to keep a pipe or network link saturated
--train / -D dict
Build and use a dictionary — transformative for many small similar files
-l, --list
Show frame information
--format=gzip\|lz4\|xz
Build-dependent: zstd can emit and read other formats
compress — the one you need to recognise, not use#
compress implements LZW and produces .Z. It was the Unix standard until the early 1990s, when Unisys began enforcing a patent on LZW — which is precisely why gzip was written and why it uses the unencumbered DEFLATE. The patent expired in 2003; the tool never recovered. It is not installed by default on modern distributions (Debian/Ubuntu ship it in ncompress).
bash
compressbigfile# → bigfile.Z
uncompressbigfile.Z
zcatbigfile.Z# gzip's zcat reads .Z too
tar-xZfarchive.tar.Z# or just: tar -xf archive.tar.Z
You need it for exactly one reason: recognising .Z and .tar.Z on an ancient FTP mirror and knowing that gunzip can read them.
lz4 compresses and decompresses at multiple gigabytes per second with a modest ratio (~2:1 on text). Used where compression must be effectively free: ZFS compression=lz4, kernel zram/zswap, Kafka, database page compression. zstd --fast now covers most of the same ground with a better ratio, but lz4 is still the floor of the latency scale.
One run, one machine — 83 MiB of mixed real text (documentation, Python source, man pages), on a 12-core x86-64 box with the file already in page cache. Your absolute numbers will differ; the ordering will not.
Tool & level
Output size
Ratio
Compress time
Decompress time
(uncompressed)
83.1 MiB
1.00×
—
—
zstd -3 (default)
17.2 MiB
4.82×
0.16 s
0.10 s
gzip -6 (default)
17.2 MiB
4.82×
2.36 s
0.24 s
gzip -9
17.0 MiB
4.88×
8.20 s
0.24 s
bzip2 -9 (default)
13.8 MiB
6.01×
5.72 s
2.49 s
zstd -19
11.8 MiB
7.03×
15.84 s
0.11 s
xz -6 (default)
11.7 MiB
7.09×
12.47 s
0.17 s
Four conclusions worth internalising, all of them visible in that table:
zstd -3 matched gzip -6’s size in one fifteenth of the time. There is no trade-off being made here; it is simply better. This is why the whole industry migrated.
gzip -9 bought 1.2% for 3.5× the CPU. Almost never worth it. gzip -6 is the default because the curve flattens hard after it.
bzip2 is beaten on both axes.zstd -19 is smaller and decompresses 22× faster; xz -6 is smaller and decompresses 15× faster. There is no workload in 2026 for which bzip2 is the right answer.
Decompression asymmetry is the number that matters in production. You compress a backup once and restore it under pressure. zstd decompresses at ~800 MiB/s here, bzip2 at ~33 MiB/s.
tar -czf archive.tar.gz mydir Create, archive and compress
tar -xzf archive.tar.gz Extract
tar -tzf archive.tar.gz List contents (always verify first)
Remember: tar archives, gzip compresses. Two tools, one pipe.
# Compress and time it
tar-czfbackup-$(date+%F).tar.gz-C/var/wwwhtml
# Extract with progress
tar-xvflarge.tar.gz|head-20
# List what is inside before extracting (ALWAYS DO THIS)
tar-tvfarchive.tar.gz|head-20
# Extract one file without unpacking everything
tar-xzOfarchive.tar.gzpath/to/file|head
# Archive while excluding patterns
tar-czfapp.tar.gz\--exclude=node_modules\--exclude=.git\--exclude='*.log'\app/
# Read exclusions from a file
tar-czfbackup.tar.gz--exclude-from=/etc/backup/exclude.txt/srv/app
# Copy a tree between hosts, preserving everything
tar-czf--C/src.|sshremote'tar -xzf - -C /dst'# Stream to object storage
tar-czf-/data|awss3cp-s3://bucket/backup-$(date+%F).tar.gz
# Incremental backup: full on Sunday, incrementals Mon–SatSNAR=/var/backups/app.snar
tar-czfbackup-full-$(date+%F).tar.gz--listed-incremental="$SNAR"-C/srvapp
# Restore an incremental chain
tar-xzffull.tar.gz--incremental-C/restore
tar-xzfincr-2.tar.gz--incremental-C/restore
tar-xzfincr-3.tar.gz--incremental-C/restore
# Compress with best ratio (but slower)
zstd-19-T0bigfile
# Compress a directory: use tar, not gzip -r
gzip-rlogs/# Wrong: leaves directory, compresses each file alone
tar-czflogs.tar.gzlogs# Right: one solid archive# Recompress harder (works with gzip, bzip2, xz)
gzip-9file.gz# Decompresses and recompresses at level 9# Parallel compression
tar-cfbackup.tar.zst-I'zstd -T0 -9'/srv/data
tar-xfbackup.tar.zst-I'zstd -T0'-C/restore
# Keep the original file
gzip-kreport.csv# Produces report.csv.gz AND keeps report.csv
zstd-k--rmdump.sql# zstd keeps by default, use --rm to delete# Check what is inside a `.gz` without decompressing
gzip-lbackup.tar.gz
# Read a compressed file without unpacking
zcatbackup.log.gz|grepERROR|head
bzcatbackup.log.bz2|tail-100
xzcatbackup.log.xz|wc-l
A disk knows nothing about files. A 500 GB SSD is a numbered array of fixed-size blocks — block 0, block 1, block 122,070,311 — and it will happily hand you any one of them. It has no idea which blocks belong to /etc/passwd, who owns them, when they were last written, or whether they are in use at all.
A filesystem is the data structure that invents all of that on top of a flat array of blocks. It is the difference between “sector 4,192,301” and open("/var/log/nginx/access.log").
And then there is a second problem, which is the one that trips up everybody arriving from Windows. Once you have a filesystem on a device, where does it appear? Windows answers with a letter: this volume is D:, that one is E:, and each gets its own separate tree. Linux answers differently and more strangely: there is exactly one tree, rooted at /, and a filesystem is grafted into a directory inside that tree. That grafting operation is called mounting, and it is the single most consequential operation in Linux system administration, because a mistake in it can make a server refuse to boot.
This chapter is about both halves: what is inside a filesystem, and how you attach one to the tree without destroying the machine.
Suppose there were no filesystem layer. To append a line to a log file your program would need to:
find which blocks currently hold the file, in order
find a free block if the last one is full, and record that it is no longer free
update the file’s length somewhere durable
do all of that without another process simultaneously claiming the same free block
survive a power cut in the middle without corrupting the whole disk
Every one of those responsibilities lives in the filesystem. And because Linux puts a Virtual File System (VFS) layer above all filesystems, your program does not even need to know which one — the same open/read/write/close calls work on ext4, XFS, a USB stick formatted FAT32, an NFS share three racks away, and /proc, which is not on a disk at all.
Availability. A filesystem that fills up or corrupts takes an application down, and an fstab error takes a whole host down. This is the most common self-inflicted outage in Linux estates.
Cost. Storage is metered in the cloud. Knowing the difference between apparent size and allocated blocks, and being able to find the 40 GB of deleted-but-open log files, is directly money.
Compliance. Encryption at rest (LUKS), mount hardening, and per-user quotas are audit line items.
Portability. Containers only work because of overlayfs and bind mounts. An engineer who does not understand mounting cannot debug a volume that appears empty inside a pod.
Think of a blank disk as an empty warehouse full of identical numbered shelves.
A filesystem is the library system you impose on it:
The card catalogue is the inode table: one card per book, recording its size, who donated it, when it was last read, and which shelves hold it. The card does not contain the book’s title.
The shelf labels on the wall — “Fiction / Aisle 3” — are directories: lists that map a human-readable name to a catalogue card number.
The books themselves are the data blocks.
The noticeboard by the entrance, listing how big the library is, how many cards exist, how many shelves are free and whether the library was closed properly last night, is the superblock.
The day-book at the front desk, where a librarian writes “about to move book #4471 from aisle 3 to aisle 9” before doing it, is the journal. If the lights go out mid-move, the next librarian reads the day-book and finishes or undoes the job.
That one analogy predicts almost every behaviour in this chapter. Two shelf labels can point at the same catalogue card — that is a hard link. Removing a shelf label does not destroy the book; the book is only pulped when the last label referring to its card is gone. You can run out of catalogue cards while shelves are still empty — that is inode exhaustion, and it is a real production outage.
BEFORE AFTER
mount /dev/sdb1 /data
/ /dev/sdb1 /
├── etc (a formatted ├── etc
├── home filesystem, ├── home
├── var nobody can ├── var
└── data ← empty reach it yet) └── data ← the root of sdb1
(on /dev/sda2) ┌─────────────┐ ├── reports/
│ reports/ │ ├── archive/
│ archive/ │ └── notes.txt
│ notes.txt │
└─────────────┘ one tree, two devices,
and no path says "sdb1"
The directory you mount onto is the mount point. After mounting, /data no longer means “that empty directory on the root filesystem” — it means “the root of the filesystem on /dev/sdb1”. Anything that was inside /data beforehand is still there on the root filesystem, but it is hidden until you unmount. That shadowing behaviour is the cause of one of the classic “df says full, du says empty” mysteries later in this chapter.
A hard link is a second shelf label pointing at the same catalogue card. Both labels are equally real; neither is “the original”.
A symbolic link is a sticky note that says “the book you want is in Aisle 7, Shelf 4.” The note is a real object with its own catalogue card, but its entire content is a piece of text — a path. If the book moves or is destroyed, the note still exists and still says Aisle 7; following it just fails.
The smallest unit of space a filesystem will allocate, chosen when the filesystem is created. On ext4 the default is 4096 bytes (4 KiB). Underneath it, the disk exposes 512-byte or 4096-byte hardware sectors; the filesystem block is a multiple of those. A one-byte file still consumes a whole block.
Superblock
A fixed structure holding metadata about the filesystem itself: total size, block size, block and inode counts, free counts, the UUID and label, feature flags, mount count, last-mounted time, last-checked time, and the clean/dirty state. On ext filesystems the primary superblock sits 1024 bytes from the start of the device, and backup copies are scattered through the volume so a damaged primary can be recovered.
Inode (index node)
The per-file metadata record: file type and permission bits, owner UID, group GID, size in bytes, link count, timestamps, and pointers (or extents) locating the file's data blocks. Every file, directory, symlink, device node, socket and FIFO has exactly one inode, identified by an inode number unique within that filesystem. An inode does not contain the filename.
Directory entry (dentry)
A record inside a directory's data that maps a name to an inode number. A directory is therefore just a file whose contents are a list of such records. Names live here, not in inodes.
Journal
An on-disk circular log of intended changes, written and flushed before the changes are applied to their final locations. After a crash, the filesystem replays or discards incomplete transactions, restoring metadata consistency in seconds rather than hours.
Mounting
Attaching a filesystem's root directory to an existing directory in the running tree, so its contents become reachable through that path. Performed by the mount(2) system call, exposed as the mount command and driven at boot from /etc/fstab.
Mount point
The directory at which a filesystem is attached. It must exist. Its previous contents are hidden while something is mounted over it.
Block device
A device file (in /dev) representing storage addressable in fixed-size blocks with random access and kernel buffering — /dev/sda, /dev/sdb1, /dev/nvme0n1p2, /dev/mapper/vg0-lv_data. Contrast a character device (/dev/tty, /dev/random), which is a byte stream with no block addressing.
Filesystem Hierarchy Standard (FHS)
The specification (currently FHS 3.0, maintained by the Linux Foundation) that defines what belongs in each top-level directory, so that software and administrators can rely on a predictable layout across distributions.
/ the root of everything. One tree, always.
├── bin → usr/bin essential user commands (ls, cp, cat, bash)
├── sbin → usr/sbin essential system commands (fsck, ip, mount)
├── lib → usr/lib essential shared libraries + kernel modules
├── lib64 → usr/lib64 64-bit libraries (on multilib distros)
│
├── boot kernel (vmlinuz-*), initramfs, GRUB config
│ └── efi the EFI System Partition, vfat, mounted here
│
├── etc ALL host-specific configuration. Text files.
│ ├── fstab what to mount at boot ← this chapter
│ ├── mtab → /proc/self/mounts what IS mounted now
│ ├── exports NFS shares this host offers
│ ├── crypttab LUKS volumes to unlock at boot
│ └── systemd/, ssh/, nginx/, ... NO binaries live in /etc
│
├── home ordinary users' home directories
│ ├── alice
│ └── bob
├── root the root user's home. NOT /home/root.
│
├── usr shareable, read-only program data ("the OS")
│ ├── bin nearly every executable on a modern system
│ ├── sbin system executables
│ ├── lib, lib64 libraries, /usr/lib/modules/<kver>/
│ ├── share architecture-independent data: man pages,
│ │ icons, locales, /usr/share/doc
│ ├── include C headers
│ ├── src kernel and library source
│ └── local software YOU installed, not the package
│ ├── bin manager. Same layout again, one level down.
│ ├── lib `make install` defaults here (/usr/local).
│ └── share Package managers never touch it.
│
├── var VARIABLE data that grows while running
│ ├── log logs — journald, syslog, nginx, app logs
│ ├── lib application state: databases, dpkg, docker
│ ├── cache regenerable caches (apt archives)
│ ├── spool queues: mail, cron, print
│ ├── tmp temp files that MUST survive a reboot
│ └── www (Debian tradition) web content
│
├── tmp scratch space. World-writable + sticky bit
│ (1777). Cleared at boot; often tmpfs = RAM.
│
├── opt self-contained third-party packages, each in
│ its own /opt/<vendor> or /opt/<product> dir
├── srv data this server SERVES: /srv/www, /srv/ftp
│
├── mnt a spare hook for YOU to mount things by hand
├── media where the desktop auto-mounts removable media
│ (/media/alice/USB-STICK)
│
├── dev device files. devtmpfs, kernel-populated.
│ ├── sda, sda1, nvme0n1p1 block devices
│ ├── null, zero, random character devices
│ ├── shm tmpfs for POSIX shared memory
│ └── disk/by-uuid/… stable symlinks ← this chapter
├── proc procfs. Processes + kernel state as files.
│ ├── mounts → self/mounts the kernel's real mount table
│ ├── swaps active swap areas
│ └── 1/, 2410/, … one directory per PID
├── sys sysfs. Kernel objects, devices, tunables.
│ └── block/sda/size, /sys/fs/ext4/…
├── run tmpfs. Runtime state since boot: PID files,
│ sockets. Replaces the old /var/run.
│
└── lost+found per-filesystem. fsck puts recovered files
with no directory entry here.
What actually belongs where — and the tests that catch people out#
Path
Contents
The distinguishing rule
/
The root filesystem
Must contain everything needed to boot and repair the system before other filesystems are mounted
/bin, /sbin, /lib
Essential commands and libraries
Historically had to work before /usr was mounted. On modern distros these are symlinks into /usr
/usr
The operating system’s programs and data
Shareable and treated as read-only at runtime. Nothing host-specific
/usr/bin vs /bin
Same directory now
The /usr merge — see below
/usr/local
Software you installed by hand or from source
The package manager must never write here. ./configure defaults to --prefix=/usr/local
/etc
Host-specific configuration, plain text
No binaries. If two machines differ, the difference is almost always in /etc
/home
User data
Frequently its own filesystem or an NFS mount so it survives a reinstall
/root
root’s home directory
Deliberately on /, not /home, so root can log in when /home fails to mount
/var
Data that changes as the system runs
The filesystem most likely to fill up. Give it its own volume on servers
/var/log
Logs
Where the 3 a.m. disk-full page comes from
/var/tmp
Temporary files that must survive a reboot
Contrast /tmp
/tmp
Temporary files that need not survive
Cleared at boot (or is tmpfs). Mode 1777 — sticky bit, so users cannot delete each other’s files (Chapter 13)
/opt
Add-on application packages
Self-contained vendor trees: /opt/google/chrome, /opt/splunk. Not FHS-managed internally
/srv
Data served by this host
/srv/www, /srv/git. Rarely used on Debian/Ubuntu, which prefer /var/www
/mnt
Temporary manual mounts
For an administrator, right now, by hand
/media
Removable media
Automatic mounts by the desktop: /media/<user>/<label>
/dev
Device nodes
A devtmpfs, populated by the kernel and managed by udev. Nothing here is on disk
/proc
Process and kernel information
A procfs. Generated on read. df reports it as 0 bytes
/sys
Kernel object model
A sysfs. Where you read and write device and kernel tunables
/run
Runtime state since boot
A tmpfs — RAM. Empty at every boot by design
/boot
Kernel, initramfs, bootloader
Often a small separate partition; /boot/efi is the vfat EFI System Partition
/lost+found
Orphaned inodes after a repair
One per ext filesystem, at its root — so /lost+found, /home/lost+found, /var/lost+found all exist if those are separate ext filesystems
5 · Internal Working — What a Filesystem Actually Is#
This section is the heart of the chapter. Everything about links, deletion, permissions and mysterious full disks falls out of it.
byte 0 end of device
├──────┬───────────────────────┬───────────────────────┬─────────────────┤
│ boot │ BLOCK GROUP 0 │ BLOCK GROUP 1 │ BLOCK GROUP n │
│ 1 KiB│ │ │ │
└──────┴───────────────────────┴───────────────────────┴─────────────────┘
│
▼ inside one block group
┌────────────┬──────────────┬────────┬────────┬──────────────┬────────────┐
│ SUPERBLOCK │ group │ block │ inode │ INODE TABLE │ DATA │
│ (or backup)│ descriptors │ bitmap │ bitmap │ n × 256 bytes│ BLOCKS │
└────────────┴──────────────┴────────┴────────┴──────────────┴────────────┘
"how big am where every which which one record the file
I, how many structure blocks inodes per file contents
inodes, am lives are are
I clean?" free free
Plus, somewhere in the data area: the JOURNAL (a hidden file, inode 8,
up to ~128 MiB on a large filesystem).
Reserved inodes: 2 = the root directory "/", 8 = journal, 11 = lost+found
Two structures do the heavy lifting, and they are the two the quiz bank asks about.
The superblock answers “what kind of filesystem is this, and is it healthy?” It is small, it is read at mount time, and if it is destroyed the kernel cannot mount the filesystem at all. Which is why ext filesystems keep backups — mkfs.ext4 prints their locations:
terminal
$ sudomkfs.ext4/dev/sdb1
mke2fs 1.47.0 (5-Feb-2023)Creating filesystem with 26214144 4k blocks and 6553600 inodesFilesystem UUID: 9e8d7c6b-5a4f-3e2d-1c0b-9a8b7c6d5e4fSuperblock backups stored on blocks: 32768, 98304, 163840, 229376, 294912, 819200, 884736, 1605632, 2654208, 4096000, 7962624, 11239424, 20480000, 23887872Allocating group tables: doneWriting inode tables: doneCreating journal (131072 blocks): doneWriting superblocks and filesystem accounting information: done
Keep that output. If the primary superblock is later damaged, you recover with a backup:
bash
sudoe2fsck-b32768/dev/sdb1# use the backup superblock at block 32768
The inode table is a fixed-size array of records, one per possible file, laid down at mkfs time and never grown. That last clause is important enough to be a section of its own later: you cannot add inodes to an existing ext4 filesystem.
flowchart LR
subgraph DIR["Directory /data — its DATA blocks"]
D1["name: 'report.csv'<br/>inode: 245891"]
D2["name: 'archive'<br/>inode: 245900"]
D3["name: 'notes.txt'<br/>inode: 245899"]
end
subgraph IT["INODE TABLE"]
I["inode 245891<br/>─────────────<br/>type: regular file<br/>mode: rw-r--r--<br/>uid 1000 gid 1000<br/>size: 9,412 bytes<br/>links: 1<br/>atime mtime ctime<br/>extents → …"]
end
subgraph DB["DATA BLOCKS"]
B1["block 812340"]
B2["block 812341"]
B3["block 812342"]
end
D1 -->|"inode number"| I
I -->|"extent: 3 blocks<br/>starting at 812340"| B1
I --> B2
I --> B3
Reading /data/report.csv therefore means: read the inode of /, find the entry named data, read that inode, read its data blocks to find the entry named report.csv, read inode 245891, then read the blocks it points at. Every path component is one lookup, which is why the kernel aggressively caches dentries and inodes in RAM.
Six consequences that only make sense once you know the above#
Renaming a file is nearly free.mv old.txt new.txt within one directory rewrites one directory entry. The inode and every data block are untouched — which is why renaming a 40 GB file is instant, while moving it to a different filesystem is a full copy plus delete.
You can delete a file you do not own and cannot write. Deleting means removing a name from a directory, so the permission checked is write + execute on the containing directory, not on the file. This is precisely why /tmp needs the sticky bit (Chapter 13).
Hard links are not copies and have no “original”.
Hard link vs symbolic link
HARD LINK SYMBOLIC LINK
ln original.txt hard.txt ln -s original.txt soft.txt
directory /data directory /data
┌───────────────┬────────┐ ┌───────────────┬────────┐
│ "original.txt"│ 245891 │ │ "original.txt"│ 245891 │
│ "hard.txt" │ 245891 │ │ "soft.txt" │ 245902 │
└───────────────┴────────┘ └───────────────┴────────┘
│ │ │ │
└───┬───┘ │ └──┐
▼ ▼ ▼
┌────────────────────┐ ┌────────────────────┐ ┌────────────┐
│ inode 245891 │ │ inode 245891 │ │inode 245902│
│ links = 2 │ │ links = 1 │ │type=symlink│
│ mode, uid, size │ │ │ │data = │
│ → data blocks │ │ → data blocks │ │"original. │
└────────────────────┘ └────────────────────┘ │ txt" │
▲ └─────┬──────┘
Both names are equally real. └──────resolved at──────┘
rm either one → links = 1, every access
data survives.
Delete original.txt and the
Cannot cross filesystems. symlink still exists but is
Cannot (normally) link a dir. BROKEN — "No such file or
Same inode number in `ls -i`. directory" when followed.
A file is destroyed only when its link count hits zero and no process has it open.unlink() removes a directory entry and decrements i_nlink. If that reaches 0 the kernel checks the in-memory open count; if any file descriptor still refers to the inode, the blocks stay allocated and the file continues to exist with no name at all. It disappears when the last descriptor closes. This is the mechanism behind the df/du mismatch in section 13, and also behind the standard Unix trick for temporary files: create, open, unlink immediately, keep using the descriptor, and the kernel cleans up even if you crash.
Symlinks can point anywhere, including nowhere. Because their content is just a path string, a symlink may cross filesystems, point at a directory, point at a path that does not exist yet, or point at itself. The kernel limits chained resolution (40 links) to stop loops.
Inodes are finite, and running out of them looks exactly like running out of space.
stateDiagram-v2
[*] --> Named: "creat() — new inode, links = 1"
Named --> MoreNames: "link() — links = 2, 3, …"
MoreNames --> Named: "unlink() — links back to 1"
Named --> Orphan: "unlink() — links = 0<br/>but a process still has it open"
Named --> Freed: "unlink() — links = 0<br/>and nothing has it open"
Orphan --> Freed: "last close() or the process exits"
Freed --> [*]: "blocks and inode returned to the free bitmaps"
mkfs.ext4 allocates one inode per 16 KiB of space by default (the inode_ratio in /etc/mke2fs.conf). A 100 GB filesystem therefore gets roughly 6.5 million inodes. If your workload is a mail spool, a Squid cache, a session store, or a CI runner that writes millions of 200-byte files, you will exhaust inodes long before you exhaust blocks:
The application reports No space left on device (ENOSPC) while df -h cheerfully shows 62 GB free. There is no online fix — you cannot add inodes to an ext4 filesystem. Your options are: delete files, or back up, mkfs again with -i 4096 (or -N <count>), and restore. XFS avoids this by allocating inodes dynamically, which is one of the strongest arguments for XFS on mail and cache servers.
$ ls-li/data
total 16245891 -rw-r--r-- 2 alice devs 9412 Jul 30 14:02 report.csv245891 -rw-r--r-- 2 alice devs 9412 Jul 30 14:02 report-hardlink.csv245902 lrwxrwxrwx 1 alice devs 10 Jul 31 09:15 report-link.csv -> report.csv245900 drwxr-xr-x 2 alice devs 4096 Jul 29 11:40 archive
Read it as evidence: the first two entries share inode 245891 and both show a link count of 2 — they are hard links to one file. The third has its own inode, type l, a size of 10 bytes (exactly the length of the string report.csv), and permissions lrwxrwxrwx, which are meaningless on a symlink because access is always checked on the target.
stat is the full dump, and interviewers do ask you to read it:
Appending one block to a file requires several independent writes: mark a block used in the block bitmap, add the block to the inode’s extent list, update the inode’s size and mtime, update the free-block counter in the group descriptor. A power cut between any two of them leaves the filesystem inconsistent — a block marked used but belonging to no file (a leak), or worse, a block claimed by two files (silent corruption).
Without a journal, the only recovery is to walk every inode and every bitmap in the entire filesystem and cross-check them. On a 2 TB disk that is hours, and the machine is down the whole time. That is what fsck on ext2 does, and it is why servers used to take twenty minutes to boot after a crash.
Write your intentions to a dedicated log first, flush it, then do the real work. If you crash before the log entry is complete, the transaction never happened. If you crash after the log entry is complete but before the real work finished, replay the log and finish it. Either way the filesystem is consistent.
sequenceDiagram
autonumber
participant A as "Application"
participant K as "Kernel / jbd2"
participant J as "Journal (on disk)"
participant F as "Final locations (on disk)"
A->>K: "write() then fsync()"
K->>J: "write transaction: 'inode 245891 gains block 812343'"
J-->>K: "flush complete — transaction COMMITTED"
K-->>A: "fsync() returns — the change is durable"
K->>F: "checkpoint: apply the change for real"
Note over K,F: "crash here? → on next mount, replay the<br/>committed transaction from the journal"
F-->>K: "done"
K->>J: "mark transaction free — space reused (circular log)"
Set with the data= mount option, or as a default with tune2fs -o.
Mode
What is journalled
Crash behaviour
Speed
When to use
data=journal
Metadata and file data both go through the journal
Strongest. Both metadata and data are consistent
Slowest — every byte is written twice
Financial/medical data where a torn write is unacceptable; also required for some dax-free integrity setups
data=ordered
Metadata only, but data blocks are forced to disk before the metadata transaction commits
Metadata consistent; you never see another file’s old data in yours
Good — the default
Almost everything
data=writeback
Metadata only, no ordering between data and metadata
Metadata consistent, but a recently extended file may contain stale blocks — old, unrelated data or garbage
Fastest
Scratch space, build directories, caches you would rebuild anyway
The subtle one is writeback. The metadata says “inode 245891 now owns block 812343” and that survives the crash, but the write of the content into block 812343 did not. So you read your file and get whatever was in that block before — possibly a fragment of a deleted file belonging to another user. That is both a correctness and a security problem, which is why ordered is the default.
After an unclean shutdown, mounting an ext4 filesystem replays the journal — a sequential read of at most a few hundred megabytes — and the filesystem is consistent again. fsck at boot sees a clean state flag and exits in under a second. It performs a full structural scan only when you force it (-f), when the mount count or check interval set by tune2fs -c/-i is exceeded, or when the superblock says the filesystem was not cleanly unmounted and the journal itself is damaged.
A journal guarantees metadata consistency, not that your data is intact. It will not recover a file the application never flushed, and it will not fix a lying disk cache or bad hardware. For data integrity you need checksums — which is what Btrfs and ZFS add, and ext4/XFS do not (ext4 checksums metadata and the journal, not file data).
ext4 is the safe default. It is the most tested, most documented and most widely deployed filesystem on Linux. Every recovery tool understands it, every forum answer assumes it, and you can grow it online and shrink it offline. If you have no specific reason to choose otherwise, choose ext4.
XFS is the RHEL default, and it cannot shrink. XFS was designed at SGI in 1993 for large-scale parallel I/O, and it is excellent at it: dynamically allocated inodes (so no inode exhaustion), delayed allocation, extremely good performance with many concurrent writers and with large files. The catch is structural: XFS divides the volume into fixed allocation groups and stores metadata references that assume the volume never gets smaller. There is no code to relocate metadata out of the tail of the device, so xfs_growfs exists and a shrink command does not — and will not. To make an XFS filesystem smaller, you back it up (xfsdump), recreate it (mkfs.xfs) and restore (xfsrestore).
Btrfs and ZFS when you want snapshots and checksums. Both are copy-on-write: they never overwrite a live block, so a snapshot is just a second reference to the current tree — instant, and initially free of space cost. Both checksum data as well as metadata, so they detect silent corruption (“bit rot”) rather than serving it to you. ZFS is not in the mainline kernel because its CDDL licence is considered incompatible with GPLv2, so it ships as an out-of-tree module (zfs-dkms); Btrfs is in-tree and is the default on Fedora Workstation and openSUSE.
FAT32 and exFAT for cross-platform removable media. These are the two filesystems that Windows, macOS and Linux can all read and write without extra software, which is why the answer to “which filesystem for a USB stick you will hand to a colleague?” is FAT32 — with one enormous caveat:
FAT32 is also mandatory for one thing on Linux: the EFI System Partition. UEFI firmware can only read FAT, so /boot/efi is vfat on every UEFI machine.
overlayfs, because you already depend on it. An overlay filesystem stacks a read-only lowerdir under a writable upperdir and presents a single merged view. Reads come from the upper layer if present, otherwise the lower. The first write to a lower-layer file triggers a copy-up: the whole file is copied into the upper layer and modified there. Deleting a lower-layer file creates a whiteout marker in the upper layer.
overlayfs — what a container filesystem actually is
merged view (what the container sees)
┌──────────────────────────────────────────────────────────────┐
│ /etc/nginx/nginx.conf (your edit) │
│ /usr/sbin/nginx (from the image) │
│ /var/log/nginx/ (written at runtime) │
└──────────────────────────────────────────────────────────────┘
▲ ▲
upperdir │ writable, per container │ lowerdir(s), read-only,
──────────┴────────────────────────── │ SHARED between every
│ nginx.conf (copied up on write) │ │ container from this image
│ var/log/nginx/access.log │ │ ┌──────────────────────┐
│ .wh.unwanted-file ← whiteout │ └──│ layer 3: COPY app │
└───────────────────────────────────┘ │ layer 2: apt install │
│ layer 1: debian:12 │
`docker run` on one image 50 times = └──────────────────────┘
50 tiny upperdirs, one shared lowerdir.
That is the whole reason a container starts in milliseconds and why 50 containers from one image do not use 50× the disk. Chapter 19 covers it as a container topic; here, note only that mount -t overlay is a normal mount you can do by hand, and that docker run -v /host/path:/container/path is a bind mount — the mechanism in section 9.
tmpfs, because /run and /dev/shm are RAM. A tmpfs lives in the page cache, backed by swap if pressure demands it. It is fast, it is volatile, and its size counts against your memory:
The quiz bank asks “which filesystem is case-sensitive but not case-preserving?” Two independent properties are involved, and they are worth separating properly:
Case-sensitive — File.txt and file.txt are two different files.
Case-preserving — the filesystem stores the capitalisation you typed.
Behaviour
Filesystems
Example
Case-sensitive and case-preserving
ext2/3/4, XFS, Btrfs, ZFS (default), NFS, UFS
File.txt and file.txt coexist
Case-insensitive and case-preserving
NTFS (as used by Windows), exFAT, FAT32 with long filenames, HFS+/APFS by default, SMB shares
You see MyFile.TXT; myfile.txt opens it
Case-insensitive and not preserving
Original FAT 8.3 short names, ISO 9660 Level 1
You save Report.txt, it becomes REPORT.TXT
Case-sensitive and not preserving
—
Does not exist in any mainstream filesystem
8 · Practical Demonstration I — Creating a Filesystem#
mkfs itself does almost nothing. It is a front end that looks at -t <type> and executes mkfs.<type>, passing everything else through. These three lines are equivalent:
$ sudomkfs.ext4-Lappdata-m1-i8192/dev/sdb1
mke2fs 1.47.0 (5-Feb-2023)Creating filesystem with 26214144 4k blocks and 13107200 inodesFilesystem UUID: 9e8d7c6b-5a4f-3e2d-1c0b-9a8b7c6d5e4fSuperblock backups stored on blocks: 32768, 98304, 163840, 229376, 294912, 819200, 884736, 1605632, 2654208, 4096000, 7962624, 11239424, 20480000, 23887872Allocating group tables: doneWriting inode tables: doneCreating journal (131072 blocks): doneWriting superblocks and filesystem accounting information: done
Read the confirmation: 26,214,144 blocks × 4 KiB = 100 GiB; 13,107,200 inodes (double the default, because -i 8192 halved the bytes-per-inode ratio); a 131,072-block journal = 512 MiB; and a UUID that you will now put in /etc/fstab.
# XFS — note the force flag is -f, not -F
sudomkfs.xfs-Lappdata/dev/sdb1
sudomkfs.xfs-f-Lappdata/dev/sdb1# overwrite an existing filesystem# FAT32 for a USB stick — -F 32 picks FAT32 over FAT16, -n sets the label (11 chars, upper-case)
sudomkfs.vfat-F32-nTRANSFER/dev/sdc1
# exFAT for a stick that must carry files over 4 GiB
sudomkfs.exfat-nTRANSFER/dev/sdc1
# Btrfs, with a label
sudomkfs.btrfs-Lsnapshots/dev/sdb1
# Swap — a signature, not a filesystem
sudomkswap-Lswap1/dev/sdb2
# ext2 for a small /boot or a flash drive where journal writes are undesirable
sudomkfs.ext2-Lboot/dev/sda1
resize2fs /dev/sdb1 40G — must be unmounted, and run e2fsck -f first
XFS
xfs_growfs /mnt/data — takes the mount point, not the device; online only
Impossible. Dump, mkfs, restore
Btrfs
btrfs filesystem resize +100G /mnt
btrfs filesystem resize -100G /mnt — online
Enlarging a filesystem is a two-step job: first make the block device bigger (extend the LVM logical volume, or grow the cloud volume and then the partition with growpart), then tell the filesystem to use the new space.
terminal
$ sudolvextend-L+50G/dev/vg0/lv_data
Size of logical volume vg0/lv_data changed from 100.00 GiB to 150.00 GiB.$ sudoresize2fs/dev/vg0/lv_data
resize2fs 1.47.0 (5-Feb-2023)Filesystem at /dev/vg0/lv_data is mounted on /data; on-line resizing requiredold_desc_blocks = 13, new_desc_blocks = 19The filesystem on /dev/vg0/lv_data is now 39321600 (4k) blocks long.
9 · Practical Demonstration II — Mounting and Unmounting#
$ mount|head-12
sysfs on /sys type sysfs (rw,nosuid,nodev,noexec,relatime)proc on /proc type proc (rw,nosuid,nodev,noexec,relatime)udev on /dev type devtmpfs (rw,nosuid,relatime,size=3985412k,nr_inodes=996353,mode=755)devpts on /dev/pts type devpts (rw,nosuid,noexec,relatime,gid=5,mode=620,ptmxmode=000)tmpfs on /run type tmpfs (rw,nosuid,nodev,noexec,relatime,size=802532k,mode=755)/dev/sda2 on / type ext4 (rw,relatime,errors=remount-ro)tmpfs on /dev/shm type tmpfs (rw,nosuid,nodev)tmpfs on /run/lock type tmpfs (rw,nosuid,nodev,noexec,relatime,size=5120k)/dev/sda1 on /boot/efi type vfat (rw,relatime,fmask=0077,dmask=0077,codepage=437,iocharset=iso8859-1)/dev/sdb1 on /data type ext4 (rw,noatime)nfs01:/export/shared on /mnt/shared type nfs4 (rw,relatime,vers=4.2,hard,proto=tcp,timeo=600)tmpfs on /run/user/1000 on tmpfs (rw,nosuid,nodev,relatime,size=802528k,uid=1000,gid=1000)
Each line reads <source> on <mount point> type <fstype> (<effective options>). The options shown are what the kernel is actually using, including defaults you did not type — note relatime appearing everywhere even though nobody asked for it.
mount/dev/sdb2/data# type auto-detected via libblkid
mount-text4/dev/sdb2/data# state the type explicitly
mount-oro,noatime/dev/sdb1/mnt# options
mount-r/dev/sdb1/mnt# -r is shorthand for -o ro
mount-w/dev/sdb1/mnt# -w is shorthand for -o rw
mountUUID=9e8d7c6b-5a4f-3e2d-1c0b-9a8b7c6d5e4f/data
mountLABEL=appdata/data
mount-a# mount everything in /etc/fstab that is not yet mounted
mount-a-tnfs4# ...limited to one type
mount-oremount,rw/# change options on an already-mounted filesystem
mount--bind/var/log/mnt/logs# make one directory visible at a second path
mount--rbind/var/log/mnt/logs# ...including anything mounted underneath it
mount--move/mnt/old/mnt/new# relocate a mount without unmounting
mount-ttmpfs-osize=512Mtmpfs/mnt/tmp# a RAM filesystem
mount-oloop/srv/ubuntu.iso/mnt/iso# mount a file as if it were a device
mount--mkdir/dev/sdb1/mnt/newdir# create the mount point too (util-linux 2.35+)
mount-tcifs//fileserver/share/mnt/share-ocredentials=/etc/smb.cred
A placeholder so field 4 of fstab is never empty. Add to it: defaults,noatime
ro
Read-only
Forensics, shared reference data, recovering a failing disk
rw
Read-write
The default
noatime
Never update access times
The answer to “which option disables updating the file access time”. Biggest single easy win for read-heavy workloads: without it, reading a file causes a write
relatime
Update atime only if the old atime is older than mtime/ctime, or more than 24 h old
The kernel default since 2.6.30. A compromise that keeps mutt-style “has this been read?” logic working
nodiratime
Skip atime updates for directories only
Rarely used alone; noatime already implies it
strictatime
Full POSIX atime — update on every read
Almost never wanted
sync
Every write goes to the device before the call returns
Removable media you might yank; NFS clients that must not lose writes. Slow
async
Writes are buffered by the kernel
The default
noexec
Refuse to execute binaries from this filesystem
Hardening /tmp, /var/tmp, /dev/shm, /home, removable media
exec
Allow execution
The default
nosuid
Ignore set-UID and set-GID bits
Hardening. Stops a user planting a set-UID root binary on a USB stick
suid
Honour set-UID/set-GID
The default
nodev
Ignore device files on this filesystem
Hardening. Stops a crafted /tmp/mydisk character device granting raw disk access
dev
Interpret device files
The default
auto
Include in mount -a
The default
noauto
Do not mount at boot or with mount -a; only when named explicitly
Backup drives, ISOs, anything optional
user
Allow a non-root user to mount it (implies noexec,nosuid,nodev)
Only the mounting user may unmount
users
Any user may mount and unmount it
Shared workstations
nouser
Only root may mount
The default
_netdev
This filesystem needs the network — order it after networking is up
Mandatory on NFS, CIFS, iSCSI entries
nofail
Do not fail the boot if the device is absent
Put this on every non-essential mount. The difference between a degraded server and an unbootable one
discard
Send TRIM/UNMAP to the device on every delete
SSDs. Usually better to leave it off and run the weekly fstrim.timer instead — inline discard can hurt latency
errors=remount-ro
On an I/O error, remount read-only instead of continuing
The ext4 default for / on Debian/Ubuntu. Alternatives: errors=continue, errors=panic
loop
Treat the source as a file and attach it via a loop device
mount -o loop image.iso /mnt/iso
uid=, gid=
Force an owner for every file
Needed on vfat, exfat, ntfs, which have no Unix ownership
umask=, dmask=, fmask=
Force permission bits (umask for both, dmask for dirs, fmask for files)
umask=0077 on /boot/efi so only root can read it
data=ordered\|journal\|writeback
ext4 journal mode (section 6)
ordered is the default
size=
tmpfs size, e.g. size=512M, size=50%
Always set it explicitly; the default is half your RAM
x-systemd.automount
Mount on first access rather than at boot
An excellent alternative to noauto for rarely-used network shares
A bind mount makes an existing directory appear at a second location. It is not a copy and not a symlink — it is a second entry in the mount table pointing at the same filesystem subtree, so both paths are equally real and writes through either are immediately visible in the other.
Note the SOURCE column: /dev/sda2[/var/log] — the device, and in brackets the subdirectory being bound. That bracket notation is how you recognise a bind mount in findmnt output.
A bind mount can be made read-only independently of the original, which is the trick containers use constantly:
bash
sudomount--bind/var/log/mnt/logs
sudomount-oremount,ro,bind/mnt/logs# /var/log stays writable; /mnt/logs is read-only
Why containers depend on bind mounts. A container runs in its own mount namespace — its own private copy of the mount table. To give it access to a host directory, the runtime creates a bind mount of that host path into the container’s namespace before executing the entrypoint. docker run -v /srv/data:/data and a Kubernetes hostPath volume are both, at the kernel level, mount --bind. Every ConfigMap and Secret mounted into a pod is a tmpfs bind-mounted into the container’s tree. Understanding bind mounts is therefore not optional for anyone debugging volumes.
Bind mounts also solve real host problems without symlinks: exposing one subdirectory of a large filesystem into a chroot, giving an application a path it insists on (/opt/app/data) while the bytes live on a different volume, or making /var/lib/docker live on a data disk without moving the directory.
umount/mnt/data# by mount point — preferred
umount/dev/sdb1# by device — works, but ambiguous if bind-mounted
umount-R/mnt# recursive: everything under /mnt too
umount-a# everything in fstab (do not do this on a running system)
The failure you will meet:
terminal
$ sudoumount/mnt/data
umount: /mnt/data: target is busy.
The kernel refuses because something is still using the filesystem: an open file, a process whose current working directory is inside it, a mapped file, or another mount stacked on top. Diagnose in this order:
terminal
$ sudofuser-vm/mnt/data
USER PID ACCESS COMMAND/mnt/data: root kernel mount /mnt/data alice 2410 ..c.. bash www-data 3187 F.... nginx$ sudolsof+D/mnt/data
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAMEbash 2410 alice cwd DIR 8,17 4096 2 /mnt/datanginx 3187 www-data 5w REG 8,17 1048576 245891 /mnt/data/access.log
Read the ACCESS letters from fuser: c = the process’s current directory is here, f = an open file, F = an open file being written, e = an executable running from here, m = a mapped file, r = this is the process’s root directory.
Tool
What it does
Notes
fuser -vm /mnt/data
Lists every process using the mount
Fast. -v gives the readable table above
fuser -km /mnt/data
Kills them all with SIGKILL
Effective and dangerous. Never on production without knowing what you are killing
lsof +D /mnt/data
Walks the whole directory tree looking for open files
Thorough but slow on large trees
lsof /mnt/data
Just the mount point itself
Much faster; misses files deep inside
lsof +f -- /dev/sdb1
By device rather than path
Catches deleted-but-open files
cat /proc/*/mountinfo
Find another namespace holding the mount
The container case: a running container may hold your mount open
The most common answer is embarrassing and instant: your own shell is sitting in the directory.cd / and try again.
When you genuinely cannot free it:
bash
sudoumount-l/mnt/data# lazy: detach from the tree now, clean up when it stops being busy
sudoumount-f/mnt/nfsshare# force: for an unresponsive NFS server
findmnt# the tree
findmnt/data# one mount
findmnt-T/data/reports/q3.csv# which filesystem holds this PATH? ← extremely useful
findmnt-S/dev/sdb1# which mount point uses this source?
findmnt-text4,xfs# filter by type
findmnt-oTARGET,SOURCE,FSTYPE,OPTIONS,SIZE,USED,AVAIL
findmnt--df# df-like output from the mount table
findmnt--verify# SANITY-CHECK /etc/fstab ← do this before every reboot
findmnt-N3187# the mount table as seen by PID 3187 (namespaces!)
Where the truth lives:
Source
What it is
Trust it?
/proc/mounts
A symlink to /proc/self/mounts — the kernel’s live mount table for your namespace
✔ Authoritative
/proc/self/mountinfo
The richer version: mount IDs, parent IDs, root-within-device, propagation flags
✔ Authoritative; what findmnt actually parses
/etc/mtab
Historically a userspace-maintained file that mount appended to
⚠ On every modern distro it is a symlink to /proc/self/mounts, so it is now accurate. On very old systems it could drift out of sync with reality — mounts made with mount -n, or a read-only root, left it wrong
/etc/fstab
What you want mounted at boot
✘ Not what is mounted. A configuration file, not a status file
That one screen tells you: which devices exist, what filesystem each holds, its label, its UUID, how full it is, and where it is mounted. It is the correct first command when you attach a new disk and the correct first command when someone reports a full disk.
bash
lsblk-f# filesystem view
lsblk-oNAME,SIZE,FSTYPE,UUID,MOUNTPOINT# choose your own columns
lsblk-p# full paths (/dev/sdb1 instead of └─sdb1)
lsblk-d# disks only, no partitions
lsblk-a# include empty devices
lsblk-S# SCSI devices with vendor/model
lsblk-J# JSON — for scripts and Ansible facts
lsblk-t# topology: alignment, physical/logical sector size
sudoblkid# every device
sudoblkid/dev/sdb1# one device
sudoblkid-sUUID-ovalue/dev/sdb1# just the UUID — the scripting form
sudoblkid-Lappdata# which device carries this label?
sudoblkid-U9e8d7c6b-...# which device carries this UUID?
sudoblkid-p-oudev/dev/sdb1# probe the device directly, bypassing the cache
/dev/disk/by-* — the stable symlinks udev builds for you#
This is the concept most worth internalising in the whole chapter.
/dev/sdX names are assigned in the order the kernel finishes probing devices, and that order is not guaranteed. Device discovery is asynchronous and parallel. Any of the following can renumber your disks:
adding, removing or reseating a disk
adding an HBA, RAID controller or PCIe card that enumerates earlier
a kernel upgrade that changes driver initialisation order or probe timing
a disk that is slow to spin up and loses the race this boot
a cloud provider attaching volumes in a different order after a stop/start
plugging in a USB stick before boot — it can become sda
What device renaming actually does to you
BOOT 1 BOOT 2 (an SSD was added to the machine)
/dev/sda ── system disk /dev/sda ── the NEW SSD (empty)
/dev/sdb ── your 8 TB data array /dev/sdb ── the system disk
/dev/sdc ── your 8 TB data array
/etc/fstab says: Result:
/dev/sdb1 /data ext4 /data now shows the SYSTEM disk's
contents, or fails to mount, and the
application writes into the wrong
filesystem — silently.
The failure mode is not a clean error. It is the wrong disk mounted at the right path, which means an application happily writing production data onto a scratch volume, or a backup job overwriting a system partition. In the best case the boot simply hangs waiting for a device that no longer exists.
A UUID (Universally Unique IDentifier) is a 128-bit value written into the filesystem’s superblock by mkfs. It travels with the data. Move the disk to a different bay, a different controller or a different machine, and its UUID is unchanged.
bash
# Ad hoc
sudomountUUID=9e8d7c6b-5a4f-3e2d-1c0b-9a8b7c6d5e4f/data
sudomount-U9e8d7c6b-5a4f-3e2d-1c0b-9a8b7c6d5e4f/data# equivalent
sudomountLABEL=appdata/data
sudomount/dev/disk/by-uuid/9e8d7c6b-5a4f-3e2d-1c0b-9a8b7c6d5e4f/data# same thing, explicitly# In /etc/fstabUUID=9e8d7c6b-5a4f-3e2d-1c0b-9a8b7c6d5e4f/dataext4defaults,noatime,nofail02
Labels are a legitimate alternative and are far easier to read, but they are only as unique as you make them: plug in two USB drives both labelled BACKUP and the behaviour is undefined. UUIDs are generated randomly and are effectively collision-free — with one exception:
mount is temporary. Everything you mount by hand is gone at the next reboot. /etc/fstab (“filesystem table”) is the file that tells the system what to mount at boot, and the answer to “where do you define persistent mounts so they survive reboot?”
auto asks libblkid to guess; none is used for bind mounts. This is the third field — the quiz asks it directly
4
Options
fs_mntops
Comma-separated mount options; defaults as a placeholder
The fourth field. No spaces anywhere inside it
5
Dump
fs_freq
0 or 1 — whether the obsolete dump backup tool should archive this filesystem
Effectively always 0. Nobody uses dump
6
fsck pass
fs_passno
0 = never, 1 = check first, 2 = check later
1 is reserved for the root filesystem. Use 2 for other local ext filesystems, 0 for XFS/Btrfs (they self-check), swap, tmpfs and all network filesystems
# <file system> <mount point> <type> <options> <dump> <pass># The root filesystem. pass=1 so it is checked first and alone.# errors=remount-ro means an I/O error makes it read-only rather than# letting the system keep writing to a failing disk.UUID=7f3c1a92-2d4e-4a1b-9c8e-5f6a7b8c9d01 / ext4 defaults,errors=remount-ro 0 1# /boot on its own small ext4 partition, checked after root.UUID=1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d /boot ext4 defaults 0 2# The EFI System Partition. Must be vfat — UEFI firmware cannot read# anything else. Its UUID is a short FAT serial, not a 128-bit UUID.# umask=0077 hides it from non-root users. pass=0: fsck.vfat adds no value here.UUID=C3D4-E5F6 /boot/efi vfat umask=0077,shortname=winnt 0 0# A data disk mounted by UUID. noatime because the application only reads.# nofail so a missing disk degrades the service instead of blocking the boot.# XFS gets pass=0 — it has no fsck; it recovers via its own log at mount.UUID=9e8d7c6b-5a4f-3e2d-1c0b-9a8b7c6d5e4f /data xfs defaults,noatime,nofail 0 0# A backup drive that is not always plugged in.# noauto = do not mount at boot; x-systemd.automount = mount on first access.LABEL=backup /mnt/backup ext4 noauto,nofail,x-systemd.automount 0 0# A swap FILE (not a partition). Mount point is `none`; `sw` is the# conventional option; dump and pass are both 0 because neither applies./swapfile none swap sw 0 0# /tmp in RAM, hardened. size= is essential: without it tmpfs may grow# to half of physical memory and starve the applications.tmpfs /tmp tmpfs rw,nosuid,nodev,noexec,size=2G 0 0# An NFSv4 export. _netdev orders it after the network is up; nofail keeps# the boot moving if the server is down; hard makes writes retry forever# rather than failing with EIO; timeo=600 = 60 s before the first retry.nfs01.internal:/export/shared /mnt/shared nfs4 defaults,_netdev,nofail,hard,timeo=600 0 0# A bind mount: make /var/log reachable at a second path, read-only there./var/log /mnt/logs none bind,ro 0 0
Whitespace between fields can be spaces or tabs, any number of them. Lines starting with # are comments. A path containing a space must be escaped as \040.
$ sudofindmnt--verify--verbose
/ [ ] target exists [ ] FS type is ext4 [ ] source UUID=7f3c1a92-2d4e-4a1b-9c8e-5f6a7b8c9d01 exists/data [W] no final newline [E] unreachable on boot required source UUID=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee0 parse errors, 1 error, 1 warning
That [E] is the message that would otherwise have become an unbootable machine.
Recovering a machine that will not boot because of fstab#
flowchart TB
A["Boot fails:<br/>'Give root password for maintenance'<br/>or 'Cannot open access to console'"] --> B{"Do you get an<br/>emergency shell?"}
B -->|"yes"| C["Log in as root"]
B -->|"no"| D["Reboot, hold Shift/Esc for GRUB"]
D --> E["Press 'e' on the kernel line<br/>append: systemd.unit=emergency.target<br/>(or rd.break, or init=/bin/bash)"]
E --> F["Ctrl-X to boot"]
F --> C
C --> G["Root is mounted READ-ONLY:<br/>mount -o remount,rw /"]
G --> H["Fix it:<br/>vi /etc/fstab<br/>comment out the bad line,<br/>or add nofail"]
H --> I["findmnt --verify<br/>mount -a"]
I --> J["systemctl daemon-reload<br/>reboot"]
B -->|"no console at all<br/>cloud VM"| K["Attach the disk to a<br/>RESCUE instance"]
K --> L["mount /dev/xvdf2 /mnt<br/>vi /mnt/etc/fstab<br/>umount, reattach, boot"]
The step everybody forgets is the second one: in emergency mode the root filesystem is mounted read-only, so your editor cannot save. mount -o remount,rw / is the command that makes the repair possible, and it is exactly the quiz question “what does mount -o remount,rw / do?” — it changes the options of the already-mounted root filesystem in place, from read-only to read-write, without unmounting it (which would be impossible anyway).
# Basic mounts
mount-text4/dev/sdb1/data# explicit type
mount/dev/sdb1/data# auto-detect
mountUUID=9e8d7c6b-5a4f-3e2d-1c0b-9a8b7c6d5e4f/data
mountLABEL=appdata/data
# With options
mount-oro,noatime/dev/sdb1/mnt# read-only, no atime updates
mount-oremount,rw/# remount root as read-write
mount--bind/var/log/mnt/logs# bind mount (two paths, one fs)# tmpfs
mount-ttmpfs-osize=512Mtmpfs/mnt/tmp
# Mount everything in fstab
mount-a# mount all
mount-a-tnfs4# mount only NFS# Unmount
umount/mnt/data# by mount point
umount/dev/sdb1# by device
umount-l/mnt/stuck# lazy: detach now, cleanup later
umount-f/mnt/nfsshare# force (NFS only)
The six fields → “Device, Directory, Type, Options, Dump, Pass”
ini
# Root filesystem — pass 1 ensures it is checked first and aloneUUID=7f3c1a92-2d4e-4a1b-9c8e-5f6a7b8c9d01 / ext4 defaults,errors=remount-ro 0 1# Data disk — noatime for read-heavy workloads, nofail so boot continues if missingUUID=9e8d7c6b-5a4f-3e2d-1c0b-9a8b7c6d5e4f /data xfs defaults,noatime,nofail 0 0# /tmp in RAM with size limit and hardeningtmpfs /tmp tmpfs rw,nosuid,nodev,noexec,size=2G 0 0# NFS — _netdev is mandatory, hard means retry forever, nofail prevents 90s boot hangnfs01:/export/shared /mnt/shared nfs4 _netdev,nofail,hard 0 0# Bind mount/var/log /mnt/logs none bind,ro 0 0
Field 5 (Dump): Almost always 0 — the dump utility is obsolete.
Critical rules:
- Always use UUID= or LABEL=, never /dev/sdX (names can change)
- Test before reboot: sudo findmnt --verify && sudo mount -a
- Put nofail on every non-critical mount
- Add x-systemd.device-timeout=10s to non-critical local mounts
What is an inode?
- One per file, contains: mode, owner, size, timestamps, block pointers
- Does not contain the filename — that lives in the directory entry
- Identified by an inode number (unique within one filesystem)
- You can run out of inodes even with free blocks
Fix: delete files, or rebuild with -i 4096 (double the inodes):
bash
sudomkfs.ext4-i4096/dev/sdb1# 4× more inodes than default
Reading inode metadata:
bash
ls-i/data# Show inode numbers
stat/data/file.txt# Full dump: size, blocks, inode, timestamps, links
Hard links vs symlinks:
bash
lnoriginal.txthard.txt# hard link: same inode
ln-soriginal.txtsoft.txt# symlink: tiny file, text = path
ls-ioriginal.txthard.txtsoft.txt# inode numbers tell the story
Deleted-but-open files (the classic df full / du empty mystery):
bash
sudolsof+L1# Files with link count = 0
sudotruncate-s0/proc/<pid>/fd/<n># Truncate through open descriptor
Set at format time with mkfs.ext4 or at runtime with tune2fs -o:
Mode
What’s journalled
Crash safety
Speed
When
data=journal
Metadata + all file data
Strongest
Slowest
Financial data, mission-critical
data=ordered
Metadata only; data forced to disk first
Safe, no stale data
Good
Default — use this
data=writeback
Metadata only, no ordering
Metadata safe, data may be stale
Fastest
Scratch, caches you rebuild
bash
# Mount with journal mode
mount-odata=ordered/dev/sdb1/mnt
# Check current mode
sudotune2fs-l/dev/sdb1|grep-ijournal
# Change at runtime
sudotune2fs-odata=ordered/dev/sdb1
# Disable atime updates — biggest single win for read workloads
sudomount-oremount,noatime/data
echo"UUID=9e8d7c6b-5a4f… /data ext4 defaults,noatime 0 2"|\sudotee-a/etc/fstab
# Check current mount options
mount|grep/data# Shows active options
sudofindmnt/data-oOPTIONS# Just the options column# Optimize ext4 for large files
sudomkfs.ext4-Estride=32,stripe_width=256/dev/sdb1
# For SSD: trim on delete (or let fstrim.timer run weekly)
sudomount-odiscard/dev/sdb1/mnt
sudofstrim-v/mnt# Manual trim# Disable updates to directory atime (rarely used alone)
mount-onodiratime/data# already implied by noatime
# List all block devices and filesystems
lsblk-f# Shows filesystem types, labels, UUIDs# Query a specific device
sudoblkid/dev/sdb1
sudoblkid-sUUID-ovalue/dev/sdb1# Just the UUID (for scripts)# Find device by UUID or label
sudoblkid-U9e8d7c6b-5a4f-3e2d-1c0b-9a8b7c6d5e4f
sudoblkid-Lappdata
# Stable symlinks built by udev
ls/dev/disk/by-uuid/# ← use these in fstab
ls/dev/disk/by-label/
ls/dev/disk/by-id/# for physical disks (RAID, ZFS)
Why UUIDs?/dev/sdb1 names are assigned in parallel probe order — adding a disk can renumber everything. UUIDs travel with the filesystem.
# Read superblock (safe)
sudotune2fs-l/dev/sdb1# Full dump
sudoe2fsck-n/dev/sdb1# Check only, no changes# Repair (must be unmounted)
sudoe2fsck-fy/dev/sdb1# Answer yes to everything
sudofsck-f/dev/sdb1# Dispatcher (runs fsck.ext4)# XFS repair (not fsck)
sudoxfs_repair-n/dev/sdb1# Dry run
sudoxfs_repair/dev/sdb1# Actually repair# Search for deleted-but-open files
sudolsof+L1
sudolsof+D/mnt# Open files anywhere under /mnt# Space investigation — "df full but du says empty"
df-h/var# 1. Which filesystem
sudodu-xh--max-depth=1/var|sort-h# 2. Largest directory
sudodu-ah/var/log|sort-rh|head# 3. Largest files
sudolsof+L1# 4. Check for deleted-but-open
df-i/var# 5. Check inode usage
# Which filesystem holds this file?
findmnt-T/var/log/nginx/access.log
# Is this mount point a bind mount or a real filesystem?
findmnt/mnt/logs# Shows [/var/log] if bind# Disk full investigation
df-h&&du-xsh/*
# What grew recently?
sudofind/var-xdev-typef-mtime-1-size+100M
# Interactive disk usage navigator
sudoncdu-x/# Install: apt install ncdu# What is in lost+found?
sudols-l/data/lost+found# After fsck# Sync all pending writes and see cache drop
free-h&&sync&&free-h# Dramatically shows page cache# Monitor mount/unmount operations
sudostrace-emount,umount,open<command>
fsck is another dispatcher: it reads the type and runs fsck.<type>, which for ext filesystems is e2fsck.
bash
fsck/dev/sdb1# detect the type and run the right checker
fsck-text4/dev/sdb1# state the type
fsck.ext4/dev/sdb1# the same binary as...
e2fsck/dev/sdb1# ...this
fsck-A# check everything in fstab, honouring pass order
fsck-AR# ...but skip the root filesystem
Option
Meaning
When
-y
Answer yes to every question
Unattended repair. What you use in practice — a real repair asks hundreds of questions
-n
Answer no to everything: check, report, change nothing
Safe read-only assessment. The only form that is defensible on a mounted filesystem, and even then the report may be wrong
-f
Force a full check even if the superblock says “clean”
Essential after a journal replay, or before shrinking with resize2fs
-p
Preen — automatically fix only problems that are unambiguously safe; exit with an error on anything needing a decision
The answer pattern for “repair a corrupted ext4 filesystem on /dev/sda2“#
bash
# 1. Take it out of service
sudoumount/dev/sda2
# 2. Repair, answering yes to everything, forcing a full pass
sudofsck.ext4-fy/dev/sda2# or: sudo e2fsck -fy /dev/sda2# 3. Mount it back
sudomount/dev/sda2/data
# 4. Look in lost+found for anything the repair rescued
sudols-l/data/lost+found
If /dev/sda2 is the root filesystem you cannot unmount it, so you need one of:
bash
# Option A — ask systemd to fsck on the next boot, then reboot
sudotouch/forcefsck# legacy, still honoured by some distros# or, better, add to the kernel command line in GRUB:# fsck.mode=force fsck.repair=yes# Option B — force it via the superblock, then reboot
sudotune2fs-c1/dev/sda2# check on next mount# Option C — boot a rescue ISO or a live USB and repair the disk while it is not in use
Realistic output from a genuine repair:
terminal
$ sudoe2fsck-fy/dev/sdb1
e2fsck 1.47.0 (5-Feb-2023)Pass 1: Checking inodes, blocks, and sizesInode 245891 has an invalid extent node (blk 812345, lblk 0)Clear? yesInode 245891, i_blocks is 24, should be 0. Fix? yesPass 2: Checking directory structureEntry 'report.csv' in /data (245900) has deleted/unused inode 245891. Clear? yesPass 3: Checking directory connectivityUnconnected directory inode 246012 (was in /data/archive)Connect to /lost+found? yesInode 246012 ref count is 3, should be 2. Fix? yesPass 4: Checking reference countsPass 5: Checking group summary informationBlock bitmap differences: -(812340--812343)Fix? yesFree blocks count wrong for group #24 (1024, counted=1028).Fix? yes/dev/sdb1: ***** FILE SYSTEM WAS MODIFIED *****/dev/sdb1: 12/6553600 files (0.0% non-contiguous), 291453/26214144 blocks
The five passes, and what each looks for:
Pass
Checks
1
Every inode: is its mode valid, are its block pointers/extents sane, does its block count match reality
2
Every directory: are its entries well-formed, do they point at inodes that are actually in use
3
Connectivity: can every directory be reached from /? Orphans go to lost+found
4
Reference counts: does each inode’s link count match the number of directory entries found
5
Summary data: block and inode bitmaps, and the free counters in the superblock and group descriptors
Every ext2/3/4 filesystem has a lost+found directory at its root (inode 11), created by mkfs. When fsck finds an inode that contains data but has no directory entry pointing at it — an orphan — it cannot know the original name, so it links the inode into lost+found under a name derived from the inode number:
terminal
$ sudols-l/data/lost+found
total 1204-rw-r--r-- 1 alice devs 9412 Jul 30 14:02 #245891drwxr-xr-x 2 root root 4096 Jul 29 11:40 #246012
Recovery is manual archaeology: file #245891 to guess the type, head/strings to inspect it, then rename it into place. Do not delete lost+found — fsck needs it to exist and cannot always create it during a repair.
XFS deliberately does not have a useful fsck. /sbin/fsck.xfs exists only so that fsck -A at boot does not fail; it prints a message and exits 0. XFS recovers from its own log automatically at mount time.
bash
sudoumount/data
sudoxfs_repair-n/dev/sdb1# dry run: report problems, change nothing
sudoxfs_repair/dev/sdb1# actually repair
sudoxfs_repair-L/dev/sdb1# ZERO the log — last resort, expect DATA LOSS
sudomount/dev/sdb1/data
Every column, explained — this is the field-by-field reading interviewers ask for:
Column
Meaning
Filesystem
The source: a device, an LVM mapper name, an NFS server:/export, or a pseudo-source like tmpfs
Size
Total capacity usable by the filesystem — already smaller than the raw device, because metadata (inode table, journal, group descriptors) is subtracted
Used
Blocks currently allocated to files, including files that are deleted but still open
Avail
Space available to an ordinary user. This is Size − Used − reserved, where reserved is the root-only reserve (5% by default on ext4). Used + Avail is therefore normally less than Size, and that is not a bug
Use%
Used as a percentage of Used + Avail — so it can read 100% while Avail is still non-zero for root
Mounted on
The mount point
More df in practice:
terminal
$ df-hT/data
Filesystem Type Size Used Avail Use% Mounted on/dev/sdb1 ext4 98G 4.3G 89G 5% /data$ df-h/var/log/nginx/access.log
Filesystem Size Used Avail Use% Mounted on/dev/sda2 98G 22G 71G 24% /
Giving df a path rather than a device answers “which filesystem is this file on, and how full is it?” — the fastest way to find out that /var/log is not a separate filesystem after all.
df -i is the answer to both “which command shows the inode usage of a filesystem?” and “which command shows the number of files for each mounted filesystem?” — and it is the command that turns a baffling ENOSPC into a two-second diagnosis.
du reports disk usage by walking a directory tree and adding up the blocks each file actually occupies. It is the opposite end of the telescope from df: df asks the filesystem, du counts the files.
bash
du[options][path]
With no path, du examines the current working directory. By default it prints a line for every subdirectory it visits, with sizes in 1 KiB units, and a total for the starting directory last.
Option
Long form
Effect
-h
--human-readable
4.0K, 1.2G instead of raw KiB
-s
--summarize
One total per argument, no per-subdirectory breakdown
-a
--all
A line for every file, not just directories
-c
--total
Add a grand total line at the end
-d N
--max-depth=N
Only summarise down to N levels. -d1 is the triage workhorse
-x
--one-file-system
Skip anything on a different filesystem. Stops du / wandering into NFS mounts and /proc
--exclude=PATTERN
Skip matching paths: --exclude='*.iso'
--apparent-size
Report logical file length rather than allocated blocks
-b
--bytes
--apparent-size --block-size=1
-k / -m
Report in KiB / MiB
-B <size>
--block-size=
Any unit: -BM, --block-size=1G
-L
--dereference
Follow symlinks and count their targets
-l
--count-links
Count hard-linked files once per link instead of once in total
--time
Show each entry’s mtime — useful for finding what grew recently
A directory of a million tiny files therefore consumes ~4 GB while ls insists it holds 200 MB of data. This is also why du -s on a source tree is always larger than the tarball you make from it.
Sparse files. A file can have holes — regions that were never written and consume no blocks at all. Virtual machine disk images, database files and /var/log/lastlog are typically sparse:
ls says 10 GB, du says zero, and both are telling the truth. Copy that file with cp (without --sparse=always) or tar (without -S) and it becomes a real 10 GB file — a classic way to fill a disk during a backup.
The single most useful sequence in day-to-day operations. Start at the top and descend into whatever is biggest.
terminal
$ df-h# 1. WHICH filesystem is full?Filesystem Size Used Avail Use% Mounted on/dev/sda2 98G 93G 0 100% /$ sudodu-xh--max-depth=1/2>/dev/null|sort-h# 2. which top-level directory?4.0K /media4.0K /srv16K /lost+found92M /boot412M /home1.8G /usr88G /var93G /$ sudodu-xh--max-depth=1/var|sort-h# 3. descend1.2M /var/spool412M /var/cache2.1G /var/lib85G /var/log88G /var$ sudodu-ah/var/log|sort-h|tail-5# 4. name the files1.2G /var/log/syslog.13.4G /var/log/journal18G /var/log/nginx/access.log62G /var/log/app/debug.log85G /var/log
Sixty seconds, four commands, and you have the culprit: a debug log nobody rotated.
Why each flag is there:
-x keeps du on one filesystem, so it does not descend into /proc, /sys, an NFS mount or the 2 TB data volume mounted under /var. This is the answer to “which du option excludes files and directories mounted on other filesystems?”
--max-depth=1 gives one line per child directory instead of thousands.
2>/dev/null hides the Permission denied noise (though you should run it with sudo so the numbers are complete).
sort -h sorts human-readable sizes correctly — sort -n would put 900K after 2G.
Variations you will use:
bash
# Top 5 largest directories under /var
sudodu-h--max-depth=1/var|sort-h|tail-5
sudodu-h--max-depth=1/var|sort-rh|head-5# same, largest first# Top 20 largest FILES anywhere on the root filesystem
sudodu-ahx/|sort-rh|head-20
sudofind/-xdev-typef-printf'%s\t%p\n'|sort-rn|head-20# faster on huge trees# What grew in the last day?
sudofind/var-xdev-typef-mtime-1-size+100M-execls-lh{}\;# The interactive option — by far the nicest
sudoncdu-x/
The classic incident: df says full, du says empty#
Fifty gigabytes used, three accounted for. Forty-seven gigabytes are missing. There are five causes, in rough order of likelihood.
1. A deleted-but-open file (by far the most common). Someone — or a logrotate misconfiguration — deleted a large log file while the process writing to it still had it open. The directory entry is gone, so du cannot see it, but the link count reached zero with an open descriptor, so the kernel keeps every block allocated. df counts those blocks. The file will be freed when the process closes it or exits, and not before.
lsof +L1 lists open files whose link count is less than 1 — precisely the deleted-but-open set. The NLINK column is 0 and the name is suffixed (deleted). Note the SIZE/OFF values: 4.8 GB and 45 GB. That is your missing space.
The fixes, best first:
bash
# Best: tell the process to reopen its log files
sudosystemctlreloadnginx
sudokill-USR1$(cat/run/nginx.pid)# nginx's reopen-logs signal# If it has no reopen mechanism: restart it
sudosystemctlrestarttomcat
# Emergency, without restarting: truncate the file THROUGH the descriptor.# /proc/<pid>/fd/<fd> still points at the nameless inode.
sudotruncate-s0/proc/11204/fd/142
# equivalently: sudo sh -c ': > /proc/11204/fd/142'
The truncate trick frees the space instantly with no downtime. The process keeps writing at its old offset, so the file becomes sparse rather than shrinking to zero — acceptable in an emergency, and the reason you still fix the logrotate configuration afterwards.
2. Reserved blocks. ext4 keeps 5% of the filesystem for root by default, so df can show 100% and 0 available while root can still write. tune2fs -l shows Reserved block count; tune2fs -m 1 /dev/sdb2 reclaims most of it. On a 4 TB volume, 5% is 200 GB.
3. Inode exhaustion.df -h shows space free but writes fail. df -i shows IUse% 100. Covered in section 5.
4. A mount shadowing files underneath it. Files were written into /var/logbefore a separate filesystem was mounted over /var/log. They still occupy blocks on the parent filesystem, but no path can reach them, so du on the live tree cannot count them. Expose them by bind-mounting the parent filesystem’s root somewhere else:
terminal
$ sudomkdir/mnt/rootfs
$ sudomount--bind//mnt/rootfs
$ sudodu-sh/mnt/rootfs/var/log# the HIDDEN files under the mount point41G /mnt/rootfs/var/log$ sudorm-rf/mnt/rootfs/var/log/*# delete the shadowed copies$ sudoumount/mnt/rootfs
5. du was not run as root. Without permission to read a directory, du silently undercounts it. Always run the investigation with sudo, and be suspicious of Permission denied on stderr.
Two more, less common: filesystems with compression or copy-on-write (Btrfs, ZFS) where df is inherently approximate and you should use btrfs filesystem usage or zfs list instead; and hard links, which du counts once even though several paths show the file.
df ≠ du: the decision tree
df says FULL, du says small
│
├─ sudo lsof +L1 → any (deleted) files with big SIZE?
│ └─ YES → reload/restart the holder, or
│ truncate -s 0 /proc/PID/fd/N ← 80% of cases
│
├─ df -i → IUse% = 100?
│ └─ YES → inode exhaustion. Delete files or rebuild with -i 4096
│
├─ tune2fs -l | grep -i reserved → 5% held for root?
│ └─ YES → tune2fs -m 1
│
├─ mount --bind / /mnt/rootfs; du -sh /mnt/rootfs/<mountpoint>
│ └─ big → files hidden UNDER a mount point
│
└─ was du run as root? → no → rerun with sudo
You have a physical disk, and you want to turn it into something useful. That journey involves five decisions, each solving a real problem:
How do I divide the disk into chunks? — Partitioning, with a partition table.
How do I protect against disk failure? — RAID, distributing data across drives.
How do I resize filesystems without rebuilding them? — LVM, a logical layer between partitions and filesystems.
How do I handle I/O errors or disk degradation? — Hot spares, rebuild algorithms, monitoring.
How do I grow a system after it is running? — Online growth with lvextend and resize2fs.
Without these layers, an administrator could partition a 2 TB disk, fill it, and then be stuck: the filesystem is full, the partition cannot be resized without destroying data, and adding a second disk requires LVM or manual migration.
A physical disk is a building. A partition is an apartment — you own 1C, it has walls, a specific size. If you want to expand, the building is full, too bad. You own it, but you cannot resize it.
LVM is like having adjustable walls: you can shrink 1C and grow 1D without anyone moving. Your actual data is in an office suite that hangs off the logical space. If 1C fails (disk dies), RAID 1 means 2C is an exact copy, so you do not lose anything.
An extended partition is a partition table entry (16 bytes in MBR) that points to a chain of logical partitions. It consumes no physical space — it is pure metadata. You can have at most 4 entries in an MBR partition table, but with one extended partition, you can have unlimited logical partitions inside it.
The extended partition itself is not mounted; it is just a container. GPT has no extended partition concept because it allows 128+ primary partitions directly.
Example 1: Beginner — single disk, one partition, no RAID#
bash
# Discover the disk
lsblk
# Output:# NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS# sda 8:0 0 100G 0 disk# └─sda1 8:1 0 100G 0 part /# Use the entire disk for root (already done on most installs)
mdadm--create/dev/md0--level=1--raid-devices=2/dev/sda/dev/sdb
# All data on /dev/sda is mirrored to /dev/sdb# If /dev/sda fails, /dev/sdb has a full copy
mkfs.ext4/dev/md0
mount/dev/md0/data
mdadm--create/dev/md0--level=5--raid-devices=3--spare-devices=1\/dev/sda/dev/sdb/dev/sdc/dev/sdd
# md0 is 8 TB usable (3 × 4 TB − 1 for parity)# /dev/sdd waits; if any data disk fails, /dev/sdd is automatically added# Rebuild takes hours on 4 TB disks — the spare allows work to continue at reduced performance
AWS scenario: EC2 instance with a 100 GB root volume.
bash
# In AWS console: resize volume to 200 GB# Reboot or wait for reshape to complete# On the instance:
growpart/dev/xvda1# grow the partition
resize2fs/dev/xvda1# grow the filesystem
df-h# confirm
# Interactive mode
fdisk/dev/sda
# Flags to use:# m = menu# p = print partition table# n = new partition# d = delete partition# t = set partition type (e.g., type 82 for swap)# w = write table to disk# Common one-liner to see table without entering interactive mode
fdisk-l/dev/sda# list only
terminal
$ fdisk-l/dev/sda
Disk /dev/sda: 100 GiB, 107374182400 bytes, 209715200 sectorsUnits: sectors of 1 * 512 = 512 bytesSector size (logical/physical): 512 bytes / 512 bytesI/O size (minimum/optimal): 512 bytes / 512 bytesDisklabel type: dosDisk identifier: 0x00042a4eDevice Boot Start End Sectors Size Id Type/dev/sda1 * 2048 1050623 1048576 512M 83 Linux/dev/sda2 1050624 209715199 208664576 99.5G 8e Linux LVM
Field-by-field breakdown:
Device — partition name
Boot — * = boot flag set
Start / End — sector numbers (multiply by 512 to get bytes)
Sectors — count
Size — human-readable
Id — partition type (83 = Linux, 8e = Linux LVM, 82 = Linux Swap, ef = EFI)
Type — name of the type
gdisk — GPT editor (recommended for modern systems)#
bash
gdisk/dev/sda# interactive# Commands: p (print), n (new), d (delete), t (change type), w (write)# List in human format:
gdisk-l/dev/sda
terminal
$ gdisk-l/dev/sda
GPT fdisk (gdisk) version 1.0.9...Number Start (sector) End (sector) Size Code Name 1 2048 1050623 512.0 MiB EF00 EFI System Partition 2 1050624 209715166 99.5 GiB 8E00 Linux LVM
What: A pool combining multiple PVs. All extents are available to any LV.
bash
# Create a VG from one or more PVs
vgcreatevg0/dev/sda1/dev/sdb1
# View VGs
vgs# short
vgdisplay# long# Example:# VG Name vg0# Total PE 25600 # 50 GB / 4 MB = 12800 from each disk# Alloc PE / Size 1280 / 5.00 GiB# Free PE / Size 24320 / 95.00 GiB
What: A virtual disk, allocated from the VG’s free extents. Can be resized online.
bash
# Create an LV
lvcreate-L10G-nlv_datavg0
# Allocate 10 GB, name it "lv_data", in VG "vg0"# Device: /dev/vg0/lv_data or /dev/mapper/vg0-lv_data# View LVs
lvs# short
lvdisplay# long
The workflow: from empty disks to mounted filesystem#
Step-by-step:
bash
# 1. Create partition(s) or use whole disk
fdisk/dev/sda# sda1: 50 GB
fdisk/dev/sdb# sdb1: 50 GB# 2. Initialize as PVs
pvcreate/dev/sda1/dev/sdb1
pvs
# 3. Create a VG from those PVs
vgcreatevg0/dev/sda1/dev/sdb1
vgs
# 4. Create LVs from the VG
lvcreate-L30G-nlv_rootvg0
lvcreate-L40G-nlv_datavg0
lvcreate-L20G-nlv_backupvg0
lvs
# 5. Create filesystems
mkfs.ext4/dev/vg0/lv_root
mkfs.ext4/dev/vg0/lv_data
mkfs.ext4/dev/vg0/lv_backup
# 6. Mount
mkdir-p/data/backup
mount/dev/vg0/lv_root/
mount/dev/vg0/lv_data/data
mount/dev/vg0/lv_backup/backup
# 7. Add to /etc/fstab for persistenceUUID=$(blkid-sUUID-ovalue/dev/vg0/lv_root)echo"UUID=$UUID / ext4 defaults 0 1">>/etc/fstab
# Check free space
vgsvg0
# VG Size: 100 GB, Alloc: 90 GB, Free: 10 GB# Extend LV by 10 GB
lvextend-L+10G/dev/vg0/lv_data
# Grow the filesystem (ext4 — *cannot shrink* after this)
resize2fs/dev/vg0/lv_data
# Or do both in one command
lvextend-L+10G-r/dev/vg0/lv_data# -r resizes the FS too# For XFS (cannot shrink, but no issue)
xfs_growfs/data
# Verify
df-h/data
# Create a snapshot
lvcreate-L5G-s-nlv_data_backup/dev/vg0/lv_data
# Snapshot name: /dev/vg0/lv_data_backup# Size: 5 GB (change log only; actual data is shared with the original)# Mount the snapshot (read-only recommended)
mount-oro/dev/vg0/lv_data_backup/mnt/snapshot
# Back it up
tarczf/backup/data-$(date+%Y%m%d).tar.gz-C/mnt/snapshot.
# Remove snapshot when done
umount/mnt/snapshot
lvremove/dev/vg0/lv_data_backup
# WRONG
pvremove/dev/sda1# LVM metadata is lost; cannot recover LVs# CORRECT
umount/data
lvremove/dev/vg0/lv_data# remove the LV
vgremovevg0# remove the VG (all LVs must be gone)
pvremove/dev/sda1# now safe
RAID 0 (striping): Data is divided across disks in blocks.
Data: ABCDEFGHIJKLMNOP
Disk 0: ACEGIKMO
Disk 1: BDFHJLNP
One disk fails → ACEGIKMO is gone, BDFHJLNP is gone (cannot reconstruct either)
RAID 1 (mirroring): Exact copy on two disks.
Disk 0: ABCDEFGHIJKLMNOP
Disk 1: ABCDEFGHIJKLMNOP (identical)
One disk fails → the other has the full data
RAID 5 (striping + parity): Data + parity distributed across 3+ disks.
Block 1: Data D1, D2 on disks 0,1, parity P1 on disk 2
Block 2: Data D3, D4 on disks 1,2, parity P2 on disk 0
Block 3: Data D5, D6 on disks 2,0, parity P3 on disk 1
One disk fails → reconstruct its blocks using D + P − D = P formula
Three disks fail → data is unrecoverable
# Three 1 TB disks, one spare
mdadm--create/dev/md0--level=5--raid-devices=3--spare-devices=1\/dev/sda/dev/sdb/dev/sdc/dev/sdd
# Output:# mdadm: Defaulting to version 1.2 metadata and 0.90 bitmap# mdadm: array /dev/md0 started with 3 active, 0 spare, 1 working devices.# Usable capacity: 2 TB (3 × 1 TB − 1 TB for parity)
# Start monitoring daemon (one instance, runs in background)
mdadm--monitor--daemonise--mail=root/etc/mdadm.conf
# It watches `/proc/mdstat` and emails root on failures
# VM in AWS: resize EBS volume to 200 GB in console# Then on the instance:
growpart/dev/nvme0n11# grow partition 1 to fill the disk
resize2fs/dev/nvme0n1p1# grow ext4 filesystem
df-h/# verify# For XFS:
xfs_growfs/
# Wipe a disk completely (fills with zeros)
ddif=/dev/zeroof=/dev/sdabs=1M
# Wipe the partition table only
wipefs-a/dev/sda
# Securely erase (writes random data 3 times — slow)
ddif=/dev/urandomof=/dev/sdabs=1M
# SATA/SAS disk information
hdparm-I/dev/sda
# NVMe disk information
nvmeid-ctrl/dev/nvme0n1
# Read temperature
nvmesmart-log/dev/nvme0n1|grep-itemperature
Beginner — What is the purpose of a partition table?
A partition table is metadata at the beginning of a disk (or at the end, for GPT) that tells the kernel how to divide the disk into chunks. Each chunk (partition) can hold a filesystem, RAID array, or LVM volume. Without a partition table, the kernel sees the entire disk as one object.
Beginner — What is the difference between MBR and GPT?
MBR (Master Boot Record) is an old standard: 4 partitions max, 2.2 TB max disk size, 512-byte table at the start, no redundancy. GPT (GUID Partition Table) is modern: 128+ partitions, 16 EB max disk size, partition table in sectors 0–33 plus a backup copy at the end, full redundancy. **Use GPT for any new system.**
Beginner — What is a Physical Volume in LVM?
A Physical Volume (PV) is a partition or whole disk initialized for LVM. It has a small metadata header (< 1 MB) at the start that identifies it as LVM and holds configuration. Multiple PVs can be combined into a Volume Group (VG).
Intermediate — Explain the three layers of LVM.
Physical Volume (PV) is the raw partition or disk. Multiple PVs are grouped into a Volume Group (VG), which is a pool of storage. Logical Volumes (LVs) are virtual disks carved from the VG and allocated in chunks called extents (default 4 MB). You format and mount LVs as filesystems, never PVs or VGs.
Intermediate — What is the main advantage of RAID 5 in production?
RAID 5 balances three factors: capacity (66% on 3 disks, much better than RAID 1's 50%), fault tolerance (one disk can fail), and cost (only 3 disks needed). It is the standard for general-purpose storage. Its weakness is rebuild time: a 16 TB drive takes 20+ hours, during which a second failure means data loss — **RAID 6** solves this at the cost of 2 parity disks.
Intermediate — How do you grow a logical volume without downtime?
Use `lvextend -L +10G -r /dev/vg0/lv_data` to add 10 GB to the LV and resize the filesystem in one command. The `-r` flag automatically calls `resize2fs` (ext4) or `xfs_growfs` (XFS). No unmount needed. This is why LVM is standard in production — you can grow running systems.
Advanced — An MBR partition table has an extended partition entry. Does this consume disk space?
No. An extended partition is 16 bytes in the partition table — pure metadata. It points to a chain of logical partitions. The extended entry itself has no data area; the logical partitions inside it contain the actual data. GPT avoids this by simply allowing 128+ primary partitions, so extended partitions do not exist in GPT.
Advanced — A RAID 5 array with 4 data disks is rebuilding after a failure. A second disk fails. What happens?
**Data is lost.** RAID 5 tolerates one failure; parity is calculated as D1 XOR D2 XOR D3 XOR P = 0. To recover a failed disk, you need all other disks intact. If two fail during a rebuild, you have lost information and cannot reconstruct either disk. **RAID 6** adds a second parity, tolerating two failures. This is why RAID 6 is standard for large drives (> 4 TB).
Advanced — Why should you use `/dev/disk/by-uuid` in `/etc/fstab` instead of `/dev/sda1`?
Device names like `/dev/sda` are assigned by the kernel at boot based on enumeration order, which can change if you move drives to different SATA ports, add new disks, or the driver initializes in a different order. `/dev/disk/by-uuid` (or `/dev/disk/by-id`) is persistent — it stays the same even if `/dev/sda` becomes `/dev/sdb`. Without this, a reboot could cause the wrong filesystem to mount as root, breaking the boot.
Advanced — Describe the full command to create a RAID 5 array, initialize it as a PV, create a VG and LV, format it, and mount it.bash
# RAID 5: 3 drives
mdadm--create/dev/md0--level=5--raid-devices=3/dev/sda/dev/sdb/dev/sdc
# Initialize as PV
pvcreate/dev/md0
# Create VG
vgcreatevg_raid/dev/md0
# Create LV
lvcreate-L100G-nlv_datavg_raid
# Format
mkfs.ext4/dev/vg_raid/lv_data
# Mount
mount/dev/vg_raid/lv_data/data
# Persist in /etc/mdadm.conf and /etc/fstab
mdadm--detail--scan>>/etc/mdadm.conf
echo"/dev/vg_raid/lv_data /data ext4 defaults 0 2">>/etc/fstab
This gives you a 2 TB RAID 5 array with online-growable LVM on top, so you can add drives and resize later.
Scenario — A database server has a 2 TB `/var/lib/mysql` volume that is 95% full. Your company policy forbids downtime. How do you grow it?
1. Add a new physical disk to the server (or resize the virtual volume in the cloud console and run `growpart` on the instance).
2. `pvcreate` the new disk (or partition).
3. `vgextend vg0 /dev/sdd` (add it to the VG).
4. `lvextend -L +500G -r /dev/vg0/lv_mysql` (grow by 500 GB and resize the filesystem).
5. Monitor the resize: `watch df -h /var/lib/mysql`.
No downtime, the database keeps running, and the filesystem is now larger. This is the entire reason LVM exists.
Scenario — A RAID 5 array is degraded (one disk failed). You replace the physical disk in the bay. What commands do you run to rebuild?bash
# Check status
mdadm--detail/dev/md0|grep-A10"Number"# The failed disk might still be listed. Add the new one:
mdadm/dev/md0--add/dev/sda
# Watch rebuild
watchcat/proc/mdstat
It will automatically start rebuilding to parity. If the old disk is still present and marked failed:
bash
No downtime if a hot spare was configured; the spare took over automatically.
Company style — Why do cloud providers use thin provisioning and snapshots for their storage?
Thin provisioning means billing users for actual usage, not reserved capacity, which reduces their costs and increases density. Snapshots are cheap (copy-on-write) and enable instant backups without stopping the workload or doubling the storage. Together, they make it economically feasible to offer database backup and recovery as a service. On-prem operations use LVM snapshots for the same reason.
HR style — Tell me about a time a disk or filesystem issue impacted a service.
A strong answer is specific and shows the recovery: "We had a 20 TB database volume that was growing faster than expected. A junior admin filled it completely at 2 a.m., and the database stopped accepting writes. We added a disk, extended the LVM volume with `lvextend -r`, and the service recovered in 3 minutes. Afterwards, we set up monitoring to alert at 80% and automated scaling to add disks on demand. Now it is largely self-healing."
This shows understanding of the tools, the impact of the failure, the recovery, and the systemic improvement.
Which partition table supports disks larger than 2.2 TB? (a) MBR (b) GPT (c) Extended (d) RAID
How much usable space does RAID 5 provide with 3 × 4 TB disks? (a) 12 TB (b) 8 TB (c) 6 TB (d) 4 TB
A device name /dev/sda1 is: (a) always stable (b) stable until a new disk is added (c) based on kernel enumeration order (d) unique to each machine
What is an LVM extent? (a) a physical disk (b) a partition (c) a 4 MB (default) allocation unit (d) a filesystem
RAID 1 gives redundancy but sacrifices: (a) speed (b) capacity (50% usable) (c) complexity (d) rebuild time
The command to resize an ext4 filesystem online is: (a)fsck -f(b)resize2fs(c)e2fsck(d)tune2fs
An extended partition in MBR: (a) is a 4th primary partition (b) takes space on disk (c) is metadata pointing to logical partitions (d) is obsolete in RAID
After editing a partition table with fdisk on a running system, you must: (a) reboot (b) run partprobe(c) recreate the VG (d) unmount the disk
RAID 6 tolerates: (a) 1 disk failure (b) 2 disk failures (c) 3 disk failures (d) any failure if a spare is configured
The correct device name for /etc/fstab is: (a)/dev/sda1(b)/dev/disk/by-path/...(c)/dev/disk/by-uuid/...(d)/dev/md0
Answers
1. (b) — GPT supports up to 16 EB
2. (b) — 8 TB usable; 1 TB is parity
3. (c) — enumeration order is not guaranteed
4. (c) — default 4 MB
5. (b) — 50% capacity for mirroring
6. (b) — `resize2fs`
7. (c) — metadata only
8. (b) — `partprobe` to refresh the kernel
9. (b) — two failures
10. (c) — `/dev/disk/by-uuid` is persistent; device names are not
MBR supports up to __ primary partitions; GPT supports ____ by default.
A PV is initialized with ______ .
A VG combines multiple ______ into a pool.
An LV is allocated from the VG in chunks called __ (default ____ MB).
To grow an LV and its filesystem online: _ -L +10G -r /dev/vg0/lvx
RAID __ has zero fault tolerance; RAID _ has one; RAID ___ has two.
The command to create a RAID 5 array with 4 devices (3 data + 1 spare) is: ______ –create /dev/md0 –level=5 –raid-devices=3 –spare-devices=1 /dev/sd{a,b,c,d}
After editing partitions with fdisk, refresh the kernel with ______ .
An extended partition consumes physical disk space.
GPT partitions are always more stable than MBR.
You should use /dev/sda1 in /etc/fstab for permanent stability.
RAID 5 can survive two disk failures.
LVM can grow a filesystem without unmounting it.
A hot spare is automatically added to RAID when a disk fails.
resize2fs can grow ext4 while mounted and in use.
RAID 1 gives 100% capacity utilization.
Answers
1. **False** — it is metadata only.
2. **False** — stability depends on using persistent device names (UUIDs), not the table type.
3. **False** — device names are not stable; use `/dev/disk/by-uuid`.
4. **False** — RAID 5 tolerates one failure; RAID 6 tolerates two.
5. **True** — with `lvextend -L +10G -r`.
6. **True** — if configured; the spare is added and the array rebuilds automatically.
7. **True** — and it is safe.
8. **False** — RAID 1 is 50% usable (one copy for redundancy).
A file on disk does nothing. /usr/bin/nginx is 1.4 MB of inert bytes until something asks the kernel to breathe life into it. The moment it runs, it becomes a process: it acquires an identity (a PID), an owner, a private address space, a set of open files, a parent, a priority, and a place in a queue where the scheduler decides how much CPU it deserves.
Almost everything you will ever do as a Linux or DevOps engineer is a statement about processes:
The site is down → which process died, and why?
The box is at 100% CPU → which process, and can I deprioritise it instead of killing it?
The deploy hangs → which process is stuck, and on what?
The container restarted with code 137 → the kernel sent it SIGKILL because it exceeded its memory limit.
“Make this survive a reboot” → stop writing nohup ... & and write a systemd unit.
Chapter 1 gave you the mental model: the kernel abstracts hardware and arbitrates access to it. Processes are the unit that arbitration acts on. Signals are how you talk to them. systemd is how you make them somebody else’s problem — reliably, at boot, with restarts and logs.
Imagine a machine with one CPU and forty programs that all want it. You need answers to five questions, and the process subsystem exists to answer them:
The five questions the process subsystem answers
1. IDENTITY Who is running? → PID, PPID, UID, command line
2. ISOLATION Can A read B's memory? → per-process virtual address space
3. ARBITRATION Who gets the CPU next? → scheduler, nice, cgroups
4. CONTROL How do I talk to it? → signals (kill, Ctrl+C, Ctrl+Z)
5. LIFECYCLE Who starts and restarts → PID 1: init / systemd
it, and who buries it? fork, exec, wait, reap
Every command in this chapter is a tool for one of those five columns. ps and top answer identity. /proc answers isolation. nice, renice and cgroups answer arbitration. kill, pkill and killall answer control. systemctl answers lifecycle.
Availability. A service that dies at 03:00 and stays dead costs money. Restart=on-failure in a unit file is a one-line SLA improvement, and it is the difference between an outage and a blip.
Cost. Cloud bills are CPU and memory. Knowing which process is eating a core — and being able to cap it rather than remove it — turns a bigger-instance ticket into a config change.
Graceful shutdown. Rolling deploys, autoscaling and spot instances all depend on processes that clean up when asked politely. A team that reaches for kill -9 habitually ships services that corrupt data under load.
Debuggability./proc, ps, strace and lsof mean a Linux process is the most inspectable running thing in computing. Engineers who can read that state fix incidents in minutes instead of guessing.
A program is a recipe printed in a book. It is bytes; it does nothing; ten people can read the same page.
A process is one person actually cooking that recipe in one kitchen: a specific pan on a specific hob, at a specific point in the instructions, with specific ingredients on the counter.
Run nginx three times and you have one program and three processes. Same recipe, three kitchens.
The program counter is the line of the recipe you are on.
The heap is the counter space you keep grabbing more of as you go.
The stack is the pile of “I was in the middle of the sauce when I started the roux” notes.
A thread is a second pair of hands in the same kitchen: sharing the counter, the ingredients and the hob, but each following its own place in the instructions.
That last one is the whole process-versus-thread answer. Separate kitchens (processes) cannot knock each other’s bowls over. Two pairs of hands in one kitchen (threads) are faster to coordinate and can absolutely knock each other’s bowls over — which is why you need locks.
You cannot have a conversation with a running process from outside. What you can do is tap it on the shoulder in one of about sixty distinguishable ways. The process decides how to react — with two exceptions.
Tap
What it conventionally means
Can the process ignore it?
SIGTERM (15)
“Please finish up and leave.”
Yes — and it can clean up first
SIGHUP (1)
“Re-read your configuration.”
Yes
SIGINT (2)
“The user pressed Ctrl+C.”
Yes
SIGTSTP (20)
“The user pressed Ctrl+Z — pause.”
Yes
SIGSTOP (19)
“Freeze. Now.”
No
SIGKILL (9)
“You are dead. Now.”
No
SIGKILL and SIGSTOP are handled entirely by the kernel on behalf of the process. The process is never told. That is why they cannot be caught, blocked or ignored — and why kill -9 guarantees death but also guarantees no cleanup.
nohup ./myapp & is a patient who walked out of the building. Nobody is watching. If they collapse, nobody notices. If the building is evacuated (reboot), they do not come back.
A systemd unit is a patient admitted to a ward. There is a chart (the unit file), observations (systemctl status), notes (journalctl -u), a policy for what to do if they deteriorate (Restart=on-failure), a rule about who must be treated first (After=network-online.target), and a standing instruction to readmit them every morning (enable).
Every process except PID 1 has exactly one parent. Run pstree and you are looking at a genealogy that starts with systemd and ends with the shell you are typing in. When a parent dies before its child, the child is orphaned and immediately adopted by PID 1. When a child dies before its parent collects the death certificate, the child is a zombie — a name on a register with no body attached.
A passive, executable file on disk — typically ELF format on Linux — containing machine code, data and metadata. It has no state and consumes no CPU.
Process
A running instance of a program: an execution context consisting of a virtual address space, at least one thread of execution, a file-descriptor table, credentials, a scheduling state and a unique PID. The kernel represents it internally as a task_struct.
Thread
A schedulable flow of execution within a process. Threads of one process share the address space (code, heap, globals), open file descriptors, signal dispositions and working directory; each has its own stack, registers, program counter and kernel-visible ID. On Linux a thread is a task that shares those resources — which is why the kernel scheduler treats threads and processes almost identically, and why ps -eLf can list them.
PID — Process ID
A positive integer uniquely identifying a process on a running system. Allocated sequentially and wrapped at /proc/sys/kernel/pid_max (32768 by default; up to 4194304 on 64-bit systems).
PPID — Parent Process ID
The PID of the process that created this one. If the parent exits first, the PPID is re-set to 1.
TGID / thread group
The thread group ID is what userspace calls "the PID". All threads of a process share a TGID; each has its own kernel task ID (shown as LWP or SPID in ps).
UID / EUID
The real user ID is who launched the process; the effective user ID is whose privileges it currently acts with. sudo and setuid binaries such as passwd are exactly the case where they differ.
Daemon
A background service process with no controlling terminal, normally started at boot and running for the life of the system. Conventionally named with a trailing d — sshd, crond, systemd, rsyslogd. Classically a daemon forked twice, called setsid() and closed its inherited file descriptors; under systemd that dance is unnecessary because systemd supervises the process directly.
Signal
An asynchronous software interrupt delivered to a process or thread, identified by a small integer, used to notify it of an event or to request a state change. The kernel delivers it by running the process's handler, or its default action if there is none.
Zombie (defunct) process
A process that has terminated but whose exit status has not yet been read by its parent via wait(). Its memory, files and address space are already gone; only the entry in the process table remains, so that the parent can still learn how it died. Shown as state Z and <defunct> in ps.
Orphan process
A running process whose parent has terminated. The kernel immediately re-parents it to PID 1 (or to the nearest ancestor marked as a subreaper), which will reap it when it eventually exits.
init system
The userspace program the kernel starts as PID 1 after mounting the root filesystem. It brings up the rest of userspace, supervises services and reaps orphans. On modern distributions it is systemd; historically SysV init, and Upstart on Ubuntu between roughly 2006 and 2014.
/proc is a virtual filesystem generated by the kernel on read (Chapter 1 proved this with cat /proc/uptime). Every running process gets a directory, and reading it is the single most useful debugging skill in this chapter, because it needs no tools you might not have installed.
terminal
$ ls/proc/1842/
attr cgroup cmdline cwd environ exe fd fdinfo iolimits maps mounts net ns oom_score oom_score_adjroot sched smaps stack stat status syscall task wchan
Walk the six that matter.
cmdline — the exact argument vector. NUL-separated, so translate it:
Uid lists four values: real, effective, saved-set and filesystem UID. VmSize is virtual (address space reserved), VmRSS is resident (physical RAM actually occupied) — hold that distinction, you will need it again for ps aux. SigCgt is a bitmask of caught signals; note that bits 9 and 19 are always clear, because SIGKILL and SIGSTOP can never be caught.
fd/ — every open file. Symlinks, one per descriptor:
That last line is a production classic: a log file was rotated or rm‘d while the process still held it open. The disk space is not freed until the descriptor closes, which is why df says the disk is full and du says it is not. The fix is to make the process reopen its logs — usually systemctl reload, i.e. SIGHUP.
environ — the environment at exec time. Also NUL-separated, and only readable by the owner or root:
Read the permission column: r-xp is code (readable, executable, not writable — this is why you cannot overwrite your own instructions), rw-p is data and heap, p means private (copy-on-write) rather than shared.
limits — the resource ceilings this process actually has. Not what ulimit -a in your shell says — what it got:
terminal
$ cat/proc/1842/limits|head-4
Limit Soft Limit Hard Limit UnitsMax cpu time unlimited unlimited secondsMax file size unlimited unlimited bytesMax open files 1024 1048576 files
Max open files at 1024 on a web server is a bug waiting for traffic. Fix it in the unit file with LimitNOFILE=65535, not in your interactive shell.
Linux does not have a “run this program” system call. It has two calls that compose: one that duplicates the caller, and one that replaces a process’s program image. Everything else falls out of that design.
sequenceDiagram
autonumber
participant U as You
participant S as "bash — PID 2410"
participant K as Kernel
participant C as "child — PID 2411"
U->>S: "sleep 30" then Enter
S->>K: "fork()"
K->>K: "duplicate task_struct, mark all pages copy-on-write"
K-->>S: "returns 2411 — the child PID"
K-->>C: "returns 0 — I am the child"
C->>K: "execve('/usr/bin/sleep', ['sleep','30'], env)"
K->>K: "discard old address space, map new ELF + libc, reset to entry point"
S->>K: "waitpid(2411) — block until it changes state"
Note over C: "child runs sleep for 30 seconds"
C->>K: "exit(0)"
K->>K: "free memory and fds, keep exit status, state becomes Z"
K->>S: "deliver SIGCHLD"
S->>K: "waitpid returns status 0 — child reaped, Z entry removed"
K-->>U: "prompt returns, $? is 0"
Step by step, with the reasoning:
1. fork() creates a near-identical copy of the calling process. Both continue from the same line. The only difference is the return value: the parent gets the child’s PID, the child gets 0, and -1 means the fork failed (usually EAGAIN — you hit RLIMIT_NPROC or pid_max).
2. Copy-on-write makes this cheap. fork() does not copy the parent’s memory; it copies the page tables and marks every writable page read-only in both processes. The first write to a page traps into the kernel, which copies just that 4 KB page and lets the write proceed. So forking a 4 GB process costs kilobytes, not gigabytes — and the child that immediately calls execve() throws the whole map away anyway.
3. execve() replaces the process’s program: the kernel tears down the address space, maps the new ELF binary and its interpreter (ld-linux.so), resets the stack with argv and envp, and jumps to the entry point. The PID does not change. Same identity, new program. That is why exec in a shell script replaces the shell instead of spawning a child, and why ExecStart with Type=simple gives systemd a stable PID to watch.
4. wait() / waitpid() lets the parent collect the child’s exit status. Until it does, the dead child stays in the process table as a zombie. waitpid() adds targeting and options (WNOHANG to poll without blocking); waitid() is the modern superset.
5. exit() ends the process: descriptors close, memory is freed, children are re-parented to PID 1, and SIGCHLD goes to the parent.
If bash called execve() directly, bash would becomels — and when ls finished, your shell would be gone. Forking first means the child is the disposable one. It also gives the shell a window between fork and exec, running in the child, to set up everything the new program should inherit:
What the child does between fork and exec
fork() ─┬─ parent: record job, waitpid() or return prompt if "&"
│
└─ child: setpgid() put me in my own process group
dup2(fd, 1) apply > out.txt
dup2(pipe[1], 1) apply | next-command
close(pipe[0]) tidy inherited descriptors
setuid()/nice() drop privileges, adjust priority
signal(SIGINT, SIG_DFL) restore default handlers
execve("/usr/bin/ls", ...) ← only now become ls
Redirection and pipes are therefore not features of ls — they are things the shell arranges about file descriptors 0, 1 and 2 in the fraction of a millisecond before ls exists. This is the deepest thing to take from this chapter.
0x7fff_ffff_ffff high addresses
┌──────────────────────────────────────┐
│ kernel mappings (not accessible; │ a jump here → SIGSEGV
│ isolated from user page tables) │
├──────────────────────────────────────┤
│ [stack] grows ↓ │ local variables, call frames,
│ │ │ return addresses, argv/envp
│ ↓ │ 8 MB default → RLIMIT_STACK
├──────────────────────────────────────┤
│ unmapped guard region │ runaway recursion lands here
├──────────────────────────────────────┤
│ mmap region grows ↓ │ shared libraries libc.so,
│ │ mmap'd files, large mallocs,
│ │ each thread's own stack
├──────────────────────────────────────┤
│ ↑ │
│ [heap] grows ↑ │ malloc / brk — dynamic memory
├──────────────────────────────────────┤
│ .bss uninitialised statics │ rw- zero-filled at load
│ .data initialised statics │ rw- static int x = 5;
├──────────────────────────────────────┤
│ .rodata string literals, consts │ r--
│ .text the machine code │ r-x executable, NOT writable
├──────────────────────────────────────┤
│ page 0 deliberately unmapped │ *NULL dereference → SIGSEGV
└──────────────────────────────────────┘
0x0000_0000_0000 low addresses
THREADS share: text, rodata, data, bss, heap, mmap, fds, cwd, PID
THREADS own: stack, registers, program counter, kernel task id, errno
Two facts from that diagram earn interview marks. First, .text being read-only and executable while the heap and stack are writable-and-not-executable (NX bit) is the hardware defence against classic code injection. Second, addresses shift on every run because of ASLR (Address Space Layout Randomisation) — which is why two runs of cat /proc/self/maps never match.
When a process ends, it hands the kernel an 8-bit status that its parent collects. The shell puts it in $?.
terminal
$ ls/etc/hostname;echo"exit=$?"/etc/hostnameexit=0$ ls/etc/nope;echo"exit=$?"ls: cannot access '/etc/nope': No such file or directoryexit=2$ sleep60# then press Ctrl+C^C$ echo$?130
Code
Convention
Typical cause
0
Success. The only success value
Anything that worked
1
General error
Catch-all failure; grep found no match
2
Misuse of shell builtin / bad usage
Wrong flags, missing operand; ls on a missing file
126
Command found but not executable
Missing +x, or a directory, or noexec mount
127
Command not found
Typo, not installed, not on $PATH
128+N
Killed by signal N
See below
130
128+2 = SIGINT
You pressed Ctrl+C
137
128+9 = SIGKILL
OOM killer, kill -9, container memory limit
143
128+15 = SIGTERM
Normal systemctl stop, docker stop, k8s eviction
255
Out of range / generic fatal
exit -1, SSH connection failure
The 128+N encoding matters more than it looks. In the shell, $? is 8 bits, so death-by-signal is encoded by adding 128 to the signal number. Under the covers the kernel status word keeps the “exited normally” and “died by signal” cases separate (WIFEXITED vs WIFSIGNALED); the shell flattens them, and the flattening is the everyday diagnostic.
Read that bottom line as a lineage: the SSH listener forked a per-connection child, which forked the session process, which exec’d your bash, which forked pstree. Your shell prompt is four generations from PID 1.
PID 1 is special in three specific ways:
It is the universal ancestor and reaper. When any process dies leaving children, those children’s PPID becomes 1, and PID 1 must call wait() on them when they exit. Without that, orphaned zombies would accumulate forever.
The kernel ignores signals it has no handler for. Signals sent to PID 1 with the default action are silently discarded, including SIGKILL. sudo kill -9 1 does nothing at all. This is deliberate: if PID 1 dies, the kernel panics with Attempted to kill init!.
It owns the boot and shutdown sequence.systemctl reboot is a request to PID 1, which stops units in dependency order and then asks the kernel to reboot.
Which program is PID 1 depends on the distribution’s era:
ps prints a one-or-more-character STAT field. The first character is the state; the rest are modifiers.
Code
State
What it means
Can you kill it?
R
Running / runnable
On a CPU right now, or queued and ready to run
Yes
S
Interruptible sleep
Waiting for an event — input, a timer, a socket. Most processes, most of the time
Yes
D
Uninterruptible sleep
Blocked inside the kernel on I/O that must not be interrupted
No — not even with kill -9
T
Stopped
Suspended by SIGSTOP or SIGTSTP (Ctrl+Z)
Yes
t
Stopped by debugger
Traced and stopped — gdb, strace
Yes
Z
Zombie / defunct
Terminated, awaiting reaping by its parent
It is already dead
X
Dead
Being torn down; you should never see this
n/a
I
Idle kernel thread
A kernel thread doing nothing (a variant of D that is not counted in load)
n/a
The modifier characters after the state:
Modifier
Meaning
<
High priority — a negative nice value
N
Low priority — a positive nice value
L
Has pages locked into memory (real-time, or mlock)
s
Session leader — the top of a terminal session
l
Multi-threaded
+
In the foreground process group of its terminal
So Ss is a sleeping session leader (typical for a daemon or your login shell), R+ is running in the foreground (typical for the ps command you just typed), S< is a sleeping high-priority process, and Z alone is a corpse.
stateDiagram-v2
[*] --> R : fork plus execve
R : R — running or runnable on a CPU
S : S — interruptible sleep, waiting for an event
D : D — uninterruptible sleep, blocked in the kernel
T : T — stopped, frozen by a signal
Z : Z — zombie, dead but not yet reaped
R --> S : waits for input, timer, socket
S --> R : event arrives, or a signal is delivered
R --> D : issues disk or network filesystem I/O
D --> R : I/O completes — no signal can shorten this
R --> T : SIGSTOP or SIGTSTP, Ctrl plus Z
T --> R : SIGCONT
R --> Z : calls exit, or is killed by a signal
Z --> [*] : parent calls wait and collects the status
Why a D-state process cannot be killed. It is executing kernel code, inside a call that deliberately declined to be woken by signals — because being interrupted halfway would corrupt kernel data structures. The signal is recorded as pending and delivered the instant the I/O returns. If the I/O never returns, the process is unkillable, forever. The overwhelmingly common causes:
A hung NFS mount (a hard mount whose server disappeared — the definitive real-world case).
A dying disk taking tens of seconds per request, or a stuck iSCSI/SAN path.
A misbehaving device driver or a stuck FUSE filesystem whose userspace daemon died.
Diagnosis and remedy:
terminal
$ ps-eopid,stat,wchan:24,comm|awk'$2 ~ /^D/' 4412 D nfs_wait_bit_killable rsync 4413 D io_schedule dd$ sudocat/proc/4412/stack|head-4
[<0>] nfs_wait_client_init_complete+0x54/0xd0 [nfs][<0>] nfs4_discover_server_trunking+0x8a/0x2f0 [nfs]
wchan names the kernel function it is waiting in — nfs_wait_* is a diagnosis on its own. Your options are to fix the underlying I/O (restore the NFS server, umount -f -l the mount, restore the SAN path) or to reboot. There is no signal that helps. On a modern kernel some paths are Dkillable (TASK_KILLABLE), so kill -9 occasionally works — but never rely on it.
These two are the most-asked interview pair in this chapter, and they are opposites.
Orphan vs zombie
ORPHAN ZOMBIE (defunct)
────── ────────────────
child is ALIVE child is DEAD
parent is DEAD parent is ALIVE but negligent
parent exits first child exits first
↓ ↓
kernel re-parents child to PID 1 exit status kept in process table
↓ ↓
PID 1 will reap it later parent never calls wait()
↓ ↓
HARMLESS — self-healing entry leaks — Z in ps
Fix: nothing to fix Fix: signal or restart the PARENT
kill -9 on the zombie does NOTHING
What creates a zombie. A parent forks a child, the child exits, and the parent carries on without ever calling wait()/waitpid() — usually because the author never installed a SIGCHLD handler, or the parent is stuck in a loop, or it is a container entrypoint that was never designed to be PID 1.
Why you cannot kill a zombie. There is nothing left to kill. Its memory, file descriptors and threads were freed at exit. All that remains is a row in the kernel’s process table holding the exit status, kept alive for the parent’s benefit. Signals are delivered to running code, and a zombie has none, so kill -9 <zombie-pid> is a no-op. Interviewers love this because so many candidates answer “kill -9 it”.
Why a few zombies are harmless and thousands are fatal. One zombie costs a few hundred bytes and a PID. But PIDs are a finite, wrapping resource:
terminal
$ cat/proc/sys/kernel/pid_max
32768
Leak 32,768 of them and the machine cannot fork at all. fork() returns EAGAIN, and then nothing works — you cannot even run ps to investigate, because running ps requires a fork. The symptom is bash: fork: retry: Resource temporarily unavailable.
Finding zombies and their offending parent:
terminal
$ ps-eopid,ppid,stat,comm|awk'$3 ~ /^Z/' 8871 8830 Z worker 8872 8830 Z worker 8873 8830 Z worker$ ps-p8830-opid,ppid,user,comm,args
PID PPID USER COMMAND COMMAND 8830 1 deploy job-runner /opt/app/job-runner --queue=email
The zombies all share PPID 8830. That is the broken process. Escalate in order:
bash
kill-CHLD8830# nudge it: maybe its SIGCHLD handler just needs waking
systemctlrestartjob-runner# the real fix: restart the parent# if the parent dies, its zombies are re-parented to PID 1, which reaps them instantly
And the count, at a glance — top’s second header line reports zombies directly:
A signal is the kernel’s tiny, fixed-vocabulary messaging system. You cannot send data, only a number. When a signal is delivered, the kernel interrupts the target wherever it is and does one of three things:
runs the handler the process registered for that signal, then resumes it;
performs the default action if there is no handler — terminate, terminate-with-core-dump, stop, continue, or ignore;
does nothing, because the process blocked or ignored that signal.
Two signals bypass all of that, always: SIGKILL (9) and SIGSTOP (19) are enforced by the kernel and never reach the process at all.
The numbers 1–15 above are stable across essentially every Unix, and 9/15/2 are burned into muscle memory everywhere. Above that they drift: SIGUSR1 is 10 on Linux/x86 but 30 on some other Unixes and 16 on MIPS Linux. In scripts, always use names — kill -TERM, not kill -15.
This is the single most important operational habit in the chapter.
The professional way to stop a process
1. ASK NICELY kill 4821 (SIGTERM, signal 15 — the default)
│ process runs its handler:
│ flush buffers to disk
│ commit or roll back the transaction
│ close sockets, tell the load balancer
│ remove its PID / lock files
↓
2. WAIT a few seconds give it a real chance
check: kill -0 4821 → still alive?
│
↓
3. ASK FIRMLY kill -QUIT 4821 (optional: dump state for the postmortem)
│
↓
4. COMPEL kill -9 4821 (SIGKILL — kernel destroys it)
NO handler runs. NO cleanup.
Locks, temp files, half-written
records and unflushed buffers stay.
As a one-liner you can actually use:
bash
kill4821# SIGTERMforiin$(seq10);dokill-048212>/dev/null||break;sleep1;donekill-048212>/dev/null&&kill-94821# only if it refused to go
Why reaching for kill -9 first is the mark of an amateur. SIGTERM is catchable specifically so that well-written software can shut down safely. Skip it and you skip:
Unflushed buffers. Data your app had accepted but not yet written is silently lost. A database mid-checkpoint can require crash recovery on restart.
Corrupt state. A file being rewritten is left half-written; a rebuilt index is left inconsistent.
Orphaned lock files./var/run/app.pid, .lock files and advisory locks are removed by the handler. Skip it and the next start refuses with “another instance is already running”.
Child processes. The handler is what tells the workers to stop. SIGKILL the master and the workers become orphans that keep holding port 80.
Coordination. The handler is where a service deregisters from a load balancer or consumer group. Skip it and traffic keeps arriving at a dead address for the length of a health-check interval.
No logs. SIGKILL cannot be logged by the victim. You lose the only record of what it was doing.
kill -9 is the right answer in exactly one situation: you already sent SIGTERM, you waited, and the process is still there.
SIGHUP is signal 1 for a historical reason. On a serial terminal, when the line dropped — the modem hung up — the kernel sent SIGHUP to every process in that terminal’s session so they would not linger on a connection that no longer existed. That behaviour is alive and well today: when your SSH session ends, the kernel sends SIGHUP to the foreground process group of that terminal, which is why your long-running job dies when you close the laptop lid. The command nohup exists literally to say “no hangup” — run this immune to SIGHUP.
Because daemons have no terminal, SIGHUP was free for reuse, and Unix convention gave it a second meaning: re-read your configuration files without restarting. This is why:
bash
sudosystemctlreloadnginx# sends SIGHUP to the nginx master process
sudokill-HUP$(cat/run/nginx.pid)# exactly the same thing, done manually
A reload keeps the process alive, keeps listening sockets open, and drops no connections — a restart does not. When an interviewer asks “how do you apply an nginx config change with zero downtime”, reload/SIGHUP is the answer, and nginx -t first is the answer that gets you hired.
Beginner — watch a process live through its whole life#
terminal
$ sleep300&[1] 5120$ ps-opid,ppid,stat,ni,comm-p5120 PID PPID STAT NI COMMAND 5120 9841 S 0 sleep$ kill-STOP5120;ps-opid,stat,comm-p5120 PID STAT COMMAND 5120 T sleep$ kill-CONT5120;ps-opid,stat,comm-p5120 PID STAT COMMAND 5120 S sleep$ kill5120;sleep1;ps-p5120[1]+ Terminated sleep 300
Five states, five commands, ninety seconds. S → T → S → gone, driven entirely by signals.
$ psaux--sort=-%cpu|head-4
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMANDbatch 7712 98.7 0.4 128440 34120 ? RN 09:14 42:18 /opt/report/generate.py --fullpostgres 2019 9.3 4.9 1284512 402336 ? Ss Jun18 88:12 postgres: primarywww-data 1842 4.1 1.0 512340 84120 ? S Jul02 12:41 nginx: worker process$ sudorenice-n15-p77127712 (process ID) old priority 0, new priority 15
Nothing was killed. The report still runs; it now yields to the database and the web server whenever they want CPU. That is the professional move — and the N in RN on the first line confirms it is now low priority.
137 − 128 = 9 = SIGKILL. Nothing in the application log will mention it, because SIGKILL runs no handler. Confirm from the node:
terminal
$ dmesg-T|grep-i-m2'killed process'[Fri Jul 31 14:06:44 2026] Memory cgroup out of memory: Killed process 20144 (node) total-vm:2894112kB, anon-rss:1048180kB
Memory cgroup out of memory names the culprit precisely: the container exceeded its cgroup limit, not the host’s RAM. Raise limits.memory, or fix the leak.
kill is badly named. It does not kill; it sends a signal, and terminating happens to be the default action of the default signal.
Syntax
bash
kill[-signal|-sSIGNAL|-nNUMBER]PID...
kill-l[N]
Form
Effect
kill 1234
Send SIGTERM (15) — the default. Polite request to exit
kill -9 1234
Send SIGKILL by number
kill -KILL 1234
Same, by name (preferred in scripts)
kill -s SIGKILL 1234
Same, using the real -s option
kill -HUP 1234
Reload config / hangup
kill -STOP 1234 / kill -CONT 1234
Freeze / resume
kill 1234 5678 9012
One signal, several PIDs
kill %1
Signal job 1 of this shell (a shell-builtin feature)
kill -TERM -4821
Negative PID = signal the whole process group 4821
kill -TERM -1
Every process you are permitted to signal. Do not do this casually
kill -0 1234
Send nothing. Only check “does it exist and may I signal it?”
kill -l
List all signal names
kill -0 is the existence test. Signal 0 is not a real signal; the kernel performs the permission and existence checks and then delivers nothing. Exit status 0 means “alive and signalable”, non-zero means gone (or not yours). It is the correct way to write a wait loop:
Signalling a process group. A pipeline is one process group. Killing the group gets every stage at once:
terminal
$ ps-eopid,pgid,comm|grep-E'tail|grep|awk' 7301 7301 tail 7302 7301 grep 7303 7301 awk$ kill-TERM-7301# note the minus: the whole group, all three
This is exactly what the terminal driver does when you press Ctrl+C — it sends SIGINT to the foreground process group, which is why Ctrl+C kills every stage of a pipeline rather than just the first.
pgrep prints PIDs matching a pattern; pkill signals them. Same options, same matching rules, so you can always test with pgrep before acting with pkill. That test-first habit is the single most useful thing in this section.
Match against the full command line, not just the process name
-x
both
Require an exact match of the whole name (no substring)
-u USER
both
Match by effective UID
-U USER
both
Match by real UID
-l
pgrep
List the process name beside the PID
-a
pgrep
List the full command line beside the PID
-c
pgrep
Print only the count of matches
-n
both
Only the newest (most recently started) match
-o
both
Only the oldest match
-P PPID
both
Only children of this parent
-t TTY
both
Only processes on that terminal
-g PGID / -s SID
both
By process group / session
-v
both
Invert the match
--signal SIG, -SIG
pkill
Which signal to send (default SIGTERM)
-e
pkill
Echo what was signalled
--older N
both
Only processes older than N seconds
terminal
$ pgrepnginx
183918421843$ pgrep-a-uwww-datanginx
1842 nginx: worker process1843 nginx: worker process$ pgrep-cchrome
27$ pgrep-f'python3 .*manage.py runserver'11204$ pgrep-nsshd# the newest sshd = most likely your own connection9840$ pkill-HUP-xnginx# reload only the process named exactly "nginx"$ pkill-udeploy-f'celery worker'$ pkill--signalSIGUSR1-xnginx
The p299 quiz question — “How do you search for processes containing nginx using pgrep?” — is pgrep nginx, and pgrep -a nginx if you want to see what you matched.
The 15-character trap. Without -f, pgrep/pkill/killall match against the kernel’s comm field, which is truncated to 15 characters. A process actually named my-long-service-worker appears as my-long-service, so pgrep my-long-service-worker finds nothing at all while pgrep -f my-long-service-worker finds it. This wastes an astonishing amount of people’s time.
ps prints one still photograph of the process table at the instant you run it. It does not refresh; that is top’s job. The PDF gives ps -A, -u and -r; the reality is that ps has three complete, overlapping option syntaxes, and knowing which is which is what separates confident users from people who copy incantations.
ps on Linux (from procps-ng) accepts all three and tries to guess which you meant. The rule is: the dash changes the meaning of the letters.
Looks similar
Actually means
ps u (BSD)
User-oriented output format for your processes
ps -u root (UNIX)
Processes whose effective user is root
ps a (BSD)
All processes with a terminal, including other users’
ps -a (UNIX)
All processes except session leaders and processes without a terminal
ps -A / ps -e (UNIX)
Every process on the system
ps x (BSD)
Your processes including those without a terminal
Hence the two canonical incantations, which are near-equivalents:
bash
psaux# BSD: a = other users' processes, u = user format, x = no-tty ones too
ps-ef# UNIX: -e = every process, -f = full format (includes PPID and start time)
ps aux gives you %CPU, %MEM, VSZ and RSS. ps -ef gives you PPID and C. Use aux for resource questions, -ef for parentage questions.
Four columns only: PID, controlling terminal (? means none — the signature of a daemon or a kernel thread), cumulative CPU TIME consumed (not wall-clock), and the command. ps -A answers “what exists”; it is deliberately terse. Note PID 2, kthreadd — the parent of all kernel threads, which appear in brackets like [kworker/0:1] and are not real userspace processes at all.
A complete field-by-field reading of one ps aux line#
This is the exam question. Learn to narrate this output.
terminal
$ psaux|head-4
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMANDroot 1 0.0 0.1 168420 13284 ? Ss Jun18 3:22 /sbin/init splashwww-data 1842 18.6 1.0 512340 84120 ? S Jul02 12:41 nginx: worker processdeploy 9931 0.0 0.0 12148 4020 pts/1 R+ 14:22 0:00 ps aux
Take the middle line, nginx, one field at a time:
Field
Value
What it means
USER
www-data
The effective user the process runs as. Not who started it — nginx starts as root to bind port 80 then drops to www-data
PID
1842
Process ID. The handle you pass to kill, strace, renice, /proc
%CPU
18.6
CPU time used divided by wall-clock time the process has existed, as a percentage. Can exceed 100% on multiple cores — 350% means it is saturating three and a half cores. Under ps this is a lifetime average, so a process that hammered the CPU an hour ago still shows high; top shows the recent interval instead
%MEM
1.0
RSS as a percentage of physical RAM
VSZ
512340
Virtual Set Size in KiB (≈500 MiB): every byte of address space the process has mapped — code, libraries, heap, stack, memory-mapped files, and reservations it has never touched
RSS
84120
Resident Set Size in KiB (≈82 MiB): the pages actually present in physical RAM right now. Excludes anything swapped out
TTY
?
Controlling terminal. ? = none, so it is a daemon. pts/1 = pseudo-terminal 1 (an SSH or terminal-emulator session); tty1 = a physical console
STAT
S
State plus modifiers. S = interruptible sleep (waiting for a request). Compare Ss on init (sleeping session leader) and R+ on ps itself (running, in the foreground group)
START
Jul02
When it started. A time (14:22) if today, a date if older
TIME
12:41
Cumulative CPU time consumed — 12 minutes 41 seconds of actual CPU, across a month of wall-clock uptime
COMMAND
nginx: worker process
The command line, as the process rewrote it. nginx edits its own argv to be self-documenting; most programs show their real argv. Bracketed names like [kworker/1:2] are kernel threads
VSZ versus RSS — the distinction that matters. VSZ is a promise; RSS is a bill.
Why VSZ is almost never the number you want
A Java process: VSZ 12.4 GB RSS 1.8 GB
│ │
│ └─ actually occupying 1.8 GB of RAM
│
└─ the JVM reserved a huge address space for the heap,
mapped every .so it might need, and mmap'd its jars.
Reserved-but-untouched pages cost NOTHING in RAM.
Virtual address space on 64-bit is ~128 TiB. Reserving it is free.
→ Capacity-plan with RSS. Alert on RSS. Ignore VSZ unless you are
debugging address-space exhaustion or a 32-bit process.
Why %MEM and RSS double-count. RSS includes shared pages — and a page shared by ten processes is counted in full by all ten. Three nginx workers each showing 82 MiB RSS are not using 246 MiB; they share the code, libc, libssl and the copy-on-write parent memory. Summing %MEM across a process tree therefore routinely exceeds 100%. When you need honest per-process numbers, use PSS (proportional set size, which divides each shared page by the number of sharers):
terminal
$ sudosmem-k-c'pid user command uss pss rss'-Pnginx
PID User Command USS PSS RSS 1842 www-data nginx: worker process 18.4M 31.2M 82.1M 1843 www-data nginx: worker process 17.9M 30.7M 81.4M$ sudoawk'/^Pss:/ {s+=$2} END {print s" kB PSS"}'/proc/1842/smaps
31948 kB PSS
USS is memory unique to that process — what you would actually get back by killing it. PSS is the fair-share figure that does sum meaningfully.
New here: PPID (note PID 1’s parent is 0, the kernel’s notional swapper task) and C, a crude integer CPU-utilisation figure used by the scheduler. The p259 and p265 quiz questions — “What is the PPID of a process?” and “Which command displays the PPID?” — are answered by ps -ef, ps -o ppid= -p PID, or grep PPid /proc/<pid>/status.
Elapsed wall-clock time — formatted, and in seconds
time,cputime
Cumulative CPU time
lstart
Exact absolute start timestamp — invaluable for incident timelines
comm,args,cmd
Short name (15 chars), full argument vector
nlwp
Number of threads
cgroup
Which cgroup / systemd unit it belongs to
The everyday one-liners:
bash
psaux--sort=-%cpu|head-11# top 10 CPU consumers
psaux--sort=-%mem|head-11# top 10 memory consumers
ps-eopid,ppid,stat,comm|awk'$3 ~ /^Z/'# zombies and their parents
ps-eLf|wc-l# total thread count on the box
ps-olstart=-p1842# exactly when did this start?
ps-eopid,cgroup,comm|grepmyapp# which unit owns this process?
ps-Cnginx-opid,rss,etimes# by name, no grep needed
PID is the same on every row — one process. LWP (lightweight process) is the per-thread kernel ID and NLWP the thread count. This is the observable proof of the process/thread definition: one address space, several schedulable flows.
The PDF gives top -p, -d and -u, with the example top -u root. Here is the full treatment.
Syntax
bash
top[options]
Option
Meaning
-p PID[,PID]
Monitor only these PIDs (up to 20). top -p 1842
-d SECONDS
Delay between refreshes. Default 3.0; -d 0.5 accepts fractions
-u USER
Only this user’s processes (effective UID); -U for real UID
-b
Batch mode — plain text, no cursor control. Essential for scripts and logs
-n COUNT
Exit after COUNT refreshes. top -bn1 is the standard one-shot snapshot
-o FIELD
Sort by a field at startup — top -o %MEM
-c
Show the full command line instead of just the program name
-H
Show individual threads instead of processes
-i
Hide idle processes
-e/-E
Change memory scale units (k, m, g, t)
-w [COLS]
Wider output in batch mode, so command lines are not truncated
terminal
$ top-uroot
The PDF’s example: real-time resource usage for root-owned processes only. In practice, top -bn1 -o -%CPU | head -20 (batch, one iteration, sorted by CPU) is what you paste into a ticket, and interactive top is what you watch during an incident.
$ top
top - 14:22:07 up 43 days, 2:11, 3 users, load average: 2.41, 1.87, 1.52Tasks: 312 total, 2 running, 306 sleeping, 0 stopped, 3 zombie%Cpu(s):24.3us,4.1sy,0.0ni,68.9id,2.0wa,0.0hi,0.4si,0.3st
MiB Mem : 7936.4 total, 412.8 free, 3120.6 used, 4403.0 buff/cacheMiB Swap: 2048.0 total, 2048.0 free, 0.0 used. 4512.1 avail Mem PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND 7712 batch 35 15 128440 34120 4408 R 98.7 0.4 42:18.12 generate.py 1842 www-data 20 0 512340 84120 12044 S 18.6 1.0 12:41.03 nginx 2019 postgres 20 0 1284512 402336 388120 S 9.3 4.9 88:12.77 postgres 1 root 20 0 168420 13284 8320 S 0.0 0.2 3:22.41 systemd
Line 1 — uptime and load average. Same content as the uptime command. The three load numbers are the average number of tasks runnable or waiting on uninterruptible I/O, over the last 1, 5 and 15 minutes. Read them as a trend: 2.41, 1.87, 1.52 is rising, 0.4, 1.9, 2.6 is recovering. And read them against your core count:
Load 2.41 on 4 cores is roughly 60% busy — healthy. Load 2.41 on 1 core means a queue. The extra fields in /proc/loadavg are: running/total tasks, and the last PID allocated. Crucially, Linux load average includes D-state tasks, so a machine with an idle CPU and a stuck NFS mount can show a load of 30 while doing nothing at all.
Line 2 — Tasks by state.312 total, 2 running, 306 sleeping, 0 stopped, 3 zombie. This is your zombie counter and your first check for “is anything actually running or is everything blocked?”
Line 3 — the CPU line. Eight percentages, and two of them are the reason to read this line at all:
Field
Name
Meaning
us
user
Running user-space code — your application. Where you want time to go
sy
system
Running kernel code on behalf of processes — syscalls, I/O, networking. Persistently high sy suggests syscall churn or context-switch storms
ni
nice
User time spent on processes with a positive nice value
id
idle
Doing nothing
wa
iowait
Idle, but with outstanding disk I/O. The CPU has nothing to run because tasks are blocked on storage. High wa = the disk is the bottleneck, not the CPU. Buying more CPU will not help
hi
hardware irq
Servicing hardware interrupts
si
software irq
Servicing softirqs — mostly the network stack. High si on one core is a classic sign of unbalanced NIC interrupts at high packet rates
st
steal
Time the hypervisor gave to somebody else while your vCPU wanted to run. Only meaningful on a VM
Why st (steal) matters more than any other field on a cloud VM. Your guest kernel thinks it has a CPU. The hypervisor knows it is timesharing that physical core with other tenants. st is the percentage of time your vCPU was ready to run and was not given a core. Non-zero steal means:
a noisy neighbour on shared/burstable instance types (AWS t3, GCP e2), or
you have exhausted a CPU credit balance and are being throttled, or
the host is genuinely oversubscribed.
The tell is that your application is slow, your own %us is modest, and %st is 10–30%. No amount of application tuning fixes it. The fix is a dedicated/compute-optimised instance type, or moving the workload. This is the single most valuable “reading top” insight for cloud work, and it comes up in interviews as “how would you detect a noisy neighbour?”
Lines 4 and 5 — memory. As in Chapter 1: free is nearly meaningless because Linux deliberately uses spare RAM as buff/cache, and avail Mem is the number that matters — how much a new process could get without swapping. Watch Swap used climbing over time as your real memory-pressure alarm.
PR is the kernel’s priority (lower = more favourable; rt for real-time), NI is the nice value, VIRT = VSZ, RES = RSS, SHR is the shared portion of RES, S is the state, TIME+ is cumulative CPU time to hundredths.
Help — the built-in list of every key. Learn this one and you need no others
M
Sort by memory (%MEM) descending
P
Sort by CPU (%CPU) descending — the default
T
Sort by cumulative TIME+ — finds the process that has burned the most CPU in total, which a spot-check misses
N
Sort by PID
c
Toggle the full command line vs the short name. Instantly distinguishes three python3 processes
k
Kill: prompts for a PID, then for a signal (default 15). Sending 9 here needs no separate command
r
Renice: prompts for a PID, then a new nice value
1
Toggle per-CPU rows instead of one aggregate line — reveals one saturated core out of sixteen, which the average hides
u
Filter by user — prompts for a username; empty to clear
f
Field management: choose, order and sort columns interactively
W
Write the current configuration to ~/.toprc so it starts this way next time
q
Quit
H
Toggle threads view
V
Forest/tree view of parents and children
d or s
Change the refresh delay
z / x / b
Colour; highlight the sort column; bold
e / E
Cycle memory units in the task list / the summary
Space
Refresh immediately
htop — the interactive one people actually prefer#
bash
htop[options]
Option
Meaning
-d SECONDS
Update delay — in tenths of a second (-d 10 = 1 second)
-u USER
Show only this user’s processes
--tree, -t
Start in tree view, showing parent-child hierarchy
-p PID[,PID]
Show only these PIDs
-s COLUMN
Sort by a column at startup — htop -s PERCENT_MEM
-C
Monochrome
-H
Hide userland threads
terminal
$ htop
Why people prefer it over top:
Per-core bar graphs at the top, with memory and swap meters — you see a single pegged core immediately.
Mouse support: click a column header to sort, click a process to select.
Scrolling, vertically and horizontally, so long command lines are readable without a flag.
Function keys instead of memorised letters: F3 search, F4 filter (incremental, live), F5 tree view, F6 sort column, F7/F8 nice down/up, F9 kill with a menu of signal names, F10 quit.
Multi-select with Space then one action on all of them.
Sensible defaults: colour, human-readable units, no flag needed for the full command line.
F9’s signal menu is genuinely educational — it lists every signal by name and number.
The trade-off is that htop is not installed by default on minimal images and hardened production hosts, while top is always there. Learn top for reliability, use htop for comfort. The p261 quiz question — “What does htop provide over top?” — wants: an interactive, colour, scrollable, mouse-capable interface with per-core meters, tree view, incremental search/filter and easier signal sending.
Show only the ancestors of this PID — brilliant for “who started this?”
-T
Hide threads (they otherwise appear in {braces})
-h
Highlight the current process’s ancestry
-n
Sort by PID rather than name
-c
Do not compact identical subtrees
The equivalent without pstree installed, and the answer to the p250/p264 quiz questions:
bash
ps-ejH# every process, job format, hierarchy — the classic
psauxf# BSD style with ASCII branches
ps-e--forest
systemctlstatus# systemd's own tree, grouped by unit
pidstat, lsof, strace, fuser — the four diagnostic escalations#
When ps and top have told you which process, these four tell you what it is doing.
pidstat (from sysstat) — per-process resource rates over time.top shows you a moment; pidstat shows you a series you can put in a report.
Useful flags: -u CPU, -r memory, -d disk I/O, -w context switches, -t threads, -p ALL everything, -C name by command pattern. pidstat -d 1 is the fastest way to find which process is writing to disk.
lsof -p — every file, socket and pipe a process has open.
txt is the executable, mem a mapped library, cwd the working directory, numbered rows are descriptors (r/w/u = read/write/both). Companion invocations: lsof -i :80 (who owns port 80), lsof /var/log/app.log (who has this file open), lsof +D /mnt/data (everything under a directory — what to run before umount fails as “target is busy”), lsof -u deploy, and lsof -nP | grep deleted to find the deleted-but-open files that are eating your disk.
strace -p — every system call the process makes, live. This is the tool that answers “it is hung and I have no idea why”.
That third line is a complete diagnosis: the app is trying to reach PostgreSQL on 10.0.2.40:5432 and timing out — a firewall or security-group problem, not an application bug. Key flags: -p PID attach, -f follow children/threads, -T time each call, -tt wall-clock timestamps, -e trace=openat,connect filter by call, -c summarise counts on exit, -o file write elsewhere, -s 256 show longer strings, -y decode file descriptors to names.
fuser — which processes are using a file, directory, socket or mount.
terminal
$ fuser-v/var/log/nginx/access.log
USER PID ACCESS COMMAND/var/log/nginx/access.log: www-data 1842 F.... nginx www-data 1843 F.... nginx$ fuser-v-m/mnt/data# everything using this MOUNT POINT USER PID ACCESS COMMAND/mnt/data: deploy 8811 ..c.. bash deploy 8902 F.... rsync$ fuser-k-TERM/mnt/data# SIGTERM everything holding it, so umount can work$ fuser-ntcp80# who has TCP port 8080/tcp: 1839 1842 1843
The access letters: c = current directory, f = open file, F = open for writing, r = root directory, m = mmap’d. fuser -k is the standard answer to umount: /mnt/data: target is busy — but read fuser -v first, because -k sends real signals.
Every normal process carries a nice value — a hint to the scheduler about how much CPU it deserves relative to its peers. The name is literal: a process with a high nice value is being nice to others by demanding less.
The nice scale
−20 ─────────────────── 0 ─────────────────── +19
│ │ │
HIGHEST priority DEFAULT LOWEST priority
"get out of my way" (everything you "run me only when
start normally) nothing else wants
the CPU"
LOWER NUMBER = MORE FAVOURABLE = MORE CPU
40 distinct values. Root only for negatives.
top's PR column = 20 + NI (so NI 0 → PR 20, NI −20 → PR 0, NI 19 → PR 39)
Two facts that are pure exam material:
Lower is more favourable.-20 is the highest priority, +19 the lowest. Beginners reliably get this backwards because “higher priority” and “higher number” feel like they should agree.
Only root can lower a nice value. An unprivileged user may raise their own process’s nice value (be nicer) but can never lower it back — not even to undo their own change. This is a security property: otherwise every user would run everything at −20. Formally, decreasing nice requires the CAP_SYS_NICE capability, or headroom granted by RLIMIT_NICE in /etc/security/limits.conf.
$ nice-n10./nightly-report.sh&[1] 8420$ nice-n19tar-czf/backup/all.tar.gz/srv# only when the box is idle$ sudonice-n-5./latency-sensitive-daemon# needs root$ nice./job.sh# no -n: defaults to +10$ nice# with no arguments: print current nice value0
renice — change the priority of a running process#
bash
renice[-n]PRIORITY[-pPID][-gPGID][-uUSER]
terminal
$ sudorenice-n15-p77127712 (process ID) old priority 0, new priority 15$ sudorenice-n-5-p20192019 (process ID) old priority 0, new priority -5$ sudorenice-n19-ubatchuser
1000 (user ID) old priority 0, new priority 19$ renice-n5-g7301# a whole process group
Target flag
Meaning
-p PID
A specific process (the default if you just give a number)
-g PGID
Every process in a process group
-u USER
Every process owned by a user
renice on a running process is the humane alternative to killing it: the batch job still completes, it just stops competing with production traffic. The p265 quiz question — “Which command changes the priority of a running process?” — is renice. In top, the same thing is the r key.
Nice values only govern CPU. A nice -n 19 backup can still saturate your disk queue and make the database crawl. ionice (which drives the CFQ/BFQ I/O scheduler classes) is the missing half.
bash
ionice[-cCLASS][-nLEVEL][-pPID][command]
Class
Name
Behaviour
1
Real-time
Gets disk access first, always. Can starve everything else. Root only
2
Best-effort (default)
Round-robin within levels 0–7, 0 highest
3
Idle
Only gets the disk when nothing else wants it
terminal
$ ionice-c3tar-czf/backup/all.tar.gz/srv# backup that yields entirely$ sudoionice-c2-n7-p7712# deprioritise a running process$ ionice-p1842best-effort: prio 4
The combination nice -n 19 ionice -c 3 <command> is the standard “run this in the background and never let it hurt anything” wrapper for backups, rsync jobs, updatedb and find / sweeps.
For the small class of work where a late answer is a wrong answer — audio, industrial control, packet processing — Linux offers real-time policies that pre-empt all normal tasks.
SCHED_OTHER (also called SCHED_NORMAL) is the fair-share scheduler that nice values apply to — CFS historically, EEVDF from kernel 6.6. SCHED_FIFO runs until it yields or blocks; SCHED_RR is the same with time slices; SCHED_DEADLINE schedules by explicit runtime/period guarantees. A SCHED_FIFO process at priority 99 that enters an infinite loop can lock a core so hard you cannot get a shell — which is why real-time scheduling needs root and a very good reason.
Nice values are relative hints with no hard ceiling. Modern Linux does resource control with control groups (cgroup v2), which enforce absolute limits — and this is the mechanism containers and systemd both use.
terminal
$ systemd-run--scope-pCPUQuota=50%-pMemoryMax=512M./import.py
Running scope as unit: run-r8b21f4c.scope
That process now cannot exceed half a core or 512 MiB, enforced by the kernel — exceed the memory and it is SIGKILLed with exit 137, exactly as in a container. In a unit file the same directives are permanent:
ini
[Service]CPUQuota=50%# never more than half of one coreCPUWeight=50# relative share when contended (default 100)MemoryMax=512M# hard ceiling — OOM-kill beyond thisMemoryHigh=400M# soft ceiling — throttle and reclaim firstIOWeight=20# relative disk bandwidth shareTasksMax=256# cap the number of processes/threads
The kernel knows about processes and process groups. Jobs are a shell concept layered on top: a job is one pipeline that the shell tracks with a small number so you can suspend, resume and background it.
$ ./long-import.sh>import.log2>&1&[1] 8420$ echo"the PID of the last background job is $!"the PID of the last background job is 8420
[1] is the job number (shell-local, small, reusable); 8420 is the PID (system-wide). $! holds the PID of the most recent background job — the correct way to capture it in a script. The p266 quiz question — “What does & at the end of a command do?” — runs it in the background, returning the prompt immediately without waiting.
Pressing Ctrl+Z sends SIGTSTP (20) to the foreground process group. The processes enter state T and stop consuming CPU entirely, but keep all their memory and open files. This is the p257 quiz answer for “what suspends a foreground process and puts it in the background?” — Ctrl+Z (and then bg if you want it to keep running).
Read the markers, because this is a quiz question (p263):
Marker
Meaning
+
The current job — the one fg and bg act on when you give no argument. %% and %+ also name it
-
The previous job — %- names it
(nothing)
Any other job
terminal
$ jobs-p
842085118590$ kill$(jobs-p)# signal every job of this shell$ jobs-s# what have I accidentally left suspended?[2]+ Stopped vim /etc/nginx/nginx.conf
The p306 quiz question — “What does the jobs command display?” — the background and suspended jobs of the current shell session. Note the limitation implied by that phrasing: jobs is per-shell. A second SSH session sees nothing, and neither does a script. Jobs are not a system-wide facility.
$ fg%1# bring job 1 to the foreground (also: fg 1, or just fg)./long-import.sh > import.log 2>&1$ bg%2# resume job 2 in the background — this sends SIGCONT[2]+ vim /etc/nginx/nginx.conf &
bg literally sends SIGCONT to a stopped job and detaches it from terminal input. fg sends SIGCONT and hands the terminal back. Job specifications:
Spec
Means
%1, %2
Job by number
%+ or %%
The current job (the + in jobs)
%-
The previous job
%vim
The job whose command starts withvim
%?nginx
The job whose command containsnginx
The p258 quiz questions: “which command brings a background job to the foreground?” → fg. “How do you start a command in the background?” → append & (or Ctrl+Z then bg).
$ ./build-frontend.sh&./build-backend.sh&./build-docs.sh&[1] 9101[2] 9102[3] 9103$ wait$ echo"all three finished"all three finished
wait with no argument blocks until every background job of this shell has exited. wait PID or wait %1 waits for one and — importantly — returns that job’s exit status, which is how you write correct parallel shell scripts:
bash
pids=()forhostinweb1web2web3;dossh"$host"'sudo systemctl restart myapp'&pids+=("$!")donefail=0forpin"${pids[@]}";dowait"$p"||fail=1;done[[$fail-eq0]]||{echo"at least one restart failed">&2;exit1;}
wait -n (bash 4.3+) returns as soon as any one job finishes, which lets you implement a worker pool. The p266 quiz question — “What does wait do in shell scripting?” — it suspends the script until the specified background job (or all of them) completes, and yields its exit status.
Why a background job dies when you log out — and the three fixes#
This is the most practically important paragraph in the section.
What SIGHUP does at logout
You close the SSH session / the network drops
│
sshd notices the connection is gone, tears down the pseudo-terminal
│
The kernel sends SIGHUP to the SESSION LEADER (your bash)
│
bash, exiting, sends SIGHUP to every job in its job table
│
┌───────────┴────────────┐
default action of SIGHUP = TERMINATE
│ │
your background job dies so does everything it started
command & is not detachment. The job is still your shell’s child, in your shell’s session, attached to a terminal that is about to disappear. Three fixes, in increasing order of quality:
Fix 1 — nohup: start it immune to SIGHUP.
terminal
$ nohup./long-import.sh&[1] 8420nohup: ignoring input and appending output to 'nohup.out'$ nohup./long-import.sh>import.log2>&1&# better: name your own log[1] 8421
nohup sets SIGHUP to ignored before exec’ing the command, and redirects stdout/stderr to nohup.out (or $HOME/nohup.out if the directory is not writable) because the terminal they pointed at will vanish. It also detaches stdin from the terminal. The p262 and p305 quiz answers: nohup runs a command immune to hangups, so it keeps running after you log out, with output redirected to nohup.out by default.
Fix 2 — disown: retroactively remove a job from the shell’s table.
terminal
$ ./long-import.sh&[1] 8420$ disown-h%1# keep it in the job table but do not send it SIGHUP$ disown%1# remove it from the job table entirely$ disown-a# all jobs$ disown-r# only running jobs
Use this when you forgot nohup and the job has already been running for two hours. Caveat: disown does not redirect output, so if the job writes to the terminal it will get SIGHUP-adjacent I/O errors (EIO) once the tty is gone. The safe recovery sequence is Ctrl+Z, redirect is no longer possible — so in practice: disown -h and hope, or reach for fix 3 next time.
Fix 3 — setsid: put it in a brand-new session with no terminal at all.
Note PPID 1 and TTY ? — it has been orphaned to systemd and has no controlling terminal, which is precisely what “daemon” means. No terminal means no SIGHUP is even possible.
Fix 4 — and the actually correct one for interactive work: tmux or screen.
tmux runs a server process outside your session that owns the terminal, so your programs never notice the SSH connection coming and going. Unlike nohup, you can reattach and interact — scroll back, type input, watch progress. For any long interactive task over SSH (a database migration, a large rsync, a kernel upgrade) this is the professional default, because the alternative is losing a four-hour operation to a dropped Wi-Fi connection.
Blocked in kernel I/O that must not be interrupted
R, Z
I/O completes (signal recorded, delivered after)
Stopped
T
Frozen by SIGSTOP or SIGTSTP
R, Z
SIGCONT or termination
Stopped (debugged)
t
Frozen by a debugger
R, Z
Debugger continues or termination
Zombie
Z
Terminated, exit status waiting for parent to wait()
(nothing)
Parent calls wait(), removes entry
Dead
X
Being torn down (rarely visible)
(nothing)
Process cleanup completes
Idle
I
Kernel thread doing nothing
(nothing)
Part of background work
Key transitions:
- R → S: Process yields CPU, waits for something
- S → R: Event arrives (socket readable, timer expired, user input)
- R ↔ D: Issue disk/network I/O (D cannot be interrupted by signals)
- R → T: SIGSTOP (kernel enforced, cannot be caught) or SIGTSTP (can be caught, e.g., Ctrl+Z)
- T → R: SIGCONT resumes it
- R/S/D/T → Z: Process calls exit() or is killed by signal; remains until parent calls wait()
Signal categories:
- Non-catchable (kernel enforces): SIGKILL (9), SIGSTOP (19) — process never sees them
- Graceful termination: SIGTERM (15) — process can catch, clean up, exit normally
- Debugging: SIGABRT (6), SIGQUIT (3), SIGSEGV (11) — often produce core dumps
- Application-defined: SIGUSR1 (10), SIGUSR2 (12) — meaning depends on program
- Process control: SIGTSTP (20), SIGCONT (18), SIGCHLD (17) — job control and reaping
- Terminal: SIGINT (2), SIGQUIT (3), SIGTSTP (20) — generated by the terminal driver
# Find the top CPU consumer
psaux--sort=-%cpu|head-2|tail-1
# Find the top memory consumer
psaux--sort=-%mem|head-2|tail-1
# Show process tree for a specific PID and its children
pstree-p1839# Watch a process's resource usage every 5 seconds
watch-n5'ps aux | grep nginx | grep -v grep'# Get the exact start time of a process (for timelines)
ps-olstart=-p$(pgrepnginx|head-1)# Find what's holding a file open and prevent umount
fuser-v/mnt/data
# Kill a batch of processes by pattern (always pgrep first!)
pgrep-f'python.*worker'|xargskill-TERM
# Reload nginx config without dropping connections
sudosystemctlreloadnginx
# Check if a service will auto-start on next boot
systemctlis-enabledmyapp
# Watch the system load, CPU cores, and top processes in one view
top
# See which process woke the CPU from sleep (on a laptop)
dmesg|grep-iwakeup
# Find processes stuck in D state (uninterruptible I/O)
psaux|awk'$8 ~ /D/ {print $0}'# Get the exit status of the last background job
wait;echo$?# Run a background job that survives logout
nohup./long-job.sh>job.log2>&1&# Properly daemonize with no terminal
setsid./daemon.sh>/var/log/daemon.log2>&1</dev/null&# Send SIGTERM to all processes matching a pattern, but only after verification
pgrep-af'java.*myapp'&&pkill-TERM-af'java.*myapp'
III — System Internals
10The Boot Process
The ten stages between pressing the power button and seeing a login prompt, and how to recover when one of them fails.
Everything else in this handbook assumes a running system. You ssh in, and a shell is waiting. Processes exist. / is mounted. sshd is listening. Someone — something — arranged all of that before you arrived.
That arrangement is the boot process: a chain of roughly ten handovers, each one loading and starting the next, from a CPU that knows nothing to a machine running two hundred processes. It is the only part of Linux where the machine has no operating system yet and has to bootstrap one from raw sectors on a disk.
Two facts make this chapter disproportionately valuable.
First, it is the single most-asked topic in Linux interviews. “Walk me through the boot process” is asked in first-round screens, in system-administrator interviews, in SRE interviews and in cloud-engineer interviews. It is asked because it is a perfect probe: a candidate who can narrate it accurately has necessarily learned firmware, partitioning, the kernel, filesystems, init systems and logging, because the boot process touches all of them in order. A candidate who cannot has memorised commands.
Second, it is where you are most alone. When a web server returns 500, you have logs, metrics, a debugger and Google. When a machine does not boot, you have a black screen, possibly a serial console, and whatever you remember. There is no SSH into a machine that has not booted. Engineers who can recover an unbootable host are visibly more senior than engineers who cannot, and the gap is almost entirely knowledge — the actual commands are few.
Think about the state of a machine one microsecond after you press the power button. RAM is uninitialised noise. No filesystem driver is loaded, so the disk is not a filesystem — it is a numbered array of 512-byte or 4096-byte sectors. There is no concept of a file, a process, a user or a network. The CPU has one instruction pointer and it must point at something.
The boot process is the answer to a bootstrapping paradox:
The bootstrapping paradox
To read a file from the disk you need a filesystem driver.
The filesystem driver is a file on the disk.
To decrypt the root filesystem you need the LUKS module.
The LUKS module lives inside the root filesystem.
To start services you need an init system.
The init system is a program that something must execute.
┌────────────────────────────────────────────────────────────┐
│ Every stage of boot exists to solve one instance of this │
│ problem: load just enough capability, from somewhere you │
│ can already reach, to reach the next thing. │
└────────────────────────────────────────────────────────────┘
The universal technique is staged bootstrapping: each stage is deliberately dumb and small, understands only how to find and load the next stage, and then hands over control and disappears. Firmware understands sectors but not filesystems. The bootloader’s first stage understands one disk and one hard-coded sector list. The kernel understands hardware but not your disk layout. The initramfs understands your disk layout but is thrown away seconds later. Only at the end does a full, general-purpose system exist.
Availability. A host that reboots cleanly is a host you can patch. Teams that fear reboots stop patching, and unpatched kernels are how estates get compromised. Confidence in boot is confidence in your patch cycle.
Scaling latency and cost. In an autoscaling group, boot time is the time between “we need capacity” and “we have capacity”. Trimming 40 seconds off boot across a 500-instance fleet is real money and real headroom during a traffic spike.
Recovery time objective. “How long to bring a dead host back?” is a number in a contract. It is dominated by whether the person on call knows how to chroot from a live image.
Security posture. Secure Boot, signed kernel modules, GRUB passwords and full-disk encryption are all boot-stage controls. So is the uncomfortable fact that console access equals root access unless you have configured otherwise.
Immutability. Modern infrastructure practice is to never repair a host — replace it. That only works if the replacement boots reliably and configures itself, which is exactly what cloud-init does.
Boot is a relay race in which each runner is faster and better equipped than the last, and each one dies after passing the baton.
The firmware runner is ancient, lives in a chip on the motherboard, and can only run 50 metres. It knows how to read the very first sector of a disk and nothing more.
The stage 1 bootloader runner has 446 bytes of instructions — about the length of a tweet. It knows one thing: the sector numbers of the next runner.
The stage 2 bootloader runner is the first who understands what a file is. It can read /boot, draw a menu, and load two large files into memory.
The kernel runner is enormously capable but arrives knowing nothing about this machine’s storage layout.
The initramfs runner is a temporary specialist, carried along precisely to find and open the real root filesystem, and is discarded the moment it succeeds.
systemd is the anchor runner and never stops. It runs until you power off.
The relay framing predicts the failure modes correctly: if any runner cannot find the next, the race stops dead where it is. That is exactly what a boot failure looks like — the machine halts at a specific stage, and which stage tells you what is broken.
firmware POST → your eyes open; you check your limbs still work
boot order → you decide which room you are in
GRUB menu → you decide what kind of day this will be
(normal day / recovery day / yesterday's plan)
kernel → your brain comes online; senses start reporting
initramfs → you fumble for your glasses in the dark, because
you cannot find your glasses without your glasses
switch_root → you can see; the temporary fumbling is forgotten
systemd PID 1 → you start your routine: kettle, shower, email,
several things at once, in dependency order
login prompt → you are ready to be spoken to
The initramfs step is the one people find strange, and the glasses image is why: you genuinely need a small, pre-positioned capability to reach the large capability. That is not bad design, it is the only design.
Imagine a treasure hunt where clue 1 is engraved on the front door, because it must be somewhere you can read with no tools at all. It is short, so it can only say “look in the third drawer”. The drawer holds a bigger note, which can afford to say “read the index on the bookshelf”. Only the index is long enough to describe a whole library.
The engraving is the MBR — 446 bytes, because that is all the space the 1983 disk format left. The drawer is the MBR gap. The library is /boot. Every “why is the bootloader in stages?” answer is that engraving: the first stage is tiny because physics and history made it tiny, so its only job can be to point at something bigger.
Booting. The process by which a computer initialises its hardware and loads an operating system kernel into memory, transferring control to it, up to the point where the system is ready to accept user interaction. It is divided into a firmware phase, a bootloader phase, a kernel phase and an init phase.
POST (Power-On Self-Test). The firmware’s first action: a self-test of the CPU, memory, and essential controllers, followed by enumeration and initialisation of attached hardware. Failures at this stage are reported by beep codes, POST codes on a two-digit display, or diagnostic LEDs — not by anything on screen, because the display may not be initialised yet.
Firmware. Software stored in non-volatile memory on the motherboard (an SPI flash chip) that runs before any operating system. The two families are BIOS (Basic Input/Output System, 1981, 16-bit real mode) and UEFI (Unified Extensible Firmware Interface, the successor, 32/64-bit, with its own executable format, filesystem driver and variable store).
Bootloader. A program whose sole purpose is to load an operating system kernel and its initial ramdisk into memory, supply the kernel a command line, and jump to the kernel’s entry point. On Linux this is almost always GRUB 2 (GRand Unified Bootloader, version 2). Alternatives you will meet: systemd-boot (UEFI only, minimal), rEFInd, SYSLINUX/ISOLINUX (live media), U-Boot (embedded and ARM boards), and LILO (historical).
MBR (Master Boot Record). The first 512-byte sector (LBA 0) of a disk using the 1983 DOS partitioning scheme. Its layout:
Bytes
Contents
Notes
0–445
Bootstrap code
The 446-byte boot area. Strictly, modern MBRs use 440 bytes of code, then 4 bytes of disk signature and 2 reserved bytes
446–509
Partition table
Four 16-byte entries — hence the famous maximum of four primary partitions
510–511
Boot signature 0x55AA
Firmware refuses the disk as bootable without it
GPT (GUID Partition Table). The UEFI-era replacement for MBR: a protective MBR for compatibility, a primary GPT header and partition array at the start of the disk, and a backup copy at the end. Typically 128 partition entries, 64-bit LBAs, and CRC32 checksums on the headers.
EFI System Partition (ESP). A FAT32 partition, usually 100–550 MB, flagged as ESP, containing UEFI executables (.efi) and their support files. On Linux it is conventionally mounted at /boot/efi.
Kernel image (vmlinuz). A compressed, self-extracting bootable kernel. The name is historic: vmlinux was the plain ELF kernel with virtual-memory support, and the trailing z marks it compressed. The bootloader loads it whole; it decompresses itself in place.
initramfs (initial RAM filesystem). A compressed cpio archive which the kernel unpacks into a tmpfs and uses as a temporary root filesystem. It contains just enough userspace — a shell, a handful of tools, and the kernel modules needed for this machine’s storage — to locate, prepare and mount the real root filesystem.
init / PID 1. The first userspace process the kernel starts, conventionally /sbin/init. It is the ancestor of every other process, it adopts orphaned processes and reaps them, and it cannot be killed — if it exits, the kernel panics. On modern distributions /sbin/init is a symlink to /lib/systemd/systemd.
systemd. The init system and service manager used by essentially all mainstream distributions since 2014–2015. It starts services in parallel according to declared dependencies, tracks them in cgroups, and replaces the sequential shell scripts of SysV init.
Target. A systemd unit that groups other units and acts as a synchronisation point. Targets replaced SysV runlevels. The one reached by default is whatever default.target points to.
Kernel command line. The string of parameters the bootloader hands the kernel, readable afterwards at /proc/cmdline. It is the single most important lever you have during a boot failure, because you can edit it interactively at the GRUB menu.
Unpacking the dense phrase people get asked to explain:
Term in “the kernel unpacks a compressed cpio archive into a tmpfs and executes /init“
What it means
compressed
gzip, or on modern distributions zstd, lz4 or xz — chosen for decompression speed
cpio archive
An old, extremely simple archive format: header, filename, data, repeat. Simple enough for the kernel itself to parse, unlike tar in practice
unpacks into a tmpfs
A RAM-backed filesystem. There is no disk involved, so no disk driver is required yet
executes /init
The kernel looks for /init in that tmpfs. On Debian it is a shell script; with dracut it may be systemd itself
This section is the spine of the chapter. Number the stages and learn them in order; when an interviewer says “walk me through the boot process”, you are being asked to recite exactly this.
flowchart TB
A["1 · Power on<br/>CPU starts at the reset vector"] --> B["2 · Firmware POST<br/>self-test, hardware init"]
B --> C["3 · Boot device selection<br/>boot order, NVRAM entries"]
C --> D1["4a · BIOS path<br/>execute the 446-byte MBR code"]
C --> D2["4b · UEFI path<br/>execute grubx64.efi from the ESP"]
D1 --> E["5 · GRUB stage 1.5 and 2<br/>modules, grub.cfg, the menu"]
D2 --> E
E --> F["6 · Kernel loaded<br/>vmlinuz decompresses itself"]
F --> G["7 · initramfs unpacked into tmpfs<br/>drivers, LVM, RAID, LUKS"]
G --> H["8 · switch_root to the real root<br/>mounted ro, then remounted rw"]
H --> I["9 · PID 1 is systemd<br/>default.target, parallel startup"]
I --> J["10 · getty or display manager<br/>login prompt"]
The most useful mental upgrade in this chapter is knowing what medium each stage is stored on, because that tells you what tool can repair it.
Stage by stage, on the metal
STAGE WHAT RUNS WHERE IT PHYSICALLY LIVES
──────────────────────────────────────────────────────────────────────────────
1 CPU reset vector on-die microcode; a hard-wired address
2 firmware / POST SPI flash chip soldered to the board
3 boot device selection BIOS: CMOS/NVRAM settings
UEFI: NVRAM boot variables (Boot0001…)
4 bootloader stage 1
BIOS boot.img LBA 0 — the first 446 bytes of the disk
UEFI shimx64.efi ESP (FAT32): /EFI/ubuntu/shimx64.efi
fallback: /EFI/BOOT/BOOTX64.EFI
5 stage 1.5 core.img the "MBR gap": LBA 1–62, ~31 KiB
or, on GPT, the BIOS boot partition (ef02)
stage 2 modules + config /boot/grub/ grub.cfg, *.mod, fonts
6 kernel /boot/vmlinuz-6.8.0-45-generic
7 initramfs /boot/initrd.img-6.8.0-45-generic
→ unpacked into a tmpfs mounted at /
8 real root filesystem /dev/sda2, located via root=UUID=…
9 PID 1 /sbin/init → /lib/systemd/systemd
unit files /etc/systemd/system (admin — wins)
/run/systemd/system (runtime)
/lib/systemd/system (package defaults)
10 login getty@tty1.service → /bin/login
or gdm.service / sddm.service
──────────────────────────────────────────────────────────────────────────────
From stage 6 downwards, every stage is a FILE: you can list it, checksum it,
copy it, replace it from a rescue shell.
Above stage 6 it is firmware or raw sectors — no `ls` will show it to you.
The power supply asserts a “power good” signal. The CPU comes out of reset with a fixed, architecturally defined instruction pointer — the reset vector. On x86 this is 0xFFFFFFF0, which the chipset maps to the firmware flash chip. The CPU is in 16-bit real mode with one core active; there is no RAM initialisation yet, so the earliest firmware code runs out of CPU cache used as memory.
You cannot influence this stage. You just need to know it exists, because “nothing at all happens, no fans, no logo” is a stage 1 or stage 2 problem — hardware, not Linux.
Runs the POST: verifies the CPU, initialises the memory controller and trains RAM, checks essential buses.
Initialises devices: PCIe enumeration, storage controllers, USB, and (importantly) the display, which is why a vendor logo is the first visible sign of life.
Reads its configuration — boot order, Secure Boot state, virtualisation flags — from CMOS (BIOS) or NVRAM (UEFI).
Presents its setup interface if you press the magic key (Del, F2, F10, F12, Esc — vendor-dependent).
Failure here produces beep codes or POST codes, never a Linux message. If you see nothing on screen and no beeps, suspect power, RAM seating or the GPU before you suspect anything in this handbook.
The firmware walks its boot order looking for something bootable.
BIOS tries each device in turn, reads LBA 0, and checks for the 0x55AA signature at bytes 510–511. If present, it copies the 512 bytes to memory address 0x7C00 and jumps there. That is the entire BIOS contract: one sector, one signature, one jump.
UEFI reads its own NVRAM boot variables, each of which names a disk, a partition and a path to a .efi file. It mounts the ESP itself — UEFI contains a FAT driver — and executes the named binary. If no variable matches, it falls back to the removable-media path /EFI/BOOT/BOOTX64.EFI, which is why a USB installer boots on machines that have never seen it.
The message “No bootable device” / “Operating system not found” is this stage failing: wrong boot order, wiped bootloader, dead disk, or a UEFI machine looking for an .efi file that a Windows installer overwrote.
446 bytes is not enough space for a filesystem driver. It is barely enough to set up a stack, reset the disk controller, and read a hard-coded list of sectors. So GRUB’s boot.img does precisely that: it contains the LBA address of the next stage, blasted directly into it by grub-install, and loads it.
On UEFI there is no stage 1 in this sense: the firmware can read files from the ESP, so it loads a full bootloader binary immediately. That is the single biggest practical simplification UEFI brought.
Stage 1.5 (core.img) lives in the ~31 KiB gap between the MBR and the first partition — historically sectors 1 to 62, because DOS aligned the first partition at sector 63. That is enough room for GRUB to embed the filesystem driver for whatever filesystem /boot uses. On GPT disks booted via BIOS there is no gap, so you must create a small BIOS boot partition (type ef02, ~1 MiB) to hold core.img — forgetting it is a classic manual-partitioning failure.
Stage 2 is now a normal program reading normal files from /boot/grub/: it loads modules (ext2.mod, lvm.mod, luks.mod, part_gpt.mod), reads grub.cfg, draws the menu, and waits out GRUB_TIMEOUT. When the timeout expires or you press Enter, it:
Loads /boot/vmlinuz-… into memory.
Loads /boot/initrd.img-… into memory.
Hands the kernel its command line (root=UUID=… ro quiet splash).
Jumps to the kernel’s entry point and ceases to exist.
vmlinuz begins with a small uncompressed decompression stub. It runs first, inflates the real kernel into memory, and jumps to it. From there the kernel:
switches the CPU to long mode (64-bit) and enables paging,
initialises the memory manager and builds the page tables,
starts the scheduler and brings up the other CPU cores,
initialises built-in drivers and the console,
and writes its first log lines to the kernel ring buffer — the ones you later read with dmesg.
The very first line is always the kernel banner, and the line after the hardware probing is Command line: — worth knowing, because reading it confirms exactly which parameters GRUB actually passed.
Two things are already visible: efi: EFI v2.7 proves this machine booted via UEFI, and Command line: shows the root filesystem is identified by UUID and mounted read-only (ro) to begin with.
The kernel unpacks the cpio archive into a tmpfs, makes it /, and executes /init. That /init is a userspace program running with a full kernel underneath it but almost no filesystem. Its job list:
Mount the pseudo-filesystems: /proc, /sys, /dev (devtmpfs), /run.
modprobe the storage drivers this machine needs — NVMe, AHCI, virtio_blk/virtio_scsi on a VM, megaraid on a server, plus the filesystem module for the root filesystem.
Run udev to settle device naming, so /dev/disk/by-uuid/… exists.
Assemble anything composite: mdadm --assemble for software RAID, vgchange -ay for LVM, cryptsetup luksOpen for encryption — this is where the disk-password prompt comes from.
Wait for the device named by root= to appear (with a timeout — the source of “Gave up waiting for root device”).
Mount it, read-only, at /root (Debian) or /sysroot (dracut).
switch_root moves the mount to /, deletes the entire tmpfs to reclaim its RAM, and execs the real /sbin/init. Because it execs rather than forks, PID 1 stays PID 1 — the initramfs’s /init was PID 1, and systemd inherits that number.
The root filesystem is still mounted read-only at this point. systemd-remount-fs.service runs fsck if needed and then remounts it read-write, honouring the options in /etc/fstab. This ordering is the reason a recovery shell so often lands you with a read-only / and why mount -o remount,rw / is the most-forgotten command in Linux recovery.
sequenceDiagram
autonumber
participant F as Firmware
participant G as GRUB
participant K as Kernel
participant I as initramfs /init
participant S as systemd PID 1
F->>G: load 446-byte stage 1, then core.img
G->>G: read /boot/grub/grub.cfg, draw menu
G->>K: load vmlinuz + initrd, pass cmdline
K->>K: decompress, paging, SMP, drivers
K->>I: unpack cpio into tmpfs, exec /init as PID 1
I->>I: modprobe storage, udev settle
I->>I: vgchange -ay / cryptsetup luksOpen
I->>I: mount root=UUID=... read-only on /sysroot
I->>S: switch_root, exec /sbin/init
S->>S: remount / rw, reach default.target
S->>S: start units in parallel by dependency
S->>S: getty@tty1 / gdm → login prompt
systemd reads default.target, computes the transitive closure of its dependencies, and starts everything it can in parallel. Detail is in section 9 below and in Chapter 09.
The last visible step. On a server, getty@tty1.service spawns agetty, which opens the terminal, prints /etc/issue and the login: prompt, and execs /bin/login. On a desktop, gdm.service (GNOME), sddm.service (KDE) or lightdm.service draws a graphical greeter. On a cloud VM there may be no interactive login at all — you arrive over the network, via sshd.service, which is why “boots but I cannot SSH in” is a stage 10 problem with a stage 9 cause.
Every Linux machine you touch booted one of two ways, and the difference changes the partition scheme, the location of the bootloader, the recovery procedure and whether your NVIDIA driver loads.
If that directory exists, the kernel was handed control by UEFI. If you get No such file or directory, you booted in BIOS/CSM mode. The kernel only creates /sys/firmware/efi when EFI boot services were present at hand-over, so this is authoritative — unlike guessing from the partition table, because a GPT disk can perfectly well be booted by BIOS.
Two useful corroborations:
terminal
$ [-d/sys/firmware/efi]&&echoUEFI||echoBIOS
UEFI$ dmesg|grep-i'efi v'[ 0.000000] efi: EFI v2.7 by EDK II$ lsblk-oNAME,SIZE,FSTYPE,PARTTYPENAME,MOUNTPOINT
NAME SIZE FSTYPE PARTTYPENAME MOUNTPOINTsda 40G├─sda1 512M vfat EFI System /boot/efi├─sda2 1G ext4 Linux filesystem /boot└─sda3 38.5G LVM2_m Linux LVM
An EFI System partition mounted at /boot/efi is strong evidence, but the /sys/firmware/efi test is the proof.
Create an entry — disk, partition, label, loader path
efibootmgr -a 0002 / -A 0002
Activate / deactivate an entry
Secure Boot, and the day your driver stops loading#
Secure Boot makes the firmware verify a cryptographic signature on everything it loads. The chain on a typical Linux install:
The Secure Boot chain of trust
firmware (holds Microsoft's + OEM's public keys in the db)
│ verifies signature
▼
shimx64.efi — a tiny loader signed by Microsoft, shipped by the distro,
which also carries the distro's own key and the MOK list
│
▼
grubx64.efi — signed by the distribution
│
▼
vmlinuz — signed by the distribution
│
▼
kernel modules — must ALSO be signed if the kernel enforces lockdown
That last line is where Secure Boot stops being an abstraction. A signed, locked-down kernel refuses to load unsigned modules, so out-of-tree drivers built on your machine by DKMS fail:
terminal
$ sudomodprobenvidia
modprobe: ERROR: could not insert 'nvidia': Key was rejected by service$ sudodmesg-lerr|tail-3
[ 312.884211] Loading of unsigned module is rejected[ 312.884219] nvidia: module verification failed: signature and/or required key missing - tainting kernel[ 312.884301] nvidia: Unknown symbol drm_gem_object_put (err -2)
The same failure hits VirtualBox (vboxdrv), ZFS, and many vendor storage and network drivers. Check the state first:
terminal
$ mokutil--sb-state
SecureBoot enabled$ sudodmesg|grep-i'secure boot'[ 0.000000] secureboot: Secure boot enabled[ 0.000000] Kernel is locked down from EFI Secure Boot mode; see man kernel_lockdown.7
Three ways out, in order of preference:
Enrol your own key with MOK — sudo mokutil --import /var/lib/shim-signed/mok/MOK.der, set a one-time password, reboot into the blue MokManager screen and enrol it. DKMS on Ubuntu and Debian signs modules with that key automatically. This keeps Secure Boot on. Correct answer in an interview.
Use a signed, in-tree driver — the distribution’s own packaged NVIDIA driver, or nouveau, is already signed.
Disable Secure Boot in firmware setup, or sudo mokutil --disable-validation. Fast, and it is what most people actually do on a laptop, but it removes the protection and may violate a corporate baseline. Say so if you offer it.
The firmware can load exactly one thing, from exactly one place, with no choices. Real systems need choices:
Which kernel? After an upgrade you have two or three installed, and if the new one panics you need the old one.
Which operating system? Dual boot with Windows, or several Linux installs.
Which parameters?nomodeset for a broken GPU, single for recovery, console=ttyS0 for a serial console.
Which root filesystem? Found by UUID, possibly inside LVM, possibly encrypted.
A bootloader is the layer that turns “load these 512 bytes” into “present a menu, read a config file, accept keyboard input, and load an arbitrary kernel with an arbitrary command line”. GRUB 2 is a small operating system in its own right: it has modules, a scripting language, filesystem drivers, a font renderer and a rescue shell.
GRUB is roughly 200 KB of code plus modules. It must be started by 446 bytes. That gap is bridged in three hops:
The three GRUB stages and why they exist
┌──────────────────────────────────────────────────────────────────────┐
│ STAGE 1 boot.img 446 bytes, LBA 0 │
│ Contains: the LBA of core.img, hard-coded at install time. │
│ Can do: reset the disk, read sectors via INT 13h, jump. │
│ Cannot do: understand a filesystem, read a config file. │
└───────────────────────────────┬──────────────────────────────────────┘
▼
┌──────────────────────────────────────────────────────────────────────┐
│ STAGE 1.5 core.img ~25-30 KiB, LBA 1-62 (the "MBR gap"), │
│ or the BIOS boot partition on GPT │
│ Contains: a minimal kernel plus the ONE filesystem driver │
│ needed to read /boot. Now it can open files by name. │
└───────────────────────────────┬──────────────────────────────────────┘
▼
┌──────────────────────────────────────────────────────────────────────┐
│ STAGE 2 /boot/grub/* Unlimited size, ordinary files │
│ grub.cfg, *.mod, fonts, themes, locale. │
│ Draws the menu, edits command lines, loads the kernel. │
└──────────────────────────────────────────────────────────────────────┘
UEFI collapses stages 1 and 1.5: firmware reads FAT itself, so it can
load the whole grubx64.efi in one step. No gap, no embedded sector list.
The MBR gap is also a security footnote: it is unpartitioned, unmonitored space on the disk that malware has historically used to hide bootkits. Secure Boot exists partly to close that door.
Editing /etc/default/grub changes nothing until you regenerate. This is the step people forget, and then conclude the setting does not work.
bash
# Debian, Ubuntu — the wrapper everyone uses
sudoupdate-grub
# ...which is exactly this underneath
sudogrub-mkconfig-o/boot/grub/grub.cfg
# RHEL, CentOS, Rocky, Alma, Fedora — note "grub2", and the path differs
sudogrub2-mkconfig-o/boot/grub2/grub.cfg
# openSUSE
sudogrub2-mkconfig-o/boot/grub2/grub.cfg
terminal
$ sudoupdate-grub
Sourcing file `/etc/default/grub'Sourcing file `/etc/default/grub.d/init-select.cfg'Generating grub configuration file ...Found linux image: /boot/vmlinuz-6.8.0-45-genericFound initrd image: /boot/initrd.img-6.8.0-45-genericFound linux image: /boot/vmlinuz-6.8.0-40-genericFound initrd image: /boot/initrd.img-6.8.0-40-genericFound memtest86+x64 image: /boot/memtest86+x64.binWarning: os-prober will not be executed to detect other bootable partitions.done
Read that output as a checklist: it found two kernels and their matching initrds. If a kernel is listed with no matching initrd line, that machine will panic when you select it.
$ ls-l/etc/grub.d/
-rwxr-xr-x 1 root root 10046 Mar 26 09:12 00_header-rwxr-xr-x 1 root root 6260 Mar 26 09:12 05_debian_theme-rwxr-xr-x 1 root root 18150 Mar 26 09:12 10_linux-rwxr-xr-x 1 root root 42990 Mar 26 09:12 10_linux_zfs-rwxr-xr-x 1 root root 14180 Mar 26 09:12 20_linux_xen-rwxr-xr-x 1 root root 1414 Mar 26 09:12 25_bli-rwxr-xr-x 1 root root 12894 Mar 26 09:12 30_os-prober-rwxr-xr-x 1 root root 1372 Mar 26 09:12 30_uefi-firmware-rwxr-xr-x 1 root root 214 Mar 26 09:12 40_custom-rwxr-xr-x 1 root root 216 Mar 26 09:12 41_custom
They run in numeric order and their combined stdout isgrub.cfg. 00_header emits settings, 10_linux scans /boot for kernels, 30_os-prober looks for Windows and other Linuxes, 40_custom is copied through verbatim for your own entries. Making a snippet non-executable (chmod -x /etc/grub.d/30_os-prober) is how you disable it.
/etc/grub.d/40_custom — a memtest entry that survives updates
#!/bin/shexectail-n+3$0# Lines below are copied verbatim into grub.cfg.
menuentry"Boot with serial console and no graphics"{linux/boot/vmlinuz-6.8.0-45-genericroot=UUID=8f3a1c7e-2b4d-4e19-9a76-c5d0e8f21b43roconsole=ttyS0,115200n8nomodeset
initrd/boot/initrd.img-6.8.0-45-generic
}
update-grub rewrites a file. grub-install rewrites boot sectors and/or the ESP. You need it after replacing a disk, cloning a system, restoring from backup, or when Windows has overwritten your bootloader.
bash
# BIOS/MBR — note the DISK, not a partition. /dev/sda, never /dev/sda1
sudogrub-install/dev/sda
# UEFI — writes to the ESP and creates an NVRAM entry
sudogrub-install--target=x86_64-efi--efi-directory=/boot/efi--bootloader-id=ubuntu
# UEFI, also writing the removable fallback path /EFI/BOOT/BOOTX64.EFI
sudogrub-install--target=x86_64-efi--efi-directory=/boot/efi\--bootloader-id=ubuntu--removable
# RHEL family
sudogrub2-install/dev/sda
Two prompts, and the difference tells you how much is broken:
grub> — the normal shell. Stage 2 loaded, modules are available, but grub.cfg was missing or broken.
grub rescue> — the rescue shell. Stage 1.5 ran but could not find its module directory (prefix is wrong, /boot moved, or a partition number changed). Very few commands work: ls, set, unset, insmod.
Recovering from grub rescue>:
terminal
grub rescue> ls(hd0)(hd0,gpt1) (hd0,gpt2) (hd0,gpt3) (hd1) (hd1,msdos1)grub rescue> ls (hd0,gpt2)/lost+found/ boot/ etc/ usr/ var/ home/ bin/ sbin/ lib/ tmp/ root/grub rescue> ls (hd0,gpt2)/boot/grubi386-pc/ x86_64-efi/ grub.cfg grubenv fonts/ locale/grub rescue> set root=(hd0,gpt2)grub rescue> set prefix=(hd0,gpt2)/boot/grubgrub rescue> insmod normalgrub rescue> normal
ls with no argument lists devices; ls (hd0,gpt2)/ lists that partition’s root, which is how you identify which partition holds your system — look for etc/ and usr/. Setting prefix tells GRUB where its modules are; insmod normal loads the module that implements the menu; normal starts it.
If grub.cfg itself is gone, boot the kernel by hand from the grub> prompt:
terminal
grub> set root=(hd0,gpt2)grub> linux /boot/vmlinuz-6.8.0-45-generic root=/dev/sda2 rogrub> initrd /boot/initrd.img-6.8.0-45-genericgrub> boot
Four commands, in that order: set root, linux, initrd, boot. Memorise them; they are worth more in an outage than any other four commands in this chapter. Note the two different meanings of “root”: set root= tells GRUB where to read files from, while root= on the linux line tells the kernel which filesystem to mount. They are usually the same partition and always confuse people. GRUB’s tab-completion works, so you can type linux /boot/vmli and press Tab.
Once booted, make it permanent from inside the running system:
bash
sudoupdate-grub
sudogrub-install/dev/sda# or the UEFI form above
Editing the kernel command line at the menu — the most useful skill in this chapter#
Everything above is preparation for this. If you learn one practical technique from this chapter, learn this one.
Getting the menu to appear. On a machine with a hidden menu, hold Shift during boot (BIOS) or tap Esc repeatedly (UEFI). On a VM, start pressing before the firmware logo clears. In VirtualBox and VMware you may need to click into the window first; on a cloud VM, use the provider’s console and increase GRUB_TIMEOUT beforehand.
Editing an entry.
Interrupting GRUB and editing the command line
1. Boot. Hold Shift (BIOS) or tap Esc (UEFI).
2. The menu appears:
┌────────────────────────────────────────────────────┐
│ *Ubuntu │
│ Advanced options for Ubuntu │
│ Memory test (memtest86+) │
└────────────────────────────────────────────────────┘
3. Highlight the entry with ↑ ↓ — do NOT press Enter.
4. Press e to edit it. You get the entry's script.
5. Find the line beginning with linux (may be wrapped):
linux /boot/vmlinuz-6.8.0-45-generic \
root=UUID=8f3a1c7e-... ro quiet splash
6. Move to the END of that line with ↓ and End.
7. Append what you need (see the table below).
8. Press Ctrl+X or F10 to boot with the edit.
9. Press Esc to abandon the edit and return to the menu.
The edit is IN MEMORY ONLY. Nothing on disk changes. The next
boot is unaffected — which is exactly what makes this safe.
That last point is the reason this technique is so good: it is a completely non-destructive experiment. You can try five different parameter sets in five reboots and leave no trace.
What to append, and what each one gives you:
Append
Effect
Use when
single or 1
systemd maps this to rescue.target: single-user, root filesystem mounted, minimal services
Standard recovery. Filesystems are available
systemd.unit=rescue.target
The explicit, modern form of the same thing
You want to be unambiguous, or single was ignored
systemd.unit=emergency.target or emergency
Almost nothing started; / read-only; a bare root shell
rescue itself fails, e.g. a broken /etc/fstab
systemd.unit=multi-user.target
Boot without the graphical layer
The display manager crashes or a GPU driver hangs boot
init=/bin/bash
Replace init entirely with a shell as PID 1. No services, no fstab processing, no password
Lost root password; systemd itself is broken
rd.break (dracut/RHEL)
Drop to a shell inside the initramfs, before the real root is mounted
RHEL root password reset; debugging LVM/LUKS
nomodeset
Do not load kernel-mode-setting graphics drivers
Boot ends at a black screen after the kernel starts
Remove quiet splash
Show every kernel message on screen instead of a logo
You need to see where it hangs. Do this first, always
The canonical exam and interview task. Two routes; know both.
Route A — init=/bin/bash (Debian, Ubuntu, and anything systemd).
Root password reset via init=/bin/bash
1. Reboot; hold Shift / tap Esc to reach the GRUB menu.
2. Highlight the normal entry; press e .
3. On the linux line:
· change ro to rw (optional but convenient)
· append init=/bin/bash
4. Ctrl+X to boot. You land at:
bash-5.2#
with NO password asked — you are root, PID 1.
5. THE STEP EVERYONE FORGETS — make / writable:
mount -o remount,rw /
(skip only if you changed ro→rw in step 3; do it anyway, it is harmless)
6. Set the password:
passwd root
7. RHEL / any SELinux system — schedule a relabel, or you will not
log in afterwards because /etc/shadow's context is now wrong:
touch /.autorelabel
8. Flush and restart. Do NOT just power-cycle — buffers are unwritten:
sync
exec /sbin/init ← continues a normal boot, cleanest
or
mount -o remount,ro /
reboot -f
terminal
bash-5.2# mount -o remount,rw /bash-5.2# passwd rootNew password:Retype new password:passwd: password updated successfullybash-5.2# syncbash-5.2# exec /sbin/init
Why mount -o remount,rw / matters: at that moment / is mounted read-only, because the remount to read-write is done by systemd-remount-fs.service, which never ran — you replaced systemd with bash. Without it, passwd fails with Authentication token manipulation error, which reads like a permissions problem and is actually a read-only filesystem. This single line is the most commonly missed step in the whole procedure.
Why exec /sbin/init rather than reboot: reboot needs to talk to systemd over D-Bus, and systemd is not running. exec replaces bash with systemd in place, keeping PID 1 valid, and the boot continues normally.
Route B — rd.break (RHEL, CentOS, Rocky, Fedora).
terminal
# Appendrd.breaktothelinuxline,Ctrl+X,then:
switch_root:/# mount-oremount,rw/sysroot
switch_root:/# chroot/sysroot
sh-5.1# passwdroot
Changing password for user root.New password:passwd: all authentication tokens updated successfully.sh-5.1# touch/.autorelabel
sh-5.1# exitswitch_root:/# exit
Here you are still inside the initramfs, so the real root is at /sysroot and you must chroot into it. touch /.autorelabel makes SELinux relabel the whole filesystem on the next boot, which takes a few minutes and is not optional on an enforcing system.
The full list is enormous (/usr/share/doc/linux-doc/Documentation/admin-guide/kernel-parameters.txt). These are the ones that come up.
Parameter
Meaning
root=UUID=8f3a1c7e-…
Which filesystem to mount as /. UUID, not /dev/sda2, because device names are not stable across reboots or hardware changes. Also root=/dev/mapper/vg0-root for LVM, or LABEL=
ro
Mount the root filesystem read-only initially, so fsck can run safely. systemd remounts it rw
rw
Mount root read-write immediately — useful in recovery, wrong for normal boots
quiet
Suppress most kernel messages during boot
splash
Show the graphical boot animation (Plymouth)
nomodeset
Skip kernel mode setting; the GPU stays in basic mode. The standard fix for a black screen after the kernel loads
systemd.unit=…
Which target to boot into; overrides default.target
single, 1, emergency
SysV-era shorthands that systemd maps to rescue.target / emergency.target
init=/bin/bash
Which program becomes PID 1
mem=2G
Limit the RAM the kernel will use. Genuinely useful for reproducing out-of-memory conditions, and for working around firmware that misreports memory
console=tty0 console=ttyS0,115200n8
Where kernel messages go. Multiple console= are allowed; the last one also receives /dev/console, so order matters
panic=10
Reboot automatically 10 seconds after a panic instead of hanging. Standard on unattended servers
resume=UUID=…
The swap device to resume hibernation from
crashkernel=256M
Reserve memory for kdump to capture a crash dump
ipv6.disable=1
Disable IPv6 in the kernel
intel_iommu=on
Enable the IOMMU — required for PCI passthrough to VMs
elevator=none
Legacy I/O scheduler selection (superseded by /sys/block/*/queue/scheduler)
The Linux boot process is one of the most critical sequences in a system administrator’s toolkit because it is simultaneously:
Completely deterministic — the same sequence, in the same order, every time. This makes boot failures diagnosable: if a specific stage fails, you know exactly what was running, where it lived on disk, and what tool can fix it.
A chain of handovers — each stage is a relay runner that loads the next, verifies it can run, and then steps aside. No stage is rebooted into; each one forks the CPU over to the next. This is why failures are total — if the kernel cannot load, nothing after it runs at all.
Stagewise bootstrapping — each stage is deliberately small and specialized. The firmware knows sectors but not filesystems. The bootloader knows files but not your disk layout. The initramfs knows your storage but is thrown away as soon as the real root mounts. systemd knows services. Nobody knows everything, which is the entire reason the chain works at all: every stage only solves the bootstrapping problem for itself and the next stage.
The reliability of this process is why you can patch and reboot machines in production, why autoscaling groups can spin up new instances in seconds, and why a lost root password does not require a data recovery service — you can always reach the GRUB menu and edit the kernel command line. Conversely, it is why a broken /etc/fstab or a missing initramfs module breaks the entire machine with no recovery path except a live image: you cannot SSH in, you cannot log in locally, and the system will not reach the point where sysadmin tools are available. Understanding this chain is the difference between “the server is broken” and “here is exactly what failed and how to fix it”.
# See the current GRUB config
cat/boot/grub/grub.cfg# Debian/Ubuntu
cat/boot/grub2/grub.cfg# RHEL/Fedora/SUSE# The file you actually edit
cat/etc/default/grub
cat/etc/grub.d/40_custom# Custom entries that survive updates# Regenerate grub.cfg after editing /etc/default/grub
sudoupdate-grub# Debian/Ubuntu
sudogrub2-mkconfig-o/boot/grub2/grub.cfg# RHEL# Install GRUB to a disk (MBR or UEFI)
sudogrub-install/dev/sda# BIOS/MBR — the disk, not a partition
sudogrub-install--target=x86_64-efi--efi-directory=/boot/efi--bootloader-id=ubuntu# UEFI
Emergency GRUB operations at the boot menu:
bash
# Boot manually from grub> prompt (when grub.cfg is missing)setroot=(hd0,gpt2)
linux/boot/vmlinuz-6.8.0-45-genericroot=/dev/sda2ro
initrd/boot/initrd.img-6.8.0-45-generic
boot
# From grub rescue> prompt (when modules are missing)
ls# list devices
ls(hd0,gpt2)/# list partition contents to find rootsetroot=(hd0,gpt2)setprefix=(hd0,gpt2)/boot/grub
insmodnormal
normal
Changing GRUB settings:
ini
# /etc/default/grub — key settings to knowGRUB_DEFAULT=0# 0=first entry, saved=use last bootedGRUB_TIMEOUT=5# Seconds to wait. Never set to 0 on remote serversGRUB_TIMEOUT_STYLE=menu# menu, hidden, countdownGRUB_CMDLINE_LINUX_DEFAULT="quiet splash"# Normal boot parameters onlyGRUB_CMDLINE_LINUX="console=ttyS0,115200n8"# Every entry (normal + recovery)GRUB_DISABLE_RECOVERY="false"# Keep recovery entries (safety net)GRUB_TERMINAL=console# serial for headless servers
Editing the kernel command line at boot (the single most useful skill):
1. Hold Shift during boot (BIOS) or tap Esc repeatedly (UEFI)
2. Highlight an entry; press e to edit
3. Find the line beginning with linux (may be wrapped)
4. Move to the end; append what you need (see table below)
5. Ctrl+X to boot with the edit
6. Esc to abandon and return to menu
# List files in the initramfs
lsinitramfs/boot/initrd.img-6.8.0-45-generic|head-20
# Extract for manual inspectioncd/tmp
unmkinitramfs/boot/initrd.img-6.8.0-45-genericinitrd_extract
lsinitrd_extract/
Rebuild the initramfs (after changing modules or drivers):
bash
# Debian/Ubuntu
sudoupdate-initramfs-u-kall# Regenerate all
sudoupdate-initramfs-u-k6.8.0-45-generic# Regenerate one version# RHEL/CentOS/Fedora
sudodracut-f# Force regenerate current kernel
sudodracut-f/boot/initramfs-6.8.0-45-generic.img6.8.0-45-generic# Force specific version
When to rebuild: after installing a driver needed to boot, e.g.:
Bare shell; root read-only; almost nothing started
SysV runlevel s
poweroff.target
Shut down
reboot.target
Reboot
default.target
Whatever the distribution chose (usually multi-user.target or graphical.target)
Check current target:
bash
systemctlget-default# Shows which target boots by default
systemctlstatus# Shows current target
Switch targets (running system):
bash
# Boot into rescue (single-user) mode — root mounted rw
sudosystemctlisolaterescue.target
# Boot into emergency (bare shell) mode — root read-only
sudosystemctlisolateemergency.target
# Switch to multi-user (text-only)
sudosystemctlisolatemulti-user.target
# Switch to graphical (with display manager)
sudosystemctlisolategraphical.target
Change what boots by default:
bash
# Show options
ls/lib/systemd/system/*.target
# Set graphical as default
sudosystemctlset-defaultgraphical.target
# Set multi-user (text) as default
sudosystemctlset-defaultmulti-user.target
Method A: init=/bin/bash (Debian/Ubuntu; systemd systems) — fastest and most common:
bash
# At GRUB menu: highlight entry, press e, append init=/bin/bash to the linux line, Ctrl+X# You land at a bash shell, PID 1, no password. Then:
mount-oremount,rw/# CRITICAL — this line alone stops 90% of failures
passwdroot
sync
exec/sbin/init# Continue normal boot# On RHEL with SELinux: before exec /sbin/init
touch/.autorelabel# Schedule SELinux relabel on next boot
Method B: rd.break (RHEL/CentOS/Fedora) — for dracut-based systems:
bash
# At GRUB menu: append rd.break to the linux line, Ctrl+X# You land at initramfs shell, with real root at /sysroot. Then:
mount-oremount,rw/sysroot
chroot/sysroot
passwdroot
touch/.autorelabel# Schedule SELinux relabelexitexit
Critical reminders:
Do not just reboot without sync — kernel hasn’t flushed buffers yet; shadow file is corrupt.
mount -o remount,rw / is required in method A because you skipped systemd, which normally remounts /.
On SELinux systems (/.autorelabel), wait 5–10 minutes on next boot for relabel to finish; it is not optional.
This proves that console access equals root access. The only true protection is full-disk encryption (LUKS) and firmware/GRUB passwords.
# What bootloader and firmware?[-d/sys/firmware/efi]&&echoUEFI||echoBIOS
ls/boot/grub*# BIOS: /boot/grub; UEFI: may also have /boot/efi# What did the kernel receive?
cat/proc/cmdline
# What did the kernel output during boot?
dmesg|head-20# First lines show boot parameters, hardware
dmesg|grep-ierror# Errors during boot
systemd-analyze# Boot time by stage# Current systemd target
systemctlget-default
systemctlstatus
# Initramfs details
lsinitramfs/boot/initrd.img-$(uname-r)|grep-E'^lib|^bin'|head-10
One-line reminders (for quick reference when on call)#
bash
# "Is this UEFI?"
ls/sys/firmware/efi
# "What's on the kernel command line?"
cat/proc/cmdline
# "Can I write to /?
touch/.test&&rm/.test&&echowritable||echoreadonly# "Can I boot?"
sudoupdate-grub&&echoOK
# "Rebuild initramfs"
sudoupdate-initramfs-u-k$(uname-r)# "Watch the boot live"# (append to linux line at GRUB menu) remove quiet splash# "Root password reset"# (append to linux line at GRUB menu) init=/bin/bash# then: mount -o remount,rw / && passwd root && sync && exec /sbin/init
IV — Networking & Remote Access
11Linux Networking
How a packet gets from your process to a machine on the other side of the world, and the handful of commands that tell you where it stopped.
Almost nothing you will ever debug in production is a computation problem. The CPU is fine. The code is fine. The disk is fine. What is broken is that two machines cannot talk to each other, and the failure gives you nothing but a timeout.
Connection refused. Connection timed out. Name or service not known. 502 Bad Gateway. curl: (7) Failed to connect. dial tcp 10.0.2.15:5432: i/o timeout. Every one of those is a networking failure wearing a different costume, and each one points to a different layer of the stack. A junior engineer treats them as one undifferentiated fog and starts restarting things. A senior engineer reads the exact wording, knows which of six layers it implicates, and runs three commands.
Two processes on the same machine can talk trivially — they share memory, a filesystem, a kernel. Two processes on different machines share nothing at all. To get a byte from one to the other, something has to solve, simultaneously:
Naming — you know the name api.example.com, but wires only understand numbers.
Addressing — of the four billion possible IPv4 addresses, which one, and how does anyone find it?
Routing — your packet must cross a dozen networks owned by a dozen companies, none of which know your destination personally. Each only knows a next step.
Multiplexing — one machine, one address, but hundreds of programs. Which one gets this packet?
Reliability — the wire drops, duplicates and reorders. Somebody must fix that, or decide not to.
Framing — a network moves fixed-size chunks. Your 8 MB upload does not fit in one.
The answer the industry settled on is layering: solve each problem once, in its own layer, and let each layer treat the one below it as a dumb pipe. That single design decision is why the tools in this chapter come in a stack, and why diagnosing in layer order is the entire professional method.
The same failure, six different causes
You run: curl https://api.example.com/health → it hangs, then fails
Which layer?
┌────────────────────────────────────────────────────────────────────────┐
│ L1/L2 cable unplugged, NIC down → ip link (state DOWN) │
│ L3 no IP address, no route, no gw → ip addr / ip route │
│ L3 gateway unreachable → ping <gateway> │
│ Naming DNS broken but network fine → dig api.example.com │
│ L4 port closed / firewall dropping → nc -zv host 443 │
│ L7 app listening on wrong address → ss -tulpn (127.0.0.1 vs 0) │
│ Cloud security group never let it in → console, not the OS at all │
└────────────────────────────────────────────────────────────────────────┘
Six causes. One error message. This is why you diagnose bottom-up.
Every outage is a network outage eventually. Latency, packet loss, DNS TTLs and MTU mismatches cause the failures that no amount of application testing catches.
Cloud networking is charged and audited. Subnets, CIDR allocations, NAT gateways and cross-AZ traffic are line items on the bill and entries in a compliance report.
Security is network-shaped. Firewalls, security groups, zero-trust, mTLS, egress filtering — you cannot reason about any of it without knowing what a port and a route are.
The skill does not go stale. IPv4 subnetting has been the same since 1993. dig output looks the same as it did in 1999. This is the highest-durability knowledge in the handbook.
Forget protocols for a moment. Think about posting a letter.
You write a letter (your data). You put it in an envelope with a street address (the IP packet). You hand it to your local post office (your default gateway). The post office does not know where “14 Rue de Rivoli, Paris” is. It knows one thing: anything foreign goes to the international sorting hub. The hub knows: anything for France goes to Paris. Paris knows the arrondissement. The arrondissement knows the street.
That is routing. No single node knows the whole path. Each node knows only the next hop, and the letter still arrives. This is the deepest idea in networking and the reason the internet scales.
Extend the analogy and it keeps paying:
The flat number on the envelope, after the street address, is the port. The building is the machine; the flat is the process.
Recorded delivery with signature is TCP. Dropping a postcard in the box and hoping is UDP.
The phone book you use to look up an address from a name is DNS.
A PO box that forwards to your real address is NAT.
The security guard who opens some letters and bins others is the firewall.
traceroute is sending a series of letters marked “return to sender after 1 sorting office”, then “after 2”, then “after 3” — and writing down who sends each one back. That is literally how it works.
When you send an HTTP request, nothing rewrites it. It gets wrapped, like a present in four boxes.
Encapsulation — nothing is rewritten, everything is wrapped
What your program hands to the kernel:
┌──────────────────────────────────────────────┐
│ GET /health HTTP/1.1 │ L7 application data
│ Host: api.example.com │ (your bytes, untouched)
└──────────────────────────────────────────────┘
Kernel adds a TCP header (20 bytes: src port, dst port, seq, ack, flags):
┌──────────┬──────────────────────────────────┐
│ TCP hdr │ GET /health HTTP/1.1 ... │ L4 segment
│ 20 bytes │ │
└──────────┴──────────────────────────────────┘
Kernel adds an IP header (20 bytes: src IP, dst IP, TTL, protocol=6):
┌──────────┬──────────┬────────────────────────┐
│ IP hdr │ TCP hdr │ GET /health ... │ L3 packet
│ 20 bytes │ 20 bytes │ │
└──────────┴──────────┴────────────────────────┘
└──────────── must fit inside the MTU: 1500 ────┘
NIC driver adds an Ethernet header (14 bytes: dst MAC, src MAC, type=0x0800):
┌────────┬──────────┬──────────┬───────────────┬─────┐
│ Eth │ IP hdr │ TCP hdr │ payload │ FCS │ L2 frame
│ 14 B │ 20 B │ 20 B │ ≤ 1460 B │ 4 B │
└────────┴──────────┴──────────┴───────────────┴─────┘
└──────────── 1514 bytes on the wire (+4 FCS) ─────────┘
Then: voltage / light / radio on the medium. L1
Read that diagram twice. Three consequences fall straight out of it:
MTU (Maximum Transmission Unit) is the largest IP packet a link will carry — classically 1500 bytes on Ethernet. Subtract 20 for IP and 20 for TCP and you get MSS = 1460: the most application data one segment can hold.
Fragmentation is what happens when a packet larger than the next link’s MTU must cross it. The router (IPv4) splits it into fragments that are reassembled at the destination. It is slow, it hurts, and losing one fragment loses the whole packet. IPv6 forbids routers from fragmenting — the sender must discover the path MTU instead.
Every header is overhead. A VPN or an overlay network adds another IP header inside your IP header, so the usable MTU drops (typically to 1450, 1420 or 1380). This is the cause of the classic “SSH connects but ls of a big directory hangs” bug — small packets get through, big ones are silently dropped.
One more, because it makes ARP and switching click.
A switch is the person pushing the internal mail trolley on one floor. They know every desk on that floor by name badge (MAC address) and nothing else. Hand them mail for someone on their floor and it arrives. Hand them mail for another company and they are useless.
A router is the mail room in the lobby. It knows nothing about desks. It knows buildings and streets (IP networks), and which door to send things out of.
ARP is shouting “WHO IS SITTING AT 192.168.1.1?” across the floor and waiting for someone to raise a hand and tell you their badge number. That shout only reaches your floor — which is exactly why ARP works only inside one broadcast domain, and why you never ARP for a machine on the internet. You ARP for your gateway, and let the gateway worry about the rest.
The layered model, mapped to things you can actually type#
OSI is a teaching model, not an implementation. Linux implements TCP/IP. The reason to know OSI at all is that engineers say “that’s a layer 3 problem” and “we need an L7 load balancer” and you must know what they mean.
OSI layer
TCP/IP layer
What it does
Concrete Linux artefact
Tool that shows it
7 Application
Application
The actual protocol: HTTP, SSH, DNS, SMTP
nginx, sshd, HTTP headers
curl -v, dig
6 Presentation
Application
Encoding, serialisation, TLS sits about here
TLS certificates, JSON, UTF-8
openssl s_client
5 Session
Application
Sessions, dialogue control
HTTP keep-alive, SSH channels
—
4 Transport
Transport
Ports, reliability, ordering
TCP / UDP, port numbers, sockets
ss -tuln, nc
3 Network
Internet
Global addressing, routing between networks
IP address, netmask, routing table, ICMP
ip addr, ip route, ping, traceroute
2 Data link
Link
Local delivery on one physical segment
MAC address, ARP, VLAN tag, switch, eth0
ip link, ip neigh
1 Physical
Link
Volts, photons, radio
Cable, NIC, SFP, LOWER_UP flag, link speed
ip link, ethtool eth0
Two rows of that table are worth memorising in isolation, because interviewers use them as shibboleths:
Layer 3 devices route; layer 2 devices switch. A router looks at the destination IP and picks an outgoing interface. A switch looks at the destination MAC and picks a port.
A “layer 4 load balancer” forwards TCP connections without reading them; a “layer 7 load balancer” terminates the connection and reads the HTTP request — which is how it can route on URL path or Host header, but also why it must hold the TLS certificate.
An IPv4 address is a 32-bit unsigned integer. Nobody can read 3232238117, so it is written as four 8-bit chunks in decimal, separated by dots — dotted-quad notation. Each chunk (an octet) is 0–255.
An address on its own is meaningless. 192.168.10.37 tells you nothing until you know where the boundary is between the part that identifies the network and the part that identifies the host on that network.
The subnet mask draws that line. It is another 32-bit number, and its rule is absolute: a 1 bit means “this bit is network”, a 0 bit means “this bit is host”, and the 1s always come first.
The mask draws the line
Address 192.168.10.37 11000000 10101000 00001010 00100101
Mask 255.255.255.0 11111111 11111111 11111111 00000000
└────── network (24) ─────┘└ host (8)┘
Written together: 192.168.10.37/24 ← CIDR notation
"the first 24 bits are the network"
CIDR (Classless Inter-Domain Routing, pronounced “cider”) notation replaces the dotted mask with a slash and a count of network bits. /24 means 24 one-bits, which is 255.255.255.0. They are the same statement.
Network address ....... 192.168.10.0 (not usable)
First usable host ..... 192.168.10.1 ← usually the gateway
Last usable host ...... 192.168.10.62
Broadcast address ..... 192.168.10.63 (not usable)
Usable hosts .......... 2^6 − 2 = 64 − 2 = 62
Total addresses ....... 64
The shortcut, once you trust the long way. For any mask, the block size in the interesting octet is 256 − mask_octet. Here 256 − 192 = 64. Subnets therefore begin at multiples of 64: .0, .64, .128, .192. Your address .37 sits in the block starting at .0, which runs to .63. Done in five seconds.
IPv4 has 4.3 billion addresses and the last unallocated blocks were handed out in 2011. IPv6 uses 128 bits, written as eight groups of four hex digits separated by colons.
IPv6 notation and compression
Full: 2001:0db8:0000:0000:0000:ff00:0042:8329
Drop leading zeros in each group:
2001:db8:0:0:0:ff00:42:8329
Replace ONE run of all-zero groups with "::" :
2001:db8::ff00:42:8329
Rule: "::" may appear at most ONCE in an address, or it would be ambiguous.
Loopback ::1 (the IPv6 127.0.0.1)
Unspecified :: (the IPv6 0.0.0.0)
Link-local fe80::/10 auto-configured on every interface, always present
Unique local fc00::/7 the IPv6 "private" range (RFC 1918 equivalent)
Documentation 2001:db8::/32 safe to use in examples, like example.com
Three facts that cover most IPv6 questions:
A /64 is the standard subnet size, always. The first 64 bits are the network, the last 64 identify the interface. Yes, that is 18 quintillion hosts per subnet, and yes, that is deliberate — it makes stateless autoconfiguration (SLAAC) possible.
Every interface gets an fe80:: link-local address automatically, whether you configured IPv6 or not. Run ip -6 addr show on any modern machine and you will see one. It is used for neighbour discovery and router advertisements.
There is no ARP and no broadcast in IPv6. Both are replaced by NDP (Neighbour Discovery Protocol) over ICMPv6 multicast. ip neigh still shows you the table.
An IP address is where you are. A MAC address is who you are. You need both, and you need them for different distances.
A MAC address (Media Access Control) is 48 bits, written as six hex bytes: 08:00:27:4e:8b:2a. The first three bytes are the OUI — the vendor. 08:00:27 is VirtualBox; 52:54:00 is QEMU/KVM; 02:42:ac prefixes are Docker. Seeing a MAC and recognising the vendor is a genuinely useful party trick when reading tcpdump output.
Switch vs router — the one diagram that fixes ARP
┌──────────────────── ONE BROADCAST DOMAIN (one subnet) ────────────────────┐
│ │
│ hostA SWITCH (layer 2) hostB │
│ 192.168.1.10 ───────────┐ forwards by MAC ┌─────── 192.168.1.20 │
│ aa:bb:cc:00:00:01 │ learns MACs per port │ aa:bb:cc:00:00:02
│ ├──────────────────────────┤ │
│ ARP works here: "who has 192.168.1.20?" is BROADCAST to this domain only │
│ │ │ │
└───────────────────────────┼──────────────────────────┘
│
┌──────┴───────┐
│ ROUTER │ layer 3 — forwards by IP
│ 192.168.1.1 │ ← hostA's default gateway
│ (gateway) │ STRIPS and REBUILDS the Eth header
└──────┬───────┘
│
the internet — ARP is meaningless out here
To reach 8.8.8.8, hostA does NOT arp for 8.8.8.8. It:
1. masks 8.8.8.8 with its own /24 → not local
2. therefore sends to the default gateway
3. ARPs for 192.168.1.1 (local!) to get the router's MAC
4. builds a frame: dst MAC = router, dst IP = 8.8.8.8
That last block is the single most important mechanical fact in this chapter. The destination IP stays the same across the entire journey; the destination MAC changes at every hop. Every router rewrites the layer 2 header and leaves the layer 3 header alone (except decrementing TTL).
Inspect the ARP table — the cache of IP→MAC mappings the kernel has learned:
terminal
$ ipneighshow
192.168.1.1 dev enp0s3 lladdr 04:d4:c4:5f:11:a0 REACHABLE192.168.1.20 dev enp0s3 lladdr aa:bb:cc:00:00:02 STALE192.168.1.77 dev enp0s3 FAILEDfe80::1 dev enp0s3 lladdr 04:d4:c4:5f:11:a0 router STALE
Field
Meaning
192.168.1.1
The IP address that was resolved
dev enp0s3
Which interface learned it — the same IP can exist on two segments
lladdr 04:d4:...
The link-layer (MAC) address it maps to
REACHABLE
Confirmed within the last few seconds. Good
STALE
Cached but unverified; the kernel will re-probe before relying on it. Normal
FAILED
ARP was sent and nobody answered — that host is not on this segment, or is off
router
This neighbour advertised itself as a router (IPv6 NDP)
bash
ipneighshow# the modern command
ipneighshowdevenp0s3# one interface only
ipneighflushall# clear the whole cache — forces re-ARP
ipneighadd192.168.1.50lladdraa:bb:cc:dd:ee:ffdevenp0s3# static entry
arp-n# legacy net-tools equivalent (still on exams)
arp-a# legacy, BSD-style output
NAT — why your VM has an address nobody else can see#
There are not enough IPv4 addresses. NAT (Network Address Translation) is the workaround that made the internet last thirty years past its expiry date: a router rewrites the source address of outgoing packets to its own public address, remembers the mapping, and rewrites the replies on the way back.
NAT / masquerading, with the mapping table
PRIVATE SIDE (RFC 1918) NAT ROUTER PUBLIC INTERNET
public IP 203.0.113.9
laptop 192.168.1.10:51000 ──┐
phone 192.168.1.11:44230 ──┼──► rewrites SOURCE addr+port ──► 93.184.216.34:443
VM 192.168.1.12:38102 ──┘
Translation table the router keeps:
┌────────────────────────┬──────────────────────────┬─────────────────────┐
│ inside (private) │ outside (public) │ destination │
├────────────────────────┼──────────────────────────┼─────────────────────┤
│ 192.168.1.10:51000 │ 203.0.113.9:51000 │ 93.184.216.34:443 │
│ 192.168.1.11:44230 │ 203.0.113.9:44231 │ 93.184.216.34:443 │
│ 192.168.1.12:38102 │ 203.0.113.9:38102 │ 1.1.1.1:53 │
└────────────────────────┴──────────────────────────┴─────────────────────┘
Replies arrive at 203.0.113.9:51000 → table lookup → forwarded to 192.168.1.10:51000
Consequence: the outside world cannot START a connection inwards.
That is a security side-effect, not a security design.
Three flavours, and the exam wants the names:
Kind
What is rewritten
Direction
Typical use
SNAT (Source NAT)
Source address of outbound packets
Inside → out
A fixed, known public IP for a server farm
Masquerading
Source address, taken dynamically from the outgoing interface
Inside → out
Home routers, laptops sharing a connection, Docker. Use when the public IP can change
DNAT (Destination NAT)
Destination address of inbound packets
Outside → in
Port forwarding: publish an internal service
Port forwarding is just DNAT with a port change: “anything arriving on my public IP port 2222, send to 192.168.1.12 port 22.” In iptables terms:
bash
# SNAT/masquerade: let a private LAN out through this box (needs ip_forward=1)
sudoiptables-tnat-APOSTROUTING-s192.168.1.0/24-oeth0-jMASQUERADE
# DNAT: forward public :2222 to the VM's SSH
sudoiptables-tnat-APREROUTING-ieth0-ptcp--dport2222\-jDNAT--to-destination192.168.1.12:22
One host, one IP, hundreds of programs. A port is a 16-bit number (1–65535) that says which program. The combination that uniquely identifies one end of a conversation is the socket: protocol + IP + port. A full connection is a 4-tuple: source IP, source port, destination IP, destination port.
Range
Name
Who uses it
Notes
0–1023
Well-known / system
Standard services
Binding these requires root (or CAP_NET_BIND_SERVICE)
1024–49151
Registered
Applications, databases
3306, 5432, 8080 live here
49152–65535
Dynamic / ephemeral
Client source ports, picked by the kernel
Linux actually uses 32768–60999 by default — see sysctl net.ipv4.ip_local_port_range
The ports a Linux engineer knows cold. Interviewers ask these as rapid-fire; there is no clever way to learn them, so learn them.
Port
Protocol
Service
Note
20 / 21
TCP
FTP
21 = control, 20 = data (active mode)
22
TCP
SSH
Also SCP and SFTP. Chapter 12
23
TCP
Telnet
Plaintext, obsolete — but a superb TCP test tool
25
TCP
SMTP
Mail submission between servers; often blocked by cloud providers
53
UDP + TCP
DNS
UDP for normal queries, TCP for large responses and zone transfers
67 / 68
UDP
DHCP
67 = server, 68 = client
69
UDP
TFTP
Trivial FTP — PXE boot, switch firmware
80
TCP
HTTP
110
TCP
POP3
995 with TLS
123
UDP
NTP
Time sync. Broken time breaks TLS and Kerberos
143
TCP
IMAP
993 with TLS
161 / 162
UDP
SNMP
161 queries, 162 traps. Network monitoring
389
TCP
LDAP
Directory. Chapter on LDAP covers it
443
TCP (+UDP for QUIC)
HTTPS
HTTP over TLS
445
TCP
SMB / CIFS
Windows file sharing, Samba
514
UDP
syslog
Remote logging
587
TCP
SMTP submission
The port mail clients should use
636
TCP
LDAPS
LDAP over SSL/TLS
993
TCP
IMAPS
IMAP over TLS
995
TCP
POP3S
POP3 over TLS
2049
TCP/UDP
NFS
Network File System
3306
TCP
MySQL / MariaDB
3389
TCP
RDP
Windows remote desktop
5432
TCP
PostgreSQL
6379
TCP
Redis
Famously exposed to the internet by accident
8080
TCP
HTTP alternate
Tomcat, dev servers, proxies. Unprivileged, so no root needed
9090 / 9100
TCP
Prometheus / node_exporter
Modern ops
27017
TCP
MongoDB
Also famously exposed by accident
The canonical list on your own machine is /etc/services:
HTTP, SSH, SQL, email, file transfer — anything where a missing byte is fatal
DNS, DHCP, NTP, SNMP, VoIP, games, video, QUIC — anything where late is worse than missing
The one-sentence version worth memorising: TCP is a phone call — you dial, they answer, you both confirm you can hear each other, and you notice immediately if the line drops. UDP is shouting across a room — fast, cheap, and you will never know if they heard you.
sequenceDiagram
autonumber
participant C as "Client 10.0.0.5"
participant S as "Server 93.184.216.34:443"
Note over C: socket() then connect()
C->>S: "SYN seq=x (I want to talk, my seq starts at x)"
Note over C: state SYN_SENT
Note over S: was LISTEN, now SYN_RECV
S->>C: "SYN-ACK seq=y ack=x+1 (fine, and mine starts at y)"
C->>S: "ACK ack=y+1 (agreed)"
Note over C,S: both now ESTABLISHED — data may flow
C->>S: "GET /health HTTP/1.1"
S->>C: "HTTP/1.1 200 OK"
Note over C,S: teardown
C->>S: "FIN"
S->>C: "ACK then FIN"
C->>S: "ACK"
Note over C: state TIME_WAIT for 2×MSL (60s on Linux)
Three packets before a single byte of your request moves. On a 100 ms round-trip link that is 100 ms of handshake, plus another 100–200 ms for the TLS handshake on top. This is why connection reuse — HTTP keep-alive, database connection pools, curl sending several URLs in one invocation — is such a large performance win, and why QUIC moved the web to UDP to collapse the handshakes together.
The socket states you actually see, and what each one tells you#
You will meet these in every ss and netstat output. Knowing what they imply is the difference between reading and diagnosing.
The service is up. Check the address it is bound to, not just the port
SYN_SENT
We sent SYN, nothing came back yet
Piling up = the destination is unreachable or a firewall is silently dropping (a refusal would give you RST immediately). Classic security-group symptom
SYN_RECV
We received SYN, awaiting the final ACK
Thousands of these = a SYN flood, or clients dying mid-handshake
ESTABLISHED
Data can flow both ways
Healthy. Count them to see real concurrency
FIN_WAIT1 / FIN_WAIT2
We initiated close, waiting on the peer
Many FIN_WAIT2 = the remote app is not closing its end
CLOSE_WAIT
The peer closed; we have not called close()
Always an application bug on this machine. Your code is leaking file descriptors. It will never time out on its own — it clears only when the process closes the fd or dies. Ends in EMFILE: too many open files
TIME_WAIT
We closed, and are waiting 2×MSL (60 s on Linux) before reusing the 4-tuple
Normal and healthy. Present in thousands on any busy client or proxy. Do not “fix” it by enabling tcp_tw_recycle (removed in kernel 4.12 for good reason)
ip is the modern unified tool for all network configuration. It replaces ifconfig, route, arp, and netstat (though ss is still needed for socket inspection). Learn the structure: ip [object] [command] [arguments].
bash
# Interfaces (formerly ifconfig)
iplinkshow# all interfaces and their state
ip-clinkshoweth0# colour output for one interface
iplinkseteth0up# bring up an interface
iplinkseteth0down# bring it down
iplinkseteth0mtu1450# change MTU on this link# Addresses (formerly ifconfig with numbers)
ipaddrshow# all addresses on all interfaces
ipaddrshowdeveth0# one interface
ipaddradd192.168.1.50/24deveth0# assign an address
ipaddrdel192.168.1.50/24deveth0# remove an address# Routes (formerly route)
iprouteshow# routing table
iprouteadddefaultvia192.168.1.1deveth0# set default gateway
iprouteadd10.0.0.0/8via192.168.1.254# route a subnet via a gateway
iproutedel10.0.0.0/8# remove a route# Neighbours (ARP, formerly arp command)
ipneighshow# ARP cache
ipneighflushall# clear ARP cache
ss is faster and more informative than netstat. The syntax is similar but the flags are cryptic until memorised.
bash
# The pattern: ss [flags] [filter]# Flags: -t(TCP) -u(UDP) -a(all) -l(listening) -n(numeric) -p(processes) -i(info)
ss-tuln# TCP and UDP, listening, numeric
ss-tan# TCP, all, numeric (ESTABLISHED + TIME_WAIT)
ss-tanstateCLOSE_WAIT# filter by state
ss-tulpn# with process names/pids
ss-tunpsport=:22# connections on port 22
ss-tunpdport=:443# destination port 443
ss-s# summary (total connections, by state)
ping-c48.8.8.8# send 4 pings
ping-i0.58.8.8.8# one ping per 500ms
ping-W28.8.8.8# 2 second timeout per ping
ping-Mdo8.8.8.8# don't fragment (path MTU discovery)
digexample.com# full output
digexample.com+short# just the answer
digexample.comMX# mail exchange records
digexample.comCNAME# canonical name
dig-x8.8.8.8# reverse DNS
dig@8.8.8.8example.com# query a specific nameserver
nslookupexample.com# legacy tool, simpler output
# curl shows headers and can use multiple protocols
curl-vhttps://example.com# verbose (headers visible)
curl-Ihttps://example.com# headers only (HEAD request)
curl-w"%{http_code}\n"https://example.com# just the HTTP status
curl--resolveexample.com:443:192.168.1.1https://example.com# force IP
curl--connect-timeout5https://example.com# timeout after 5 seconds
curl-o/dev/null-s-w"%{time_connect}:%{time_starttransfer}:%{time_total}\n"https://example.com# timing# wget is a full downloader
wget-q-O-https://example.com# download to stdout, quiet
wget--timeout=5https://example.com# timeout
# Syntax: tcpdump [options] [filter]# Without root or CAP_NET_RAW it cannot run
sudotcpdump-ieth0-n'tcp port 443'# capture TLS traffic, no DNS
sudotcpdump-ieth0-n'icmp'# just ping packets
sudotcpdump-ieth0-n'dst 8.8.8.8'# traffic destined for 8.8.8.8
sudotcpdump-ieth0-w/tmp/capture.pcap-n# write to file for Wireshark
sudotcpdump-ieth0-c10-n# capture 10 packets then exit
The Linux kernel’s routing table is the decision engine. When a packet arrives, the kernel matches its destination IP against the table in longest-prefix order and picks an action.
bash
# The default setup on most machines
iprouteshow
# default via 192.168.1.1 dev eth0 ← everything not local goes to the gateway# 192.168.1.0/24 dev eth0 scope link ← local subnet, no gateway needed# Add a static route
sudoiprouteadd10.0.0.0/8via192.168.1.254deveth0
# Any packet destined for 10.0.0.0/8 will be sent to 192.168.1.254# Delete it
sudoiproutedel10.0.0.0/8
Linux firewalls are built on netfilter, a kernel subsystem that hooks into the packet processing pipeline. The user-space tool is iptables (IPv4) or ip6tables (IPv6). Modern distributions also use nftables as a replacement.
bash
# DANGER: opening all ports is trivial
sudoiptables-PINPUTACCEPT
sudoiptables-POUTPUTACCEPT
sudoiptables-PFORWARDACCEPT
# Sane defaults: drop by default, allow SSH
sudoiptables-PINPUTDROP
sudoiptables-POUTPUTACCEPT
sudoiptables-PFORWARDDROP
sudoiptables-AINPUT-ilo-jACCEPT# allow loopback
sudoiptables-AINPUT-mstate--stateESTABLISHED,RELATED-jACCEPT
sudoiptables-AINPUT-ptcp--dport22-jACCEPT
# Inspect rules
sudoiptables-L-n# list rules
sudoiptables-L-n-v# verbose, with packet counts
sudoiptables-L-n-tnat# NAT table rules# Save and restore
sudoiptables-save>/tmp/firewall.txt# back up
sudoiptables-restore</tmp/firewall.txt
A rule flows through three tables (filter, nat, mangle) in sequence:
filter — the main table, decides ACCEPT/DROP/REJECT
nat — address translation; NAT rules must be explicit
mangle — packet modification (TOS bits, TTL)
Each table has chains: PREROUTING, INPUT, FORWARD, OUTPUT, POSTROUTING.
A network namespace is a complete, isolated copy of the network stack: interfaces, routing tables, ARP tables, iptables rules, all private to that namespace. This is how containers isolate their networking.
bash
# Create a new namespace called "test"
sudoipnetnsaddtest
sudoipnetnslist
# Run a command inside it
sudoipnetnsexectestipaddrshow
# (you will see only a loopback interface)# Add a virtual interface to it
sudoiplinkaddveth-testtypevethpeernameveth-test-peer
sudoiplinksetveth-testnetnstest
sudoip-ntestaddradd192.168.100.10/24devveth-test
sudoip-ntestlinksetveth-testup
# Clean up
sudoipnetnsdeletetest
Multi-homing — multiple interfaces on one machine#
A single machine with multiple interfaces can reach different networks. This is common in routers, firewalls, and multi-homed servers.
bash
# Add a second IP on the same interface
sudoipaddradd192.168.2.50/24deveth0
# Or add a virtual interface (alias)
sudoiplinkaddvlan100typevlanid100linketh0
sudoipaddradd192.168.10.1/24devvlan100
sudoiplinksetvlan100up
Lab 1: Setting up a test network and assigning addresses#
Objective: Understand interface configuration from first principles.
Setup:
bash
# Create two virtual network namespaces
sudoipnetnsaddhost1
sudoipnetnsaddhost2
# Create a virtual Ethernet pair (veth) connecting them
sudoiplinkaddveth1typevethpeernameveth2
# Move each end to its namespace
sudoiplinksetveth1netnshost1
sudoiplinksetveth2netnshost2
# Assign addresses
sudoip-nhost1addradd192.168.10.10/24devveth1
sudoip-nhost2addradd192.168.10.20/24devveth2
# Bring up the interfaces
sudoip-nhost1linksetveth1up
sudoip-nhost2linksetveth2up
# Verify connectivity
sudoip-nhost1ping192.168.10.20# should succeed
Expected output:
PING 192.168.10.20 (192.168.10.20) 56(84) bytes of data.
64 bytes from 192.168.10.20: icmp_seq=1 ttl=64 time=0.123 ms
Why it works: The two namespaces are directly connected via a veth pair. No routing or gateway needed — they are on the same /24 subnet, so ARP finds the MAC address directly.
Objective: Configure an interface with a static IP and validate DNS resolution.
Setup (on your actual machine or in a VM):
bash
# List current interfaces
iplinkshow
# Assume eth0 is your test interface; bring it down first
sudoiplinkseteth0down
# Assign a static IP
sudoipaddrflushdeveth0# remove all addresses
sudoipaddradd10.0.0.100/24deveth0
sudoiplinkseteth0up
# Set a default gateway
sudoiprouteadddefaultvia10.0.0.1deveth0
# Verify
ipaddrshoweth0
iprouteshow
Testing DNS:
bash
# Query your local resolver
digexample.com
# Test a different nameserver
dig@8.8.8.8example.com
# Check what nameservers the system uses
cat/etc/resolv.conf
Troubleshooting:
- ping 10.0.0.1 → tests layer 3 (routing)
- dig +trace example.com → shows the full DNS hierarchy
- tcpdump -i eth0 -n 'udp port 53' → watch DNS queries in real time
Lab 3: HTTP requests and response headers with curl#
Objective: Understand HTTP layers and use curl for debugging.
Fetch a real page with full tracing:
bash
curl-vhttps://httpbin.org/get2>&1|head-50
Expected output (abbreviated):
> GET /get HTTP/1.1
> Host: httpbin.org
> User-Agent: curl/7.68.0
> Accept: */*
< HTTP/1.1 200 OK
< Date: Thu, 02 Aug 2024 12:34:56 GMT
< Content-Type: application/json
Simulate a specific client IP (with a local server):
bash
# Start a simple server in one terminal
python3-mhttp.server8000# Connect from another, specifying a source IP
curl-v--local-port8000http://localhost:8000
traceroute to 8.8.8.8 (8.8.8.8), 30 hops max, 60 byte packets
1 192.168.1.1 (192.168.1.1) 1.234 ms
2 10.0.0.1 (10.0.0.1) 5.123 ms
3 * * * (timeout)
...
15 8.8.8.8 (8.8.8.8) 25.456 ms
Reading the output:
- Hops that return * * * are firewalled (ICMP blocked) — normal and does not mean unreachable
- Each hop shows three measurements (three probe packets per hop)
- The final hop is the destination
Using traceroute to diagnose network splits:
bash
# If a path stops responding mid-route, that router or its link is broken
traceroute-I8.8.8.8# use ICMP instead of UDP (try if UDP blocked)
The symptom: “DNS is slow” or “DNS works sometimes.”
Common causes:
- Wrong nameserver in /etc/resolv.conf — if the IP listed is not reachable, every query times out
- Localhost 127.0.0.1 without a caching resolver — if a process tries to reach a resolver on 127.0.0.1 and none is running, timeouts on every query
- Search domains causing NXDOMAIN for short names — if /etc/resolv.conf has search example.com, a query for host becomes host.example.com?, and if that doesn’t exist, fails even if host.another.com would work
- TTL too low or nonexistent — each query re-queries the root nameservers instead of caching
Fix:
bash
# Check current resolver
cat/etc/resolv.conf
resolvectlstatus# systemd-resolved status# Test against a known-good resolver
dig@8.8.8.8example.com# bypasses your configured servers
Mistake 2: Wrong default gateway or routing table#
The symptom: “I can ping the gateway but not anything beyond it.”
Common causes:
- No default route — ip route show does not list default via ...
- Multiple default routes — the kernel picks one (usually the first) and traffic to other subnets is lost
- Destination unreachable despite the route existing — the return path is broken (asymmetric routing)
Fix:
bash
# Check your routing table
iprouteshow
# If there is no default gateway
sudoiprouteadddefaultvia192.168.1.1
# If there are multiple defaults, delete the wrong one
sudoiproutedeldefaultvia192.168.1.254
The symptom: Connection hangs, then times out (no “connection refused” error).
Why:iptables -P INPUT DROP with no allow rules means every incoming packet is silently dropped. The client waits for a response and never gets one.
Fix:
bash
# Check the firewall policy
sudoiptables-L-n|head-5
# If it says "policy DROP", you need rules
sudoiptables-AINPUT-mstate--stateESTABLISHED,RELATED-jACCEPT
sudoiptables-AINPUT-ptcp--dport22-jACCEPT
# Make sure the rules are persistent
sudoaptinstalliptables-persistent
sudoiptables-save|sudotee/etc/iptables/rules.v4
Mistake 4: MTU mismatch causing silent packet loss#
The symptom: “SSH works but ls of a large directory hangs; small files transfer fine but large ones fail.”
Why: A path carries MTU 1500, but your end is sending 1500 (or more with VPN overhead). The packet is silently dropped by a router that cannot fragment it. The source never learns the packet was dropped — TCP eventually times out.
Fix:
bash
# Check your interface MTU
iplinkshoweth0|grepmtu
# Test path MTU discovery
ping-Mdo-s14728.8.8.8# 1472 data + 8 ICMP + 20 IP = 1500
ping-Mdo-s14738.8.8.8# should fail if MTU is 1500# Reduce MTU if VPN is in use
sudoiplinkseteth0mtu1450# common for VPN tunnels
Mistake 5: ARP issues — duplicate IPs or VLAN mismatches#
The symptom: “Traffic works for a few seconds then stops; ip neigh shows FAILED for the gateway.”
Common causes:
- Duplicate IP addresses — two machines answered the ARP request; the kernel’s cached MAC flaps
- Wrong VLAN — the host and gateway are on different VLANs; ARP broadcast does not cross
- Stale ARP entry — the MAC changed but the kernel still has the old one cached
Fix:
bash
# Inspect the ARP cache
ipneighshow
# Force re-ARP
sudoipneighflushall
# Manually set a static entry if the gateway's MAC is known
sudoipneighadd192.168.1.1lladdraa:bb:cc:dd:ee:ffdeveth0
# Check VLAN membership
ip-dlinkshoweth0
Mistake 6: Listening on 127.0.0.1 instead of 0.0.0.0#
The symptom: “curl localhost:8080 works but curl from another machine times out.”
Why: The service is bound to 127.0.0.1 (loopback), so only local connections reach it. Remote traffic reaches 0.0.0.0 (all interfaces) and is dropped or refused.
Fix:
bash
# Check what a service is bound to
sudoss-tulpn|grep8080# Output showing the problem:# LISTEN 0 128 127.0.0.1:8080 0.0.0.0:* users:(("python3",pid=1234,fd=3))# ↑ only local# Reconfigure the service to bind to 0.0.0.0 or a specific interface IP
Recognizing patterns — CLOSE_WAIT means your app leaked a socket; TIME_WAIT in thousands is normal; 169.254.x.x means DHCP failed
Avoiding classic mistakes — MTU mismatches, firewall misconfiguration, DNS timeouts, duplicate IPs, wrong gateway
The skills in this chapter do not go stale. They are the same tools and concepts used by every production engineer, from small startups to the largest cloud providers. Spend time with them until the commands are muscle memory.
Quick reference — commands you need muscle memory for#
Interface and addressing:
bash
iplinkshow# all interfaces and state
ipaddrshow# all addresses
ipaddradd192.168.1.50/24deveth0# add an IP
ipaddrdel192.168.1.50/24deveth0# remove an IP
iplinkseteth0up/down# up or down
iplinkseteth0mtu1450# change MTU
Routing:
bash
iprouteshow# current routing table
iprouteadddefaultvia192.168.1.1# set default gateway
iprouteadd10.0.0.0/8via192.168.1.254# route to a subnet
iproutedel10.0.0.0/8# remove a route
ss-tuln# listening sockets, numeric, TCP/UDP
ss-tan# all TCP, numeric
ss-tanstateCLOSE_WAIT# filter by state
ss-tulpn# with process names/pids
ss-s# summary by state
netstat-tulpn# legacy equivalent
Connectivity:
bash
ping-c48.8.8.8# 4 pings
ping-Mdo8.8.8.8# path MTU discovery
traceroute8.8.8.8# hops to destination
mtr8.8.8.8# continuous traceroute
DNS:
bash
digexample.com# full output
digexample.com+short# just IPs
dig@8.8.8.8example.com# query a nameserver
dig-x8.8.8.8# reverse DNS
digexample.comMX# mail records
dig+traceexample.com# full hierarchy
nslookupexample.com# simpler output
HTTP / REST:
bash
curl-vhttps://example.com# verbose (headers + response)
curl-Ihttps://example.com# headers only
curl-w"%{http_code}\n"https://example.com# just status
curl--resolveexample.com:443:1.2.3.4https://example.com# force IP
curl-m5https://example.com# 5 second timeout
Packet capture:
bash
sudotcpdump-ieth0-n'tcp port 443'# TLS traffic
sudotcpdump-ieth0-n'icmp'# ping
sudotcpdump-ieth0-n'dst 8.8.8.8'# to a specific IP
sudotcpdump-ieth0-A'tcp port 80'# HTTP, show ASCII
sudotcpdump-ieth0-w/tmp/capture.pcap# save to file
Packet analysis:
bash
ipaddrshow# your IPs and masks
iprouteshow# where packets go
iptables-L-n# firewall rules
iptables-L-n-tnat# NAT rules
One-liners for diagnosis:
bash
# Find what port a process is listening on
sudoss-tulpn|greppython
# Check for CLOSE_WAIT (app leaking sockets)
sudoss-tanstateclose-wait|wc-l
# Check for TIME_WAIT (normal on busy clients)
sudoss-tanstatetime-wait|wc-l
# Show all connections to/from a single host
sudoss-tan|grep192.168.1.1
# Test connectivity to a port
nc-zv192.168.1.10443;echo$?# Show only established connections with timing
sudoss-tano|awk'NR>1 && $6=="ESTAB" {print}'
You are given a /22 network 172.16.0.0/22. Divide it into four equal subnets and list:
- The CIDR notation for each
- The network address of the third subnet
- The usable host range in the third subnet
- The broadcast address of the third subnet
Solution:
/22 = 2 host bits, so 4 addresses per "chunk"
Block size = 256 - 192 = 64 at the interesting octet
Subnets:
172.16.0.0/24 (0–255)
172.16.1.0/24 (256–511)
172.16.2.0/24 (512–767) ← third subnet
172.16.3.0/24 (768–1023)
Network address: 172.16.2.0
First usable: 172.16.2.1
Last usable: 172.16.2.254
Broadcast: 172.16.2.255
Problem 2: Reading a routing table
You run ip route show and see:
default via 192.168.1.1 dev eth0
192.168.1.0/24 dev eth0 scope link
10.0.0.0/8 via 192.168.1.254 dev eth0
A packet destined for:
- 8.8.8.8 — which route? Which interface?
- 192.168.1.50 — which route?
- 10.5.6.7 — which route?
Solution:
8.8.8.8 → default via 192.168.1.1 dev eth0
192.168.1.50 → 192.168.1.0/24 dev eth0 (direct, no gateway)
10.5.6.7 → 10.0.0.0/8 via 192.168.1.254 dev eth0
Problem 3: Diagnosing port binding
You start a service on port 8080, but it does not answer from another machine. Run the commands to:
- Check what port it is actually listening on
- Verify if it is listening on all interfaces or just loopback
- Capture evidence of the mistake
Solution:
bash
sudoss-tulpn|grep8080# see if it is listening# If output shows 127.0.0.1:8080, it is only local# Reconfigure service to bind to 0.0.0.0:8080# Then verify:
curlhttp://localhost:8080# works
curlhttp://192.168.1.50:8080# now works from other machines
Line 1: LISTEN → service is up and accepting connections ✓
Line 2: ESTAB → a normal active connection ✓
Line 3: CLOSE_WAIT → BUG: remote closed, but your app did not call close()
→ memory leak, file descriptor leak, restart the app
Line 4: TIME_WAIT → normal, connection closed cleanly, waiting 60s
Problem 5: DNS resolution troubleshooting
dig google.com times out. Use three independent methods to isolate whether the problem is:
1. Your machine’s DNS configuration
2. The configured nameserver itself
3. Internet connectivity
Solution:
bash
# Method 1: check local config
cat/etc/resolv.conf# is nameserver reachable?
ping8.8.8.8# can you reach the nameserver IP?# Method 2: bypass local config
dig@8.8.8.8google.com# use Google's public DNS directly# If this works, your nameserver is the problem# Method 3: check internet connectivity
ping8.8.8.8# can you reach the internet at all?# If this fails, you have no gateway/route
Problem 6: MTU discovery and fragmentation
You have a VPN tunnel with MTU 1400. The VPN runs over ethernet. Explain why scp hangs on large files when ssh works fine.
Solution:
SSH sends small requests/responses → fits in 1400 byte tunnel
scp sends large file chunks → exceeds 1400
Router cannot fragment (IPv6) or fragments then reassembles (IPv4)
Fragmentation overhead + reassembly timeout → hang
Fix: set MTU to 1400 on the interface, or use -P 32768 (smaller packets)
Problem 7: Firewall rule interpretation
You see this iptables rule:
bash
iptables-AINPUT-ptcp--dport22-jACCEPT
But SSH still times out from another machine. What might be missing?
Solution:
The rule allows TCP port 22 in, but does not allow ESTABLISHED,RELATED
Also need:
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
And the INPUT policy must not be DROP by default
Problem 8: ARP cache mystery
A host at 192.168.1.10 suddenly stops responding. ip neigh show shows:
192.168.1.10 dev eth0 FAILED
Name two reasons this could happen and one fix for each.
Solution:
Reason 1: The host is powered off or unplugged
Fix: Power it on or check the cable
Reason 2: The host has a new MAC address (replaced NIC, duplicate IP)
Fix: Clear ARP cache (ip neigh flush all) and re-ARP
Reason 3: The host is on a different VLAN than you
Fix: Check VLAN configuration on the switch port
Problem 9: Layered diagnosis — the full stack
You try to reach a database at db.internal:5432 and get dial tcp db.internal:5432: i/o timeout. Walk through all seven layers (starting at L1) and name one command for each that would help isolate the problem.
Solution:
L1: Cable up? ip link show → LOWER_UP?
L2: MAC reachable? ip neigh show → REACHABLE or STALE?
L3: Route to host? ip route show → is there a route?
L3: Ping it? ping db.internal → success?
DNS: Name resolves? dig db.internal → answer section?
L4: Port open? nc -zv db.internal 5432 → success or refused?
L7: App listening? ss -tulpn | grep 5432 → port bound to the right address?
Problem 10: Container networking — bring it together
A Docker container with address 172.17.0.5 cannot reach the host at 192.168.1.100. The container can reach other containers. Explain the problem and propose two fixes.
Solution:
Problem: The container is on 172.17.0.0/16 (Docker's default), the host is on 192.168.1.0/24
ARP only works locally, routing is needed
Fix 1: Add a route inside the container
docker exec <container> ip route add 192.168.1.0/24 via 172.17.0.1
Fix 2: Bridge the container network to the host
docker network create --driver bridge --subnet 192.168.1.0/24 mynet
docker run --net mynet --ip 192.168.1.100 <image>
Fix 3: Use host networking (simplest, least isolation)
docker run --net host <image>
Everything you do on a server, someone did over the network. Decades ago, that “someone” used telnet to log in, rsh to run commands, rlogin for passwordless login, and FTP to move files. All of them transmitted usernames and passwords in cleartext — visible to anyone watching the network. By the 1980s that was obviously broken.
In 1995, Tatu Ylönen, frustrated by a password-sniffing attack on his university network, wrote SSH — the Secure Shell. It replaced telnet/rsh/rlogin with encrypted sessions where even an attacker reading every byte of traffic sees only random data. It replaced FTP with SCP and SFTP. Today, SSH is the only sane way to remotely administer a Unix system; it is effectively universal on Linux and macOS, and increasingly standard on Windows.
This chapter teaches you:
Why cleartext protocols die — telnet, FTP and rsh compared
How SSH works — the connection sequence, key exchange, authentication
Telnet is a bare TCP connection. You type telnet example.com 23 and your keystrokes go straight into the network unencrypted. An attacker on the same network (or with access to your ISP’s routing) reads:
login: admin
Password: MySecureP@ss
FTP is the same — username and password in the clear. The attacker now has root.
SSH changed the model: before any credentials are sent, both sides prove they have shared secrets without revealing those secrets. The server proves its identity, the client proves theirs, and then they encrypt everything. All of this is auditable and automatic.
Imagine you and a friend each have a secret colour — say, you have blue and they have red. You both also have a public colour — say, yellow.
You mix your secret blue + public yellow → a unique shade you send to them.
They mix their secret red + public yellow → a unique shade they send you.
Now both of you mix the shade you received + your own secret colour.
Mathematically, you both arrive at the same final colour, even though you never shared either secret.
SSH uses the same idea with math (Diffie-Hellman or elliptic curves) instead of paint. Two strangers derive a shared secret while an eavesdropper watches every exchange and learns nothing.
Asymmetric encryption (RSA, Ed25519) is slow. Encrypting a gigabyte with an RSA key takes minutes. Symmetric encryption (AES) is fast — gigabytes per second.
So SSH does this:
Use asymmetric (public-key) to prove identity and securely agree on a session key. The server proves it is who it claims; the client proves it is authorized.
Use symmetric (AES-128-GCM, ChaCha20) to encrypt all the subsequent traffic. Fast, proven, standard.
Your private key never leaves your machine and is never sent to the server. It only ever signs messages, proving you have it without revealing it.
SSH (Secure Shell). A protocol (RFC 4251–4254) providing confidentiality, integrity and authentication over an insecure network. It comprises three layers:
Layer
Purpose
Mechanism
Transport
Encrypt the stream
TCP + chosen cipher (AES-GCM, ChaCha20) + MAC
Authentication
Prove identity
Public-key, password, or other methods
Connection
Open channels
Shell, command, port forward, SFTP subsystem
Confidentiality means an eavesdropper cannot read the plaintext. Integrity means a modified packet is detected and discarded. Authentication means you know you are talking to the claimed server and the server knows you are authorized.
Asymmetric (public-key) cryptography. A key pair where one key (private) is kept secret and the other (public) is shared. Anything encrypted with the public key can only be decrypted with the private key. In SSH, this is used for:
Host authentication — the server proves its identity by signing a challenge.
Client authentication — the client signs a challenge with its private key.
Symmetric (session) key cryptography. A single secret key shared between both sides. Fast; used for bulk encryption of the session after key exchange.
Key exchange (Kex). The process of both sides negotiating a shared session key without either sending the key itself. SSH supports Diffie-Hellman (over integers or elliptic curves) and now prefers the elliptic-curve variants.
Host key. The asymmetric key pair that identifies a specific server. Stored in /etc/ssh/ssh_host_*_key on the server. The public half is checked against ~/.ssh/known_hosts on the client to prevent man-in-the-middle (MITM) attacks.
sequenceDiagram
autonumber
participant C as Client<br/>(your laptop)
participant K as Kernel
participant S as SSH server<br/>(remote host)
C->>K: TCP connect to 192.168.1.50:22
K->>S: establish TCP connection
S-->>K: TCP established
K-->>C: TCP established
C->>S: "SSH-2.0-OpenSSH_9.3"
S-->>C: "SSH-2.0-OpenSSH_9.3"
Note over C,S: Version exchange (negotiate protocol version)
C->>S: key_init: propose ciphers, MACs, kex algorithms
S-->>C: key_init: agree on algorithms
Note over C,S: Key exchange (Diffie-Hellman or ECDH)
C->>S: [kex_ecdh_init with ephemeral public key]
S-->>C: [kex_ecdh_reply: server's ephemeral key + host key + signature]
Note over C,S: Both sides derive same session key; client verifies host key
C->>C: Check server's host key against ~/.ssh/known_hosts
alt Host key unknown or changed
C->>C: Prompt user: "Host key not in known_hosts. Accept? (yes/no/fingerprint)"
C->>S: DISCONNECT if user says no
end
Note over C,S: Transport layer now encrypted with session key
C->>S: [SSH_MSG_SERVICE_REQUEST ssh-userauth]
S-->>C: [SSH_MSG_SERVICE_ACCEPT]
Note over C,S: User authentication
C->>S: [SSH_MSG_USERAUTH_REQUEST: user=alice, method=publickey, pubkey_blob]
S-->>C: Check if user alice has this public key in authorized_keys
alt Public key matches
S-->>C: [SSH_MSG_USERAUTH_SUCCESS]
Note over C,S: Channel open; shell or command ready
C->>S: [SSH_MSG_CHANNEL_OPEN session]
S-->>C: [SSH_MSG_CHANNEL_OPEN_CONFIRMATION]
C->>S: [SSH_MSG_CHANNEL_REQUEST shell or exec]
S-->>C: PTY allocated / command executes
S-->>C: Output streaming over encrypted channel
else Public key not found or auth fails
S-->>C: [SSH_MSG_USERAUTH_FAILURE]
Note over C: Connection closed or retry with different key
end
When you SSH to a server for the first time, you see:
terminal
$ sshexample.com
The authenticity of host 'example.com (93.184.216.34)' can't be established.ED25519 key fingerprint is SHA256:8AV2FQ42SkZgqPDjd4...This key is not known by any system.Are you sure you want to continue connecting (yes/no/[fingerprint])?
What is actually happening:
The server sent its public key (an Ed25519 key in this case).
SSH computed its SHA256 fingerprint: 8AV2FQ42SkZgqPDjd4...
SSH checked /home/youruser/.ssh/known_hosts and found no entry for example.com.
SSH is asking: do you trust this key?
If you type yes, SSH appends the entry to known_hosts:
Next time you connect, SSH verifies the key matches. If it does not — e.g., someone redirected example.com to a different server — SSH aborts with:
terminal
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!Someone could be eavesdropping on you right now (man-in-the-middle attack)!
This is your only defence against MITM attacks. The server is also authenticating you (via keys), so credential theft is not possible.
It is encrypted with a passphrase you choose (or empty if you skip it). When you use the key:
ssh reads the file and decrypts it using your passphrase.
ssh signs a challenge issued by the server: sign(challenge, private_key).
The server checks the signature using your public key: verify(signature, challenge, public_key).
If it matches, you are authenticated. The private key was never sent.
The permission model: SSH will refuse loose permissions#
SSH has a philosophy: if the permissions are wrong, the key does not work, period. This prevents accidentally making your private key world-readable or making authorized_keys modifiable by untrusted users.
File / Directory
Required permission
Why
~/.ssh
700 (drwx------)
Only you can read, write, execute (enter)
~/.ssh/id_ed25519
600 (-rw-------)
Only you can read the private key
~/.ssh/id_ed25519.pub
644 (-rw-r–r–)
World-readable public key, only you write
~/.ssh/authorized_keys
600 (-rw-------)
Only you should modify who can log in
~/.ssh/known_hosts
600 (-rw-------)
Sensitive: fingerprints of servers you trust
~/.ssh/config
600 (-rw-------)
May contain credentials or private hostnames
If permissions are wrong, you get errors:
terminal
$ sshexample.com
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ WARNING: UNPROTECTED PRIVATE KEY FILE! @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@Permissions 0644 for '/home/alice/.ssh/id_ed25519' are too open.It is recommended that your private key files are not accessible by others.This private key will be ignored.
Fix it with chmod 600 ~/.ssh/id_ed25519. SSH will not use a key it considers unsafe.
You launch a cloud VM. The cloud provider gives you an IP address and a private key (usually a .pem file).
bash
# You have the key locally
ls-la~/Downloads/mykey.pem
# Fix permissions (the file is usually world-readable after download)
chmod600~/Downloads/mykey.pem
# SSH in, specifying the key
ssh-i~/Downloads/mykey.pemubuntu@203.0.113.42
You will see the host key prompt. Accept it (unless you have reason to suspect MITM).
terminal
The authenticity of host '203.0.113.42 (203.0.113.42)' can't be established.ED25519 key fingerprint is SHA256:AbCdEf...Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
You have a development server where you will work frequently. You want to avoid typing a password (or a passphrase) every time.
On your local machine:
bash
# Generate a new key pair for this server (optional, or reuse ~/.ssh/id_ed25519)
ssh-keygen-ted25519-C"alice@laptop-2024"# Generates ~/.ssh/id_ed25519 and ~/.ssh/id_ed25519.pub# Leave passphrase empty to avoid typing it on each `ssh`
Deploy the public key to the server:
bash
# Option A: use ssh-copy-id (recommended)
ssh-copy-id-i~/.ssh/id_ed25519.pubalice@192.168.1.50
# Option B: do it manually (if ssh-copy-id is not available)
cat~/.ssh/id_ed25519.pub|sshalice@192.168.1.50"mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
Test:
bash
sshalice@192.168.1.50
# No password prompt; you are logged in immediately
sshalice@192.168.1.50"ps aux | grep nginx | wc -l"# Output appears locally; connection closes after command finishes
Important: quote the command to prevent local shell expansion:
bash
# WRONG: $HOSTNAME is expanded locally
sshuser@remoteecho$HOSTNAME# Output: your local hostname, not the remote one# CORRECT: $HOSTNAME is expanded on the remote server
sshuser@remote'echo $HOSTNAME'# Output: the remote hostname
# Copy local file to remote
scp-P22/path/to/file.txtalice@192.168.1.50:/tmp/
# Note: -P (capital) for port on scp, not -p# Copy remote file to local
scp-P22alice@192.168.1.50:/var/log/syslog~/Downloads/
# Copy a directory recursively
scp-r-P22alice@192.168.1.50:/home/alice/project~/
Example 5: Production setup — deploy key with restricted permissions#
You have a CI/CD system that auto-deploys. You want to let it SSH to production servers, but only to run the deploy script, not to open a shell.
Generate a deploy key (no passphrase):
bash
ssh-keygen-ted25519-f~/.ssh/deploy_key-N""# ~/.ssh/deploy_key and ~/.ssh/deploy_key.pub
On the production server, add to authorized_keys with restrictions:
Your PostgreSQL server has no public IP. You can SSH to the web server that can reach it internally. You want to connect from your laptop to Postgres as if it were local.
bash
ssh-L5432:localhost:5432ubuntu@web.example.com
# Now, on your laptop:
psqlpostgres://localhost:5432/mydb
# Connection is tunnelled through SSH to the web server, then locally to Postgres
The -L flag syntax is -L [local_addr:]local_port:remote_host:remote_port.
# Backup /var/www from a server to local
rsync-avz--deletealice@backup.example.com:/var/www/~/backups/www/
# Flags:# -a (archive): recursive, preserve permissions, timestamps, symlinks# -v (verbose): list files as they are transferred# -z (compress): compress on the wire# --delete: remove files locally that do not exist remotely# (trailing slash matters: see Cheat Sheet)
The command to remember: ssh-keygen -t <type> -C <comment>
bash
# Best practice: Ed25519 (modern, small, fast, secure)
ssh-keygen-ted25519-C"alice@laptop-2024"
You will be prompted:
terminal
Generating public/private ed25519 key pair.Enter file in which to save the key (/home/alice/.ssh/id_ed25519):Enter passphrase (empty for no passphrase):Enter same passphrase again:Your identification has been saved in /home/alice/.ssh/id_ed25519Your public key has been saved in /home/alice/.ssh/id_ed25519.pubThe key fingerprint is:SHA256:aBcDeFgHiJkLmNoPqRsTuVwXyZ1234567890abcd alice@laptop-2024
Every option to ssh-keygen:
Option
Long form
Purpose
Example
-t
--type
Key type: ed25519, rsa, ecdsa, dsa
ssh-keygen -t ed25519
-b
--bits
Key size (RSA/ECDSA only)
ssh-keygen -t rsa -b 4096
-f
--file
Output file path
ssh-keygen -f ~/.ssh/deploy_key
-N
--new-passphrase
Passphrase (empty string = no passphrase)
ssh-keygen -N ""
-p
--change-passphrase
Change passphrase on existing key
ssh-keygen -p -f ~/.ssh/id_ed25519
-y
--show-key
Extract public key from private key
ssh-keygen -y -f ~/.ssh/id_ed25519
-l
--fingerprint
Print key fingerprint
ssh-keygen -l -f ~/.ssh/id_ed25519
-R
--remove-host
Remove host from known_hosts
ssh-keygen -R example.com
-o
--format
Use modern format (OpenSSH instead of PEM)
ssh-keygen -o -t rsa
-a
--rounds
KDF rounds for key encryption (higher = slower but more resistant to brute force)
~/.ssh/config lets you define host-specific settings so you never repeat them:
ini
# Default for all hostsHost *ServerAliveInterval 60Compression yesControlMaster autoControlPath ~/.ssh/control-%h-%p-%rControlPersist 600# Production serverHost prodHostName prod.example.comUser ubuntuPort 2222IdentityFile ~/.ssh/prod_keyIdentitiesOnly yesStrictHostKeyChecking accept-newUserKnownHostsFile ~/.ssh/prod_known_hosts# Through a bastionHost prod-internalHostName 10.0.1.50User ubuntuProxyJump bastion.example.com# Allow any hostname under *.internal via proxyHost *.internalProxyJump bastion.example.comUser ubuntu# Git over SSHHost github.comHostName github.comUser gitIdentityFile ~/.ssh/github_keyIdentitiesOnly yes
Now you can:
bash
sshprod# Uses prod settings
sshuser@prod.example.com:2222-p2222# Still works, but repetitive
sshprod-internal# Goes through bastion automatically
sshsome-app.internal# ProxyJump applies via pattern
Important config options:
Option
Value
Meaning
Host
pattern
This block applies to hostnames matching pattern
HostName
IP or hostname
Actual host to connect to
User
username
Remote username
Port
number
Remote port
IdentityFile
path
Private key to try
IdentitiesOnly
yes/no
Only use keys in IdentityFile; do not try agent or default keys
ProxyJump
hostname
SSH through this host first
ForwardAgent
yes/no
Allow remote host to use your agent (-A flag)
ServerAliveInterval
seconds
Keep-alive: send null packet every N seconds
ControlMaster
auto/yes/no/ask
Session multiplexing: reuse connections
ControlPath
path
Where to put the control socket for multiplexing
ControlPersist
yes/no/seconds
Keep control socket open after disconnecting
StrictHostKeyChecking
yes/accept-new/no
Behavior when host key changes: yes=fail, accept-new=add but do not change, no=no checking
AddKeysToAgent
yes/no/ask/confirm
Automatically add keys to agent on auth success
Compression
yes/no
Compress traffic (usually no, as ciphers are already fast)
A passphrase-protected key is great for security, but entering it for every command is tedious. The SSH agent caches decrypted keys in memory.
bash
# Start the agent (usually automatic in modern shells)eval"$(ssh-agent-s)"# or, in fish:eval(ssh-agent-c)# Add your key to the agent (you will be prompted for the passphrase once)
ssh-add~/.ssh/id_ed25519
# ssh-add: Identity added: /home/alice/.ssh/id_ed25519 (alice@laptop-2024)# List keys currently in the agent
ssh-add-l
# 256 SHA256:aBcD... alice@laptop-2024 (ED25519)# Remove a key from the agent
ssh-add-d~/.ssh/id_ed25519
# Remove all keys
ssh-add-D
Now, ssh will use the cached key without prompting. If your computer restarts, the agent is emptied; you will need to run ssh-add again.
You want to reach a service that is not publicly accessible. It is reachable from a machine you can SSH to.
bash
ssh-Llocal_port:internal_host:internal_portuser@ssh_host
# -L [bind_addr:]local_port:remote_host:remote_port# Example: access internal PostgreSQL through a jump host
ssh-L5432:db.internal:5432ubuntu@bastion.example.com
# Now, on your local machine:# psql postgresql://localhost:5432/mydb# The connection is tunnelled: local → SSH tunnel → bastion → db.internal:5432
Keep the SSH session open. To background it:
bash
ssh-f-N-L5432:localhost:5432ubuntu@bastion.example.com
# -f = background after auth# -N = do not execute a command
You want to expose a service on your local machine to a remote server that cannot reach you directly. You have outbound SSH access to that server.
bash
ssh-Rremote_port:localhost:local_portuser@remote_host
# Example: expose your local dev server to production bastion for testing
ssh-R8080:localhost:8080ubuntu@bastion.example.com
# Now, on bastion.example.com:# curl http://localhost:8080/# Connection is tunnelled: bastion → SSH tunnel → your local machine:8080
By default, the remote port is only accessible from the remote machine itself (localhost). To make it accessible to other machines on the remote network, set GatewayPorts yes in /etc/ssh/sshd_config on the remote host.
# Local to remote
scp/path/to/fileuser@host:/remote/path/
# Remote to local
scpuser@host:/remote/file/path/to/local/
# Remote to remote
scpuser1@host1:/path/fileuser2@host2:/path/
# Directory
scp-r/path/to/diruser@host:/remote/
Every important option:
Option
Purpose
Example
-P port
SSH port (note: capital P, unlike ssh)
scp -P 2222 file user@host:/
-r
Recursive (directories)
scp -r ~/project user@host:/
-C
Compression
scp -C largefile user@host:/
-p
Preserve file modification times and permissions
scp -p file user@host:/
-q
Quiet; suppress progress meter
scp -q file user@host:/
-i key
Private key
scp -i ~/.ssh/deploy_key file user@host:/
-3
Copy via local machine (not direct remote-to-remote)
scp -3 user1@host1:/a user2@host2:/b
-o option
SSH option (e.g. -o ConnectTimeout=10)
scp -o ConnectTimeout=10 file user@host:/
Real example — backup a remote file with compression:
rsync is the standard for moving large amounts of data efficiently. It uses rolling checksums to detect changes and only transfers differences, not whole files.
Recursive, preserve permissions/ownership/timestamps/symlinks — usually what you want
-v
--verbose
List files as transferred
-z
--compress
Compress on the wire (slower CPU, less bandwidth)
-h
--human-readable
Show file sizes in human form
-P
--partial --progress
Show progress bar; keep partial files if interrupted
-n
--dry-run
Do not transfer; show what would happen
--delete
—
Remove files from destination that do not exist in source
--delete-after
—
Delete after transfer (safer; you can see what is deleted)
--exclude PATTERN
—
Skip files matching PATTERN
--exclude-from FILE
—
Exclude files listed in FILE
--include PATTERN
—
Include files (useful with --exclude for complex rules)
-e 'ssh -p 2222'
—
Use custom SSH (different port, key, etc.)
--bwlimit KB
—
Rate limit (KB per second)
--checksum
—
Use checksum instead of modification time and size
--size-only
—
Skip files if size matches (do not check time)
--link-dest DIR
—
Hard-link unchanged files from previous backup (snapshot backup)
--numeric-ids
—
Preserve UID/GID numerically (useful across different systems)
The trailing slash rule — critical:
bash
# Case 1: no trailing slash on source
rsync-av~/projectuser@host:/dest/
# Copies ~/project ITSELF into /dest/ → /dest/project/# Case 2: trailing slash on source
rsync-av~/project/user@host:/dest/
# Copies CONTENTS of ~/project into /dest/ → /dest/[contents]
This is subtle and the source of many mistakes. Remember: trailing slash on source means copy contents; no trailing slash means copy the directory itself.
Dry-run before --delete:
bash
# ALWAYS do this first
rsync-avnz--deletesourcedest
# -n = dry run, show what would be deleted# Only if the output looks right:
rsync-avz--deletesourcedest
Real example — nightly hardlinked backup:
bash
#!/bin/bashBACKUP_DIR=/mnt/backups
LATEST=$BACKUP_DIR/latest
DATED=$BACKUP_DIR/backup-$(date+%Y-%m-%d-%H%M%S)# First backup (no hard-link)if[!-d"$LATEST"];thenrsync-avz--delete~/data/"$LATEST/"else# Subsequent backups: hard-link unchanged filesrsync-avz--delete--link-dest="$LATEST"~/data/"$DATED/"ln-sfn"$DATED""$LATEST"fi
This creates incremental backups where unchanged files are hard-linked (taking no additional space).
Beginner — Why is SSH better than telnet?
Telnet sends everything in cleartext: usernames, passwords, commands, output. An eavesdropper on the network reads it all. SSH encrypts the session, proves both sides' identity using cryptography, and lets you authenticate with keys instead of passwords. Telnet is 1980s technology; SSH is the only sane choice for remote admin.
Beginner — What is the difference between a private key and a public key?
A private key is secret (kept on your machine) and proves your identity when you sign a challenge. A public key is shared widely and verifies that a signature could only come from someone with the corresponding private key. In SSH, the server has your public key in `authorized_keys`, and your client uses the private key to prove you are you. The private key is never sent to the server.
Beginner — Which file holds the list of servers you have connected to?
`~/.ssh/known_hosts`. It contains entries like `example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...`. Each line is `hostname key_type public_key_blob`. SSH uses this to verify that the server is the one you expect, preventing MITM attacks.
Beginner — What does `ssh-keygen -t ed25519` do?
Generates a new Ed25519 key pair: a private key (usually `~/.ssh/id_ed25519`) and a public key (`~/.ssh/id_ed25519.pub`). Ed25519 is modern, fast, and secure. You can optionally protect the private key with a passphrase.
Intermediate — Why does SSH check the host key against `~/.ssh/known_hosts`?
To prevent man-in-the-middle attacks. If an attacker redirects your connection to their machine, they will present a different host key. SSH compares it against what you saw before; if it does not match, SSH aborts with "REMOTE HOST IDENTIFICATION HAS CHANGED." This is the only practical defence against MITM in SSH; if the host key changes, you must investigate.
Intermediate — What do the permissions `600` on `~/.ssh/id_ed25519` protect against?
If the permissions are looser (e.g. `644`), other users or processes on the same machine could read your private key and impersonate you. SSH refuses to use keys with loose permissions, enforcing that only the owner can read the private key. This is why SSH will not log in and tells you to run `chmod 600`.
Intermediate — How does `ssh-agent` improve usability without sacrificing security?
You set a strong passphrase on your private key. The first time you use it, you type the passphrase once; `ssh-agent` decrypts the key and caches it in memory. Subsequent SSH commands use the cached key without prompting. If your machine restarts, the agent is cleared; you enter the passphrase again. This balances security (strong passphrase) with usability (no re-typing).
Intermediate — What is the difference between `-L` and `-R` in SSH port forwarding?
`-L` (local forward) opens a listening port on your machine and tunnels connections through to a remote internal service: `ssh -L 5432:db:5432 bastion` makes port 5432 local reachable to a DB on the internal network. `-R` (remote forward) opens a listening port on the remote machine and tunnels back to you: `ssh -R 8080:localhost:8080 remote` lets the remote server reach your local port 8080. One reaches inward; the other exposes outward.
Intermediate — If a file size and modification time match, does rsync skip it?
By default, yes. rsync uses the `size-only` or modification time comparison, not checksums. If you fear a file was corrupted but kept the same size and timestamp, use `--checksum` to compare content hashes. This is slower but guarantees correctness.
Advanced — Walk me through the SSH connection sequence from TCP to authenticated session.
1. TCP connect on port 22.
2. Both sides send version strings (SSH-2.0).
3. Both sides send `SSH_MSG_KEXINIT` listing supported algorithms.
4. Key exchange (Diffie-Hellman or ECDH): both sides derive a shared session key without sending it.
5. Each side computes a hash of the exchange; the server signs it with its host key.
6. The client retrieves the server's host key and verifies the signature. It also checks the host key against `~/.ssh/known_hosts`.
7. If the host key is unknown or changed, the client asks the user and either continues or disconnects.
8. Transport layer switches to using the session key with the negotiated cipher (AES-GCM, ChaCha20) and MAC.
9. Client sends `SSH_MSG_SERVICE_REQUEST` for user authentication.
10. Client sends `SSH_MSG_USERAUTH_REQUEST` with username and public-key signature (or password).
11. Server checks if the public key (or password) is authorized and sends success or failure.
12. If success, the client can open a channel for a shell or command.
Advanced — Your SSH login hangs and eventually times out. What do you check?
1. Is the host reachable? `ping -c 1 example.com`.
2. Is the SSH port open? `nc -zv example.com 22` or `telnet example.com 22`.
3. Are you using the right username? `ssh -l alice example.com`.
4. Do you have network connectivity? `ip route`, `cat /etc/resolv.conf`.
5. Is the server's SSH daemon running? Log into it another way (console, different network, etc.) and check `systemctl status ssh`.
6. Is the firewall blocking? Check `sudo iptables -L` or cloud security group.
7. Are you behind a proxy? `ssh -vvv example.com` shows where it hangs.
8. Does `~/.ssh/config` have a `ProxyJump` that itself is unreachable? Test each hop.
Advanced — How does rsync's rolling checksum and delta-transfer algorithm save bandwidth?
rsync does not transfer whole files. It splits large files into fixed-size blocks (typically 4 KB), computes a rolling hash for each block, and sends these hashes to the remote. The remote compares its hashes; for matching blocks, rsync sends nothing. For non-matching blocks, rsync sends the bytes that differ. This means a 1 GB file with a 1 KB change transfers only ~1 KB (plus metadata), not the whole file. The algorithm is complex (using weak hashes for speed and strong hashes for correctness), but the upshot is: incremental backups and large dataset syncs are orders of magnitude faster.
Advanced — What is the security implication of `ssh -A` (agent forwarding) to an untrusted server?
When you use `-A`, the remote server can read your SSH agent socket and use your keys to authenticate to any other server your agent knows about. If the remote server is compromised, an attacker gains the ability to impersonate you to all your other systems. This is not a weakness in SSH; it is the intended behaviour, but it means you must trust the remote server completely. For multi-hop scenarios, prefer `ProxyJump` or `-J`, which do not expose your agent.
Scenario — You deploy a CI/CD pipeline that needs to SSH to production servers. The pipeline has a private key, but you want to restrict what it can do. How do you set it up?
1. Generate a deploy key: `ssh-keygen -t ed25519 -f ~/.ssh/deploy_key -N ""` (no passphrase, since the CI system will use it).
2. On each production server, add a restricted entry to `~/.ssh/authorized_keys`:
```
command="/opt/deploy.sh",no-port-forwarding,no-X11-forwarding,no-agent-forwarding ssh-ed25519 AAAAC3... ci-deploy@build-001
```
3. Now, when the CI pipeline SSH's to the server, it can only run `/opt/deploy.sh`, not open a shell, and cannot forward ports or agents.
4. The pipeline stores the private key securely (in a secret store, not in the repo).
Scenario — Your `known_hosts` file shows "REMOTE HOST IDENTIFICATION HAS CHANGED." Is this always an attack?
No, but investigate. Innocent causes:
1. The server's host key was regenerated (e.g. after an OS reinstall).
2. The IP address was reassigned to a different machine.
3. DNS misconfiguration (different IPs resolve to the same name now).
4. You are behind a proxy that is now presenting a different key.
**Dangerous cause:**
- A man-in-the-middle attack is redirecting your connection.
**Three-step response:**
1. Do not ignore it. Temporarily SSH to the host via a different route (console, second network, etc.) and check the host key. You can get it with `ssh-keyscan example.com`.
2. If the key is legitimate, remove it from `known_hosts`: `ssh-keygen -R example.com`.
3. SSH again; accept the new key.
**Hardening:** In production, use `StrictHostKeyChecking=accept-new` in config, which accepts new keys but still warns on changes. For scripted deploys, pre-populate `known_hosts` during server provisioning.
Company style — We have a 10-minute session timeout. How do you keep SSH alive?
Set `ServerAliveInterval` in your `~/.ssh/config`:
Host *
ServerAliveInterval 60
This sends a keep-alive packet every 60 seconds (adjust to be before the timeout). The connection stays open even if there is no activity. Alternatively, on the server, set `ClientAliveInterval 300` and `ClientAliveCountMax 2` in `/etc/ssh/sshd_config` (300 seconds, 2 missed packets before disconnect).
HR style — Describe a time you had to troubleshoot an SSH connection that was failing.
A specific example: "A deployment script was failing because `ssh user@prod.internal` timed out. I ran `ssh -vvv user@prod.internal` to see debug output. The logs showed it was trying to use a key from the agent, but the key was not authorized on the server. I checked `~/.ssh/authorized_keys` and found the key was there, but the permissions were wrong (world-readable). I `chmod 600`'d it, and the connection worked. This taught me to always check permissions first, and to always run `-vvv` on connection issues." The lesson: systematic debugging, and knowing which tool (`ssh-add -l`, `ls -la`, `-vvv`) answers each question.
SSH encrypts remote login and file transfer, replacing cleartext telnet/FTP with authenticated, encrypted sessions.
The connection sequence is: TCP → version exchange → key exchange (deriving shared session key) → host key verification → user authentication → encrypted channel.
Your private key proves your identity; it is never sent to the server, only used to sign a challenge the server issues.
~/.ssh/known_hosts prevents MITM attacks by checking the server’s host key matches what you saw before; if it changes, SSH aborts with a warning.
Ed25519 is modern, fast and small; use it for new keys; RSA 4096 is acceptable for legacy systems; DSA and RSA 2048 are obsolete.
File permissions on ~/.ssh/ (700) and private keys (600) are not optional — SSH refuses to use keys or read files with loose permissions.
ssh-agent caches decrypted keys in memory so you enter your passphrase once per session, not once per command.
SCP is one-shot; SFTP is interactive; rsync is for large syncs and incremental backups, using rolling checksums to transfer only changed blocks.
The trailing slash on rsync source changes the copy: with slash, copy contents; without slash, copy the directory.
Port forwarding (-L, -R, -D) lets you reach internal services, expose local ones, or use a SOCKS proxy through SSH; ProxyJump is safer for multi-hop than agent forwarding.
Which command generates a new Ed25519 key pair? (a)ssh-keygen -t rsa(b)ssh-keygen -t ed25519(c)ssh-key-create ed25519(d)ssh ed25519 key-gen
What is the correct permission for ~/.ssh/id_ed25519? (a) 644 (b) 755 (c) 600 (d) 777
If authorized_keys is world-writable, SSH will: (a) accept it and warn (b) use it but log an error (c) refuse to read it, denying login (d) strip world permissions automatically
What does ssh -vvv user@host provide? (a) Three simultaneous connections (b) Very verbose debug output (c) Triple encryption (d) Three parallel port forwards
The SSH host key is checked against: (a)/etc/ssh/ssh_config(b)~/.ssh/known_hosts(c)/etc/ssh/sshd_config(d)~/.ssh/authorized_keys
scp -P port uses capital -P because: (a)scp is old and predates convention (b) lowercase -p means preserve time/perms (c) OpenSSH designers wanted it different (d)-p is reserved for another protocol
For rsync, --dry-run with --delete is important because: (a) it prevents actual deletion on first run (b) it shows what would be deleted so you verify (c) it reverses the operation (d) it runs in “safe mode”
If you use ssh -A to a compromised server, an attacker can: (a) only read your SSH keys (b) impersonate you to other systems your agent knows about (c) do nothing — agent forwarding has no risks (d) access your terminal history
ssh -L 5432:db:5432 bastion — what does db resolve as? (a) your local machine (b) the bastion server’s perspective (internal network) (c) a public DNS name (d) must be an IP address
The trailing slash in rsync ~/src/ host:/dst/ means: (a) copy the src directory (b) copy src’s contents; do not create src/ on remote (c) use relative paths (d) do not follow symlinks
Answers
1. (b) — `ssh-keygen -t ed25519`.
2. (c) — 600 (owner read/write only).
3. (c) — SSH refuses to read files with loose permissions.
4. (b) — Very verbose debug output.
5. (b) — `~/.ssh/known_hosts`.
6. (b) — lowercase `-p` preserves file modification times and permissions in scp.
7. (b) — shows what would be deleted; prevents accidents.
8. (b) — agent forwarding allows the remote to use your cached keys.
9. (b) — from the bastion's perspective, it resolves via the internal network or hosts file.
10. (b) — trailing slash on source copies contents; without it, copies the directory itself.
Your SSH private key must always be protected with a passphrase.
SSH will refuse to use a private key with permissions 644.
The server stores a copy of your private key in authorized_keys.
ssh -p 22 -i key user@host and scp -P 22 -i key file user@host:/ use the same port flag syntax.
If the host key in known_hosts changes, SSH always aborts; it never connects.
rsync transfers entire files, even if only one byte changes.
The trailing slash in rsync makes a difference: src/ vs src copy different things.
Agent forwarding (-A) is always safe because SSH has built-in protections.
Deploy keys should be passphrased so CI systems do not accidentally expose them.
StrictHostKeyChecking=accept-new adds new host keys but warns if they change.
Answers
1. **False** — a passphrase is recommended but not required. You can generate keys with no passphrase for automation.
2. **True** — SSH refuses permissions looser than 600 on private keys.
3. **False** — the server stores your *public* key in `authorized_keys`. Private keys never leave your machine.
4. **False** — `ssh` uses lowercase `-p` and `scp` uses capital `-P`.
5. **False** — if the key is unknown, SSH prompts; if it changed, it warns but you can investigate and continue.
6. **False** — rsync uses rolling checksums to detect changed blocks and only transfers those.
7. **True** — this is the critical trailing slash rule.
8. **False** — if a compromised remote server can access your agent socket, it can impersonate you to other systems.
9. **False** — deploy keys should have *no* passphrase (or a passphrase in a secret store) so CI systems can use them unattended.
10. **True** — `accept-new` adds unknown keys; still warns on changes.
Do these on a lab VM (or two VMs for multi-hop scenarios).
Generate a key pair. Run ssh-keygen -t ed25519 -C "lab@test" and store it in ~/.ssh/lab_key. Set a passphrase. Then view the public key with ssh-keygen -y -f ~/.ssh/lab_key and verify it matches ~/.ssh/lab_key.pub.
Deploy a public key to another machine. On a second VM, run ssh-copy-id -i ~/.ssh/lab_key alice@192.168.1.X. SSH back without a password to verify. Check ~/.ssh/authorized_keys on the remote to see your key was added.
Manually break and fix permissions.chmod 644 ~/.ssh/lab_key and try ssh -i ~/.ssh/lab_key alice@192.168.1.X. Observe the error. Fix it: chmod 600 ~/.ssh/lab_key. Retry and confirm it works.
Host key mismatch simulation. Add a bogus entry to ~/.ssh/known_hosts for a server you will SSH to. Then SSH to it and observe “REMOTE HOST IDENTIFICATION HAS CHANGED.” Remove it with ssh-keygen -R and retry.
Port forwarding to an internal service. On VM2, run a simple HTTP server: python3 -m http.server 8000. From VM1, use ssh -L 9000:localhost:8000 user@vm2, then curl localhost:9000. Confirm you reach VM2’s service through the tunnel.
SFTP batch operations. Create a file with SFTP commands:
cd /tmp
put ~/file.txt
quit
Run sftp -b cmds.txt user@host and verify the file was uploaded.
Rsync with dry-run and --delete. Sync a directory, then delete a file from the source. Run rsync -avnz --delete source user@host:/dest and observe which files would be deleted. Verify correctness, then remove -n and sync for real.
SSH multiplexing (ControlMaster). Add to ~/.ssh/config:
Host *
ControlMaster auto
ControlPath ~/.ssh/control-%h-%p-%r
ControlPersist 600
Then run ssh user@host twice in quick succession; the second should be instant (reusing the first connection).
You have a CI/CD system that needs to deploy to 10 production servers. Design a secure key distribution strategy: which key type, passphrase policy, and authorized_keys restrictions would you use to minimize blast radius if one CI system is compromised?
A teammate is syncing a large codebase with rsync but occasionally files disappear from the destination. Investigate: what questions would you ask, and how would you design a safer sync procedure?
You need to SSH to a server behind a NAT; you only have outbound SSH access from the NAT-ed server to your bastion. Design a reverse tunnel setup (using -R) that lets you SSH inward. What are the security implications?
Your team is migrating terabytes of data between two data centres. Design an rsync command with intermediate verification, compression, rate limiting, and recovery from interruption. Write a shell script that resumes if interrupted.
Investigate the “REMOTE HOST IDENTIFICATION HAS CHANGED” scenario: research the three innocent causes and the one dangerous one. Write a decision tree for what to do in each case.
Set up SSH agent forwarding through three servers (A → B → C) and demonstrate where the agent socket is accessible and where it is not. Explain the security boundary.
Harden /etc/ssh/sshd_config on a test VM: disable password auth, set specific algorithms, rate-limit login attempts, and configure logging. Verify each setting with sshd -t.
Compare SCP vs SFTP vs rsync on a test VM by transferring a large file with 1% changed, then 10% changed, then 50% changed. Measure bandwidth and time for each tool.
Write a monitoring script that alerts if someone logs into a server via SSH with a password (using grep on /var/log/auth.log). Design it to be robust against log rotation.
Design an SSH bastion architecture for a company with 200 production servers: key distribution, audit logging, fail2ban rules, and CI/CD access patterns.
V — System Administration
13Users, Permissions, ACLs & sudo
How Linux keeps multiple users and programs isolated from each other, and how to grant privileges without surrendering your machine.
You are not the only user on a Linux system. Even on a laptop you control completely, there is root, system daemons running as unprivileged users, maybe a development user and a production user. When three users exist on one machine, three questions become urgent:
Who is trying to do this? (Identity)
What are they trying to do? (Action)
What does the target resource allow? (Permission)
The answers to these three questions make the difference between a system that isolates work (a web server cannot read your SSH keys; a compromised service cannot become root) and a system where one vulnerability spreads everywhere. Permissions are not a security feature that companies choose to use — they are infrastructure, like power distribution. Get them wrong and the system does not work.
Imagine two users, Alice and Bob, on a machine with a single shared disk and no permission system. Alice writes a file. Bob can read it, modify it, or delete it. If Bob runs a buggy script that runs rm -rf /home, Alice’s data is gone and Alice cannot stop him. If a web server Alice runs gets compromised by a remote attacker, the attacker has all of Alice’s privileges and can read Bob’s documents and SSH keys.
A permission system solves this by answering three questions at the kernel level:
Permission checking — the kernel's three questions
User alice requests: open("/home/bob/secret", O_RDONLY)
│
┌───────────┴───────────┐
│ │
1. WHO? 2. WHAT? 3. WHAT'S ALLOWED?
UID 1000 = alice read from file file owner: bob
mode: 600 (rw-------)
UID 1000 vs owner UID 1001
│ │
└───────────┬───────────┘
│
KERNEL DECISION
│
┌───────────┴───────────┐
│ │
DENY ALLOW
return -EACCES fd = open()
Every file on a Linux system is protected by this three-question gate.
Only the owner can read ~/.ssh/id_ed25519 (mode 600). SSH will refuse to use a key with wrong permissions. Bad permissions = lockout
Web server isolation
nginx runs as the www-data user, can read /var/www but not /root. A remote exploit cannot become root or read admin SSH keys
Container images
A Dockerfile specifies which user the app runs as (USER 1000), and volumes mount with restricted ownership to prevent privilege escalation
CI/CD pipelines
A build runner runs as an unprivileged user; it can read source but not write to /etc or package repos. Secrets are injected at runtime as files only that user can read
Sudo audit trail
Every sudo invocation is logged by UID, command, and outcome; it is the entire audit model for who did what as root
Kubernetes RBAC
Role-based access control for who can run kubectl and what they can do — built directly on the same “who/what/allowed” model
Compliance — SOC 2, PCI, HIPAA all require “strong access controls”. Permissions are the first line.
Incident response — when a web server is compromised, logs show what the attacker read and wrote, and the permission system contained them to that user’s files, limiting exposure.
Team isolation — developers, DBA, infra teams can share a machine with zero visibility into each other’s work.
DevOps automation — infrastructure code runs with minimal permissions; if it is compromised, the blast radius is bounded.
Imagine an office building with five floors and 20 employees.
User (UID) — each employee has a unique ID badge
Group (GID) — marketing team, engineering team, executive team — some people belong to multiple groups
File mode — a door has three locks:
owner lock — only the key owner carries
group lock — only members of that group’s team carry
other lock — a generic lock anyone can use
Read — you can go through the door and see the office
Write — you can move furniture, write on the whiteboard, throw things away
Execute — on a file, it means “run this code”; on a directory, it means “walk through and access files inside”
If you are the owner and the owner lock is off, you enter. If you are not the owner but in the group and the group lock is off, you enter. Otherwise you check the “other” lock. If none apply, the door stays locked.
In the building analogy, employees are not allowed to enter the executive floor. But sometimes an employee must, and they carry a permission slip signed by a manager:
“This employee, Alice, has permission to:
- Restart the web server”
Alice shows the slip to the security guard (the kernel). If her name is on it and it is signed, she gets in.
sudo works the same way. It is not a command that runs as root; it is a permission check. If your name is in /etc/sudoers, you can ask to become root (or another user) to run a specific command, and the kernel enforces it.
Analogy 3: ACLs as a more expressive permission slip#
A standard UNIX permission is: owner, one group, and others.
ACLs say: “This file is readable by Alice, Bob, and the marketing group, writable by Alice only, executable by no one.” That is more granular than a single group.
User (UID — User ID). A number between 0 and 4,294,967,295 that uniquely identifies a user on the system.
UID range
Meaning
Example
0
root — full kernel privilege
The superuser
1–999
System/service users — reserved for daemons
www-data (nginx), mysql, postgres
1000+
Regular/human users
alice, bob, on a fresh install 1000 is the first interactive user
Primary group and supplementary groups. Every user has a primary group (one GID) set at account creation. Additionally, a user can be a member of any number of supplementary groups.
Alice’s primary group is 1000 (alice). She is also a member of sudo (GID 4) and adm (GID 27). If a file is owned by group sudo, Alice can access it because she is in that group.
Special case: the root user. UID 0 is root. Running as root bypasses all permission checks. Never run as root unless a specific command requires it.
The user database: /etc/passwd, /etc/shadow, /etc/group, /etc/gshadow#
/etc/passwd — the public user database. Readable by everyone. Format:
name:x:UID:GID:GECOS:home_directory:shell
Field breakdown:
Field
Meaning
Example
name
Login name (1–32 chars, alphanumeric + underscore)
alice
x
Historically the password hash; now always x — hash moved to /etc/shadow for security
x
UID
Numeric user ID (0 = root, 1–999 = system, 1000+ = human)
1000
GID
Numeric primary group ID
1000
GECOS
Full name, office, phone — arbitrary comment field (originally “General Electric Comprehensive Operating System”)
Alice Smith,Room 42,555-1234
home_directory
Path to user’s home; shell scripts and apps use $HOME
/home/alice
shell
Login shell — the program the user gets after SSH or login
You can list entries: ls dir works, you see filenames
w (write)
You can create, delete, or rename entries inside — touch dir/newfile, rm dir/oldfile, mv dir/a dir/b all require write permission on the directory, not the file
x (execute)
You can traverse the directory — cd dir and access dir/file if you have permission on the file. Without x, you cannot enter, even with r
Example: why you can delete a read-only file in a directory you own
Why? Because rm checks permission on the directory (testdir), not the file. You own the directory and have write permission, so you can delete entries in it. The file being read-only is irrelevant. This is deliberate: it prevents a user from hostaging a directory by creating undeletereable files.
Example: why you cannot access a file in a directory without execute on the directory
terminal
$ mkdirnoexec
$ touchnoexec/file
$ chmod755noexec# owner rwx, group r, other r — but no x$ catnoexec/file
cat: noexec/file: Permission denied
Why? Because you need x on the directory to traverse into it. With only r, you can see the filename but not access the inode.
chmodu=rwx,go=rxdir# owner rwx, group r-x, other r-x (same as 755)
chmodgo-wfile# remove write from group and other
chmoda-xscript# remove execute from everyone
chmod+x/usr/local/bin/myscript# add execute for owner (if not set), group (if set), other (if set) — varies
chmodu=rw,g=r,o=file# owner rw, group r, other nothing
Capital X (smart execute):
bash
chmod-R+Xdir# add execute only to directories, not files
This is useful: you want 755 on directories (so users can enter) but 644 on files (executable only if already marked as such). +X does this in one pass.
$ ls-ltestfile
-rw-r--r-- 1 alice alice 0 Feb 1 12:00 testfile$ chmod755testfile
$ ls-ltestfile
-rwxr-xr-x 1 alice alice 0 Feb 1 12:00 testfile$ chmodgo-wtestfile
$ ls-ltestfile
-rwxr-xr-x 1 alice alice 0 Feb 1 12:00 testfile # already removed$ chmodu=rw,g=r,o=testfile
$ ls-ltestfile
-rw-r----- 1 alice alice 0 Feb 1 12:00 testfile$ chmod-v644testfile
mode of 'testfile' changed from 0640 to 0644 (rw-r--r--)
Recursive with verbose and changes:
terminal
$ chmod-R-v-c755/tmp/test
mode of '/tmp/test' changed from 0755 to 0755mode of '/tmp/test/dir1' changed from 0755 to 0755mode of '/tmp/test/file1' changed from 0644 to 0755'/' skipped (ELOOP)
The -c flag suppresses lines that did not change; -v shows all.
Recursive best practice: handle files and directories separately to avoid making files executable:
This sets all directories to 755 and all files to 644 — the standard mode. (The + is an optimization: it groups multiple files into one command instead of spawning chmod once per file.)
Change owner to username; group to that user’s primary group
Real examples:
terminal
$ ls-ltestfile
-rw-r--r-- 1 alice alice 1024 Feb 1 testfile$ chownroottestfile
$ ls-ltestfile
-rw-r--r-- 1 root alice 1024 Feb 1 testfile$ chownbob:developerstestfile
$ ls-ltestfile
-rw-r--r-- 1 bob developers 1024 Feb 1 testfile$ chown-vbob:developers/home/alice/project
ownership of '/home/alice/project' changed from alice:alice to bob:developers
Recursive: changing ownership of a shared project directory
terminal
$ ls-ld/srv/project
drwxr-xr-x 1 admin admin 4096 Feb 1 /srv/project$ chown-R-v-cadmin:developers/srv/project
ownership of '/srv/project' changed from admin:admin to admin:developersownership of '/srv/project/src' changed from admin:admin to admin:developersownership of '/srv/project/src/main.py' changed from admin:admin to admin:developers... (output continues)
Important: only root can change ownership. Even if you own a file, you cannot chown it to another user to prevent users hiding files from admins.
terminal
$ chownalicetestfile
chown: changing ownership of 'testfile': Operation not permitted
Exception: a user can change owner to themselves (that is, chown alice:alice when owner is already alice has no effect but no error).
When you run a setuid binary, the process runs with the UID of the file owner, not your UID.
Use case: passwd command
Users must change their own password, but passwords are stored in /etc/shadow which is readable/writable only by root. How can a non-root user write their password?
When setgid is set on a directory, new files created inside inherit the directory’s group, not the creator’s primary group.
Use case: shared project directory
A team wants to share code in /srv/project where anyone in the developers group can create and edit files, and all files belong to the developers group.
Setup:
bash
groupadddevelopers
usermod-aGdevelopersalice
usermod-aGdevelopersbob
mkdir-p/srv/project
chgrpdevelopers/srv/project
chmod2775/srv/project
umask002# or set in a .bashrc for developers
What this does:
groupadd developers — create the group
usermod -aG developers alice — add alice to the developers group (as a supplementary member; -a means append, not replace)
mkdir -p /srv/project — create the directory
chgrp developers /srv/project — set the group owner
chmod 2775 /srv/project — set mode and setgid:
Owner (admin): rwx
Group (developers): rwx
Other: r-x
Setgid: 2
umask 002 — remove write from “other”, keeping group writable
Now alice creates a file:
terminal
$ cd/srv/project
$ touchmyfile
$ ls-lmyfile
-rw-rw-r-- 1 alice developers 0 Feb 1 myfile ↑ group is "developers", not "alice"
Why? Because:
alice has write on the directory (passes permission check)
The directory has setgid, so the new file gets the directory’s group (developers)
The umask 002 leaves group write, so file is 664 (rw-rw-r–)
Without setgid, the file would be:
-rw-r--r-- 1 alice alice 0 Feb 1 myfile
Bob also in the group can now edit alice’s file:
terminal
$ bob@host:/srv/project$echo"new content">>myfile
$ ls-lmyfile
-rw-rw-r-- 1 alice developers 0 Feb 1 myfile
This is the standard shared project recipe: setgid + umask 002 + supplementary groups.
Capital S means setgid without execute (unusual; usually means a bug).
sticky bit (1000): only owner/directory owner/root can delete#
The sticky bit restricts deletion. Normally, anyone with write on a directory can delete files in it. Sticky bit says: only the file owner, the directory owner, or root can delete an entry.
Use case: shared /tmp
/tmp must be writable by everyone. But you do not want alice to delete bob’s temporary files, or vice versa. Solution: setgid + sticky.
terminal
$ ls-ld/tmp
drwxrwxrwt 15 root root 4096 Feb 1 17:31 /tmp ↑↑↑ setgid + sticky + rwx all
Mode 1777:
1 = sticky
7 = owner rwx
7 = group rwx
7 = other rwx
Now alice creates /tmp/file1:
terminal
$ alice@host:/tmp$touchfile1
$ ls-lfile1
-rw-r--r-- 1 alice alice 0 Feb 1 file1
Bob tries to delete it:
terminal
$ bob@host:/tmp$rmfile1
rm: cannot remove 'file1': Operation not permitted
Why? Bob has write on /tmp (group and other get rwx), but bob is neither the file owner, the directory owner (root), nor root himself, so deletion fails.
mask:: — the effective permissions mask. If you set a specific permission, the mask is updated to the logical OR of all non-owning entries. The mask silently limits effective permissions. #effective: annotations show what actually takes effect:
This removes:
- User entry from /etc/passwd and /etc/shadow
- Primary group (if it matches the username)
- Home directory /home/alice
- Mail spool /var/mail/alice
Without -r, only the user entry is removed; the home directory remains orphaned (owned by the deleted UID).
Example: provisioning two users with shared directory#
bash
# Create the group
groupadddevelopers
# Create two users
useradd-m-s/bin/bash-Gdevelopersalice
useradd-m-s/bin/bash-Gdevelopersbob
# Set passwords
passwdalice
passwdbob
# Create shared directory
mkdir-p/srv/project
chgrpdevelopers/srv/project
chmod2775/srv/project
setfacl-d-mg:developers:rwx/srv/project
umask002# Verify
ls-ld/srv/project
getfacl/srv/project
idalice
idbob
Now alice and bob can create and edit files together.
usermod-aGnewgroupalice# add alice to newgroup (-a = append, critical!)
usermod-lnewnamealice# rename alice to newname
usermod-d/new/homealice# change home directory
usermod-s/bin/shalice# change shell
usermod-e2026-12-31alice# set expiry date
usermod-Lalice# lock account (same as passwd -l)
usermod-Ualice# unlock
groupadd / groupdel / groupmod — manage groups:
bash
groupadddevelopers# create group with auto-assigned GID
groupadd-g1500infra# create group with specific GID
groupdeldevelopers# delete group (fails if users have it as primary)
groupmod-g1600infra# change group GID
groupmod-nnewinfinfra# rename group
gpasswd — manage group membership:
bash
gpasswd-aalicedevelopers# add alice (same as usermod -aG)
gpasswd-dalicedevelopers# remove alice
gpasswd-Malice,bobdevelopers# set members (replaces all)
newgrp — switch primary group in current shell:
bash
newgrpdevelopers
Then files created have developers as primary group (and alice:developers ownership in ls -l).
You are root, but $PATH and other environment variables are wrong (you have alice’s PATH, not root’s). This is dangerous — which vi might find alice’s vi (which could be malicious).
Non-interactive (fail if password required; useful for scripts)
sudo -l — list what you may run
terminal
$ sudo-l
User alice may run the following commands on this host: (root) NOPASSWD: /usr/bin/systemctl restart nginx (root) /usr/sbin/useradd (ALL) ALL
This shows:
- alice may restart nginx without a password
- alice may run useradd with a password prompt
- alice may run any command as any user (full sudo access)
If sudo is not configured for you:
terminal
$ sudo-l
Sorry, user alice is not allowed to run sudo on this host.
sudo -i vs sudo -s:
bash
sudo-i# full login shell (load /root/.profile, set $HOME=/root, etc.)
sudo-s# shell without login (simpler environment)
Examples:
bash
sudoaptupdate# update package list as root
sudo-uwww-datawhoami# run as www-data (output: www-data)
sudo-ualicesu-bob# run as alice, inside become bob (nested)
sudo-l# show permissions
sudo!!# rerun previous command as root
This requires:
- Minimum 12 characters
- At least 1 digit (dcredit=-1)
- At least 1 uppercase (ucredit=-1)
- At least 1 lowercase (lcredit=-1)
- At least 1 special character (ocredit=-1)
- 3 retries if weak
AppArmor is a simpler alternative to SELinux, using profiles instead of a system-wide policy.
Check status:
bash
sudoaa-status
Output:
apparmor module is loaded.
28 profiles are in enforce mode.
0 profiles are in complain mode.
9 processes have profiles defined.
28 processes are in enforce mode.
0 processes are unconfined but have a profile defined.
Each profile restricts what a program can do (which files it can read, which network ports, etc.).
AppArmor is also deeper, and is not detailed further here.
Beginner — What are the three questions the kernel asks when checking file permissions?
Who (what UID), what (read/write/execute), and what does the resource (mode) allow. The kernel checks in order: is the accessor the owner? Is the accessor in the group? Is the accessor "other"? At each step, it checks the relevant permission bits and allows or denies.
Beginner — What does write permission on a directory mean?
Write permission on a directory means you can create, rename, or delete files *inside* it. It does *not* mean you can write to files in the directory — that requires write on the *file*. This is why you can delete a read-only file from a directory you own; the delete is a directory operation, not a file operation.
Beginner — What is the difference between `su` and `su -`?
`su` switches to another user but keeps the current environment (`$PATH`, `$HOME`, shell variables). `su -` starts a login shell, loading the target user's profile, home, and correct PATH. Always use `su -` to avoid surprises.
Beginner — What does `sudo -l` do?
It lists what commands the invoking user is allowed to run with `sudo`. This is the first debugging step if `sudo` refuses a command, and it is the way to audit what privileges users have.
Intermediate — Explain setuid on the `passwd` command. Why does `passwd` need it, and what is the security risk?
`passwd` is a setuid binary owned by root. When a user runs `passwd`, the process runs as UID 0 (root) instead of the user's UID, so it can write to `/etc/shadow`. Without setuid, non-root users could not change their own password. The risk is: if `passwd` has a security hole, an attacker can escalate to root instantly. This is why setuid binaries are rare and carefully audited — every one is a potential privilege escalation.
Intermediate — A user is in three groups: `users`, `developers`, and `sudo`. One file is owned by the `developers` group with mode `750`. Can the user access it?
Yes. Mode 750 means owner has rwx, group has rx, other has nothing. The user is in the `developers` group, so they get the group permission (rx). They can read and enter the file (or directory), but not modify it (no write on group).
Intermediate — What is the difference between `usermod -G developers alice` and `usermod -aG developers alice`?
`-G` without `-a` *replaces* all supplementary groups with `developers`, destroying membership in any other groups (like `sudo` or `docker`). `usermod -aG` *appends* `developers` to the existing groups. Always use `-aG`. Forgetting `-a` has locked many users out of systems.
Intermediate — What is the umask, and how does it work?
Umask is a mask of bits to *remove* from the default permissions when creating files. Default is 666 for files and 777 for directories. If umask is 022, new files get 644 (666 & ~022) and directories get 755 (777 & ~022). Umask 002 (common in teams) produces 664 and 775, allowing group write by default.
Intermediate — What does the sticky bit do on `/tmp`, and why is it there?
The sticky bit (mode 1000) restricts deletion: only the file owner, the directory owner, or root can delete files. On `/tmp` (mode 1777), everyone can create files and read/write everyone else's files, but they cannot delete someone else's temporary file. Without it, alice could delete bob's `/tmp/` files, which is undesirable.
Intermediate — How do ACLs extend UNIX permissions, and what problem do they solve?
UNIX permissions are owner, one group, and others. ACLs allow per-user and per-group entries: file can be readable by alice, writable by alice and bob, executable by the `developers` group, and unreadable by everyone else. This solves the "one group limitation" — you can express complex permission schemes without creating dozens of groups.
Intermediate — What is the mask in an ACL, and when does it matter?
The ACL mask silently limits the effective permissions of all non-owning entries (users, groups, and group permissions). If you set an ACL entry to `rwx` but the mask is `rw-`, the effective permission is `rw-` (no execute). When you `chmod g+w` on an ACL'd file, it modifies the mask, not individual entries. This can be surprising and is a common source of confusion.
Intermediate — What does `NOPASSWD` in `/etc/sudoers` do, and when should it be used?
`NOPASSWD` allows a user to run specific commands via `sudo` without entering a password. This is useful for automated systems (monitoring scripts, backup tasks) that need to run privileged commands. However, overusing it defeats the purpose of `sudo` — which is to log and require authentication for privileged actions. Use it sparingly, and only for specific, well-scoped commands.
Advanced — A file is mode 644, owned by alice:developers, with ACL `user:bob:rw-`. Bob tries to edit the file. Will he succeed?
No. Permissions are checked by the AND of ownership. Bob is neither the owner nor the group, so he gets "other" permissions: `---`. Even though there is an ACL entry for bob, it is shadowed by the "other" entry. The fix: change the mode to at least `664` or `754` so "other" has read/write, or set a mask that makes the ACL effective. Actually, wait — ACLs take precedence over mode. Let me reconsider: if an ACL entry exists for bob with rw-, bob gets rw on the file, regardless of mode. The mode is consulted only if there is no ACL entry for that user. So yes, bob succeeds. (The mode acts as a fallback, not an upper limit.)
Advanced — You are setting up a secure web server. The admin user is alice, the web app runs as `www-data`, and you need to manage updates via git pull. Design a directory structure with permissions for `/var/www/app` such that alice can manage it via git and www-data can only read files and serve them.
Alice can `cd` into `/var/www/app` (owner rwx), edit and git pull (she owns the files). www-data can `cd` and read files (group rx on directories, group r on files), but cannot write or delete anything (no write on group). `.git` and `config` are 700 alice:alice, so www-data cannot even list them. This is standard for web applications.
Advanced — You run `sudo useradd -m bob`, and now `bob` has UID 1000 but `alice` (who was the first user) also has UID 1000. How did this happen, and what is the risk?
The system auto-assigned UID 1000 to bob because the admin did not check what UIDs were already in use. Now alice and bob have the same UID; they can read and modify each other's files (the kernel sees them as the same user). This breaks all permission isolation between them. Fix: delete bob (`userdel -r bob`) and recreate with an explicit UID (`useradd -m -u 1001 bob`). Prevention: check `/etc/passwd` and use explicit UIDs in automation.
Advanced — A developer needs to run `docker` commands but should not have full `sudo` access. Write a sudoers rule that grants this and explain the security trade-off.
alice ALL=(ALL) NOPASSWD: /usr/bin/docker
This allows alice to run any docker command without a password. The trade-off: `docker run --rm -v /:/mnt ubuntu cat /mnt/etc/shadow` mounts the entire filesystem inside a container and reads secrets. Docker access is nearly equivalent to full `sudo` access. Better alternatives: (1) add alice to the `docker` group (`usermod -aG docker alice`), which gives the same privilege without sudo, or (2) use a Docker-level security policy (signing, only certain images allowed). The lesson: granting docker to a user is granting root; do it deliberately, not as a convenience.
Scenario — You SSH into a production server and notice a file `/var/www/app/config.php` is owned by `www-data:www-data`. The web server should not be able to modify it, but it currently can (mode 644). What is the vulnerability, and how would you fix it?
The vulnerability: if the web app is compromised by a remote attacker, they can modify `config.php` (write permission) and potentially inject code, read API keys, or change database connection strings. Fix: change the owner to the admin user and remove group write:
bash
Now the web server can only read (necessary to run), and cannot modify (attack surface reduced). If the app needs to write config at runtime, use a separate writable directory and keep the main config read-only, or use immutable ACLs.
Company style — How do you approach auditing user permissions on a Linux system?
Three steps: (1) **User enumeration:** `getent passwd | grep -E ':[0-9]{4}:' | wc -l` — how many users? Check UIDs < 1000 (system) vs >= 1000 (human). (2) **Privilege audit:** `sudo -l` for each user; list all entries in `/etc/sudoers.d/`; check setuid binaries (`find / -perm -4000 -type f`). (3) **File permissions:** sample `/etc/shadow`, `/etc/ssh`, `/var/log` — are permissions 600 (user only)? Check SSH keys: `find / -name id_\* -exec ls -l {} \;` — all 600? Look for world-readable secrets, overly permissive directories. Automate with tools like `lynis` or a custom Ansible playbook.
Company style — What is your strategy for managing sudo access in a team of 50 developers?
Centralize: `/etc/sudoers.d/` with one file per role, not per user. Example:
/etc/sudoers.d/developers # all developers can restart services, no password
/etc/sudoers.d/ops # ops can run any command with password
/etc/sudoers.d/ci # CI runner can update app, no password
Use groups (`%developers`, `%ops`) instead of individual users — add/remove users via `usermod -aG` without touching sudoers. Log to a centralized syslog server. Audit monthly: `sudo grep "sudo:" /var/log/auth.log | wc -l` — how many sudo commands? Are there any unexpected ones?
The kernel enforces permissions by answering three questions: who (UID), what (action), what does the resource (mode) allow.
User identities are stored in /etc/passwd (public) and /etc/shadow (secret 640); GID defines membership.
A 10-char mode string describes file type, owner/group/other rwx, and special bits (setuid, setgid, sticky).
Write on a file lets you modify contents; write on a directory lets you create, delete, or rename entries inside — deletion depends on directory perms, not file perms.
Octal modes (644, 755, 600) combine permission bits (r=4, w=2, x=1); umask removes bits from defaults (666 for files, 777 for dirs).
chmod, chown, and chgrp are the tools; -R for recursion, but always distinguish files and directories to avoid making files executable.
setuid (4000) makes binaries run as their owner (e.g., passwd as root); setgid (2000) makes new files inherit directory’s group; sticky (1000) restricts deletion to owner/root.
ACLs extend ownership to per-user and per-group entries, solving the “one group limitation” and supporting default inheritance.
Users and groups are managed via useradd, passwd, userdel, and usermod — always use usermod -aG (with -a), not -G alone.
su - starts a login shell (correct environment); sudo logs privilege escalation and requires authentication (audit trail).
/etc/sudoers is edited only via visudo (validates syntax); a broken file locks admins out; use /etc/sudoers.d/ for drop-ins.
PAM handles authentication, account checks, password policy, and sessions via /etc/pam.d/ modules.
SELinux and AppArmor add MAC (mandatory access control) on top of DAC (discretionary), further restricting what programs can do even if permissions allow it.
PERMISSION MODEL "Who? What? Allowed?"
-rw-r--r-- = owner rw, group r, Three questions, always in order:
other r (mode 644) 1. What UID is acting?
2. What action (r/w/x)?
Four digits: SUID GUID STICKY 3. Does the target (mode) allow it?
then O G O Answer yes to first match.
OCTAL r=4 w=2 x=1 total per triad
755 = rwxr-xr-x (owner 7, group 5, Example: 644 = 6(rw) 4(r) 4(r)
other 5) Umask: 022 removes those bits from
Default files: 666, dirs: 777 default 666/777, so files → 644,
dirs → 755
SPECIAL BITS setuid 4000 (run as owner, e.g. passwd)
setgid 2000 (inherit group on dir)
sticky 1000 (only owner/root delete)
Look for s/S in owner/group/other Mode 4755 = setuid+rwxr-xr-x
spot in ls -l find / -perm -4000 -type f to audit
KEY COMMANDS chmod u=rwx,g=rx,o= file (symbolic)
chmod 755 dir chmod -R +X dir (smart execute)
chmod u+s /usr/bin/passwd chown alice:developers file
chown -R owner:group /path chgrp group file
WARNING: chmod -R 777 breaks SSH/sudo/everything
USERS useradd -m -s /bin/bash -G docker alice
/etc/passwd: name:x:UID:GID:GECOS passwd alice (set password)
:home:shell usermod -aG newgrp alice (ALWAYS -aG!)
/etc/shadow: name:hash:... userdel -r alice (delete + home)
(600 perms)
id alice, whoami, groups alice
SU VS SUDO su - user (login shell)
su (not recommended) sudo -i (interactive root)
su - (login shell) sudo -l (what can I run?)
sudo -u user (run as user) sudo -k (forget password)
SUDOERS /etc/sudoers (edit with visudo!)
alice ALL=(ALL) ALL alice can run anything as root
%wheel ALL=(ALL) ALL wheel group can run anything
nagios ALL=NOPASSWD: /usr/bin/check* nagios: specific command, no password
Defaults secure_path=...
Use /etc/sudoers.d/ for drop-ins
ACL getfacl file (read)
setfacl -m u:bob:rw file setfacl -d -m g:devs:rwx / (default)
setfacl -x u:bob file setfacl -b file (remove all)
+ in ls -l means ACL present cp --preserve=all preserves ACL
Shared project: 2775 + setfacl -d + umask 002
UMASK 022 → files 644, dirs 755
002 → files 664, dirs 775 077 → files 600, dirs 700
umask 002 in ~/.bashrc for team env. DO NOT set 077 system-wide
Mode 755 in octal is: (a) rwxrwxrwx (b) rwxr-xr-x (c) rw-r–r– (d) rw-rw-rwx
Which statement about write permission on directories is true? (a) Write on a file lets you delete it (b) Write on a directory lets you create files inside (c) Write lets you modify the directory’s name (d) Write is never needed on directories
A user is in groups sudo (GID 4) and developers (GID 1001), but not in the file’s owning group. Which permission set applies to the file? (a) owner’s permission (b) group’s permission (c) other’s permission (d) depends on the file type
UID 0 is: (a) the first regular user (b) root (c) always a system service (d) undefined
The primary purpose of umask is to: (a) hide files (b) remove permission bits from defaults when creating files (c) encrypt permissions (d) restrict access to /dev devices
usermod -G developers alice is dangerous because: (a) it adds alice to developers (b) it removes alice from all other groups, including sudo (c) it requires alice’s password (d) it creates a new group
What does /etc/shadow require to read? (a) Any user can read it (b) Only root (c) Only root and users in the shadow group (typically) (d) The file owner
sudo -l shows: (a) all users on the system (b) what this user may run with sudo (c) the sudo command line history (d) the last time sudo was used
setuid on a file means: (a) the file is secret (b) only the owner can run it (c) the process runs as the file’s owner, not the invoker (d) setuid is a deprecated feature
Which is the correct way to add a user to a group without losing existing groups? (a)usermod -G newgroup alice(b)usermod -aG newgroup alice(c)gpasswd -M alice groupname(d)groupadd alice
Answers
1. (b) — 7 = rwx, 5 = r-x
2. (b) — write on the *directory* allows create/delete/rename inside
3. (c) — if the user is not in the group, the "other" permission applies
4. (b)
5. (b) — umask is a mask of bits to *remove* from defaults
6. (b) — `-G` without `-a` replaces all groups
7. (c) — typically mode 640, readable by root and the shadow group
8. (b) — lists what the invoking user may run
9. (c)
10. (b) — `-a` = append, `-G` alone = replace
You can delete a read-only file from a directory you own.
Mode 777 is a secure choice for shared project directories.
su - root and su root produce the same environment.
A user can run sudo commands without being in /etc/sudoers.
The sticky bit on /tmp prevents non-owners from reading files.
/etc/passwd is world-readable; /etc/shadow is not.
usermod -G developers alice safely adds alice to the developers group.
All Linux filesystems support ACLs.
The UID range 1000+ is reserved for system services.
sudo -l requires a password to view permissions.
Answers
1. **True** — deletion is a directory operation, not a file operation.
2. **False** — 777 is dangerous; owner rwx, group rwx, others nothing is better (750).
3. **False** — `su -` loads a login shell and environment; `su` keeps yours.
4. **False** — they must be explicitly allowed in `/etc/sudoers`.
5. **False** — sticky (1000) restricts *deletion*, not reading.
6. **True** — world-readable password file is a security hole; shadow is 640.
7. **False** — forgetting `-a` replaces all groups and locks users out. Use `-aG`.
8. **False** — older filesystems or unmounted with `-o noacl` do not support them.
9. **False** — UID 1-999 is system/service; 1000+ is regular/human users.
10. **False** — `sudo -l` typically requires a password (first time), then caches for 15 min.
Do these on a VM you can reset. Create a sandboxed user account to practice safely.
Permission basics. Create a file, change its mode to 644, then to 755. Use chmod with both octal and symbolic notation. ls -l to verify after each change.
Directory permissions. Create a directory, set mode 700. Verify you can enter it. Then remove all permissions (chmod 000). Can you still ls its contents? Can you cd into it? Why? (Test: cd dir; pwd — does it work?)
The write-on-directory rule. In a directory you own, create a read-only file (chmod 000 file). Verify you can delete it (rm file succeeds). Now create a directory you don’t own (sudo helps), add it to a group, and try to delete a file inside — it should fail.
umask in action. Create two files with different umasks:
bash
umask 022; touch file1; umask 002; touch file2; umask 077; touch file3
ls -l file*
Observe the different modes (644 vs 664 vs 600).
User and group creation. Create three users (alice, bob, charlie), a shared group (developers), and add alice and bob to it:
bash
groupadd developers
useradd -m -G developers alice
useradd -m -G developers bob
id alice; id bob; id charlie
Verify their groups.
Shared project directory. Create /tmp/project, owned by root:developers, mode 2775. Have alice and bob create files in it. Verify files are owned by them but group is developers (not alice/bob). Try to delete alice’s file as bob — should fail (sticky bit).
ACLs. Set up a file with mixed permissions:
- Owner: rw
- Alice: rw
- Bob: r only
- Others: nothing
bash
touch file; chmod 600 file
setfacl -m u:alice:rw file
setfacl -m u:bob:r file
getfacl file
Sudo audit. Add yourself to sudoers (dangerous — use in a VM only): echo "$USER ALL=(ALL) NOPASSWD: ALL" | sudo tee /etc/sudoers.d/$USER. Then run sudo whoami and search the audit log: sudo grep "sudo:" /var/log/auth.log | tail — see your entry?
Find setuid binaries. On a clean system (important!):
bash
find / -perm -4000 -type f 2>/dev/null
Examine a few: ls -l /usr/bin/passwd, ls -l /usr/bin/sudo. Understand why each is setuid.
Umask + setgid + ACL: the full recipe. Replicate a real team project setup:
bash
groupadd devs
usermod -aG devs alice
usermod -aG devs bob
mkdir /tmp/collab
chown alice:devs /tmp/collab
chmod 2775 /tmp/collab
setfacl -d -m g:devs:rwx /tmp/collab
echo "umask 002" >> /home/alice/.bashrc
echo "umask 002" >> /home/bob/.bashrc
# Now alice and bob create files; verify they're group-writable
Explain the three-question permission model: Design a scenario where a user is not the owner, not in the owning group, and explain which permissions apply and why.
Design a secure web directory: A website runs as www-data, the admin is alice, and the site must be updateable by git. Design directory structure, ownership, and permissions.
ACL real-world use: A marketing database must be readable/writable by 3 users (alice, bob, charlie) and readable-only by 10 others (read team). Design this with standard perms vs. ACLs — why are ACLs better?
Setuid audit: Find all setuid binaries on your system. Explain why each one (pick 5) needs setuid. What is the blast radius if each one is compromised?
Umask in context: Explain umask values 022, 002, and 077 — when would you use each, and what breaks if you use the wrong one?
Group membership crisis: A developer alice was using sudo, you run usermod -G docker alice to grant Docker access, and now alice cannot use sudo. Explain what happened and how to recover without losing docker access.
Sticky bit behaviour: On /tmp with sticky bit, alice can delete her own files but not bob’s. Explain the permission checks the kernel makes, and how the sticky bit changes the outcome.
PAM + sudo: Explain how PAM’s four management groups (auth, account, password, session) interact with sudo. When does PAM run, and what does it check?
ACL mask confusion: A file has user:bob:rwx in the ACL, but bob can only read it. Investigate: what might the mask be, and how do you diagnose this?
Recursive permission disasters: You have a directory /var/data with critical backups. Write a safe recursive permission change that sets directories to 750 and files to 640, without accidentally making files executable or directories untraversable. Test it.
How the kernel checks permissions (UID/GID matching algorithm)#
When a process attempts an operation on a file, the kernel executes the following algorithm:
Fetch process metadata: Get the process’s UID and all GIDs (primary + supplementary).
Fetch file metadata: Get the file’s owner UID, owner GID, and mode bits.
Three-question decision tree:
- Is process UID == file owner UID? If yes, apply owner permissions (bits 6-8) and stop.
- Is any process GID == file owner GID? If yes, apply group permissions (bits 3-5) and stop.
- Otherwise: Apply other permissions (bits 0-2) and stop.
Check if required permission bit is set: If the relevant triad (owner/group/other) has the required bit (r/w/x), allow. Else, deny with EACCES.
Key insight: The kernel stops at the first match. If you are the owner, your UID match takes precedence — the kernel never checks group membership. This is why a file owned by alice:developers with mode 700 cannot be read by alice if she is running in a different process context (e.g., via su alice from a different user).
The kernel reads the file’s inode, sees the setuid bit (04xxx mode).
Instead of setting the process’s UID to the calling user’s UID, the kernel sets it to the file’s owner UID.
The process runs with the elevated UID for the duration of the program. When the program exits, the kernel returns the original UID.
The process’s GID remains unchanged (set by the caller).
Example: passwd execution
User alice (UID 1000) runs /usr/bin/passwd
Inode of /usr/bin/passwd:
owner: root (UID 0)
mode: 04755 (setuid + rwxr-xr-x)
Kernel executes:
1. Recognizes setuid bit
2. Creates process with UID=0, GID=1000 (alice's gid)
3. Process can write /etc/shadow (owner: root, mode 640)
4. Process exits, shell restores UID=1000
Setgid (2000):
On a file: When a process executes a setgid file, the process’s effective GID becomes the file’s owner GID. This is rare for files; more common on directories.
On a directory: When a file is created inside a setgid directory:
The directory’s inode has mode bit 02xxx (setgid set).
The kernel bypasses the creator’s primary GID and assigns the file’s group to the directory’s owning group.
The file inherits the group even though the creator’s primary group differs.
Example: shared project directory setup
Directory /srv/project:
owner: admin (UID 100)
group: developers (GID 1001)
mode: 02775 (setgid + rwxrwxr-x)
User alice (UID 1000, primary GID 1000) creates file:
touch /srv/project/myfile
Kernel executes:
1. alice's gid is 1000 (from her /etc/passwd primary)
2. Directory has setgid bit, so IGNORE alice's gid
3. Use directory's group: 1001 (developers)
4. File created with owner=alice, group=developers (GID 1001)
Result:
-rw-rw-r-- 1 alice developers 0 Feb 1 myfile
↑
group is developers, not alice
ACL evaluation order (standard perms vs ACL mask)#
When a file has ACL entries, the kernel evaluates permissions differently from standard Unix permissions.
Standard permissions only (no ACL):
1. Check if UID matches owner → use owner bits
2. Else check if GID matches → use group bits
3. Else use other bits
With ACL entries:
1. Check if UID matches owner UID → use owner bits
2. Check if UID is in an ACL user entry → use ACL bits AND mask (effective permission)
3. Check if GID matches owner GID → use group bits AND mask
4. Check if GID is in an ACL group entry → use ACL bits AND mask
5. Else use other bits
Critical: the mask
The mask (mask::rwx) is an upper bound on the effective permission for all non-owning entries (named users, named groups, group owner). Even if an ACL entry is set to rwx, if the mask is only rw-, the effective permission is rw- (no execute).
Example ACL evaluation:
File: shared.txt
owner: alice (UID 1000)
group: editors (GID 2001)
mode: 644
ACL entries:
user::rw- (owner alice)
user:bob:rwx (named user bob)
group::r-- (group editors)
group:readers:rwx (named group readers)
mask::rw- ← CRITICAL
other::r--
Scenario 1: alice accesses the file
→ UID matches owner → use owner bits (rw-) → allow read, write
Scenario 2: bob accesses the file
→ UID matches ACL user entry for bob
→ ACL says rwx, but mask is rw-
→ Effective permission: rw- (read+write only; no execute)
→ bob can read and write, but not execute
Scenario 3: charlie (in readers group) accesses the file
→ UID doesn't match any user entry
→ GID matches ACL group entry (readers)
→ ACL says rwx, but mask is rw-
→ Effective permission: rw-
→ charlie can read and write, but not execute
Scenario 4: david (in editors group, but not readers) accesses the file
→ UID doesn't match any user entry
→ GID doesn't match any ACL group entry (not in readers)
→ GID matches file's group (editors)
→ Group bits are r--, mask is rw-
→ Effective: r-- ∩ rw- = r--
→ david can read only (limited by group bits, not mask in this case)
Umask is not a permission; it is a mask of bits to remove from the default permissions. The kernel applies umask when a process calls open(), mkdir(), mknod(), or similar.
Key insight: Umask is a process attribute, set at login time via PAM or shell initialization. It affects that process and its children. When you run touch file in your shell, the shell’s umask is applied, not a global system umask.
bash
# Check current umaskumask# output: 0022# Set for this shell and childrenumask002# Verifyumask# output: 0002# Create a file; observe permissions
touchtestfile
ls-ltestfile# -rw-rw-r-- if umask is 002
Permission checks for directory traversal vs listing#
Directories require different permissions for different operations.
Execute (x) on a directory: traversal permission
To cd into a directory or access a file inside it (e.g., cat dir/file), you must have execute permission on the directory.
bash
$mkdirnoexec
$chmod644noexec# rw-r--r-- (no x)
$touchnoexec/file
$catnoexec/file
cat:noexec/file:Permissiondenied
Why? The kernel must walk the directory inode to reach the file inside. Without x, the lookup fails.
However, if you already know the inode number or the full path is cached, you might still access the file (this is an edge case and depends on kernel caching).
Read (r) on a directory: listing permission
To ls a directory’s contents (list filenames), you need read permission.
bash
$mkdirnoread
$chmod711noread# rwx--x--x (r removed)
$touchnoread/file
$lsnoread
ls:cannotopendirectory'noread':Permissiondenied
$catnoread/file# This still works if you know the name!(contents)
Why? To list filenames, the kernel reads the directory’s name-to-inode mapping (stored in the directory block). You need r for that. But if you know the filename and have x, you can still access it directly.
Write (w) on a directory: modification permission
To create, delete, or rename entries in a directory, you need write permission.
bash
$mkdirreadonly
$chmod555readonly# r-xr-xr-x (no w)
$touchreadonly/file# Works at creation time (I own it)
$rmreadonly/file
rm:cannotremove'readonly/file':Permissiondenied
Why? Even though I own the file, deletion is a directory operation (modifying the directory’s inode and block to remove the entry). I need w on the directory.
Summary table: directory permission semantics
Permission
Operation
Example
x
Traverse
cd dir, cat dir/file, stat dir/file
r
List
ls dir, see filenames
w
Modify entries
touch dir/newfile, rm dir/oldfile, mv dir/a dir/b
Typical directory modes:
- 755 (rwxr-xr-x) — owner full, group/other can enter and list
- 750 (rwxr-x—) — owner full, group can enter and list, other cannot
- 700 (rwx------) — owner only
- 711 (rwx–x–x) — owner full, others can only enter (if they know the name)
File readable by alice+bob, writable by alice only, executable by developers group
Mask complexity
None
Mask can silently limit permissions; surprising if not understood
When to choose
Most cases; default Linux model
When one group is insufficient
Decision tree:
Do you need MORE than one group to have
different permissions on the same file?
├─ NO → Use standard rwx (simpler)
└─ YES → Use ACL (more flexible)
Do you need per-user permissions?
├─ NO → Use standard rwx or groups
└─ YES → Use ACL (ACL can do per-user)
Default on most systems. Owner read/write; group and others read-only
002
664 (rw-rw-r–)
775 (rwxrwxr-x)
Team environment. Owner and group can read/write; others read-only. Requires coordinated group membership
077
600 (rw-------)
700 (rwx------)
Paranoid. Only owner can access. Do not set system-wide; breaks package managers and config sharing
007
660 (rw-rw----)
770 (rwxrwxr-x)
Group full access, others none. Rare in practice
033
644 (rw-r–r–)
744 (rwxr–r–)
Directories are restrictive; files are standard. Unusual mix
Real examples:
bash
# Standard developer workstation (022)
$umask0022
$touchfile;mkdirdir
$ls-lfiledir
-rw-r--r--alicealicefile
drwxr-xr-xalicealicedir
# Team project (002)
$umask002
$touchfile;mkdirdir
$ls-lfiledir
-rw-rw-r--alicedevelopersfile# (if group set to developers)
drwxrwxr-xalicedevelopersdir
# Locked-down (077)
$umask077
$touchfile;mkdirdir
$ls-lfiledir
-rw-------alicealicefile
drwx------alicealicedir
# This is too restrictive — system breaks if set globally
Why NOT to use 077 system-wide:
- /etc/ config files become unreadable by non-root (packages fail)
- /usr/share/ documentation becomes inaccessible
- /tmp becomes per-user isolated (breaks shared temp workflows)
Run file as program (if binary or script with shebang)
Traverse into directory (cd, access dir/file if you know the name)
Critical misunderstandings:
❌ "I made the file read-only, so I can't delete it."
✓ Deletion is a directory operation. Write on the DIRECTORY determines deletion.
❌ "I can't list a directory because I don't have execute."
✓ You need READ to list. You need EXECUTE to enter.
❌ "I set 777 on a directory so everyone can do everything."
✓ 777 on a directory means everyone can create/delete files (w).
Without EXECUTE (x), they cannot traverse into it.
Result: everyone can write there but not enter (weird edge case).
❌ "Write on a file means I can delete it."
✓ Write on a file means you can modify its contents.
Deletion always requires write on the parent DIRECTORY.
Practical table: what permission do I need?
Operation
Permission required
On what
Read file contents
r
file
Modify file contents
w
file
Run executable file
x
file
List directory contents
r
directory
Create file in directory
w
directory
Delete file in directory
w
directory
Rename file in directory
w
directory
Enter directory (cd)
x
directory
Access file inside (cat dir/file)
x
directory + r (if listing needed) or x (if direct access)
Example scenario: shared project directory
/srv/project ← owner: alice, mode: 750 (rwxr-x---)
├── README.md ← owner: alice, mode: 644 (rw-r--r--)
└── data/ ← owner: alice, mode: 750
What can bob do if bob is in the project group?
Try: cat /srv/project/README.md
1. Traverse /srv/project → need x on /srv/project → bob has it (group x)
2. Read README.md → need r on README.md → bob has it (other r)
3. SUCCESS
Try: rm /srv/project/README.md
1. Traverse /srv/project → need x → bob has it
2. Delete entry → need w on /srv/project → bob DOES NOT (group: r-x, no w)
3. FAIL: Permission denied
Try: cd /srv/project
1. Traverse (cd) → need x on /srv/project → bob has it
2. SUCCESS: bob can cd
Try: ls /srv/project
1. Traverse → need x → bob has it
2. List contents → need r on /srv/project → bob DOES NOT (group: r-x)
3. FAIL: Permission denied (but bob could still access files if he knew the names)
Key distinctions:
r on a directory ≠ x on a directory
r allows listing (seeing names)
x allows traversal (accessing known entries)
Both are often needed together, but they are separate
Edge case: --x (execute only) means “I can access files inside if I know their names, but I cannot list”
w on a file ≠ w on a directory
w on file = modify contents
w on directory = create/delete/rename entries
Deletion never depends on file permissions, only directory
VI — Automation
17Bash Scripting
From one-liners to production-grade scripts — how to write shell code that does not destroy your system, handles errors correctly, and runs reliably at 3 a.m.
You have typed one-liners in Bash: ls -la | grep ".txt". You have run commands sequentially in the shell. But a shell script is different. It is a program — it must handle errors, work with variables, loop over data, make decisions, and run correctly the first time on a machine you have never touched before.
The difference between a working script and a dangerous one is not intelligence; it is discipline. Four specific practices prevent 99% of production script failures:
set -euo pipefail at the top — stop on error, undefined variable, or pipe failure
Always quote variables:"$var" not $var
Always use find or while read instead of ls in a loop
On Friday it works. On Monday, someone adds a file with a space in the name. The loop breaks. You copy the file to the wrong location. Or you forget to check if the backup succeeded, and later discover your backups have been corrupted for six months.
Or you write a deployment script that does not set -e, and a docker build fails silently, but the script continues and deploys the old image.
Production scripts must:
- Fail loudly when any command fails
- Never silently drop variables
- Parse filenames correctly, even with spaces
- Handle signals (SIGTERM, SIGINT) gracefully
- Log what they are doing
- Be testable — you must watch the exact commands being run
A shell script is a checklist that a computer runs. Each line is a step:
Open the log file
Copy the backup
Compress it
Upload it to S3
Delete the local copy
Email the admin
But unlike a human checklist, a computer follows it literally. If you forget to specify “stop if any step fails,” the computer will keep going even if step 3 (compress) fails. You end up uploading a corrupt file.
The set -e option makes the script ask the computer: “Stop if any step fails.”
Shell script. A plain text file containing shell commands, executed line by line by the shell. Typically starts with a shebang line (#!/bin/bash). Run with bash script.sh, sh script.sh, or ./script.sh (requires the script to be executable, with chmod +x).
Shebang (hashbang). The first line of a script, beginning with #!, followed by the path to an interpreter. Examples:
Shebang
Meaning
#!/bin/bash
Use Bash from /bin/bash; not portable if Bash is at /usr/local/bin/bash
#!/usr/bin/env bash
Search $PATH for bash; more portable, the preferred approach
#!/bin/sh
Use POSIX shell (stricter, more portable, but fewer features)
#!/usr/bin/env python3
Use Python; the file extension .py is not required
The shebang is not a comment; it is metadata that tells the kernel which interpreter to use when you execute the script directly (./script.sh).
Exit code (exit status). Every command returns a number: 0 means success, 1–255 means failure. Bash stores it in $?.
terminal
$ ls/nonexistent2>&1ls: cannot access '/nonexistent': No such file or directory$ echo$?2
Variable. A name-value pair. Assignment (no spaces around =):
bash
name="Alice"count=42flag=true
Expansion (always quote):
bash
echo"$name"# Aliceecho"${name}"# Alice (same, more explicit)echo"$count"# 42
Parameter expansion. Syntax for accessing variables with optional defaults, transformations, or error checking:
Syntax
Meaning
Example
$var or ${var}
Value of var
${user} → alice
${var:-default}
Value of var, or default if unset
${editor:-vim} → vim (if editor is unset)
${var:=default}
Value of var, or set var to default if unset
${logdir:=/var/log} → sets logdir
${var:?error}
Value of var, or error and exit if unset
${required:?missing input} → exits if required is unset
${var##pattern}
Remove longest match of pattern from start
${file##*/} removes path, leaving filename
${var%%pattern}
Remove longest match of pattern from end
${file%%.*} removes extension
${#var}
Length of var
${#name} → 5 (if name="Alice")
Quoting rules. Three types:
Type
Behavior
Example
Double quotes "..."
Expansions and command substitutions work; single \ escapes special chars
"$var", "$(cmd)" both expand
Single quotes '...'
Nothing expands; literal string
'$var' is the string $var, not its value
$'...'
ANSI-C quoting; interprets escape sequences like \n, \t, \"
$'line1\nline2' creates a newline
Special variables. Always available:
Variable
Meaning
$0
Name of the script itself (script.sh)
$1, $2, ...
Command-line arguments (first, second, etc.)
$@
All arguments as separate words; must quote: "$@" expands to "$1" "$2" ...
$*
All arguments as one string; almost never what you want
Every command in a production script should be written assuming:
- The file does not exist
- The directory does not have write permission
- The network is slow
- A signal (SIGTERM) arrives mid-execution
flowchart TB
A["User types: ls -la \"/home/alice/my files\"] --> B["1. Lexing<br/>Split into tokens, respecting quotes"]
B --> C["2. Expansion<br/>Substitute \$var, \$(cmd), etc."]
C --> D["3. Quote removal<br/>Strip quotes, leaving bare string"]
D --> E["4. Globbing<br/>Expand * ? if no quotes"]
E --> F["5. Word splitting<br/>Split on IFS=unquoted spaces"]
F --> G["6. Command lookup<br/>Find ls in PATH"]
G --> H["7. Execution<br/>fork + execve"]
H --> I["8. Wait<br/>Parent waits for child"]
I --> J["9. Exit code<br/>Store in \$?"]
This explains many errors. For example:
bash
file="/tmp/test file.txt"
cp$file/backup# WRONG: $file expands to /tmp/test file.txt# Then word-split into /tmp/test, file.txt, /backup — 3 args to cp
cp"$file"/backup# RIGHT: expands to /tmp/test file.txt, one argument
#!/bin/bashset-euopipefail
cleanup(){echo"Cleaning up..."rm-f/tmp/working_file
echo"Done"}trapcleanupEXIT
# Your script code here
cp/important/file/tmp/working_file
# If the script exits (normally or via error), cleanup() runs
#!/usr/bin/env bashset-euopipefail
# Back up a directory with a timestampsource_dir="/home/alice/data"backup_dir="/backup/$(date+%Y-%m-%d_%H-%M-%S)"
mkdir-p"$backup_dir"
cp-r"$source_dir"/*"$backup_dir/"echo"Backup complete: $backup_dir"
Issues with this script:
- Does not check if $source_dir exists
- Does not check disk space
- No error message if mkdir fails
- Does not log the action
$ name="Alice"$ echo"$name"Alice$ echo"${name}"Alice$ echo"${name:-default}"Alice$ echo"${nonexistent:-default}"default$ path="/home/alice/file.txt"$ echo"${path##*/}"# Remove everything up to last /file.txt$ echo"${path%%.*}"# Remove everything from first ./home/alice/file$ echo"${#path}"# Length23
#!/bin/bashfile="/etc/passwd"# File testsif[[-f"$file"]];thenecho"$file exists and is a regular file"fiif[[-r"$file"]];thenecho"$file is readable"fiif[[!-w"$file"]];thenecho"$file is not writable"fi# String comparisonname="Alice"if[["$name"=="Alice"]];thenecho"Name is Alice"fiif[["$name"=~^A]];thenecho"Name starts with A"fi# Arithmeticcount=5if((count>3));thenecho"count > 3"fi# Combining conditionsif[[-f"$file"&&-r"$file"]];thenecho"$file is a readable regular file"fi# Case statementcase"$1"instart)echo"Starting...";;stop)echo"Stopping...";;*)echo"Unknown command: $1"exit1;;esac
#!/bin/bash# Loop with C-style syntax (efficient, no subshell)for((i=1;i<=5;i++));doecho"Iteration $i"done# Loop over arrayfiles=("file1.txt""file2.txt""file3.txt")forfilein"${files[@]}";doecho"Processing $file"done# Loop with whilecount=1while((count<=5));doecho"Count: $count"((count++))done# Loop with until (opposite of while)count=1until((count>5));doecho"Count: $count"((count++))done# Loop over command output (safe, using process substitution)whileIFS=read-rline;doecho"Line: $line"done<<(cat/etc/hosts)# break and continueforiin{1..10};doif((i==3));thencontinue# Skip this iterationfiif((i==7));thenbreak# Exit loop entirelyfiecho"$i"done
#!/bin/bash# Simple function
greet(){echo"Hello, $1"}
greet"Alice"# Hello, Alice# Function with local variables
add(){locala=$1localb=$2localresult=$((a+b))echo"$result"}sum=$(add35)echo"Sum: $sum"# Sum: 8# Function with error handling
safe_read(){localfile="$1"if[[!-f"$file"]];thenecho"Error: $file not found">&2return1ficat"$file"return0}ifsafe_read"/etc/passwd";thenecho"Read succeeded"elseecho"Read failed with exit code $?"fi# Recursive function
factorial(){localn=$1if((n<=1));thenecho1elselocalm=$((n-1))localsubmult=$(factorial"$m")echo$((n*submult))fi}echo"5! = $(factorial5)"# 5! = 120
#!/bin/bash# Indexed arrayfruits=("apple""banana""cherry")echo"${fruits[0]}"# appleecho"${fruits[@]}"# all elementsecho"${#fruits[@]}"# 3 (length)# Append to arrayfruits+=("date")# Loop over arrayforfruitin"${fruits[@]}";doecho"$fruit"done# Associative array (like a dictionary)declare-Aperson
person[name]="Alice"
person[age]=30
person[city]="London"echo"${person[name]} is ${person[age]} years old"# Alice is 30 years old# Loop over associative arrayforkeyin"${!person[@]}";doecho"$key: ${person[$key]}"done
#!/bin/bash# Read from keyboard (interactive)read-p"Enter your name: "name
echo"Hello, $name"# Read from stdin (with newline preservation)whileIFS=read-rline;doecho"Got: $line"done# Read with timeoutread-t5-p"Quick, enter something: "response
if[[-z"$response"]];thenecho"You didn't respond in time"fi# Here-document (multi-line string)
cat<< 'EOF'This is amulti-linestringEOF# Here-document with variable expansionname="Alice"
cat<< EOFHello, $nameYour home is $HOMEEOF# Here-document with redirection
cat<< EOF > /tmp/config.conf[settings]name=myappport=8080EOF# Command substitutionfiles=$(find/tmp-typef-name"*.log")echo"$files"# Process substitution (connect command output to a file descriptor)
diff<(ls/dir1)<(ls/dir2)# Show differences between two directories
#!/bin/bashset-euopipefail
# Debug the next commandsset-x
mkdir-p/tmp/mydir
cd/tmp/mydir
set+x# Turn debugging offecho"Now debugging is off"# Or run the entire script with debugging:# bash -x script.sh
Output when run:
+ mkdir -p /tmp/mydir
+ cd /tmp/mydir
Now debugging is off
Beginner — What is a shebang, and why does it matter?
The shebang (or hashbang) is the first line of a script, starting with `#!`, followed by the path to an interpreter. For example, `#!/bin/bash` tells the kernel to run the script with the Bash interpreter when you execute the script directly (e.g., `./script.sh`).
It matters because:
1. **Portability** — without a shebang, the system does not know which shell to use
2. **Clarity** — readers know immediately which language the script is in
3. **Execution** — it enables direct execution (if the file has execute permission) without explicitly typing `bash script.sh`
Use `#!/usr/bin/env bash` rather than `#!/bin/bash` for maximum portability, because `bash` might be installed at a different path on different systems.
Beginner — What does `set -e` do, and when would you use it?
`set -e` enables "exit on error" mode. If any command exits with a non-zero status, the script immediately exits. Without it, the script keeps running even if a command fails, often silently causing data loss or incorrect behaviour.
You should use `set -e` in almost every production script. The only exception is if you intentionally want to catch and handle specific failures (e.g., `command || handle_error`).
Combine it with `set -u` (error on undefined variables) and `set -o pipefail` (fail if any command in a pipe fails) for the maximum safety line: `set -euo pipefail`.
Beginner — Explain the difference between `$var`, `"$var"`, and `'$var'`.
- `$var` — expands to the value of `var`, but if the value contains spaces, it is word-split into multiple arguments
- `"$var"` — expands to the value of `var`, preserving spaces as one argument
- `'$var'` — does not expand; it is the literal string `$var`
Example:
bash
name="Alice Smith"
cp$name/backup# WRONG: splits into cp Alice Smith /backup (3 args)
cp"$name"/backup# RIGHT: one argument "Alice Smith"
cp'$name'/backup# WRONG: tries to copy a file literally named "$name"
Always quote variables: `"$var"`. You will rarely regret it, and it prevents most scripting bugs.
Beginner — Why should you never use `ls` in a loop?
Because `ls` is meant for human-readable output, not for parsing. If a filename contains spaces or newlines, `ls` splits it, and the loop breaks.
Example:
bash
# WRONGforfilein$(ls/data);doprocess"$file"done# If /data/file one.txt exists, ls outputs:# file# one.txt# The loop processes "file" and "one.txt" separately, not "file one.txt"
Instead, use `find` with `-print0` and `read`:
bash
This handles any filename correctly — spaces, newlines, special characters.
Intermediate — What is a subshell, and how does it affect variables?
A subshell is a child Bash process created whenever:
- You use a pipe: `command1 | command2`
- You use command substitution: `$(command)` or `` `command` ``
- You run a command in the background: `command &`
- You explicitly start one: `(command)`
Variables set in a subshell do not affect the parent shell. Example:
bash
count=0echo"line1"|whilereadline;do((count++))doneecho"$count"# Still 0, not 1 (the increment happened in a subshell)
To avoid this, use process substitution instead of pipes:
bash
count=0whilereadline;do((count++))done<<(echo"line1")echo"$count"# Now 1 (no subshell)
Intermediate — Explain parameter expansion. What does `${file##*/}` do?
Parameter expansion is syntax for accessing or transforming variables. The syntax `${var##pattern}` removes the longest match of `pattern` from the start of `var`.
For `${file##*/}`:
- `file` is a variable (e.g., `/home/alice/myfile.txt`)
- `##` means "remove the longest match"
- `*/` means "anything up to and including the last slash"
- Result: just the filename (`myfile.txt`)
Other common expansions:
| Expansion | Result |
|---|---|
| `${file%%.*}` | Filename without extension (remove from first `.` to end) |
| `${file%.*}` | Remove shortest match (e.g., `.txt`); shortest is one `.` |
| `${#file}` | Length of `$file` |
| `${file:0:5}` | First 5 characters (substring) |
| `${file:5}` | Everything after character 5 |
| `${file:-default}` | Value of `file`, or `default` if unset |
Intermediate — When would you use `trap`, and what is a common use case?
`trap` is a handler for signals and special conditions. The most common use is `trap cleanup EXIT`, which ensures cleanup code runs no matter how the script exits (normally, on error, or via signal).
Example:
bash
#!/bin/bashset-euopipefail
cleanup(){echo"Cleaning up..."rm-f/tmp/working_file
[[-f"$lock_file"]]&&rm-f"$lock_file"}trapcleanupEXIT
# Script code here
Other common traps:
- `trap 'echo Interrupted; exit' SIGINT` — handle Ctrl+C gracefully
- `trap 'echo Error at line $LINENO' ERR` — catch errors with line number
Intermediate — What is the difference between `[[ ]]` and `[ ]`?
Both are conditionals, but `[[ ]]` is newer and safer:
| Feature | `[[ ]]` | `[ ]` |
|---|---|---|
| Word-splitting | No (safer) | Yes (need more quoting) |
| Regex matching `=~` | Yes | No |
| Logical `&&` and `\|\|` | Work as expected | Must use `-a` and `-o` |
| Performance | Faster (built-in) | Slower (external command) |
| Portability | Bash/Ksh only | POSIX (`sh` too) |
Use `[[ ]]` in Bash scripts. Use `[ ]` only if you need POSIX portability (running in `sh`).
Intermediate — What is `set -u`, and what problem does it solve?
`set -u` (or `set -o nounset`) makes Bash error immediately if you reference an undefined variable. Without it, undefined variables silently expand to an empty string, often causing bugs.
Example:
bash
set-u
count=$nonexistent# Error: nonexistent: unbound variableecho"$count"# Never reached
This catches typos like `$HOMEE` (you meant `$HOME`) or forgetting to define a variable. Combine with `set -e` and `set -o pipefail` for safe production scripts:
bash
set-euopipefail
Advanced — Explain the difference between `$*` and `$@`, and why it matters for function arguments.
Both expand to all positional arguments, but:
- `$*` → a single string: `"$1 $2 $3"` (joined by `IFS`)
- `"$@"` → separate strings: `"$1" "$2" "$3"`
This matters when you pass arguments to another function. Example:
bash
outer(){inner"$@"# Each arg passed separately (RIGHT)}
inner_bad(){inner$*# All args joined into one string (WRONG)}
If the caller invokes `outer "arg one" "arg two"`, the function receives two arguments. Using `$*` would join them into one argument `"arg one arg two"`.
**Always use `"$@"` when passing arguments to another function.**
Advanced — How does `bash -x` help you debug a script, and when would you use it?
`bash -x` runs a script with execution tracing enabled (`-x` is short for `xtrace`). Every command is printed to stderr before it runs, showing the exact command with all expansions and substitutions applied.
Example:
bash
#!/bin/bash# save as script.shname="Alice Smith"
cp"$name"/backup
Running:
terminal
$ bash-xscript.sh
+ name='Alice Smith'+ cp 'Alice Smith' /backupcp: cannot stat 'Alice Smith': No such file or directory
You see immediately that `name` contains a space, and the exact command that failed.
Use `bash -x` when:
- A script fails and you do not understand why
- A script produces unexpected output
- You are developing and need to verify expansions
Alternatively, add `set -x` inside the script to debug specific sections, and `set +x` to turn it off.
Advanced — Design a robust script that deletes files older than 7 days, with a dry-run mode and proper error handling.
Here is a production-grade script:
bash
#!/usr/bin/env bashset-euopipefail
readonlytarget_dir="/var/log/old"readonlymax_age_days=7readonlylog_file="/var/log/cleanup.log"dry_run="${1:-false}"
log(){echo"[$(date+'%Y-%m-%d %H:%M:%S')] $*"|tee-a"$log_file"}
cleanup(){# Ensure lock file is removed[[-f"$lock_file"]]&&rm-f"$lock_file"}trapcleanupEXIT
readonlylock_file="/var/run/cleanup-old-files.lock"if[[-f"$lock_file"]];thenlog"ERROR: Cleanup already running (lock file exists)"exit1fi
touch"$lock_file"if[[!-d"$target_dir"]];thenlog"ERROR: Target directory $target_dir does not exist"exit1fi
log"Starting cleanup: target=$target_dir, max_age=${max_age_days}d, dry_run=$dry_run"deleted_count=0total_freed_bytes=0# Use find to locate old files safely
find"$target_dir"-maxdepth1-typef-mtime"+${max_age_days}"-print0|whileIFS=read-rd''file;dofile_size=$(stat-c%s"$file"2>/dev/null||echo0)if[["$dry_run"=="true"]];thenlog"[DRY RUN] Would delete: $file ($(numfmt--to=iec$file_size2>/dev/null||echo$file_sizebytes))"elseifrm-f"$file";thenlog"Deleted: $file ($(numfmt--to=iec$file_size2>/dev/null||echo$file_sizebytes))"((deleted_count++))||true((total_freed_bytes+=file_size))||trueelselog"ERROR: Failed to delete $file"fifidoneif[["$dry_run"!="true"]];thenlog"Cleanup complete: deleted=$deleted_count files, freed=$(numfmt--to=iec$total_freed_bytes2>/dev/null||echo$total_freed_bytesbytes)"elselog"[DRY RUN] Complete (no files actually deleted)"fi
Key production techniques:
1. **`set -euo pipefail`** — catch all errors
2. **Lock file** — prevent concurrent runs
3. **Dry-run mode** — test before destroying
4. **Logging** — timestamped to a file
5. **Proper cleanup** — `trap cleanup EXIT`
6. **Safe file handling** — `find ... -print0 | while IFS= read -rd '' file`
7. **Human-readable output** — `numfmt` for byte sizes
Bash scripting is the glue that holds Linux systems together. The core idea is simple: a text file containing commands, run line by line, with the ability to branch (conditionals), repeat (loops), and reuse (functions). The challenge is not the syntax — it is discipline.
The four pillars of safe scripts separate working code from production code:
set -euo pipefail — stop on error, undefined variables, and pipe failures
Always quote variables — "$var" not $var
Avoid ls in loops — use find or while read
Debug with bash -x — see the actual commands
Internalize these four, and you will write scripts that do not destroy production systems at 3 a.m.
mindmap
root(("Bash Scripting"))
Fundamentals
Shebang & execution
Variables & expansion
Quoting (single/double/$'')
Exit codes & $?
Control Flow
if/[[ ]]/case
Loops (for/while/until)
break/continue
trap for cleanup
Data Structures
Indexed arrays
Associative arrays
${#arr[@]}
Functions
Parameters ($1 $@ $#)
local variables
Recursion
return codes
I/O
read, read -r
Redirects (>/>>/</<< EOF)
Here-documents
Command substitution
Safety (4 pillars)
set -e (errexit)
set -u (nounset)
set -o pipefail
Always quote "$var"
Debugging
bash -x execution trace
set -x / set +x
trap ERR
PS4 for debug prefix
Real-world
File operations safely
Logging & timestamps
Lock files
Error handling with || exit
T / F — In a Bash script, [ ] and [[ ]] are identical. False — [[ ]] is newer, safer, and handles word-splitting better.
T / F — The exit code 0 means the command failed. False — 0 means success; non-zero means failure.
T / F — You can use $* instead of "$@" when passing arguments to a function. False — "$@" preserves argument boundaries; $* concatenates them.
T / F — Using set -e guarantees the script will not fail silently. True — The script exits immediately on any non-zero exit code.
T / F — A shebang is just a comment. False — It is metadata that tells the kernel which interpreter to use.
T / F — Variables set in a pipe command are available after the pipe. False — The pipe runs in a subshell; variables set there are lost.
Lab: Write a script that deletes files older than 7 days#
Task: Write a script called cleanup.sh that:
- Deletes files in /var/log older than 7 days
- Supports a --dry-run flag (show what would be deleted without deleting)
- Logs each action with a timestamp
- Handles errors gracefully (file does not exist, permission denied)
- Prevents concurrent runs with a lock file
Example invocations:
terminal
$ ./cleanup.sh--dry-run# Show what would be deleted$ ./cleanup.sh# Actually delete
Hints:
1. Use find with -mtime +7 to find files older than 7 days
2. Parse --dry-run from $1
3. Create /var/run/cleanup.lock to prevent concurrent runs
4. Log output to /var/log/cleanup.log with timestamps
5. Use trap cleanup EXIT to remove the lock file
6. Check permissions before deleting: [[ -w "$dir" ]]
Answers
Here is a production-grade solution:
bash
#!/usr/bin/env bashset-euopipefail
readonlytarget_dir="/var/log"readonlymax_age_days=7readonlylog_file="/var/log/cleanup.log"readonlylock_file="/var/run/cleanup.lock"dry_run=falseif[["${1:-}"=="--dry-run"]];thendry_run=truefi
log(){localmsg="$1"echo"[$(date+'%Y-%m-%d %H:%M:%S')] $msg"|tee-a"$log_file"}
cleanup(){if[[-f"$lock_file"]];thenrm-f"$lock_file"log"Lock file removed"fi}trapcleanupEXIT
# Prevent concurrent runsif[[-f"$lock_file"]];thenlog"ERROR: cleanup already running (lock file exists)"exit1fi
touch"$lock_file"# Verify target directory exists and is writableif[[!-d"$target_dir"]];thenlog"ERROR: target directory $target_dir does not exist"exit1fiif[[!-w"$target_dir"]];thenlog"ERROR: target directory $target_dir is not writable"exit1fi
log"Starting cleanup: dry_run=$dry_run, target=$target_dir, age>${max_age_days}d"deleted_count=0error_count=0# Find and delete old files
find"$target_dir"-maxdepth1-typef-mtime"+${max_age_days}"-print02>/dev/null|whileIFS=read-rd''file;doif[[$dry_run=="true"]];thenlog"[DRY RUN] Would delete: $file"elseifrm-f"$file"2>/dev/null;thenlog"Deleted: $file"((deleted_count++))||trueelselog"ERROR: Failed to delete $file"((error_count++))||truefifidoneif[[$dry_run=="true"]];thenlog"Dry run complete (no files deleted)"elselog"Cleanup complete: deleted=$deleted_count, errors=$error_count"fi
**Key points:**
- `set -euo pipefail` at the start
- `readonly` for constants
- `trap cleanup EXIT` ensures lock is removed
- `find -mtime +7` finds files older than 7 days
- `-print0` and `read -rd ''` handle filenames safely
- `--dry-run` flag for testing before destructive operation
- Logging to file and stdout simultaneously with `tee -a`
The source PDF includes the following challenges (all preserved here):
Challenge 1: Write a script that reads a list of usernames from a file and creates a home directory for each if it does not already exist.
Challenge 2: Write a script that monitors a directory and alerts (via email or log) if a file is modified.
Challenge 3: Write a deployment script that pulls the latest code, runs tests, and rolls back if tests fail.
Challenge 4: Write a script that backs up all .conf files in /etc to a timestamped directory, compresses it, and uploads it to an S3 bucket.
Challenge 5: Write a system health check script that reports CPU, memory, disk usage, and service status; exit with a non-zero code if any metric is critical.
Answers
**Challenge 1: Create home directories**
bash
#!/usr/bin/env bashset-euopipefail
cpu_usage=$(top-bn1|grep"Cpu(s)"|awk'{print int($2)}')mem_usage=$(free|grepMem|awk'{print int($3/$2*100)}')disk_usage=$(df/|tail-1|awk'{print int($5)}')critical=0[[$cpu_usage-gt80]]&&{echo"CRITICAL: CPU at $cpu_usage%";critical=1;}[[$mem_usage-gt85]]&&{echo"CRITICAL: Memory at $mem_usage%";critical=1;}[[$disk_usage-gt90]]&&{echo"CRITICAL: Disk at $disk_usage%";critical=1;}ifsystemctlis-active--quietnginx;thenecho"OK: nginx running"elseecho"CRITICAL: nginx not running"critical=1fiexit$critical
VI — Automation
18The Automation Script Library
Twenty-six production-ready Bash scripts for system monitoring, backups, user management, container orchestration, and cloud operations — hardened and ready to copy into your toolchain.
Why write the same backup script three times? Why debug a log-rotation failure in production at 2 a.m. when the solution has already been documented and tested by dozens of teams?
A script library is a curated, version-controlled collection of automation routines that solve recurring problems. It is not poetry. It is industrial infrastructure — code that does one job reliably, works under stress, and comes with enough explanation that a colleague can modify it without breaking it at deploy time.
This chapter explains 26 production Bash scripts organised by category: system monitoring, file operations, backups, user provisioning, log management, service orchestration, Docker and Kubernetes operations, and cloud platforms (AWS, Jenkins). Each script includes:
What it does — one sentence
The hardened code — fully copy-pasteable, using shell safety practices from Chapter 17
How it works — line-by-line explanation
Real context — when you would use it, from where, and what success looks like
Think of a script library as a filing system for solutions.
In your personal life, you have one way you back up your laptop, one way you clean your desk, one recipe for coffee. Doing each task manually works fine. But at a company with 200 servers, 50 services, 100 cron jobs, and 10 teams, you have 200 backup routines, 50 health-check routines, and 100 different log-rotation attempts — many of them broken, conflicting, or undocumented.
A library says: here are the 10 scripts that actually work, tested in production, with clear input/output contracts. A new service copies them, changes the service name, and gets monitoring and backups for free.
The scripts in this chapter are Layer 2 — portable building blocks. Layer 3 (Ansible, Terraform) orchestrates them. Layer 1 (system utilities) is what they build on.
Script library. A collection of standalone, reusable shell scripts, each solving one focused problem (backup, monitor, clean, provision), with clear input parameters and predictable exit codes.
Hardening. The application of safety practices — set -euo pipefail, robust quoting, explicit loop constructs, and debugging-friendly output — to reduce the chance of silent failures or data loss when scripts run under stress (cron, automation, infrequent manual runs).
Idempotence. A script that produces the same result when run once or ten times: a backup script should be safe to run daily; a user-creation script should detect existing users and not fail if they already exist.
Exit codes. The return status of a script. 0 means success; non-zero means failure. Scripts in a library must return meaningful exit codes so orchestrators (cron, Ansible, Kubernetes) know whether to retry, alert, or proceed to the next step.
set -e — exit the script if any command exits with a non-zero code. Without this, errors cascade silently.
set -u — exit if you reference an undefined variable. Without this, typos like $DESTINATIO become empty strings and wipe your data.
set -o pipefail — if any command in a pipeline fails, the pipeline fails. Without this, cat file | grep pattern | awk ... succeeds even if cat fails.
Below that, every variable is quoted: "$var", "${array[@]}", $(command) instead of $var, ${array[@]} (unquoted), $(command) (sometimes unquoted). This prevents word splitting and glob expansion from turning file names into disaster.
Loops use while read -r line instead of for line in $(cat file), avoiding memory blowups and glob issues.
Checks CPU, memory, and disk usage against thresholds, prints colour-coded alerts, and logs results to a CSV file for trending and alerting.
What it does. One pass: read CPU/memory/disk, compare to thresholds, write CSV log, print coloured alerts.
When to use it. Run from cron every 5 minutes. Feed the CSV to a dashboard to trend resource usage. Alert when thresholds are exceeded.
bash
#!/bin/bashset-euopipefail
readonlyCPU_THRESHOLD=80readonlyMEM_THRESHOLD=80readonlyDISK_THRESHOLD=80readonlyLOG_FILE="${HOME}/system_report.csv"
main(){localcpu_usagemem_usagedisk_usagecpu_intmem_int
echo"Checking System Health..."# Get CPU usage (idle + iowait, subtracted from 100)cpu_usage=$(top-bn1|grep"Cpu(s)"|awk'{print $2 + $4}')cpu_int=${cpu_usage%.*}# strip decimal# Get memory usagemem_usage=$(free|grepMem|awk'{print $3/$2 * 100.0}')mem_int=${mem_usage%.*}# Get disk usage on root partitiondisk_usage=$(df-h/|awk'NR==2 {print $5}'|sed's/%//')# Log to CSV (append){echo"$(date'+%Y-%m-%d %H:%M:%S'),CPU:${cpu_int}%,Mem:${mem_int}%,Disk:${disk_usage}%"}>>"$LOG_FILE"# Print alerts in red if thresholds exceededif[[$cpu_int-ge$CPU_THRESHOLD]];thenecho-e"\033[31mCRITICAL: CPU usage high: ${cpu_int}%\033[0m">&2fiif[[$mem_int-ge$MEM_THRESHOLD]];thenecho-e"\033[31mCRITICAL: Memory usage high: ${mem_int}%\033[0m">&2fiif[[$disk_usage-ge$DISK_THRESHOLD]];thenecho-e"\033[31mCRITICAL: Disk usage high: ${disk_usage}%\033[0m">&2fiecho"System Health Check Completed."}
main"$@"
How it works:
Line 3–6: Declare thresholds and log file path as readonly constants.
Line 9–10: main() function isolates variables in a local scope.
Line 13–14: top -bn1 runs once without interactive mode; grep "Cpu(s)" extracts the CPU line; awk sums idle and iowait percentages (used metrics are user + system, so idle + iowait is inverse).
Line 15: ${var%.*} removes the decimal part (Bash parameter expansion, not cut or sed).
Line 18–19: free output: line 2 is memory. awk '{print $3/$2 * 100.0}' divides used by total.
Line 22–23: df -h / shows root partition; awk 'NR==2 {print $5}' extracts the percentage column; sed 's/%//' removes the % sign so comparison works numerically.
Line 26–28: Append a CSV row with timestamp and metrics. Using { echo ...; } >> file atomicity is better than echo ... >> file in loops.
Line 31–39: Each threshold check prints a red alert (\033[31m red, \033[0m reset) to stderr (>&2) because alerts are diagnostic, not output.
Line 41: Print completion message.
Line 44: main "$@" runs the function, passing any command-line arguments (though this script takes none).
Output example:
terminal
$ ./system-health-monitor.sh
Checking System Health...CRITICAL: CPU usage high: 85%System Health Check Completed.$ cat~/system_report.csv
2026-08-02 03:15:20,CPU:45%,Mem:72%,Disk:68%2026-08-02 03:20:21,CPU:85%,Mem:89%,Disk:72%
Production context: Run from a */5 * * * * /usr/local/bin/system-health-monitor.sh >> /var/log/health.log 2>&1 cron job. Parse the CSV with a Prometheus exporter or feed it to Grafana. Alert if CPU exceeds threshold for 3+ checks in a row (avoid false alarms on spikes).
A lightweight, single-purpose disk check. Useful when you want a fast, silent success and only print output if there is a problem.
What it does. Check if disk usage exceeds threshold. Exit 0 if OK, exit 1 if high. Print alert only if high.
bash
#!/bin/bashset-euopipefail
readonlyTHRESHOLD=80readonlyPARTITION="${1:?Partition name required (e.g., /)}"
main(){localdisk_usage
disk_usage=$(df-h"$PARTITION"|awk'NR==2 {print $5}'|sed's/%//')if[[$disk_usage-ge$THRESHOLD]];thenecho"ALERT: Disk usage on $PARTITION is ${disk_usage}% (threshold: ${THRESHOLD}%)">&2return1fireturn0}
main"$@"
How it works:
Line 5: ${1:?...} extracts the first argument (partition name like / or /home), or exits with the given error message if missing. This is better than $1 with a later check.
Line 10: Same df | awk pattern as before.
Line 12–14: If threshold exceeded, print to stderr and return exit code 1. Exit code 1 signals failure to cron or orchestrators.
Line 16: Return 0 (success) if under threshold.
Production context: Used in a monitoring dashboard (*/10 * * * * /usr/local/bin/disk-alert.sh / || curl -X POST http://alerts/notify). Can be chained: disk-alert.sh / && disk-alert.sh /home && echo "All partitions OK".
Captures uptime, CPU, memory, and disk in one report. Useful for one-off diagnostics or dashboards that poll every 30 seconds.
bash
#!/bin/bashset-euopipefail
main(){echo"=== System Snapshot at $(date'+%Y-%m-%d %H:%M:%S') ==="echo-e"\nUptime:"uptime
echo-e"\nCPU Usage:"top-bn1|grep"Cpu(s)"echo-e"\nMemory Usage (MB):"free-m
echo-e"\nDisk Space (all partitions):"df-h
}
main"$@"
Output example:
terminal
$ ./system-snapshot.sh
=== System Snapshot at 2026-08-02 03:25:30 ===Uptime: 03:25:30 up 45 days, 12:34, 1 user, load average: 0.45, 0.38, 0.42CPU Usage:%Cpu(s):15.2us,3.1sy,0.0ni,81.5id,0.2wa,0.0hi,0.0si,0.0st
Memory Usage (MB): total used free shared buff/cache availableMem: 16000 8234 4123 256 3643 7456Swap: 8000 1200 6800Disk Space (all partitions):Filesystem Size Used Avail Use% Mounted on/dev/sda1 50G 35G 15G 70% //dev/sda2 100G 42G 58G 42% /home
Production context: Call from a dashboard script that needs a full system overview; the script produces both human-readable output and can be parsed by grep to extract fields for monitoring. Useful for ad hoc diagnostics: ssh prod-server system-snapshot.sh.
Scans the filesystem (or a subtree) and ranks files by size. Useful for “the disk is full” incidents.
bash
#!/bin/bashset-euopipefail
readonlySTART_PATH="${1:-.}"readonlyLIMIT="${2:-10}"
main(){echo"Top ${LIMIT} largest files in ${START_PATH}:"echo""# -type f: regular files only# -exec du -h: size in human format# sort -rh: reverse sort by human numbers# head -n N: first N linesfind"$START_PATH"-typef-execdu-h{}+2>/dev/null\|sort-rh\|head-n"$LIMIT"\|awk'{print $2, "—", $1}'# swap columns for readability}
main"$@"
How it works:
Line 5: ${1:-.} — first arg is search path, default to current directory .
Line 6: Second arg is limit (how many files to show), default 10.
Line 13: find ... -exec du -h {} + — find all regular files, get size of each. The + batches commands to avoid starting du for each file (unlike \;).
Line 14: sort -rh — reverse sort by “human” numbers (50G > 1G, not alphabetical).
Line 15: head -n — limit to N results.
Line 16: awk swaps columns to make the output more readable.
Output example:
terminal
$ ./largest-files.sh/home5Top 5 largest files in /home:/home/archive/2025_backup.tar.gz — 25G/home/videos/movie.mkv — 8.5G/home/docker/image.tar — 4.2G/home/archive/logs-2024.zip — 3.1G/home/documents/database.db — 1.8G
Production context: When a on-call engineer reports “disk full”, run ssh prod-server largest-files.sh / 20 to identify what to archive or delete.
Sets up a development directory structure with dummy files for testing. Useful in CI/CD pipelines or local setup scripts.
bash
#!/bin/bashset-euopipefail
readonlyWORKSPACE_DIR="${1:-workspace}"readonlyFILE_COUNT="${2:-10}"
main(){echo"Creating workspace at ${WORKSPACE_DIR}..."mkdir-p"$WORKSPACE_DIR"cd"$WORKSPACE_DIR"# Create dummy test fileslocali
for((i=1;i<=FILE_COUNT;i++));dotouch"${i}.file.txt"echo"Sample content for file ${i}">>"${i}.file.txt"doneecho"Workspace created with ${FILE_COUNT} test files in ${WORKSPACE_DIR}."}
main"$@"
How it works:
Line 5–6: Accept workspace directory and file count as arguments, with defaults.
Line 11: Use C-style for loop for ((i=1; i<=N; i++)) for numeric iteration — cleaner than seq 1 N | while read i.
Line 13: Create file with a predictable name.
Line 14: Add sample content so files are not empty (useful for testing archiving, compression, etc.).
Production context: Run at the start of a CI pipeline to set up test data, or in a local script that sets up a sandbox for experimentation.
Interactively moves all .txt files from one directory to another, with validation and error handling.
bash
#!/bin/bashset-euopipefail
main(){localsrc_pathdest_pathfile_count
# Ask for source directoryread-rp"Enter the absolute path of the SOURCE directory: "src_path
# Validate source exists and is a directoryif[[!-d"$src_path"]];thenecho"ERROR: Source directory '$src_path' does not exist.">&2return1fi# Ask for destination directoryread-rp"Enter the absolute path of the DESTINATION directory: "dest_path
# Create destination if it doesn't existif[[!-d"$dest_path"]];thenecho"Destination does not exist. Creating: $dest_path"mkdir-p"$dest_path"||{echo"ERROR: Failed to create destination directory.">&2return1}fi# Count .txt files using nullglob to avoid glob expansionshopt-snullglob
localfiles=("$src_path"/*.txt)localfile_count=${#files[@]}if[[$file_count-eq0]];thenecho"No .txt files found in '$src_path'."shopt-unullglob
return0fi# Move all .txt filesecho"Moving ${file_count} .txt file(s) from $src_path to $dest_path..."mv"$src_path"/*.txt"$dest_path"||{echo"ERROR: Failed to move files.">&2shopt-unullglob
return1}echo"SUCCESS: Moved ${file_count} .txt files."shopt-unullglob
return0}
main"$@"
How it works:
Line 9–14: Read source path and validate. read -rp "prompt" is clearer than read -p "prompt" (raw mode, preserves backslashes).
Line 18–26: Create destination if needed. Use || { ... } to handle errors immediately.
Line 29: shopt -s nullglob — if no .txt files exist, the glob expands to nothing instead of the literal string *.txt. Without this, mv *.txt /dest would try to move a file named literally *.txt.
Line 30: Use an array files=(...) to count without shelling out to ls.
Line 32–36: If no files, exit cleanly with code 0 (not an error).
Line 40: Move all files. The glob is safe now because of nullglob.
Line 44–45: Always disable nullglob after use, to restore normal shell behavior.
Production context: Part of a nightly log rotation or archive script. Can be scheduled to move old files to a separate partition: cron: smart-file-mover.sh /var/log/old /archive/logs.
Archives a directory as a ZIP file and transfers it to a remote server over SCP. Requires passwordless SSH keys.
bash
#!/bin/bashset-euopipefail
main(){localsrc_pathzip_nameremote_userremote_ipremote_path
# 1. Ask for source pathread-rp"Enter the folder/file path to backup: "src_path
if[[!-e"$src_path"]];thenecho"ERROR: Path '$src_path' does not exist.">&2return1fi# Generate timestamped backup filenamezip_name="backup_$(date+%Y%m%d_%H%M%S).zip"echo"Creating archive: $zip_name"if!zip-rq"$zip_name""$src_path";thenecho"ERROR: Failed to create zip archive.">&2return1fiecho"Zip created successfully."# 2. Ask for remote detailsecho""echo"--- Remote Destination Details ---"read-rp"Enter Remote Username: "remote_user
read-rp"Enter Remote IP: "remote_ip
read-rp"Enter Remote Destination Path: "remote_path
# 3. Transfer via SCPecho"Transferring to ${remote_user}@${remote_ip}:${remote_path}"ifscp"$zip_name""${remote_user}@${remote_ip}:${remote_path}";thenecho"SUCCESS: Backup transferred."# Optionally clean up local copyread-rp"Remove local zip file? (y/n): "cleanup
if[["$cleanup"=="y"]];thenrm-f"$zip_name"echo"Local backup file removed."fireturn0elseecho"ERROR: Transfer failed.">&2return1fi}
main"$@"
How it works:
Line 8–12: Validate source path exists (file or directory).
Line 15: Generate a timestamped filename using date +%Y%m%d_%H%M%S.
Line 18–22: Create ZIP file. The if ! construct (not command) is clearer than if [ $? -ne 0 ].
Line 27–30: Prompt for remote details.
Line 33: SCP command with ${var} quoting ensures spaces in paths don’t break the transfer.
Line 34–40: On success, optionally delete the local copy to save disk.
Line 41–43: Return error code if transfer fails.
Production context: Run as a daily cron job: 0 2 * * * /usr/local/bin/remote-backup.sh. The script is interactive; in automation, pre-populate variables from a config file:
bash
# At the top:readonlyBACKUP_SOURCE="${BACKUP_SOURCE:-/var/www/html}"readonlyREMOTE_USER="${REMOTE_USER:-backups}"readonlyREMOTE_IP="${REMOTE_IP:-backup.company.com}"readonlyREMOTE_PATH="${REMOTE_PATH:-/backups/prod}"
Then source the config: . /etc/backup.conf && /usr/local/bin/remote-backup.sh.
Reads usernames from a file, creates accounts, sets temporary passwords, and forces a password reset on first login. Logs all actions.
bash
#!/bin/bashset-euopipefail
readonlyUSER_FILE="${1:-users.txt}"readonlyLOG_FILE="/var/log/user_provision.log"
main(){# Check if running as rootif[[$EUID-ne0]];thenecho"ERROR: This script must be run as root (or with sudo).">&2return1fi# Check if user file existsif[[!-f"$USER_FILE"]];thenecho"ERROR: User file not found: $USER_FILE">&2return1filocallineuser_count=0# Read each line from the user filewhileIFS=read-rline||[[-n"$line"]];do# Skip empty lines and comments[[-z"$line"]]&&continue[["$line"=~^#.*]]&&continue# Extract username (first field, space-separated or colon-separated)localusername="${line%% *}"[[-z"$username"]]&&continue# Check if user already existsifid"$username"&>/dev/null;thenecho"User '$username' already exists. Skipping."continuefi# Create user with home directory and bash shellifuseradd-m-s/bin/bash"$username";then# Set temporary passwordecho"${username}:TempPassword@2026"|chpasswd||{echo"WARNING: Failed to set password for $username">&2continue}# Force password change on first loginifchage-d0"$username";thenecho"[$(date'+%Y-%m-%d %H:%M:%S')] Created user: $username (password change required)"|tee-a"$LOG_FILE"((user_count++))elseecho"WARNING: Failed to force password change for $username">&2fielseecho"ERROR: Failed to create user $username">&2fidone<"$USER_FILE"echo"Successfully created ${user_count} user(s)."return0}
main"$@"
How it works:
Line 8–11: Check for root privileges. $EUID -ne 0 is cleaner than $(id -u).
Line 23: while IFS= read -r line || [[ -n "$line" ]] — read the file line by line. The || [[ -n "$line" ]] handles the case where the last line has no newline.
Line 25–26: Skip empty lines and comments (lines starting with #).
Line 29: Extract username (first whitespace-delimited field). The pattern ${var%% *} removes everything from the first space onward.
Line 32–34: Check if user exists using id (no need to parse /etc/passwd).
Line 37–47: Create user, set password, force password change on first login. Log each success.
Line 49–51: Count successful creations and report.
Expected input file (users.txt):
alice
bob
charlie # Full name or other metadata in comments
dave
# admin_user # This line is skipped
Production context: Used for on-boarding new team members or automated service-account provisioning. Always set a temporary password and force a change on first login — never hardcode production credentials in scripts.
Removes users and their home directories. Destructive; verify the user list before running.
bash
#!/bin/bashset-euopipefail
readonlyUSER_FILE="${1:-users.txt}"readonlyLOG_FILE="/var/log/user_provision.log"
main(){# Check if running as rootif[[$EUID-ne0]];thenecho"ERROR: This script must be run as root (or with sudo).">&2return1fiif[[!-f"$USER_FILE"]];thenecho"ERROR: User file not found: $USER_FILE">&2return1fiecho"WARNING: This script will DELETE users and their home directories."echo"Users to delete:"grep-v'^#'"$USER_FILE"|grep-v'^\s*$'||trueecho""read-rp"Continue? (yes/no): "confirm
if[["$confirm"!="yes"]];thenecho"Deletion cancelled."return0filocallineuser_count=0whileIFS=read-rline||[[-n"$line"]];do[[-z"$line"]]&&continue[["$line"=~^#.*]]&&continuelocalusername="${line%% *}"[[-z"$username"]]&&continueifid"$username"&>/dev/null;thenifuserdel-r"$username";thenecho"[$(date'+%Y-%m-%d %H:%M:%S')] Deleted user and home directory: $username"|tee-a"$LOG_FILE"((user_count++))elseecho"ERROR: Failed to delete user $username">&2fielseecho"User '$username' does not exist."fidone<"$USER_FILE"echo"Successfully deleted ${user_count} user(s)."return0}
main"$@"
How it works:
Line 20–25: Safety confirmation — show users to be deleted and ask for explicit “yes” confirmation.
Line 39: userdel -r removes the user and their home directory (-r flag).
Line 40: Log to a central audit log for compliance.
Production context: Used during off-boarding (rare, scripted with 2FA or approval gates). More commonly, disable accounts instead of deleting them:
bash
# Better approach for most cases
usermod-L"$username"# Lock password
usermod-s/usr/sbin/nologin"$username"# Disable login shell
Fast, interactive user creation for ad hoc testing or one-off provisioning.
bash
#!/bin/bashset-euopipefail
main(){if[[$EUID-ne0]];thenecho"ERROR: This script must be run as root.">&2return1firead-rp"Enter username to create: "username
if[[-z"$username"]];thenecho"ERROR: Username cannot be empty.">&2return1fiifid"$username"&>/dev/null;thenecho"ERROR: User '$username' already exists.">&2return1filocaltemp_password="TempPass@$(date+%s)"ifuseradd-m-s/bin/bash"$username";thenecho"$username:$temp_password"|chpasswd
echo"User created: $username"echo"Temporary password: $temp_password"echo"User MUST change password on first login."return0elseecho"ERROR: Failed to create user.">&2return1fi}
main"$@"
Production context: Quick provisioning in a pinch. For production, always use the bulk creation script with an approval workflow.
Scans auth.log for failed login attempts, identifies suspicious IPs, writes them to a blacklist, then archives and deletes old logs.
bash
#!/bin/bashset-euopipefail
readonlyLOG_DIR="/var/log"readonlyARCHIVE_DIR="./log_archive"readonlyBLACKLIST_FILE="./blacklist.txt"readonlyFAILED_THRESHOLD=3
main(){mkdir-p"$ARCHIVE_DIR"echo"--- Starting Security Audit and Log Rotation ---"echo""# 1. Find failed login attempts and identify suspicious IPsecho"Scanning for failed login attempts (> ${FAILED_THRESHOLD} per IP)..."if[[-f"$LOG_DIR/auth.log"]];then# Extract source IPs from failed login attempts# Format: "Failed password for root from 192.168.1.50 port..."grep-F"Failed password""$LOG_DIR/auth.log"2>/dev/null|\awk'{for (i=1; i<=NF; i++) if ($i == "from") print $(i+1)}'|\sort|uniq-c|\awk-vthreshold="$FAILED_THRESHOLD"'$1 > threshold {print $2}'>"$BLACKLIST_FILE"||truelocalthreat_count
threat_count=$(wc-l<"$BLACKLIST_FILE")echo"Threats identified: ${threat_count} suspicious IP(s) written to ${BLACKLIST_FILE}"elseecho"WARNING: $LOG_DIR/auth.log not found."fiecho""echo"--- Rotating Old Logs (> 7 days) ---"# 2. Archive logs older than 7 dayslocalarchived_count=0whileIFS=read-r-d''logfile;doecho"Archiving: $logfile"tar-rf"$ARCHIVE_DIR/old_logs_$(date+%F).tar""$logfile"||{echo"WARNING: Failed to archive $logfile">&2}((archived_count++))done<<(find"$LOG_DIR"-name"*.log"-mtime+7-print0)echo"Archived ${archived_count} log file(s)."# 3. Delete the original filesiffind"$LOG_DIR"-name"*.log"-mtime+7-delete;thenecho"Old logs deleted. Storage cleared."elseecho"WARNING: Failed to delete some old logs.">&2fiecho"--- Log Rotation Complete ---"return0}
main"$@"
How it works:
Line 17–23: Parse auth.log to extract IPs of failed login attempts. awk '{for (i=1; i<=NF; i++) if ($i == "from") print $(i+1)}' iterates through fields and prints the one after “from”.
Line 24: sort | uniq -c counts occurrences of each IP.
Line 25: Filter to IPs with more than $FAILED_THRESHOLD attempts.
Line 35: find ... -print0 outputs null-terminated filenames, used with < <(...) to safely handle files with spaces.
Line 37: Append to tar archive (non-destructive). Compress later: gzip "$ARCHIVE_DIR/old_logs.tar".
Line 44: Delete original files after archiving (cleanup is critical to free space).
A simpler cleanup-only variant (no archiving). Useful when logs are ephemeral or backed up separately.
bash
#!/bin/bashset-euopipefail
readonlyLOG_DIR="${1:-/var/log}"readonlyDAYS="${2:-30}"
main(){echo"Cleaning logs older than ${DAYS} days in ${LOG_DIR}..."localdeleted_count=0whileIFS=read-r-d''logfile;doecho"Deleting: $logfile"ifrm-f"$logfile";then((deleted_count++))elseecho"WARNING: Failed to delete $logfile">&2fidone<<(find"$LOG_DIR"-typef-name"*.log"-mtime+"$DAYS"-print0)echo"Deleted ${deleted_count} log file(s). Cleanup completed."return0}
main"$@"
Production context: Run nightly for systems with high log volume. Adjust DAYS based on retention requirements (corporate policy, compliance, auditing).
Checks if a critical service is running; if not, restarts it and logs the event.
bash
#!/bin/bashset-euopipefail
readonlySERVICE="${1:?Service name required (e.g., nginx)}"readonlyMAX_RESTARTS="${2:-3}"readonlyRESTART_LOG="/var/log/service_restarts.log"
main(){localrestart_countline
restart_count=0# Count recent restarts (today)if[[-f"$RESTART_LOG"]];thentoday=$(date+%Y-%m-%d)restart_count=$(grep"$SERVICE.*$today""$RESTART_LOG"|wc-l||true)fiifsystemctlis-active--quiet"$SERVICE";thenecho"OK: $SERVICE is running."return0elseecho"ALERT: $SERVICE is not running."# Prevent restart loops (stop if restarted too many times today)if[[$restart_count-ge$MAX_RESTARTS]];thenecho"ERROR: $SERVICE has been restarted ${restart_count} times today. Giving up.">&2echo"[$(date'+%Y-%m-%d %H:%M:%S')] $SERVICE restart limit exceeded (${restart_count} restarts)">>"$RESTART_LOG"return1fiecho"Attempting to restart $SERVICE..."ifsystemctlrestart"$SERVICE";thenecho"[$(date'+%Y-%m-%d %H:%M:%S')] $SERVICE restarted (restart ${restart_count})">>"$RESTART_LOG"echo"SUCCESS: $SERVICE restarted."return0elseecho"ERROR: Failed to restart $SERVICE.">&2echo"[$(date'+%Y-%m-%d %H:%M:%S')] $SERVICE restart FAILED">>"$RESTART_LOG"return1fifi}
main"$@"
How it works:
Line 8–10: Accept service name as an argument, and optional max restarts per day (prevent restart loops).
Line 18: systemctl is-active --quiet exits 0 if the service is active, non-zero if not.
Line 26–30: Count how many times this service was restarted today. If it exceeds MAX_RESTARTS, give up and alert (to avoid a restart loop that wastes resources).
Line 32–37: Attempt restart and log the result.
Production context: Run from cron every 5 minutes:
Lists all running containers with useful metadata.
bash
#!/bin/bashset-euopipefail
main(){echo"Running Docker containers:"echo""# Suppress errors if Docker is not running or no containers existdockerps--format"table {{.ID}}\t{{.Image}}\t{{.Names}}\t{{.Status}}\t{{.Ports}}"||{echo"ERROR: Unable to list Docker containers. Is Docker running?">&2return1}}
main"$@"
Output example:
terminal
$ ./docker-list.sh
Running Docker containers:CONTAINER ID IMAGE NAMES STATUS PORTSa1b2c3d4e5f6 nginx:latest nginx_prod Up 2 days 0.0.0.0:80->80/tcpf6e5d4c3b2a1 postgres:14 postgres_db Up 1 day 5432/tcp
Production context: Quick status check in dashboards or before deployments. Useful in CI/CD pipelines to confirm all services are running.
Commits each running container to an image and saves all images as tar archives for portability.
bash
#!/bin/bashset-euopipefail
readonlyBACKUP_DIR="${1:-/backup/docker}"
main(){mkdir-p"$BACKUP_DIR"echo"Backing up Docker containers and images..."echo""# 1. Commit each running container to an imageecho"--- Committing running containers ---"localcontainer_count=0whileIFS=read-rcontainer_id;do[[-z"$container_id"]]&&continuelocalcontainer_namebackup_image
container_name=$(dockerinspect-f'{{.Name}}'"$container_id"|sed's|^/||')backup_image="${container_name}-backup-$(date+%s)"echo"Committing container $container_id as $backup_image..."ifdockercommit"$container_id""$backup_image";then((container_count++))elseecho"WARNING: Failed to commit $container_id">&2fidone<<(dockerps-q2>/dev/null||echo"")echo"Committed ${container_count} container(s)."echo""# 2. Save all images as tar archivesecho"--- Saving all Docker images ---"localimage_count=0whileIFS=read-rimage_id;do[[-z"$image_id"]]&&continuelocalimage_name
image_name=$(dockerinspect-f'{{.RepoTags}}'"$image_id"|tr-d'[]'|sed"s/ .*//"|tr'/:''-')[["$image_name"=="<nil>"]]&&image_name="$image_id"localtar_file="$BACKUP_DIR/${image_name}.tar"echo"Saving image $image_name to $tar_file..."ifdockersave-o"$tar_file""$image_id";then((image_count++))elseecho"WARNING: Failed to save image $image_id">&2fidone<<(dockerimages-q2>/dev/null||echo"")echo"Saved ${image_count} image(s)."echo""echo"Backup completed. Total size:"du-sh"$BACKUP_DIR"}
main"$@"
How it works:
Line 15–29: Loop through running containers (using docker ps -q), commit each to an image with a timestamped name, and save the ID.
Line 19: docker inspect -f '{{.Name}}' gets the container name; sed 's|^/||' removes the leading /.
Line 33–49: Loop through all images, save each as a tar file.
Line 37: tr '/:' '-' sanitizes image names (remove colons and slashes, which are invalid in filenames).
Production context: Run before major cluster upgrades or maintenance. Restore a specific image with docker load -i /backup/docker/image-name.tar.
Reports any pod not in the Running state for a given namespace.
bash
#!/bin/bashset-euopipefail
readonlyNAMESPACE="${1:-default}"
main(){echo"Checking Kubernetes Pod Health in namespace: $NAMESPACE"echo""# Get all pods, filter for non-Running statusifkubectlgetpods-n"$NAMESPACE"--no-headers2>/dev/null|awk'$3 != "Running" {print "Pod " $1 " is in state " $3}';thenecho""echo"Health check completed."return0elseecho"ERROR: Unable to query pods. Is Kubernetes available?">&2return1fi}
main"$@"
Output example:
terminal
$ ./k8s-pod-health.shproduction
Checking Kubernetes Pod Health in namespace: productionPod nginx-abc123 is in state RunningPod postgres-xyz789 is in state CrashLoopBackOffPod redis-def456 is in state PendingHealth check completed.
Production context: Run from a monitoring agent or as part of a pre-deployment checklist. Combine with alerting:
Lists all nodes and highlights any not in “Ready” state.
bash
#!/bin/bashset-euopipefail
main(){echo"Checking Kubernetes Node Status..."echo""# Get all nodes; grep inverts to show non-Ready nodesifkubectlgetnodes2>/dev/null;thenecho""echo"Nodes NOT in Ready state:"kubectlgetnodes2>/dev/null|grep-v"Ready"||echo"All nodes are Ready."return0elseecho"ERROR: Unable to query nodes. Is Kubernetes available?">&2return1fi}
main"$@"
Production context: Part of cluster health dashboards. Alert if any node is NotReady.
Force-deletes all pods in the target namespace so they are recreated by their controllers (Deployments, StatefulSets, DaemonSets).
bash
#!/bin/bashset-euopipefail
readonlyNAMESPACE="${1:?Namespace required (e.g., production)}"
main(){echo"WARNING: This will delete ALL pods in namespace '$NAMESPACE'."echo"Pods will be recreated by their controllers (if managed)."echo""read-rp"Continue? (yes/no): "confirm
if[["$confirm"!="yes"]];thenecho"Cancelled."return0fiecho"Deleting all pods in $NAMESPACE..."ifkubectldeletepods--all-n"$NAMESPACE"--grace-period=0--force2>&1;thenecho"SUCCESS: All pods in $NAMESPACE have been deleted and will be recreated."return0elseecho"ERROR: Failed to delete pods.">&2return1fi}
main"$@"
Production context: Used to restart a misbehaving namespace without affecting other clusters. Common after a configuration update.
Syncs files from local disk to an AWS S3 bucket (one-way backup).
bash
#!/bin/bashset-euopipefail
readonlyBUCKET_NAME="${1:?Bucket name required (e.g., my-backup-bucket)}"readonlySOURCE_DIR="${2:-.}"
main(){echo"Syncing $SOURCE_DIR to s3://$BUCKET_NAME..."# --delete removes files in S3 that are not in local dir (careful!)# --include/--exclude can filter file typesifawss3sync"$SOURCE_DIR""s3://$BUCKET_NAME"\--regionus-east-1\--delete\--exclude'.git/*'\--exclude'.DS_Store'\--exclude'node_modules/*'2>&1;thenecho"SUCCESS: Sync completed."echo"Verifying..."locallocal_counts3_count
local_count=$(find"$SOURCE_DIR"-typef!-path'*/\.*'!-path'*/node_modules/*'|wc-l)s3_count=$(awss3ls"s3://$BUCKET_NAME"--recursive--regionus-east-1|wc-l)echo"Local files: ${local_count}"echo"S3 files: ${s3_count}"return0elseecho"ERROR: Sync failed.">&2return1fi}
main"$@"
How it works:
Line 14–19: aws s3 sync copies new/changed files. --delete removes files from S3 that are not on disk (use with caution). --exclude patterns skip files.
Line 23–26: Verify by counting files locally and in S3.
Line 4–7: All credentials are read from environment variables using the ${VAR:?} pattern. If an env var is unset, the script exits with an error message.
Line 15–17: curl -s (silent) posts to the Jenkins API with Basic Auth (-u user:token).
Production context: Source credentials from a secrets manager or CI/CD platform:
End-to-End: Building a Production Health & Backup Automation Stack#
This section demonstrates how to combine multiple scripts from the library into a complete, production-ready automation stack. We will assemble a health monitoring system, backup scheduler, and alert orchestrator that runs on a real server.
Create a script that runs all health checks and logs a summary:
bash
#!/bin/bash# /usr/local/lib/scripts/prod-healthcheck-all.shset-euopipefail
readonlySCRIPT_DIR="/usr/local/lib/scripts"readonlyLOG_DIR="/var/log"
main(){localcheck_timestatus_codeerrors=0check_time=$(date'+%Y-%m-%d %H:%M:%S')echo"[$check_time] Starting health checks...">>"$LOG_DIR/orchestration.log"# 1. System healthif"$SCRIPT_DIR/system-health-monitor.sh">>"$LOG_DIR/health.log"2>&1;thenecho" ✓ System health OK"elseecho" ✗ System health FAILED">&2((errors++))fi# 2. Service checksif"$SCRIPT_DIR/service-health-check.sh"nginx3>>"$LOG_DIR/service.log"2>&1;thenecho" ✓ Nginx OK"elseecho" ✗ Nginx FAILED">&2((errors++))fi# 3. Disk alertif"$SCRIPT_DIR/quick-disk-alert.sh"/>>"$LOG_DIR/disk.log"2>&1;thenecho" ✓ Disk space OK"elseecho" ✗ Disk space critical">&2((errors++))fiif[[$errors-eq0]];thenecho"[$check_time] All checks passed.">>"$LOG_DIR/orchestration.log"return0elseecho"[$check_time] $errors check(s) failed. Review logs.">>"$LOG_DIR/orchestration.log"return1fi}
main"$@"
Create a cron configuration file /etc/cron.d/prod-automation:
bash
# Production automation tasks# Health checks every 5 minutes
*/5****root/usr/local/lib/scripts/prod-healthcheck-all.sh>>/var/log/cron.log2>&1# Daily backup at 02:0002***rootBACKUP_SOURCE=/var/www/htmlREMOTE_USER=backupREMOTE_IP=backup.internalREMOTE_PATH=/backups/prod/usr/local/lib/scripts/remote-backup-transfer.sh>>/var/log/backup.log2>&1# Weekly log rotation and archive (Sunday at 03:00)03**0root/usr/local/lib/scripts/log-auditor-rotator.sh>>/var/log/audit.log2>&1# Weekly Docker cleanup (Sunday at 04:00)04**0root/usr/local/lib/scripts/docker-prune.sh<<<"yes">>/var/log/docker.log2>&1
Load it:
bash
sudoinstall-m644/etc/cron.d/prod-automation/etc/cron.d/prod-automation
sudosystemctlrestartcron# or `sudo systemctl restart crond` on some systems
$ cat/var/log/orchestration.log
[2026-08-02 03:00:00] Starting health checks... ✓ System health OK ✓ Nginx OK ✓ Disk space OK[2026-08-02 03:00:00] All checks passed.[2026-08-02 03:05:00] Starting health checks... ✓ System health OK ✓ Nginx OK ✓ Disk space OK[2026-08-02 03:05:00] All checks passed.
Feed this CSV into Grafana or Prometheus for trending:
bash
# Example: Prometheus exporter that reads the CSV and exposes metrics
cat>/etc/prometheus/node_exporter_custom.prom<<'EOF'# HELP system_health_cpu_percent CPU usage percentage# TYPE system_health_cpu_percent gaugesystem_health_cpu_percent 52# HELP system_health_memory_percent Memory usage percentage# TYPE system_health_memory_percent gaugesystem_health_memory_percent 71EOF
If a backup fails: The remote-backup-transfer.sh script logs to /var/log/backup.log. A cron monitoring daemon (or a simple wrapper) checks if the exit code is non-zero and sends a Slack alert:
bash
# Wrapper script with error handlingif!/usr/local/lib/scripts/remote-backup-transfer.sh;thensource/etc/scripts/lib-alerts.sh
alert_slack"CRITICAL""Backup failed. Check /var/log/backup.log"alert_email"Backup Failure""Backup failed on $(hostname)"exit1fi
If logs fill the disk: The log-auditor-rotator script runs weekly and archives logs older than 7 days. To prevent disk fill during high-traffic periods, add a pre-flight check:
bash
# Add to log-auditor-rotator.sh before archivingavailable_disk=$(df/var/log|awk'NR==2 {print $4}')if[[$available_disk-lt100000]];then# Less than 100MBsource/etc/scripts/lib-alerts.sh
alert_slack"CRITICAL""Log disk space critically low: ${available_disk}KB"# Force immediate cleanup of logs older than 3 days instead of 7find/var/log-name"*.log"-mtime+3-delete
fi
Add redundancy: Run critical scripts from two different cron schedulers (e.g., one on the main server, one on a backup monitoring host).
Use systemd timers instead of cron: For more advanced use cases, replace cron with systemd.timer units:
ini
# /etc/systemd/system/prod-healthcheck.timer[Unit]Description=Production health checks every 5 minutesRequires=prod-healthcheck.service[Timer]OnBootSec=1minOnUnitActiveSec=5minPersistent=true[Install]WantedBy=timers.target
This stack runs unattended, catches problems early, and keeps audit trails for compliance and debugging. Each script is independently testable and can be updated without touching the others.
This section walks through hands-on labs using 3–4 scripts from the library. You will set up a test system, run the scripts with real data, verify outputs, trigger failures, and integrate them into a production-like workflow.
# In your local script library directory
cpsystem-health-monitor.sh/usr/local/lib/scripts/
cpquick-disk-alert.sh/usr/local/lib/scripts/
cpservice-health-check.sh/usr/local/lib/scripts/
chmod+x/usr/local/lib/scripts/*.sh
Verify they are executable:
terminal
$ ls-la/usr/local/lib/scripts/
-rwxr-xr-x 1 user user 1234 Aug 2 04:00 system-health-monitor.sh-rwxr-xr-x 1 user user 567 Aug 2 04:00 quick-disk-alert.sh-rwxr-xr-x 1 user user 789 Aug 2 04:00 service-health-check.sh
Step 3: Test each script manually
Test 1: System health monitoring
Run the health monitor once to verify it works:
terminal
$ /usr/local/lib/scripts/system-health-monitor.sh
Checking System Health...System Health Check Completed.
Expected: Exit code 0 means disk is below threshold. No output (silent success).
Test with a partition that’s full (or simulate with a mock):
terminal
$ /usr/local/lib/scripts/quick-disk-alert.sh/nonexistent2>&1ALERT: Disk usage on /nonexistent is 95% (threshold: 80%)$ echo"Exit code: $?"Exit code: 1
Expected: Exit code 1 and an alert message. This script properly signals failure.
Test 3: Service health check
Verify nginx is installed and check it:
terminal
$ sudosystemctlstatusnginx
● nginx.service - A high performance web server and a reverse proxy server Loaded: loaded (/lib/systemd/system/nginx.service; enabled; vendor preset: enabled) Active: active (running)$ /usr/local/lib/scripts/service-health-check.shnginx3OK: nginx is running.$ echo"Exit code: $?"Exit code: 0
Expected: Exit code 0, confirmation that nginx is running.
Step 4: Create an orchestration script
Create a master script that runs all three checks and logs results:
bash
# Save as /usr/local/lib/scripts/health-orchestrator.sh#!/bin/bashset-euopipefail
readonlyLOG_FILE="/var/log/monitoring/orchestration.log"readonlySCRIPT_DIR="/usr/local/lib/scripts"
main(){localtimestamperrors=0timestamp=$(date'+%Y-%m-%d %H:%M:%S')echo"[$timestamp] Starting health checks..."|tee-a"$LOG_FILE"# 1. System healthif"$SCRIPT_DIR/system-health-monitor.sh">>/var/log/monitoring/health.log2>&1;thenecho" ✓ System health OK"|tee-a"$LOG_FILE"elseecho" ✗ System health FAILED"|tee-a"$LOG_FILE"((errors++))fi# 2. Disk alertif"$SCRIPT_DIR/quick-disk-alert.sh"/>>/var/log/monitoring/disk.log2>&1;thenecho" ✓ Disk space OK"|tee-a"$LOG_FILE"elseecho" ✗ Disk space critical"|tee-a"$LOG_FILE"((errors++))fi# 3. Service healthif"$SCRIPT_DIR/service-health-check.sh"nginx3>>/var/log/monitoring/service.log2>&1;thenecho" ✓ Nginx OK"|tee-a"$LOG_FILE"elseecho" ✗ Nginx FAILED"|tee-a"$LOG_FILE"((errors++))fiif[[$errors-eq0]];thenecho"[$timestamp] All checks passed."|tee-a"$LOG_FILE"return0elseecho"[$timestamp] $errors check(s) failed."|tee-a"$LOG_FILE"return1fi}
main"$@"
$ /usr/local/lib/scripts/health-orchestrator.sh
[2026-08-02 04:20:15] Starting health checks... ✓ System health OK ✓ Disk space OK ✓ Nginx OK[2026-08-02 04:20:15] All checks passed.
Check the log file:
terminal
$ cat/var/log/monitoring/orchestration.log
[2026-08-02 04:20:15] Starting health checks... ✓ System health OK ✓ Disk space OK ✓ Nginx OK[2026-08-02 04:20:15] All checks passed.
Step 6: Schedule with cron
Add a cron job to run every 5 minutes:
bash
# Edit crontab
crontab-e
# Add this line:
*/5****/usr/local/lib/scripts/health-orchestrator.sh
$ tail/var/log/monitoring/orchestration.log
[2026-08-02 04:20:15] All checks passed.[2026-08-02 04:25:20] All checks passed.[2026-08-02 04:30:18] All checks passed.
Goal: Set up a complete backup lifecycle: create backups, verify them, and practice restoring.
Step 1: Prepare backup directories
bash
mkdir-p/backup/restore_test
chmod700/backup
Step 2: Create test data
Create sample data to back up:
bash
mkdir-p/opt/myapp/data
echo"Important data $(date)">/opt/myapp/data/file1.txt
echo"Config setting=value">/opt/myapp/data/config.ini
ddif=/dev/urandomof=/opt/myapp/data/binary.datbs=1Mcount=5
Step 3: Run the backup script
Copy and run the local-backup-restore.sh:
terminal
$ /usr/local/lib/scripts/local-backup-restore.sh
Backup and Restore Utility1. Backup2. RestoreChoose an option (1 or 2): 1Starting backup of /opt/myapp/data...SUCCESS: Backup completed at /backup/backup-2026-08-02-04-35-22.tar.gz-rw-r--r-- 1 root root 5.2M Aug 2 04:35 /backup/backup-2026-08-02-04-35-22.tar.gz
Verify the archive exists:
terminal
$ ls-lah/backup/
total 5.2M-rw-r--r-- 1 user user 5.2M Aug 2 04:35 backup-2026-08-02-04-35-22.tar.gz
The backup has one extra entry (the directory itself), which is expected.
Step 5: Test restore
Simulate data loss by deleting the original:
bash
rm-rf/opt/myapp/data/*
ls/opt/myapp/data/
# (should be empty)
Restore from backup using the script:
terminal
$ /usr/local/lib/scripts/local-backup-restore.sh
Backup and Restore Utility1. Backup2. RestoreChoose an option (1 or 2): 2Available backups:-rw-r--r-- 1 user user 5.2M Aug 2 04:35 /backup/backup-2026-08-02-04-35-22.tar.gzEnter backup filename to restore (e.g., backup-2026-08-02-04-35-22.tar.gz): backup-2026-08-02-04-35-22.tar.gzRestoring from backup-2026-08-02-04-35-22.tar.gz (this will overwrite existing files)...Are you sure? (yes/no): yesSUCCESS: Restore completed.
Verify restored data:
terminal
$ ls/opt/myapp/data/
binary.dat config.ini file1.txt$ cat/opt/myapp/data/file1.txt
Important data Wed Aug 02 04:35:15 UTC 2026
Goal: Audit security logs for threats and clean up old logs.
Step 1: Generate test auth logs
Simulate failed login attempts:
bash
# Add fake auth.log entries (normally done by system)foriin{1..5};doecho"$(date'+%b %d %H:%M:%S') myhost sshd[1234]: Failed password for root from 192.168.1.100 port $((22000+i)) ssh2">>/tmp/test_auth.log
doneforiin{1..3};doecho"$(date'+%b %d %H:%M:%S') myhost sshd[1235]: Failed password for root from 192.168.1.200 port $((22100+i)) ssh2">>/tmp/test_auth.log
doneforiin{1..2};doecho"$(date'+%b %d %H:%M:%S') myhost sshd[1236]: Failed password for root from 10.0.0.50 port $((22200+i)) ssh2">>/tmp/test_auth.log
done
Review the test log:
terminal
$ cat/tmp/test_auth.log
Aug 02 04:40:15 myhost sshd[1234]: Failed password for root from 192.168.1.100 port 22000 ssh2Aug 02 04:40:16 myhost sshd[1234]: Failed password for root from 192.168.1.100 port 22001 ssh2...
ALERT: nginx is not running.
Attempting to restart nginx...
SUCCESS: nginx restarted.
Verify it came back up:
terminal
$ sudosystemctlstatusnginx
● nginx.service - A high performance web server and a reverse proxy server Loaded: loaded Active: active (running)
Scenario B: Disk full condition
Simulate disk pressure by filling a partition (on non-critical test partition only!):
bash
# Create a large test file (example: 500MB)
ddif=/dev/zeroof=/tmp/filltestbs=1Mcount=500# Run disk alert
/usr/local/lib/scripts/quick-disk-alert.sh/tmp
Expected output:
ALERT: Disk usage on /tmp is 82% (threshold: 80%)
Clean up:
bash
rm/tmp/filltest
Scenario C: Backup failure recovery
Make the backup directory read-only to simulate permission failure:
bash
sudochmod444/backup
Try to create a backup:
terminal
$ /usr/local/lib/scripts/local-backup-restore.sh
Backup and Restore Utility1. Backup2. RestoreChoose an option (1 or 2): 1Starting backup of /opt/myapp/data...ERROR: Backup failed.
Optional: Deploy the health orchestrator to a second test server via SSH, and aggregate results:
bash
# On monitoring server, aggregate status from multiple hostsforhostinweb-01web-02db-01;dossh"$host"/usr/local/lib/scripts/health-orchestrator.sh&&\echo"✓ $host OK"||echo"✗ $host FAILED"done
This demonstrates how library scripts scale from single-server automation to fleet management.
Beginner — What does set -e do, and why would you use it in a script?
`set -e` causes the script to exit immediately if any command exits with a non-zero status (i.e., an error). Without it, a script will continue executing even if a critical command fails — for example, a backup script that fails to create a ZIP but then uploads an empty file. Using `set -e` ensures errors are caught early, and the script does not proceed with invalid data.
Beginner — What is the difference between $var and "$var"?
`$var` (unquoted) is subject to word splitting and glob expansion. If `$var` contains a space or special characters, the shell will interpret it incorrectly. For example:
bash
var="hello world"echo$var# Prints: hello world (split into two arguments)echo"$var"# Prints: hello world (one argument, correct)path="/tmp/file with spaces.txt"
ls$path# Fails: ls treats it as three files
ls"$path"# Works: ls sees one file name
Always quote variables: `"$var"`, `"${array[@]}"`, `"$(command)"`.
Intermediate — Explain the difference between $(command) and `command` (backticks).
Both capture command output, but `$(command)` is preferred because it nests better and is easier to read. Backticks are legacy syntax.
bash
# Backticks (avoid)date=`date`result=`echo$(echo"nested")`# Hard to read and parse# Command substitution (prefer)date=$(date)result=$(echo$(echo"nested"))
Use `$(command)` in all modern scripts. Backticks are still valid but considered outdated.
Intermediate — When would you use while read instead of a for loop?
Use `while read` when iterating over lines from a file or command, especially if the lines contain spaces or special characters. Use `for` only for simple lists of words.
bash
# Good: for simple word listforuserinalicebobcharlie;doecho"Creating user: $user"done# Bad: for file processing (will break on spaces)forlinein$(catusers.txt);douseradd"$line"# Fails if line has spacesdone# Good: for file processingwhileread-rline;douseradd"$line"done<users.txt
# Good: for command output with -d (set delimiter)whileIFS=:read-rusernameuidgidcommenthomeshell;doecho"User: $username (UID: $uid)"done</etc/passwd
The `-r` flag tells `read` not to interpret backslashes.
Intermediate — A script is failing silently. How would you debug it?
Add `set -x` at the top of the script to print each command before execution. This shows exactly where things go wrong.
bash
#!/bin/bashset-euopipefail
set-x# Debug: print each command# Rest of script...
Or run the script with `bash -x script.sh`. The output will show which command failed and what values variables had at that point.
For production, conditionally enable debugging:
bash
[["${DEBUG:-}"=="1"]]&&set-x
Then run: `DEBUG=1 ./script.sh`.
Intermediate — How do you safely pass user input to a command in a script?
Never use user input directly in a command. Always quote it and, when possible, validate it first.
bash
# Bad: user can inject commandsread-p"Enter filename: "filename
cat$filename# User could enter: $(rm -rf /)# Better: quote the variableread-rp"Enter filename: "filename
cat"$filename"# Best: validate the input firstread-rp"Enter filename: "filename
if[[!"$filename"=~^[a-zA-Z0-9._-]+$]];thenecho"Invalid filename">&2return1fi
cat"$filename"
The key principle: **treat all input as untrusted**.
Advanced — Design a script that backs up a critical database daily, verifies the backup, and alerts if it fails.
Model answer:
bash
#!/bin/bashset-euopipefail
readonlyDB_NAME="production"readonlyBACKUP_DIR="/backup"readonlyALERT_EMAIL="oncall@company.com"readonlyALERT_SLACK_WEBHOOK="https://hooks.slack.com/services/..."
main(){localbackup_filebackup_size
# 1. Create backupbackup_file="${BACKUP_DIR}/backup_${DB_NAME}_$(date+%Y%m%d_%H%M%S).sql"if!mysqldump"$DB_NAME">"$backup_file"2>&1;thenalert"Backup FAILED"return1fi# 2. Verify backup (restore to temp DB and test)backup_size=$(du-h"$backup_file"|awk'{print $1}')if!mysql_verify_backup"$backup_file";thenalert"Backup verification FAILED (size: $backup_size)"return1fi# 3. Archive to remote storageif!scp"$backup_file""backup@remote:/archive/";thenalert"Backup transfer FAILED"return1fi# 4. Log successecho"[$(date'+%Y-%m-%d %H:%M:%S')] Backup successful: ${backup_file} (${backup_size})">>/var/log/backup.log
return0}
alert(){localmessage="$1"echo"ALERT: $message">&2curl-XPOST"$ALERT_SLACK_WEBHOOK"\-H'Content-Type: application/json'\-d"{\"text\":\"[$(hostname)] $message\"}"}
main"$@"
Key points:
- Separate the backup, verify, and transfer stages so each can fail independently.
- Log both success and failure with timestamps.
- Alert via multiple channels (email, Slack) so nothing is missed.
- Verify the backup works before considering it done (restore-to-temp-DB pattern).
Advanced — How would you design a script library that multiple teams can use?
Model answer:
1. **Version control:** Keep scripts in Git with a `CHANGELOG.md`. Tags mark stable versions.
2. **Documentation:** Each script has a header comment with usage, dependencies, and examples.
3. **Installation:** Provide a simple installer or Ansible role that copies scripts to `/usr/local/bin` and sets permissions.
4. **Testing:** Include unit tests (shell script test framework like `bats`) so teams trust the scripts.
5. **Logging:** All scripts log to a standard location and include run context (who, when, what).
6. **Configuration:** Accept config files (e.g., `/etc/company/scripts.conf`) so teams can customize thresholds without editing scripts.
7. **Exit codes:** Document exit codes (0 = success, 1 = error, 2 = invalid input) so scripts compose reliably.
Example structure:
Scripts source shared functions from `lib/` and load config from `config/defaults.conf`. Teams can override settings per-server in `/etc/scripts/local.conf`.
A script library is a curated collection of production-tested automation routines. The 26 scripts in this chapter cover five categories: system monitoring (health checks, disk alerts, snapshots), file operations and backups (local and remote), user and log management (provisioning, auditing, cleanup), service and container orchestration (health checks, Docker, Kubernetes), and cloud operations (AWS, Jenkins).
Every production script must:
Open with #!/bin/bash and set -euo pipefail to catch errors early
Quote all variables ("$var", "${array[@]}")
Use while read -r instead of for loops on external data
Validate inputs and fail loudly if dependencies are missing
Avoid hardcoded credentials; use environment variables
Include a confirmation prompt before destructive operations
A library is discovered and used because teams know where to find it, understand what each script does, and trust that it has been tested. Version it in Git, document it in markdown headers, and compose it into higher-level automation (Ansible, cron jobs, Kubernetes manifests).
mindmap
root((Script Library))
System Monitoring
Health checks
Disk space
CPU/Memory
Snapshots
File Operations
Backups (local)
Backups (remote)
Move/rotate
Cleanup
User Management
Create bulk
Delete bulk
Single creation
Log Management
Audit failures
Rotate old
Clean old
Containers
Docker list
Docker prune
Kubernetes pods
Kubernetes nodes
Cloud
EC2 list
S3 sync
Jenkins trigger
Jenkins status
The chapter teaches a production-ready pattern: each script is a standalone tool that combines core utilities (grep, awk, find, curl) with safety practices (error handling, logging, exit codes) and composition hooks (exit codes that orchestrators can use to chain scripts together).
SYSTEM MONITORING
system-health-monitor.sh CPU, memory, disk → CSV, coloured alerts
quick-disk-alert.sh Is disk > 80%? Exit 0/1, silent if OK
system-snapshot.sh One-pass: uptime, CPU, memory, disk
largest-files.sh Find top N files by size (debug "disk full")
active-ssh-sessions.sh Who is logged in? (pre-maintenance check)
FILE OPERATIONS & BACKUPS
workspace-initializer.sh Create test dir with N dummy files
smart-file-mover.sh Move *.txt from src → dest, validate, count
remote-backup-transfer.sh ZIP src, SCP to remote, optional cleanup
local-backup-restore.sh Menu: backup → tar.gz, restore from list
USER & LOG MANAGEMENT
bulk-user-creation.sh Read users.txt, create, force pwd change
bulk-user-deletion.sh Read users.txt, delete + home dirs (confirm)
quick-single-user.sh Interactive: create one user + temp password
log-auditor-rotator.sh Scan auth.log for IPs with 3+ failed logins
automated-log-cleanup.sh Delete logs > N days (simple, no archive)
SERVICE & CONTAINER MANAGEMENT
service-health-check.sh Is service running? If not, restart (max 3/day)
docker-list.sh Show running containers (ID, image, status)
docker-prune.sh Delete dangling images (confirm, measure)
docker-backup.sh Commit containers + save all images as tar
k8s-pod-health.sh Any pods not Running in namespace?
k8s-node-status.sh Any nodes not Ready?
k8s-restart-pods.sh Delete all pods in namespace (recreate)
k8s-live-monitor.sh Watch pods (equivalent to kubectl get --watch)
CLOUD & CI/CD
ec2-list.sh Query AWS: instance ID, IP, type, state
s3-sync.sh Sync local dir → S3 bucket (--delete safe)
jenkins-trigger.sh POST to Jenkins API, trigger job
jenkins-check-build.sh GET last build status (SUCCESS/FAILED/UNSTABLE)
PATTERN: All scripts validate inputs, log outcomes, return exit codes 0/1,
and source credentials from environment, not hardcoded strings.
Q1: What does set -o pipefail do?
A. Exits if any variable is undefined.
B. Exits if any command in a pipeline fails.
C. Pipes all output to a file.
D. Allows pipes to run in parallel.
**Answer: B.** Without `pipefail`, a failed command earlier in a pipeline is masked by a successful command later. Example: `cat missing.txt | grep pattern | wc -l` succeeds (reports 0 lines) even though the file is missing.
Q2: A script has a line: rm -rf $backup_dir/*. What is the risk?
A. The script will not run.
B. If `$backup_dir` is undefined, it will delete from the current directory.
C. The script will hang.
D. No risk; this is the correct syntax.
**Answer: B.** Without quotes, `$backup_dir` could be empty or undefined. Using `set -u` catches this, and quoting (`"$backup_dir"`) is a second safety layer.
Q3: When should you use `while read` instead of for in Bash?
A. Always; `for` is deprecated.
B. When reading lines from a file or command output, especially with spaces or special characters.
C. For simple word lists only.
D. When iterating over arrays.
**Answer: B.** `for` is fine for simple word lists, but `while read -r var < file` handles newlines, spaces, and special characters correctly.
Q4: Your backup script runs daily but has never failed. How do you know if it is actually working?
A. You do not need to verify; if there is no error, it worked.
B. Check the file size; if it is growing, backups are working.
C. Restore from one backup once a month to verify integrity.
D. Run the script manually once to confirm.
**Answer: C.** Backups are only as good as your ability to restore them. Schedule a monthly restore-to-test-environment to verify backups are valid and complete.
Q5: A user-creation script reads from `users.txt`. How do you prevent it from crashing if the file is missing?
A. Use `test -f "$USER_FILE" || exit 1` at the top.
B. Check at the start with `if [[ ! -f "$USER_FILE" ]]; then echo error; return 1; fi`.
C. Add a line `USER_FILE="${1:?users.txt file required}"` and fail early with a helpful message.
D. All of the above.
**Answer: D.** All three patterns work. Option C is the most concise and provides the best error message.
Q6: You run a cleanup script that deletes logs older than 30 days. The next morning, you see disk usage is up 5%. What happened?
A. The cleanup script failed silently.
B. The cleanup script deleted the wrong files.
C. New logs were created overnight, and the script did not run.
D. You cannot know without checking the logs.
**Answer: D.** Without logging from the cleanup script, you cannot tell what happened. The script should log "Deleted N files" or "No files to delete" so you can verify.
Q7: Your script calls jq to parse JSON, but it fails on a new server. Why?
A. The script syntax is wrong.
B. `jq` is not installed on that server.
C. JSON input is invalid.
D. The pipe is broken.
**Answer: B.** The script should validate dependencies at the start:
bash
command-vjq>/dev/null||{echo"jq not found";exit1;}
Q8: A Docker backup script saves images to `/backup/docker` but runs out of space. How do you fix this?
A. Increase `/backup` partition size.
B. Add a check at the start: `df /backup | awk 'NR==2 {if ($4 < 1000000) exit 1}'` (fail if < 1GB free).
C. Compress images after saving: `gzip *.tar`.
D. All of the above.
**Answer: D.** All are valid. B is the fastest (fail early), A is the real fix, C saves space.
Q9: Your Kubernetes health-check script runs `kubectl get pods` and prints all pods, even Running ones. How do you fix it to show only problems?
A. Add `| grep -v Running`.
B. Use `awk '$3 != "Running" { print }'`.
C. Change the query: `kubectl get pods --field-selector=status.phase!=Running`.
D. Both A and B.
**Answer: D.** Both `grep` and `awk` work. Option C is the best (pushes the filter to kubectl, fewer false positives).
Q10: You hardcode an AWS API token in a Jenkins trigger script. What is wrong?
A. It is inefficient.
B. Anyone with access to the script source has the token; tokens should be in environment variables or secrets managers.
C. The script will not work.
D. Nothing; this is fine for internal tools.
**Answer: B.** Hardcoding credentials is a security anti-pattern. Use `export JENKINS_API_TOKEN=$(aws secretsmanager ...)` or load from a config file with restricted permissions (mode 0600).
Scenario: You are an SRE who maintains a production web server. Set up a 5-minute monitoring script that checks CPU, disk, and a critical service (nginx). If any threshold is exceeded, write to a log file and exit with code 1. If all is OK, write a success line and exit 0.
Steps:
Create a script /usr/local/bin/prod-health-check.sh that:
- Checks CPU > 80% (use top or /proc/stat)
- Checks disk / > 80% (use df)
- Checks nginx is running (systemctl is-active nginx)
- Logs outcome (timestamp, status, any problems)
- Returns 0 if all OK, 1 if any problem
Add it to cron: */5 * * * * /usr/local/bin/prod-health-check.sh >> /var/log/health.log 2>&1
Verify by running it manually and checking the log.
Trigger a failure (fill disk, stop nginx) and confirm the script catches it.
Expected output on success:
2026-08-02 04:00:00 OK: CPU 45%, Disk 62%, nginx running
Expected output on failure:
2026-08-02 04:05:00 CRITICAL: CPU 92% (threshold 80%)
2026-08-02 04:05:00 CRITICAL: Disk 88% (threshold 80%)
Every production Bash script starts with #!/bin/bash and _________________. Answer:set -euo pipefail
To safely read a file line-by-line, use _________________ instead of a for loop. Answer:while read -r var (or while IFS= read -r var)
When a script fails, it should exit with code _________________ (not 0). Answer: Any non-zero value (typically 1, but can be 2, 3, etc. for different failure types)
A script that deletes files older than 30 days should confirm with the user first using: Answer:read -rp "Continue? (yes/no): " confirm; if [[ "$confirm" != "yes" ]]; then return 0; fi
To load credentials from an environment variable and fail if it is missing, use: Answer:${VAR:?error message} (e.g., readonly API_TOKEN="${JENKINS_API_TOKEN:?JENKINS_API_TOKEN not set}")
T/F: A script that continues after an error is safer than one that exits immediately. Answer: False. Continuing after an error causes silent failures. set -e is essential.
T/F: You should always quote variables in Bash scripts. Answer: True. Unquoted variables are subject to word splitting and glob expansion, which breaks on spaces and special characters.
T/F: Logging to a file is optional for production scripts. Answer: False. Logs are the only record of what happened. Without them, you cannot debug failures or audit compliance.
T/F: It is safe to hardcode credentials in a script if the script has restricted file permissions. Answer: False. Credentials should never be in scripts. Use environment variables or a secrets manager.
T/F: A backup script that has never failed is guaranteed to be working. Answer: False. The only way to verify a backup works is to restore from it once a month.
VII — Containers
19Containers & Docker
From images and layers to production-grade orchestration — how Docker packages applications and Linux kernel features enable isolation.
You know how to write code. You can build an application that works perfectly on your laptop, commit it to Git, and push it to production. Then it breaks: a different Python version, a missing system library, an environment variable you forgot to document, a port already in use. The person on-call at 3 a.m. — who may be you in two years — will ask: Was it installed differently? Is the OS different? Is the kernel different? This is the problem Docker was built to solve.
Docker is the answer to a simple question: How do you make “it works on my machine” true everywhere? It captures your entire application — code, libraries, runtime, dependencies, environment — in a container, a lightweight box that runs identically on your laptop, a CI server, a cloud VM, and a Kubernetes cluster. To understand Docker, you need to know what it is (an application package), what it does (isolates processes with Linux kernel features), and how you use it (through commands, images and registries).
You write a Node.js app that requires Node 18, PostgreSQL, Redis and three npm packages.
You send the code to Ops.
Ops tries to install it on a production server running Node 16, only the pg package, and no Redis.
It breaks. You ask them to upgrade. They ask you to support Node 16. You ask them to read the README.
Three days and one Slack escalation later, it works — on that one machine. But the next production server is slightly different.
Without containers, every machine is a unique snowflake, and deployment is archaeological: hunting down which specific versions are installed where, why they differ, and whether it is safe to upgrade.
Docker solves this with containerisation: you define your entire application environment in a Dockerfile, build it once into an image, and run that same image as a container on any Docker host. The image is immutable and portable. The container is the running instance.
Reproducibility — the same image produces the same container behaviour on any host with Docker installed.
Portability — develop on macOS, test on Linux in CI, deploy on cloud VMs, and run on Kubernetes — same image, zero changes.
Efficiency — containers start in milliseconds and use only the OS resources they need; 50 containers on a host is unremarkable, whereas 50 VMs would be prohibitive.
Standardisation — every team uses the same tool, format and workflow; Docker has become the lingua franca of application packaging.
Ecosystem — registries (Docker Hub, ECR, GCR), orchestrators (Kubernetes), and CI/CD platforms are all built around Docker images.
A VM is a full building. A container is a locked room inside one building.
Separation of concerns
VirtualBox / VMware / KVM Docker on one Linux host
App 1 kernel App 2 kernel Shared Linux kernel
OS image OS image ────────────────────
(2 GB) (2 GB) Isolated root Isolated root
Hypervisor ←bootloader filesystem filesystem
Hardware ←privileged code (container 1) (container 2)
Each VM is slow to boot, uses Containers share the kernel,
full OS stack, but breaks out boot instantly, light weight,
safely if something goes wrong. but share the kernel's blast
radius.
A virtual machine feels like a separate computer: it has its own kernel, own root filesystem, own boot sequence. To run it costs GB of RAM and minutes to start.
A container is a process on your Linux host, but with an isolated filesystem, isolated network interface, isolated process ID space, and limits on its memory and CPU. It shares the kernel with the host and every other container. The isolation is clever but not absolute — a kernel exploit affects all containers on that host.
This single difference drives everything else:
- Containers are fast (no bootloader, no kernel startup).
- Containers are efficient (shared kernel, shared libraries, only your app’s unique layers use space).
- Containers are dangerous in one specific way (shared kernel) and safe in another (one compromised process is one process, isolated from others by namespaces).
An image is a blueprint. A container is the house built from it.
Image vs container
IMAGE (read-only) CONTAINER (running)
┌─────────────────────────┐ ┌──────────────────────────┐
│ Base OS layer │ │ Read-only layers (image) │
│ (Ubuntu, Alpine) │ → │ Read-write layer (temp) │
│ Application layer │ │ Memory (running process) │
│ Configuration layer │ │ Open file descriptors │
│ (all read-only, shared) │ │ (unique to this running │
│ │ │ instance) │
└─────────────────────────┘ └──────────────────────────┘
File: /app/data.txt When container stops:
Always contains "original" Changes to /app/data.txt are
because image is immutable. LOST. The container is gone.
An image is a set of stacked, read-only filesystem layers. You build it once, and it never changes (unless you rebuild). Multiple containers can run from the same image.
A container is an image plus a read-write layer, running process state, and allocated resources. When you run a container, it adds a temporary filesystem layer on top of the read-only image layers. Any changes to files go into that temporary layer. When you stop the container, the temporary layer is gone and the changes are lost — unless you explicitly save them (via volumes).
This is why containers are ephemeral — they are meant to be replaced, not maintained.
Docker is one implementation. The OCI (Open Container Initiative) is the blueprint everyone follows.
When Docker was new, it was the only tool. Now podman, containerd, LXC and others implement the same OCI Image Format and OCI Runtime Specification, so a Dockerfile builds to a standard image, and any compliant runtime can run it.
Think of it like airlines: Docker is one airline with its own planes and rules, but the OCI is the international aviation standard that ensures a plane that lands in London also lands safely in Tokyo.
Container. A lightweight, isolated running environment for an application. It includes a filesystem (from an image), a process or group of processes, network interfaces, environment variables, and resource limits (memory, CPU). The isolation is achieved by Linux kernel features: namespaces (process, network, mount, IPC, UTS, user) and cgroups (resource limits). A container is ephemeral — it is meant to be replaced, not long-lived.
Image. An immutable, layered filesystem template for a container. An image is built from a Dockerfile and consists of stacked read-only layers — base OS (e.g. Ubuntu, Alpine), application binaries, libraries, and configuration. Multiple containers can be created from the same image, and each gets its own read-write layer for temporary changes. An image is identified by a name and tag (e.g. nginx:latest, postgres:15-alpine).
Dockerfile. A text file containing instructions to build an image. Each instruction (FROM, RUN, COPY, CMD) creates or modifies a layer. The Dockerfile is to containers what a recipe is to a meal — it defines the exact steps to prepare the environment.
Registry. A networked service that stores and distributes images. Docker Hub is the default public registry; AWS ECR, Google Artifact Registry, and others are private registries. Pushing an image to a registry makes it available to any host with credentials to pull it.
Docker daemon. A background process that builds images, runs containers, manages volumes and networks, and accepts commands from the Docker CLI. It typically runs as root on Linux or is managed by Docker Desktop on macOS/Windows.
Per-process isolation of PID, network, mount, IPC, hostname, user. Two processes in different namespaces cannot see each other’s processes, sockets, filesystems, or identities.
Containers live in separate namespaces so they cannot interfere with the host or each other.
cgroup (Linux kernel)
Control group; limits on a process’s memory, CPU, disk I/O, and PIDs. A container’s cgroup prevents it from consuming all the host’s resources.
Resource limits are enforced by the kernel; a container cannot exceed them.
Image layer
A set of filesystem changes from the previous layer. Each Dockerfile instruction creates a layer. Layers are read-only and shared if identical.
Efficient storage: if 50 containers run the same base image, they share that layer on disk. Only their changes are separate.
Container filesystem
The image’s layers mounted read-only, plus a container-specific read-write layer on top. Changes during runtime go into the read-write layer.
Containers are ephemeral; stopping a container discards the read-write layer.
Volumes
Named storage managed by Docker, or bind mounts (directories from the host mounted into the container). Volumes persist when the container stops.
Applications need persistent data (databases, logs, configs). Volumes decouple data from container lifecycle.
Network
Containers have isolated network namespaces; the host and other containers appear as separate machines. Docker manages bridges, overlay networks and DNS.
Containers must be able to communicate with the host and each other, but in a controlled, isolated way.
flowchart TB
subgraph Image["IMAGE (immutable, shared)"]
Base["Layer 0: Ubuntu base (20 MB)"]
Middle["Layer 1: Python 3.11 (50 MB)"]
App["Layer 2: App code (5 MB)"]
end
subgraph Container["CONTAINER (ephemeral, per-instance)"]
RW["Read-write layer (temp, empty at start)"]
Mem["Memory state (PID 1, environment)"]
FDs["File descriptors, sockets"]
end
Image -->|mounted read-only| RW
RW -->|on top of| Base & Middle & App
Base & Middle & App -.->|shared across all containers| Image
RW -.->|deleted when container stops| Container
The genius: if you run 100 containers from the same image, the read-only layers are shared (one copy on disk). Only the read-write layers are separate (100 small temporary directories). Pushing an image to a registry transmits only the new layers, not the whole application.
One container’s shm_open() call does not see another’s.
UTS
Hostname and domain name. hostname in a container returns the container ID, not the host name.
docker run --hostname myapp sets the container’s hostname.
User
UID/GID mappings. A UID inside a container can map to a different UID on the host.
A container running as UID 1000 inside can map to UID 100000 on the host (rootless containers).
Practical consequence:docker run --pid=host runs a container outside the PID namespace, so it sees all the host’s processes. This is dangerous and rarely used.
Each instruction creates a new layer. The final image is these layers stacked, each read-only. When you build again and only app.py changes, layers 0–2 are cached and reused; only layer 3 (the COPY) is rebuilt.
This is why build times matter: put statements that change frequently late in the Dockerfile.
constexpress=require('express');
constapp=express();
app.get('/',(req,res)=>res.send('Hello from Docker'));
app.listen(3000,()=>console.log('Listening on port 3000'));
This starts a container, maps port 8000 on the host to port 3000 inside, and sets the environment variable. The HEALTHCHECK allows Docker to detect if the app crashed.
The final image contains only stage 2: the built output and production dependencies, not the build tools. This cuts image size dramatically (builder stage might be 500 MB; final image 80 MB).
This defines two services: a Node app built from the local Dockerfile and a PostgreSQL database. When you run docker-compose up, both containers start, are networked together (the web service can reach the database at hostname db), and volumes persist data.
$ dockerimagels
REPOSITORY TAG IMAGE ID CREATED SIZEmycurl 1.0 a1b2c3d4e5f6 4 seconds ago 45MBubuntu 22.04 b5c6d7e8f9a0 2 weeks ago 77MBpostgres 15 c7d8e9f0a1b2 2 weeks ago 370MB
Field
Meaning
REPOSITORY:TAG
The image name; ubuntu:22.04 means the ubuntu image tagged 22.04. latest is the default tag.
$ dockerps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMESabc123def456 myapp:1.0 "node app.js" 2 minutes ago Up 2 minutes 0.0.0.0:8000->... myappxyz789uvw012 postgres:15 "postgres" 2 minutes ago Up 2 minutes 5432/tcp db
Field
Meaning
CONTAINER ID
Short hash of the running instance.
IMAGE
Which image this container was created from.
COMMAND
The command running inside (from CMD or ENTRYPOINT).
STATUS
Up/Exited and how long.
PORTS
Port mappings and protocol.
NAMES
Container name (or auto-generated if you didn’t provide one).
Returns a JSON blob with everything: image ID, environment variables, volumes, port mappings, resource limits, namespace information, and more.
One-liners to extract useful bits:
bash
dockerinspect-f'{{.State.Pid}}'myapp# PID on the host
dockerinspect-f'{{.HostConfig.Memory}}'myapp# RAM limit in bytes
dockerinspect-f'{{.Config.Env}}'myapp# Environment variables
-it means interactive (-i) and allocate a terminal (-t). You now have a shell inside the running container. Type exit to leave.
This is how you debug: if an application is behaving oddly, docker exec into it and inspect files, logs, network connections, running processes.
bash
dockerexecmyapppsaux# Processes inside the container
dockerexecmyappcurlhttp://localhost:3000# Test the app from inside
dockerexecmyappcat/etc/os-release# OS info
# Create a named volume
dockervolumecreatemydata
# Mount it into a container
dockerrun-vmydata:/app/datamyapp:1.0
# See where it lives on the host
dockervolumeinspectmydata
# Create a user-defined bridge network
dockernetworkcreatemynet
# Run two containers on the network
dockerrun-d--networkmynet--namewebmyapp:1.0
dockerrun-d--networkmynet--namecacheredis:7
# Inside 'web', you can reach 'cache' by hostname
dockerexecwebcurlhttp://cache:6379
User-defined networks support DNS hostname resolution between containers. The default bridge network does not.
# Log in (prompts for username/token)
dockerlogindocker.io
# Tag the image with a registry prefix
dockertagmyapp:1.0myusername/myapp:1.0
# Push it
dockerpushmyusername/myapp:1.0
# Pull it elsewhere
dockerpullmyusername/myapp:1.0
Beginner — What is a Docker container, in one sentence?
A lightweight, isolated running environment for an application — a process or group of processes running on the Linux host, isolated from others by kernel namespaces and constrained by cgroups, with its own view of the filesystem, network, and process tree.
Beginner — What is the difference between a Docker image and a container?
An image is an immutable template: a set of stacked read-only filesystem layers built from a `Dockerfile`. A container is a running instance created from an image — it adds an ephemeral read-write layer and runs processes with allocated resources (CPU, memory). Many containers can run from the same image; each is a separate instance.
Beginner — What does `docker run -p 8000:3000 myapp` do?
Starts a container from image `myapp` and publishes a port: traffic coming to port 8000 on the host machine is forwarded to port 3000 inside the container. The format is `host:container`.
Beginner — What is the `CMD` vs `ENTRYPOINT` in a Dockerfile?
`CMD` specifies the default command to run when the container starts. `ENTRYPOINT` defines the executable, and `CMD` becomes its arguments. If you provide a command at `docker run`, it overrides `CMD` but not `ENTRYPOINT`. Best practice: use `ENTRYPOINT` for the main executable and `CMD` for default arguments.
Intermediate — Explain Docker layers and why they matter.
Each instruction in a `Dockerfile` (`FROM`, `RUN`, `COPY`, etc.) creates a new layer — a set of filesystem changes on top of the previous layer. Layers are immutable and read-only in images. When you build, Docker caches layers; if you rebuild and only the last instruction changes, Docker reuses the cached earlier layers. This makes rebuilds fast. When you run a container, all image layers are mounted read-only, and a new read-write layer is added on top. Multiple containers from the same image share the image layers on disk; only the read-write layers are separate.
Intermediate — What are Linux namespaces, and which ones does Docker use?
Namespaces are a kernel feature that isolate resource visibility: each namespace gives a process a private view of a resource (processes, network, filesystems, IPC, hostname, user IDs). Docker uses all six: PID (process isolation), network (separate IP, port space), mount (separate `/` filesystem), IPC (message queues, shared memory), UTS (hostname), and user (UID mapping for rootless containers). Two containers in different namespaces cannot see each other's processes, sockets, or files.
Intermediate — What are cgroups, and how do they differ from namespaces?
cgroups (control groups) are a kernel feature that *limits resource usage*: memory, CPU, I/O, and PID count. Namespaces isolate visibility; cgroups enforce hard limits. A container with `-m 512m` cannot use more than 512 MB RAM — if it tries, the kernel kills processes (OOMkilled). Without cgroups, a misbehaving container could consume all the host's resources. Both are required for practical containerisation.
Intermediate — What is a volume, and when would you use a bind mount instead?
A volume is persistent storage managed by Docker, stored in `/var/lib/docker/volumes/`. Volumes persist when the container stops, so they are for application data (databases, user uploads, configs). A bind mount is a host directory mounted into the container (`-v /host/path:/container/path`); it is useful for development (editing code on the host and seeing changes in the container) or sharing a host system directory. For production, volumes are cleaner; for development, bind mounts are more convenient.
Advanced — Walk me through what happens when you run `docker run -it ubuntu bash`.
The Docker daemon reads the `ubuntu` image (or pulls it from the registry). It creates new Linux namespaces (PID, network, mount, IPC, UTS). It creates a cgroup to limit resources. It mounts the image's read-only layers and a read-write layer on top at the union mount point. It clones a new process in those namespaces (so the process is PID 1 in that namespace), which then runs `execve("/bin/bash")`. Inside the container, `bash` is PID 1, has its own network interface (visible via `eth0`), cannot see the host's processes, and any file it writes goes to the read-write layer. When you `exit` bash, the process exits, the namespaces are destroyed, and the read-write layer is discarded.
Advanced — How does the read-only + read-write layer model enable efficiency?
Image layers are read-only and shared across containers. If you run 100 containers from the same image, they all share the same 100 MB of image layers on disk — layer data is stored once. Each container gets its own small read-write layer (typically empty unless the app writes files). This is why 100 containers run efficiently on one host, whereas 100 VMs would be prohibitive. When you push an image to a registry, Docker transmits only new layers; if a layer already exists in the registry, it is not uploaded again.
Advanced — What is the difference between Docker and Podman, and why does the OCI standard matter?
Docker is the original container tool; Podman is a newer, rootless alternative developed by Red Hat. Both produce OCI-compliant images and can use OCI-compliant runtimes. The `Dockerfile` is the same, and a Docker image can be run by Podman and vice versa. The OCI (Open Container Initiative) standard defines the image format and runtime specification, so containers are not locked into one tool. This is important because if Docker becomes unsuitable or unavailable, your images are not stranded — any OCI runtime can use them.
Advanced — How would you debug an application that works locally but fails in a container?
First, capture the container's logs: `docker logs myapp`. If that is not enough, `docker exec -it myapp bash` to get a shell inside and inspect `/app`, environment variables, running processes (`ps aux`), and network connectivity (`curl`, `netstat`). Check if the container is resource-constrained: `docker stats myapp` shows live memory/CPU. Use `docker inspect myapp` to verify environment variables and volume mounts. Compare the container's environment to your local machine: `docker exec myapp env`, `docker exec myapp uname -a`, `docker exec myapp cat /etc/os-release`. Often the problem is an environment variable, a missing file (check volume mount), or a port binding issue.
Scenario — Your team's Python app works in development but crashes on startup in production (in a container). What are the first three things you check?
1. **Logs**: `docker logs myapp` to see what the error is. If the container exited immediately, `docker logs` still shows the output.
2. **Environment variables**: `docker inspect myapp | grep -A 50 '"Env"'` to see what was passed. The app might be looking for a `DATABASE_URL` that was not set.
3. **Filesystem**: `docker exec myapp ls -la /app` to verify all files are present and readable. A `COPY` in the `Dockerfile` might have failed silently, or permissions might be wrong.
If still stuck, run the image locally with the same environment (`docker run -e DATABASE_URL=... myapp`) to reproduce the failure locally.
Scenario — You need to add a system dependency (e.g. `curl`) to an image. Where would you add it, and why?
Add it as early as possible in the `Dockerfile`, after `FROM`, to maximise cache hits:
dockerfile
If you do it the other way and only `app.py` changes, rebuilding would re-run `apt-get`, wasting time. By putting `RUN` statements early, you cache the slow layer. Every rebuild only re-runs layers that changed or are after a changed layer.
Company style — Why does your company standardise on Docker?
Docker ensures reproducibility: the same `Dockerfile` produces the same image everywhere, eliminating "it works on my machine" problems. It enables efficient deployment — containers start in milliseconds and are lightweight, so we can scale horizontally. It standardises our build and deployment pipeline — every service uses the same Dockerfile format, registry, and orchestration tool (Kubernetes). It integrates with our CI/CD: every commit builds an image, every image is tested, every image is versioned and can be rolled back. Docker is the lingua franca; every developer knows it, reducing context switching.
Company style — What is the difference between our Docker images and our Kubernetes deployments?
A Docker image is the package — it defines what runs. A Kubernetes deployment is the orchestrator — it defines *how many* copies run, *where* they run, how they are networked, and how they recover if they crash. You build a Docker image locally, push it to a registry, and then write a Kubernetes deployment that says "run 3 replicas of this image, scale up if CPU > 70%, restart if unhealthy." Kubernetes watches the deployment and manages the containers.
HR style — Tell me about a time you debugged a container issue in production.
A good answer: "Our payment service container was crashing intermittently. I started with `docker logs` to see OOMkilled errors. I checked `docker inspect` and found the memory limit was set too low (512 MB) for the JVM. I looked at the deployment manifest and realised the memory request was misconfigured. I increased it to 1 GB, redeployed, and the crashes stopped. I then set up alerts on OOMkilled events so we'd catch future memory issues sooner." This shows you know the debugging tools and think systematically about root cause.
For local development and testing, docker-compose brings up a full stack:
bash
# Start all services
docker-composeup
# In background
docker-composeup-d
# View logs
docker-composelogs-fweb
# Stop and remove containers (volumes persist)
docker-composedown
# Down and remove volumes
docker-composedown-v
# Restart services
docker-composerestart
# Run a one-off command in a service
docker-composeexecdbpsql-Uuser-dappdb
Podman also supports podman-compose for multi-container setups. The OCI standard means images and containers are interchangeable between Docker and Podman.
Run as non-root: USER appuser in the Dockerfile. If you must run as root, use --read-only to prevent filesystem writes.
Drop capabilities: docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE myapp. Most apps need only NET_BIND_SERVICE (for binding ports < 1024) or CHOWN (for file ownership).
Use --read-only filesystem: docker run --read-only myapp. If the image is read-only, attackers cannot write a persistence mechanism. Pair with --tmpfs /tmp if the app needs temp files.
Scan images for vulnerabilities: docker scan myapp:1.0 or use Trivy to check for known CVEs in your base image and dependencies.
Sign images: Container image signing (via Notary or Cosign) cryptographically verifies the publisher.
Graceful shutdown: Handle SIGTERM signals so the app can close connections and flush buffers:
bash
# Bad: the container may still be receiving requests
CMD["python","app.py"]# Good: shell traps SIGTERM and forwards it
CMD["/bin/sh","-c","trap 'kill -TERM $PID' TERM; python app.py & wait $PID"]
A Docker image is: (a) a running process (b) an immutable template with read-only layers (c) a volume (d) a network bridge
Which instruction in a Dockerfile creates a layer? (a)FROM(b)ENV(c)RUN(d)EXPOSE
docker run -p 8000:3000 myapp means: (a) run myapp on port 3000 (b) expose port 8000 inside the container (c) map host port 8000 to container port 3000 (d) run myapp on port 8000
Linux namespaces isolate: (a) resource usage (b) visibility of processes, network, files (c) disk I/O (d) memory limits
cgroups enforce: (a) filesystem isolation (b) process visibility (c) resource limits (d) port mappings
A volume is used for: (a) temporary container-only storage (b) read-only image layers (c) persistent data that survives container restart (d) logging
The difference between COPY and ADD is: (a) no difference (b)ADD extracts tarballs automatically (c)COPY is used in docker-compose(d)ADD is more secure
docker exec -it myapp bash does: (a) build a new image from myapp (b) run a bash shell inside the running container myapp (c) start a new container named bash (d) SSH into myapp
Which is a rootless container runtime? (a) Docker (b) Podman (c) KVM (d) VirtualBox
The OCI standard ensures: (a) containers are faster (b) images are portable across container runtimes (c) Docker is the only runtime (d) containers use less memory
Answers
1. (b) — immutable template with read-only layers.
2. (c) — `RUN` executes a command and creates a layer.
3. (c) — left side is host, right side is container.
4. (b) — namespaces isolate visibility.
5. (c) — cgroups enforce resource limits.
6. (c) — volumes persist data.
7. (b) — `ADD` auto-extracts tarballs.
8. (b) — runs a command inside the running container.
9. (b) — Podman runs rootless by default.
10. (b) — OCI ensures portability.
An image is mutable; you can edit it after building.
Multiple containers can run from the same image.
Volumes are deleted when a container stops.
Namespaces isolate resource usage.
cgroups enforce visibility isolation.
docker exec -it bash runs a new container.
The OCI standard ensures Docker is the only portable container runtime.
EXPOSE 3000 in a Dockerfile makes port 3000 accessible from the host.
Podman and Docker produce the same OCI image format.
Answers
1. **False** — a container is a process with isolated namespace, not a full OS. It shares the kernel.
2. **False** — an image is immutable. Changes are made by editing the `Dockerfile` and rebuilding.
3. **True**.
4. **False** — volumes persist when the container stops. That is their purpose.
5. **False** — namespaces isolate visibility; cgroups limit resources.
6. **False** — namespaces isolate visibility; cgroups are about resources.
7. **False** — `docker exec` runs a command in an existing running container.
8. **False** — the OCI standard ensures *multiple* runtimes (Docker, Podman, containerd, LXC) can run images.
9. **False** — `EXPOSE` documents the port; `docker run -p 8000:3000` actually publishes it.
10. **True**.
Do these on a host with Docker installed (Docker Desktop on macOS/Windows, or Docker daemon on Linux).
Build a simple image. Write a Dockerfile that starts from ubuntu:22.04, installs curl, and sets CMD ["curl", "--version"]. Build it: docker build -t mycurl:1.0 .. Run it: docker run mycurl:1.0. What is the output?
Layer caching. Rebuild the image without changes: docker build -t mycurl:1.0 .. How much faster is it the second time? Why? Now edit the Dockerfile, change the apt-get install line, and rebuild. What layers were rebuilt?
Mount a volume. Create a directory on your host: mkdir /tmp/data. Build an image that writes a file to /app/data. Run the container with docker run -v /tmp/data:/app/data mycurl:1.0. Did the file appear on your host? Restart the container — does the file still exist?
Run a multi-container stack. Write a docker-compose.yml with two services: a simple web app (build from your Dockerfile) and a Redis cache. Port-map the web app. Run docker-compose up -d. Can you curl the web app from your host? Use docker-compose logs web to view logs.
Debug inside a running container. Start a container: docker run -d --name debug myimage. Run docker exec -it debug bash. Inside the container, inspect /proc/self, the network interfaces (ip addr), and environment variables (env). What can you learn about the container’s isolation?
Push to a registry. Create a free Docker Hub account. Tag your image: docker tag mycurl:1.0 yourusername/mycurl:1.0. Push it: docker push yourusername/mycurl:1.0. From another machine (or a fresh shell), pull and run it: docker pull yourusername/mycurl:1.0.
Explain the container model end-to-end. Describe what happens when you run docker run -p 8000:3000 -e NODE_ENV=prod -v data:/app/data myapp:1.0, from Docker daemon interactions, through namespace and cgroup creation, to the container starting. Where are the image layers, where is the read-write layer, and what happens when the container stops?
Design a production Dockerfile. Write a multi-stage Dockerfile for a Go application: build stage compiles the Go binary (with build tools), runtime stage copies only the binary to a scratch or alpine base. Measure the size difference between a single-stage and multi-stage build.
Security audit. Take any public image (e.g. nginx:latest) and check: what user does it run as? Which capabilities are granted? Write a hardened version that runs as non-root and drops unnecessary capabilities.
Networking investigation. Create three containers on a user-defined bridge network. Can they resolve each other by hostname? Can they resolve by IP? Compare to the default bridge network — why is the difference?
Troubleshooting scenario. A containerised app crashes on startup with no visible error. Walk through the debugging process: which commands would you run, in what order, and what would each tell you?
Compare Docker vs VMs. Start a container and a VM, measure startup time, RAM usage, and disk footprint. When would you choose each? When would you run containers on VMs?
Learn Podman. Install Podman and run the same Dockerfile and container commands. What is identical? What is different (e.g., networking, rootless mode)? Why does the OCI standard matter here?
Docker Compose deep dive. Write a realistic three-service stack: web app, PostgreSQL database, and Redis cache. Use health checks, volume mounts, and environment variables. Then: scale the web service to 3 replicas and observe what happens.
Layer caching optimisation. Take a complex Dockerfile (50+ lines) and reorder instructions to minimise rebuild time. Put slow, unchanging layers early and fast, frequently-changing layers late. Measure rebuild speed before and after.
Rootless and security. Set up rootless Docker or Podman. Build and run the same image. Compare the security model: what can an escaped container process do? Why is rootless better, and what are the trade-offs?