All lessons
Linux & serversBeginner15 min readIn my stack

Linux basics: the shell, files and pipes

The filesystem, the commands you type every day, and how to glue them together.

  • Filesystem
  • Shell
  • Pipes
  • Editors
  • Help
Step 1/5
cat — played here by an actual cat — pours access.log into the pipe. Each | hands one command's output to the next as input.

In 30 seconds

Linux is the operating system behind almost every web server. You talk to it through a shell: you type a command, it runs a small program and prints text back. Learn where files live, a dozen everyday commands, and how pipes chain them together, and you can find and fix most server problems on your own.

Why I use it

  • I use Linux every week: all the servers I run for my own products and for clients are Ubuntu machines that I manage over SSH.
  • When production breaks, the answer is almost always in a log file or a config file. Knowing the shell well is what lets me find it in minutes instead of hours.
  • The shell is the one tool that works the same on a cheap VPS, inside a Docker container and in a CI pipeline, so the time I invest in it pays off everywhere.
  • Small pipelines like grep, sort and uniq answer questions that would otherwise need a script or a dashboard.

How it works

01What Linux and a distribution are

Strictly speaking, Linux is only the kernel: the core program that talks to the hardware, schedules processes and manages memory. A distribution (distro) bundles that kernel with a package manager, system tools, a default configuration and a release schedule. On servers you will mostly meet two families. The Debian family (Debian, Ubuntu) uses .deb packages and apt. The Red Hat family (RHEL, Rocky Linux, AlmaLinux, Fedora) uses .rpm packages and dnf. For servers, pick a long-term support (LTS) release: Ubuntu LTS versions come out every two years and get five years of standard security updates, Debian stable gets around five years with its LTS extension, and RHEL-compatible releases get about ten. Almost everything in this lesson works the same on every distro; the main differences are package names, the package manager and a few config file locations.

  • Kernel: the core of the OS. Distro: kernel plus tools, packages and defaults.
  • Debian / Ubuntu: apt and .deb. RHEL / Rocky / Alma: dnf and .rpm.
  • Shell: the program that reads your commands. Bash is the default on most servers.

02The filesystem hierarchy: where things live

Linux has one tree of directories starting at / (the root). There are no drive letters: extra disks are attached (mounted) somewhere inside that tree. The layout follows a standard called the Filesystem Hierarchy Standard, so once you learn it you can find your way around any server. A useful rule: configuration lives in /etc, things that change while the system runs (logs, caches, databases, websites) live in /var, programs live in /usr, and people live in /home. Everything is a file, including devices (/dev) and live kernel information (/proc, /sys), which is why so many tools are just about reading and writing text.

  • /etc: system and service configuration (nginx, ssh, cron, users).
  • /var: changing data. /var/log for logs, /var/www for websites by convention, /var/lib for databases and app state.
  • /usr: installed programs and libraries. /usr/local for things you install by hand.
  • /home/your_user: personal files. /root is the root user's home.
  • /tmp: temporary files, often cleared on reboot. /opt: optional, self-contained apps.
  • /dev, /proc, /sys: devices and live kernel and process information.

03Navigating and managing files

A handful of commands cover most daily work. pwd prints where you are, cd moves you, ls lists. Paths starting with / are absolute; anything else is relative to the current directory. A single dot means here, two dots mean the parent and a tilde means your home. Be careful with rm: there is no recycle bin, and rm -r deletes a whole tree without asking. A good habit is to run ls on the exact same path first, and to never paste an rm command with a variable you have not checked. find is the Swiss army knife for locating files by name, size, age or type.

bash
pwd                          # where am I?
ls -lah /var/log             # long list, all files, human sizes
cd /etc/nginx && ls          # move, then list
cd ~ ; cd -                  # go home, then back to the previous dir
mkdir -p ~/backups/2026      # create a path, including parents
cp -a site/ site-backup/     # copy recursively, keep owners and times
mv old.conf old.conf.bak     # move or rename
rm -i notes.txt              # delete, asking first
rm -r build/                 # delete a directory tree (no undo!)
find /var/www -name "*.log" -size +100M     # big log files
find /tmp -type f -mtime +7                 # files older than 7 days

04Reading files, pipes and redirection

cat prints a whole file, less lets you scroll and search one (press / to search, q to quit), head and tail show the start or end, and tail -f keeps following a file as new lines arrive, which is perfect for watching a log while you reproduce a bug. The real power comes from pipes. Every program has standard input, standard output and standard error. The pipe character | sends one program's output into the next one's input, so small tools combine into answers. Redirection sends output to files: > overwrites, >> appends, and 2> captures errors separately. 2>&1 merges errors into normal output.

bash
less /var/log/syslog                 # scroll and search
tail -n 100 -f /var/log/nginx/error.log   # follow live
# Top 10 client IPs in an access log
awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head
# Save output, append to a file, capture errors
df -h > disk.txt
date >> deploy.log
./deploy.sh > deploy.out 2>&1
# Count 500 errors
grep -c '" 500 ' /var/log/nginx/access.log

05grep, awk and sed in five minutes

These three tools handle most text problems. grep finds lines that match a pattern: -i ignores case, -r searches directories, -n shows line numbers, -v inverts the match, and -E enables extended regular expressions. awk splits each line into fields ($1, $2…) and is ideal for pulling columns out of logs. sed edits streams of text, most often for search and replace. Always run sed without -i first to preview the result, and when you do edit in place, keep a backup with -i.bak. For anything longer than one line of awk or sed, a small script in a real language is usually clearer.

bash
grep -rn "DB_HOST" /var/www/app/config   # where is it set?
grep -i "error" app.log | grep -v "deprecated"
grep -E "timeout|refused" app.log
awk -F: '{print $1}' /etc/passwd             # first column, ":" separator
awk '$9 >= 500 {print $7}' access.log         # paths that returned 5xx
sed 's/http:/https:/g' links.txt              # preview a replacement
sed -i.bak 's/^#Port 22/Port 22/' my.conf     # edit in place, keep .bak

06Editors, help and environment variables

On a server you edit files in the terminal. nano is the friendliest: the shortcuts are listed at the bottom (Ctrl+O saves, Ctrl+X exits). vim is everywhere and worth knowing at a survival level: press i to insert text, Esc to stop, then :wq to save and quit or :q! to quit without saving. When you forget a flag, use command --help for a short summary or man command for the full manual. Environment variables are named values that programs inherit from the shell, such as PATH (where commands are searched for) and HOME. Set one for the current session with export; make it permanent in ~/.bashrc. Apps should read secrets from environment variables or an env file, never from code.

bash
nano /etc/hosts        # simple editor
vim  /etc/hosts        # i = insert, Esc, :wq = save & quit
ls --help | less       # quick help
man rsync              # full manual (q to quit)
echo $PATH             # where commands are found
export APP_ENV=production
printenv | grep APP_   # show matching variables
which node             # which binary runs?

07Your first shell script

A shell script is a text file of commands that runs top to bottom. The first line (the shebang) says which interpreter to use. Start every bash script with set -euo pipefail: -e stops on the first failed command, -u treats unset variables as errors, and pipefail makes a pipeline fail if any part fails. Without these, a script can keep going after a failure and do damage, for example deleting files in the wrong directory because a variable was empty. Quote your variables, make the file executable with chmod +x, and test it with shellcheck if you can install it.

bash
#!/usr/bin/env bash
set -euo pipefail
 
# Archive a directory with today's date
SRC="/var/www/app/storage"
DEST="$HOME/backups"
STAMP="$(date +%F)"
 
mkdir -p "$DEST"
tar -czf "$DEST/storage-$STAMP.tar.gz" -C "$SRC" .
echo "Saved $DEST/storage-$STAMP.tar.gz"
 
# Delete archives older than 14 days
find "$DEST" -name "storage-*.tar.gz" -mtime +14 -delete

Use it when

  • Managing any server, VPS or container: the shell is the common interface.
  • Investigating production issues: logs, config files and running processes are all text.
  • Automating repetitive tasks such as backups, cleanups and deploy steps with short scripts.
  • Quick data questions over logs (top IPs, error counts) without building a dashboard.

Skip it when

  • Large or complex automation: once a script grows past a page, move to Python, Node or a tool like Ansible.
  • Editing production config blindly: copy it first and validate it (for example nginx -t) before reloading.
  • Daily desktop work where a GUI is simply faster; there is no prize for doing everything in a terminal.

Alternatives

  • zsh / fish

    Friendlier interactive shells with better completion. Fine for your laptop; still write server scripts in bash or POSIX sh.

  • Python or Node scripts

    Better for logic, JSON, APIs and error handling once a task is bigger than a few commands.

  • Ansible

    Describes server setup as repeatable playbooks instead of one-off commands typed by hand.

  • Hosting control panels

    Panels like Plesk or cPanel hide the shell behind a UI. Convenient, but you still need the shell when something breaks.

Best practices

  1. Run ls on a path before you rm it, and avoid rm -rf with variables you have not checked.
  2. Back up a config file (cp file file.bak) before editing it, and validate it before reloading the service.
  3. Use less and tail -f instead of cat on large files; cat on a 2 GB log will flood your terminal.
  4. Start scripts with set -euo pipefail and always quote variables: "$VAR".
  5. Prefer absolute paths in scripts and cron jobs; they do not depend on the current directory.
  6. Learn man and --help; they are faster and more accurate than half-remembered blog posts.
  7. Keep secrets in environment variables or protected env files, not in scripts or shell history.

Common mistakes

  • Running rm -rf $DIR/ with an empty variable, which turns into rm -rf / and deletes the system.
  • Using > when you meant >>, overwriting a file instead of appending to it.
  • Editing a file with sed -i without previewing the change or keeping a backup.
  • Forgetting that Linux paths and file names are case-sensitive: Config.php is not config.php.
  • Pasting commands from the internet as root without understanding what each part does.

Deep dive

08What the shell really does when you press Enter

Before any program runs, bash rewrites your line in a fixed order: brace expansion, tilde expansion, then parameter, command and arithmetic substitution, then word splitting, then filename globbing, and finally quote removal. Only then does it look the command up: aliases, functions and builtins (cd, export, read) first, then each directory in PATH, with results cached in a hash table. For an external program the shell forks a copy of itself and the child calls exec to replace itself with the program; the parent waits and stores the exit status in $?. This explains classic surprises: cd must be a builtin because a child cannot change its parent's directory, an unquoted variable containing spaces becomes several arguments, and a glob that matches nothing is passed through literally. type and command -v tell you exactly what will run.

bash
type cd ls grep           # builtin, alias or file?
command -v python3        # path that will be executed
hash -r                   # forget cached paths after moving a binary
false; echo $?            # 1: last exit status
f="my file.txt"
ls $f                     # WRONG: two arguments, "my" and "file.txt"
ls "$f"                   # right: one argument
set -x                    # print each command after expansion (debug)

09Inodes, hard links and symlinks

A file name is not the file. The data and metadata (owner, mode, timestamps, size, block pointers) live in an inode, identified by a number; a directory is just a table mapping names to inode numbers. A hard link is a second name for the same inode, so both names are equal and the data survives until the last link is removed and no process holds it open. Hard links cannot cross filesystems or point to directories. A symbolic link is a tiny file containing a path, which can point anywhere, even to nothing (a dangling link). This model explains why mv within one filesystem is instant (only a directory entry changes) while mv across filesystems copies data, why deleting an open log frees no space, and why deploy tools switch releases atomically by replacing a 'current' symlink.

bash
ls -li /etc/hostname          # inode number in the first column
stat /etc/hostname            # full metadata, link count, times
ln data.csv data-copy.csv     # hard link: same inode
ln -s /srv/app/releases/42 /srv/app/current.new
mv -T /srv/app/current.new /srv/app/current   # atomic symlink swap
readlink -f /srv/app/current  # resolve to the real path
find /srv -xtype l            # dangling symlinks

10Scripts that survive real data

Production scripts meet file names with spaces, newlines and leading dashes, interrupted runs and partial failures. Three habits handle most of it. First, pass file lists NUL-separated: find -print0 with xargs -0 or a while read -d '' loop, never a for loop over ls output. Second, create temporary files with mktemp and remove them in a trap, so cleanup happens even when the script fails or is interrupted. Third, know where set -e does not fire: inside if conditions, on the left of && or ||, and in command substitutions assigned with local, so check critical steps explicitly. Use -- to end options before user-supplied paths, so a file called -rf is treated as a name. For locking, flock prevents two copies of the same job from running at once.

bash
#!/usr/bin/env bash
set -euo pipefail
tmp="$(mktemp -d)"
trap 'rm -rf -- "$tmp"' EXIT          # always cleaned up
exec 9>/var/lock/report.lock
flock -n 9 || { echo "already running" >&2; exit 1; }
 
find /var/www/uploads -type f -name '*.jpg' -mtime -1 -print0 |
  while IFS= read -r -d '' file; do
    cp -- "$file" "$tmp/"
  done
tar -czf /var/backups/uploads-today.tar.gz -C "$tmp" .

11Fast text processing on big files

On a multi-gigabyte log the choice of tool and flags matters. Locale is the hidden cost: with a UTF-8 locale, sort and grep do expensive character-aware comparisons; prefixing LC_ALL=C makes them compare bytes and often several times faster, which is fine for logs and IPs. grep -F treats the pattern as a fixed string and skips the regex engine. ripgrep (rg) searches in parallel and is usually fastest for code trees. Read compressed rotated logs in place with zgrep, zcat or zless instead of unpacking them. sort can use more memory with -S and parallel threads with --parallel, and writes temp files to /tmp unless you set -T, which matters when /tmp is small. Finally, put the most selective filter first in a pipeline so later stages process fewer lines.

bash
LC_ALL=C grep -F ' 502 ' access.log | wc -l
zgrep -h 'POST /login' /var/log/nginx/access.log.*.gz | wc -l
LC_ALL=C sort -S 1G --parallel=4 -T /var/tmp big.txt | uniq -c
rg -n --hidden -g '!node_modules' 'API_KEY' /srv/app
# Requests per minute around an incident
awk '{print substr($4, 2, 17)}' access.log | uniq -c | sort -rn | head

Real-world scenarios

01

Traffic spike: who is hitting the site?

Problem

The site slowed down for twenty minutes and the hosting dashboard only shows a CPU spike. You need to know whether it was real users, a crawler or an attack, using only the nginx access log.

How to solve it

Work from coarse to fine. Count requests per minute with awk on the timestamp field to find the exact window. Then, inside that window, rank client IPs with awk '{print $1}' | sort | uniq -c | sort -rn | head, and rank paths the same way using field 7. Check user agents (the last quoted field) for the top IPs; a single agent like a scraper or a misconfigured monitor is common. Use LC_ALL=C and zgrep on rotated files if the window crosses a rotation. If it is abusive, rate-limit or block at nginx or Cloudflare, and save the one-liners in a runbook so the next investigation takes two minutes.

02

A cleanup script deleted the wrong files

Problem

A nightly script ran rm -rf "$BUILD_DIR"/* from cron. One night the variable was not set because cron does not load the profile, and it deleted files from the root of the filesystem it ran in.

How to solve it

Restore from backup first, then make the script fail-safe. Add set -euo pipefail so an unset variable stops the script. Use the ${VAR:?message} form on dangerous paths so expansion itself aborts when empty. Validate the target: check it is a directory, under an expected prefix such as /srv/app/builds, and not /. Prefer find "$dir" -mindepth 1 -mtime +7 -delete over globs, and log what is removed. Set the variable inside the script or the crontab instead of relying on the environment, run shellcheck, and test with an echo in place of rm before re-enabling the job.

03

A loop breaks on file names with spaces

Problem

A script that converts uploaded images works in testing but fails in production with 'No such file or directory' for names like 'Summer menu.jpg'.

How to solve it

The script uses for f in $(ls *.jpg), so word splitting cuts each name at spaces. Replace it with a glob, for f in ./*.jpg, which yields each name as one word, and quote every use: "$f". For recursive work use find with -print0 piped to while IFS= read -r -d '' f, or find -exec ... {} +, which pass names safely even if they contain newlines. Add -- before file arguments so names starting with a dash are not read as options. Add shopt -s nullglob so an empty directory produces no iterations instead of a literal '*.jpg'. Test with a fixture directory full of hostile names.

Cheat sheet

What will actually run for a command
type -a node; command -v node
Debug a script line by line
bash -x ./script.sh (or set -x inside)
Exit status of every pipeline stage
echo "${PIPESTATUS[@]}"
Abort if a variable is empty
rm -rf -- "${DIR:?DIR is not set}"/build
Safe loop over found files
find . -type f -print0 | while IFS= read -r -d '' f; do ...; done
Run a command on many files at once
find /srv -name '*.log' -exec gzip {} +
Search compressed rotated logs
zgrep -h 'error' /var/log/nginx/error.log.*.gz
Byte-wise fast sort and grep
LC_ALL=C sort file | LC_ALL=C grep -F 'text'
Atomic symlink switch
ln -sfn new_target link.tmp && mv -T link.tmp link
Lint a shell script
shellcheck deploy.sh

In my real projects

  • I manage four production Ubuntu servers over key-based SSH, and the shell is how I read logs and config on all of them.
  • I brought one server down from 91% to 66% disk usage by finding and removing old logs, old builds and oversized node_modules with find and du.
  • Production bugs such as a localization middleware error and an image-to-WebP conversion failure were found by reading logs on the server, read-only first, before any fix.

Questions & answers

Q1What is the difference between Linux and a distribution like Ubuntu?

Linux is the kernel: the core that manages hardware, memory, processes and the filesystem. On its own it is not a usable system. A distribution packages the kernel with a package manager, system tools, a default configuration, documentation and a support lifecycle. Ubuntu and Debian use apt and .deb packages; RHEL, Rocky and Alma use dnf and .rpm. For servers the practical differences are package names, some config paths, default security tooling (AppArmor on Ubuntu, SELinux on RHEL) and how long each release is supported. I choose an LTS release so security updates keep coming for years without forced upgrades.

Q2Where would you look for configuration and logs on a Linux server?

Configuration lives under /etc: /etc/nginx, /etc/ssh/sshd_config, /etc/php, /etc/systemd/system for custom units. Logs are in two places. Classic text logs are in /var/log, for example /var/log/nginx/error.log or /var/log/auth.log for logins. Services managed by systemd also write to the journal, which you read with journalctl -u servicename. Application logs often live inside the app itself, such as storage/logs in Laravel. So my order is: journalctl for the service, then /var/log for the web server, then the app's own log directory, using tail -f while I reproduce the problem.

Q3Explain pipes and redirection with an example.

Every process has three standard streams: stdin (0), stdout (1) and stderr (2). A pipe connects one command's stdout to the next command's stdin, so small tools compose. For example, awk '{print $1}' access.log | sort | uniq -c | sort -rn | head prints the ten IPs with the most requests: awk extracts the column, sort groups equal lines, uniq -c counts them, sort -rn orders by count. Redirection sends a stream to a file: > overwrites, >> appends, 2> captures only errors, and 2>&1 sends errors to wherever stdout goes. That last one matters for cron jobs, where you want both in one log file.

Q4How do you find what is using a specific file name or setting across a project?

grep -rn "SETTING_NAME" /path/to/project searches recursively and prints file names with line numbers. I add -i for case-insensitive matches, --include='*.php' to limit file types and --exclude-dir=node_modules to skip noise. On large codebases ripgrep (rg) does the same faster and respects .gitignore by default. To find files by name rather than content I use find /path -name 'pattern', which can also filter by size, type and age, for example find /var/www -name '*.log' -size +100M to locate large logs. Combining find with -exec or xargs lets you act on the results, but I always print the list first.

Q5Why should a bash script start with set -euo pipefail?

By default bash keeps running after a command fails, treats unset variables as empty strings and only looks at the last command of a pipeline for its exit status. Each of these hides errors. set -e stops the script when a command fails. set -u turns a typo in a variable name into an error instead of an empty value, which prevents disasters like rm -rf "$DIR/" becoming rm -rf /. set -o pipefail makes the pipeline fail if any stage fails. It is not perfect, since some constructs bypass -e, so I still quote variables, check important results explicitly and run shellcheck.

Senior-level questions

S1Why does deleting a large log file sometimes free no disk space, and how do you handle it?

Deleting with rm only removes a directory entry. The inode and its blocks are released when the link count reaches zero and no process holds the file open. A web server or app that opened the log keeps writing to the now-nameless inode, so df still shows the space used while du cannot find it, which is a typical df/du mismatch. lsof +L1 lists such files with their PIDs. The fixes are to make the process reopen its logs (nginx -s reopen, a reload or restart), or to truncate the open file through /proc/PID/fd/N. Prevention is better: truncate live logs instead of deleting them and use logrotate with a postrotate reopen or copytruncate, accepting that copytruncate can lose a few lines written during the copy.

S2When does a bash script stop being the right tool?

Bash is excellent at orchestrating other programs: run this, check the exit code, move these files. It becomes risky when the task needs data structures, parsing of JSON or other structured formats, non-trivial error handling, retries with backoff, concurrency or unit tests. Its quoting and splitting rules make subtle bugs easy, and set -e has well-known gaps. My rule of thumb is that once a script passes roughly a page, handles structured data, or will be maintained by several people, I move it to Python or Node, or to a configuration tool like Ansible if it describes server state. The trade-off is an extra runtime dependency, while bash is present everywhere, including minimal containers that may only have POSIX sh.

S3How would you make a deploy switch between releases atomic?

Build each release into its own directory, such as releases/2026-11-05-1200, install dependencies and run checks there while the old release keeps serving. Then switch a single symlink, current, to the new directory. Doing it as ln -sfn directly is not atomic, because it removes and recreates the link, leaving a tiny gap. Creating a temporary link and renaming it over the old one with mv -T is atomic, because rename is a single filesystem operation. The web server or process manager points at current, so a reload picks up the new code. Rollback is the same swap back to the previous directory. Watch for caches that store resolved real paths, like PHP's OPcache and realpath cache, which may need a PHP-FPM reload after the switch.

Glossary

Inode
The record that stores a file's data location and metadata. File names are just directory entries pointing to an inode number.
Hard link
An extra name for the same inode. The data stays until every name is removed and no process has it open.
Symbolic link
A small file that contains a path to another file or directory. It can cross filesystems and can dangle.
fork / exec
How Unix starts programs: a process copies itself (fork), then the copy replaces itself with a new program (exec).
Builtin
A command implemented inside the shell itself, like cd or export, because it must change the shell's own state.
Word splitting
Bash breaking an unquoted expansion into separate arguments at spaces, tabs and newlines. Quoting prevents it.
Globbing
Expanding patterns such as *.log into matching file names, done by the shell before the command runs.
File descriptor
A small number a process uses to refer to an open file, pipe or socket. 0, 1 and 2 are the standard streams.
Locale
Language and character settings (LANG, LC_ALL) that change how tools sort, compare and display text.

Have a product in mind?Let's build it.

Tell me about your idea, your product or your team. I usually reply within one working day.