Why Linux runs the server world
Walk into any data centre, open any cloud console, or docker exec into any container and you are almost certainly on a Linux kernel. It powers the majority of web servers, every Android phone, most routers and the vast majority of cloud instances. The reasons are old and durable: it is free, it runs on almost any hardware, it is stable enough to stay up for years, and it is scriptable from top to bottom.
For a developer the practical point is narrower. The moment you deploy, you stop being a person who edits files in an editor and become a person who types commands into a shell, often over SSH, on a machine you cannot see. The skills in this guide are the ones that make that machine legible instead of frightening.
You do not need to become a system administrator. You need enough fluency to move around, read logs, fix permissions, restart a service and understand what is listening on a port.
WSL and macOS: you already know more than you think
If you use macOS, you are already on a Unix system. The terminal, the filesystem layout and most commands — ls, cd, grep, chmod, ssh — behave the same way. The differences are mostly in the package manager and in a few BSD-versus-GNU flags.
On Windows, WSL 2 gives you a real Linux kernel inside a lightweight virtual machine. wsl --install gets you Ubuntu with a shell, and your files are reachable from both sides. This is the recommended path because it is the same environment your code will run in.
Even if you never install Linux directly, the mental model transfers completely to containers, which are Linux userland processes wearing a very convincing costume.
The filesystem is one tree
Unlike Windows, where drives are separate roots, Linux has a single tree rooted at /. Every disk, network share and device is mounted somewhere inside it. A handful of directories have conventional meanings, and knowing them means you always know where to look.
/ the root of everything
├── bin essential user binaries (ls, cp, bash)
├── etc system-wide configuration files
├── home per-user home directories
├── opt optional third-party software
├── proc kernel and process state, as files
├── root the root user's home
├── tmp temporary files, cleared on reboot
├── usr installed programs and libraries
│ ├── bin local binaries
│ └── lib shared libraries
└── var variable data: logs, caches, databases
├── log system and application logs
└── www web content on Debian systems
The two you will visit most are /etc for configuration and /var/log for logs. When something is wrong, those are the first places to look. When you install an application yourself, /opt or /usr/local keeps it out of the way of the package manager.
Paths are either absolute (start with /, like /etc/hosts) or relative (resolved from your current directory). ~ is a shortcut for your home directory, and . is the current directory, .. its parent. Those three symbols appear in nearly every command you type.
Everything is a file
The deepest Unix idea is that almost everything is represented as a file. A hard disk is /dev/sda. A terminal is /dev/pts/0. Running process information lives under /proc/<pid>/. Random numbers come from /dev/urandom. A Unix domain socket is a file too.
This uniformity is what makes the shell so powerful. The same cat, grep, less and redirection operators work on a log file, a process’s memory map and a device, because they are all just streams of bytes behind a path. When you understand that, /proc and /sys stop looking like magic and become inspectable.
cat /proc/cpuinfo | grep 'model name' | head -1
ls -l /dev/null # the bit bucket
Navigation and the file commands you will use daily
The shell is a loop: it reads a line, expands it, finds the program and runs it. Six commands cover most navigation.
pwd # print working directory
ls -lah # list, long format, human sizes, hidden files
cd /var/log # change directory
cd - # back to the previous directory
cd ~ # home
tree -L 2 # a readable tree, two levels deep
Creating, copying, moving and removing files is just as small a vocabulary. mkdir -p creates parents as needed. cp -r copies directories recursively. mv renames within a filesystem and moves across them. rm -rf deletes a tree, which is why it deserves respect: there is no trash can on the command line.
mkdir -p app/{src,test,docs}
cp -r app app.bak
mv notes.txt docs/notes.txt
rm -rf build/
Two habits keep you safe. Use ls or find to confirm what a glob matches before you run rm with it, and prefer mv to a backup directory over deleting something you are unsure about. Shell history is not a safety net when you press Enter.
Reading files without opening an editor
You rarely need a full editor to inspect something on a server. These tools print just the part you need.
cat /etc/os-release # print the whole file
less /var/log/syslog # page through, / to search, q to quit
head -n 20 access.log # first 20 lines
tail -n 50 access.log # last 50 lines
tail -f app.log # follow a live log
wc -l access.log # count lines
tail -f is the single most useful command during an incident: it streams new lines as they are written. Press Ctrl+C to stop. less is faster than an editor and never accidentally modifies the file, which matters on production.
Searching with find and grep
find walks the filesystem and matches on metadata; grep searches inside file contents. Together they answer most “where is it?” questions.
find /etc -name '*.conf' # by name
find . -type d -name node_modules -prune -o -type f -name '*.ts' -print
find /var/log -type f -mtime -1 # modified in the last day
find . -type f -size +50M # larger than 50 MB
find . -type f -name '*.tmp' -delete # delete matches
grep -RIn 'TODO' src/ # recursive, line numbers, ignore binary
grep -c 'ERROR' app.log # count matches
grep -E 'timeout|refused' app.log # extended regex
grep -v '^#' config.ini # invert: drop comments
grep returning nothing is a success, not an error, but it exits with status 1. That trips up scripts under set -e; append || true when an empty result is acceptable.
Permissions: rwx and the octal shorthand
Every file has an owner, a group and three permission bits for each: read (r), write (w) and execute (x). ls -l shows them as a ten-character string.
-rwxr-xr-- 1 deploy www-data 512 Sep 16 10:00 deploy.sh
Octal is the shorthand. Each triple is the sum of 4 (read), 2 (write) and 1 (execute), so rwx is 7, r-x is 5 and r-- is 4. That makes 755 mean rwxr-xr-x and 644 mean rw-r--r--.
chmod 755 deploy.sh # scripts and directories
chmod 644 index.html # regular files
chmod 600 .env # secrets only the owner can read
chmod u+x,g-w,o-rwx script # symbolic form, easier to review
Directories need the execute bit to be entered, which is why removing x from a directory hides its contents even if read is still set. This is a subtle source of “permission denied” when read looks fine.
Ownership, groups and umask
chown changes the owner and group; chgrp changes only the group. Prefix with sudo because only root can give files away.
sudo chown deploy:www-data /var/www/app
sudo chgrp -R www-data /var/www/app/uploads
New files get their permissions from the umask, a mask subtracted from the default. A umask of 022 yields 644 for files and 755 for directories; 027 yields 640 and 750. Set it in your shell profile or a service’s unit file when a stricter default is warranted.
umask # show current, e.g. 0022
umask 027 # stricter for this shell
sudo and the least-privilege habit
sudo runs one command as another user, root by default, after checking /etc/sudoers. It is deliberately narrow: grant the specific commands a person or service needs rather than handing out a root shell. Prefer sudo -u www-data <cmd> over editing files as root and leaving them owned by root.
sudo systemctl restart my-api
sudo -u postgres psql -c '\l'
sudo -l # what am I allowed to run?
A rule of thumb: root owns system configuration, a dedicated service user owns application files, and your app never runs as root. If a compromise of your app would give an attacker root on the host, permissions are doing nothing for you.
Users and groups
Users are identified by a name and a numeric uid; groups by a name and a gid. The mapping lives in /etc/passwd and /etc/group, while hashed passwords live in /etc/shadow, readable only by root.
whoami # current user
id # uid, gid and groups
groups deploy # groups a user belongs to
sudo adduser appuser # create a user (Debian/Ubuntu)
sudo usermod -aG docker appuser # add to a group
sudo userdel -r appuser # remove the user and home
Service accounts usually have no password and no login shell. That is intentional: they exist to own files and run one process, not to be logged into.
Processes: ps, top and signals
Every running program is a process with a pid, an owner and a parent. ps takes a snapshot; top and htop update live.
ps aux | grep node # find a process
ps -eo pid,ppid,user,%cpu,%mem,cmd --sort=-%cpu | head
top -o %MEM # interactive, sort by memory
pgrep -af node # pids and full command lines
Processes communicate through signals. SIGTERM (15) asks a process to exit and is the default for kill; SIGINT (2) is Ctrl+C; SIGHUP (1) traditionally means reload; SIGKILL (9) cannot be caught and should be a last resort.
kill 1234 # SIGTERM, graceful
kill -HUP 1234 # reload config
kill -9 1234 # force, no cleanup
pkill -f 'node server.js'
Send SIGTERM first and give the process a few seconds to close connections and flush state. Escalate to -9 only when it is genuinely stuck, because it skips every cleanup path your app has.
Background jobs, & and nohup
Appending & runs a command in the background; jobs, fg and bg manage it from the current shell. But a background job still dies when you close the terminal unless you detach it from the hangup signal with nohup or a terminal multiplexer.
long-task & # background in this shell
jobs -l # list background jobs
fg %1 # bring job 1 to the foreground
nohup ./worker.sh > worker.log 2>&1 &
disown # detach from the shell's job table
For anything long-lived, use tmux or screen. They keep a session alive across disconnects, which is invaluable when a deploy takes longer than your SSH connection.
tmux new -s deploy
# Ctrl+B, then D to detach
tmux attach -t deploy
systemd services with systemctl
On modern distributions, systemd is the init system that starts services at boot and supervises them. You define a service in a unit file under /etc/systemd/system/.
# /etc/systemd/system/my-api.service
[Unit]
Description=My API
After=network.target
[Service]
Type=simple
User=appuser
WorkingDirectory=/opt/my-api
Environment=NODE_ENV=production
EnvironmentFile=/opt/my-api/.env
ExecStart=/usr/bin/node server.js
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target
After creating or editing a unit, reload the manager and enable the service so it starts on boot.
sudo systemctl daemon-reload
sudo systemctl enable --now my-api
systemctl status my-api --no-pager
sudo systemctl restart my-api
sudo systemctl reload my-api # if the service supports it
Restart=on-failure is what turns a crashed process into a self-healing service. EnvironmentFile keeps secrets out of the unit, which is often world-readable. Use Type=notify only if the program actually supports the notification protocol; simple is the safe default.
Logs with journalctl
systemd captures a service’s stdout and stderr into the journal. journalctl queries it, and you will use it constantly.
journalctl -u my-api # all logs for one unit
journalctl -u my-api -n 100 # last 100 lines
journalctl -u my-api -f # follow live
journalctl -u my-api --since today
journalctl -u my-api --since '10 min ago' -p err
journalctl -b -p warning # this boot, warnings and worse
journalctl --disk-usage
sudo journalctl --vacuum-time=7d # keep a week
The -p flag filters by priority, from emerg down to debug. Following a unit with -f while you hit an endpoint is the fastest way to see a 500 explained.
Environment variables and PATH
Environment variables are key-value strings a process inherits from its parent. PATH is the important one: it lists the directories the shell searches for a command name, in order.
echo "$PATH" | tr ':' '\n'
export NODE_ENV=production
export PATH="$HOME/.local/bin:$PATH"
printenv DATABASE_URL
Because a service does not inherit your interactive shell, set variables in its unit file or an EnvironmentFile. A variable that works in your terminal and not under systemd is almost always this. To persist your own settings, add the export lines to ~/.bashrc or ~/.profile.
Package managers: apt, dnf, apk
Each distribution family has its own package manager, but the verbs are similar: update the index, install, upgrade, remove, search.
# Debian / Ubuntu
sudo apt update && sudo apt upgrade -y
sudo apt install nginx
sudo apt remove --purge nginx
# Fedora / RHEL
sudo dnf install nginx
sudo dnf upgrade --refresh
# Alpine (common in containers)
sudo apk add nginx
sudo apk upgrade
Install only from official repositories where possible; they are signed and patched. Reach for a PPA or third-party repo deliberately, and pin versions in production images so a rebuild does not silently change what runs.
Pipes, redirection and small scripts
A pipe sends one program’s output to another’s input. Redirection sends it to a file or reads it from one. Together they turn small tools into pipelines.
ls -l | wc -l # count files
grep ' 500 ' access.log | wc -l # count 500s
cmd > out.txt # stdout to file, overwrite
cmd >> out.txt # append
cmd 2> err.txt # stderr to file
cmd > all.txt 2>&1 # both streams together
cmd < input.txt # read stdin from a file
Every process starts with three file descriptors: stdin (0), stdout (1) and stderr (2). 2>&1 means “send stderr where stdout is going”, and the order matters — it must come after the stdout redirection.
Wrap a sequence in a script and you have automation. Two lines at the top make scripts far safer:
#!/usr/bin/env bash
set -euo pipefail
-e exits on the first failed command, -u errors on an undefined variable, and -o pipefail makes a pipeline fail if any stage fails. Together they turn silent partial failures into loud ones, which is exactly what you want in a deploy script.
SSH and keys
SSH is how you reach a remote machine. Password logins work, but keys are stronger and scriptable. You keep a private key secret and put the matching public key on the server.
ssh-keygen -t ed25519 -C "you@laptop" # creates ~/.ssh/id_ed25519(.pub)
ssh-copy-id [email protected] # install the public key
ssh [email protected] # log in
A host alias in ~/.ssh/config saves typing and pins useful options:
Host prod
HostName 203.0.113.10
User deploy
IdentityFile ~/.ssh/id_ed25519
ServerAliveInterval 30
Then ssh prod connects. Once you trust keys, disable password authentication in /etc/ssh/sshd_config (PasswordAuthentication no) and reload sshd. scp and rsync reuse the same keys for copying files; rsync -avz --delete is the usual way to sync a directory.
rsync -avz --exclude node_modules ./app/ prod:/opt/app/
ssh prod 'sudo systemctl restart my-api'
Disk and memory: df, du, free
Servers rarely fail because of CPU; they fail because a disk fills up or memory runs out. These commands answer “how much is left?” and “what is using it?”.
df -h # free space per filesystem
df -i # inode usage — a disk can be full of inodes
du -sh /var/log/* # size per entry
du -h --max-depth=1 / | sort -h
free -h # memory and swap
ls -lhS /var/log | head # biggest log files
When /var is full, the cause is almost always logs, a package cache or a runaway database. Clean up deliberately — journalctl --vacuum-time, apt clean, log rotation — rather than deleting files an application expects.
Networking: ss, curl, dig
ss shows sockets and listening ports, replacing the older netstat. It answers “is anything listening, and who is connected?”.
ss -tulpn # TCP, UDP, listening, process names
curl -I https://example.com # response headers only
curl -sS localhost:3000/health
curl -X POST -H 'content-type: application/json' \
-d '{"name":"ada"}' localhost:3000/users
dig example.com +short # DNS resolution
dig @1.1.1.1 example.com MX
curl -I is the quickest way to confirm a server is responding and what status it returns. If ss shows nothing on a port but the app says it started, it may be bound to 127.0.0.1 instead of 0.0.0.0, which is invisible from outside the host.
Scheduling with cron
cron runs commands on a schedule. Each user has a crontab with five time fields followed by the command.
# ┌ min (0-59)
# │ ┌ hour (0-23)
# │ │ ┌ day of month (1-31)
# │ │ │ ┌ month (1-12)
# │ │ │ │ ┌ day of week (0-6, Sun=0)
# * * * * * command
crontab -e # edit your crontab
crontab -l # list it
0 3 * * * /opt/app/backup.sh >> /var/log/backup.log 2>&1
*/5 * * * * curl -fsS localhost:3000/health > /dev/null
0 7 * * 1 /opt/app/weekly-report.sh
Cron’s environment is minimal: it has a different PATH, no shell profile and no interactive variables. Use absolute paths, redirect output to a log so failures are visible, and remember that cron does not retry a failed run. For anything more complex, schedule a systemd timer or enqueue a job instead.
Best practices
- Use absolute paths in scripts and unit files; never rely on the interactive environment.
- Put
set -euo pipefailat the top of every non-trivial shell script. - Grant least privilege: dedicated service users,
sudofor specific commands, no app running as root. - Prefer
SIGTERMand let services shut down gracefully before reaching forkill -9. - Configure
Restart=on-failureand readjournalctl -u <unit>when debugging. - Keep configuration in version control and change one thing at a time on a live host.
- Rotate logs and watch
df -handdf -i; disks fill quietly. - Use SSH keys, not passwords, and keep private keys off servers.
- Learn
find,grep,awkand pipes — they replace a lot of one-off tooling. - Test a config before reloading the service that reads it.
Common mistakes
- Running applications as root and turning any bug into a full compromise.
- Using
chmod -R 777to “fix” permissions and removing every protection between users. - Deleting files with an unexamined wildcard and no backup.
- Sending
kill -9first and corrupting state that a graceful shutdown would have flushed. - Setting environment variables in your shell and expecting systemd to see them.
- Assuming a service is down when it is bound only to
127.0.0.1. - Forgetting
2>&1in cron entries and losing the error that explains a failed job. - Editing config directly on a server with no version control and no way back.
- Ignoring inode exhaustion because
df -hstill shows free space. - Leaving password authentication enabled on an internet-facing SSH port.
Where to go next
Linux is the substrate under everything else you deploy. Once you can move around a box, read its logs and restart its services, the Nginx guide shows the web server you will configure in front of your app, and Docker & Deployment turns these primitives into repeatable, isolated images. To automate the same commands on every push, read CI/CD, and to understand the process your service actually runs, revisit Node.js Basics.