Level 2 — Linux Basics for Developers
You have a shell prompt. This chapter makes you fluent enough to navigate, inspect, edit, and diagnose a server without looking things up. Focus is on the subset that actually matters for deployment — not a full Linux course.
The filesystem
Linux has one tree, rooted at /. There are no drive letters; additional disks are mounted into the tree at a path.
| Path | What lives there | You will use it for |
|---|---|---|
/etc | System-wide configuration. Text files, no binaries. | /etc/nginx/, /etc/ssh/sshd_config, /etc/fstab, /etc/hosts |
/var | Data that varies at runtime: logs, caches, databases, spools | Logs, PostgreSQL data, web roots |
/var/log | All system and service logs | nginx/access.log, auth.log, syslog |
/var/www | Conventional web root | Static sites, Nuxt static output |
/var/lib | Persistent service state | postgresql/, docker/, redis/ |
/home | Regular users' home directories | /home/deploy — where your app lives |
/root | The root user's home. Note: not /home/root | Rarely — you should not be working as root |
/opt | Self-contained third-party software | Vendor apps that ship as one directory |
/usr | Installed programs and their data | /usr/bin, /usr/lib, /usr/share |
/usr/local | Software you compiled/installed manually | Keeps your stuff separate from apt's |
/tmp | Temporary files, wiped on reboot, world-writable | Scratch space. Never store anything you need. |
/srv | Data served by this system | An alternative to /var/www; less common |
/proc | Virtual filesystem exposing kernel/process state | /proc/meminfo, /proc/<pid>/environ |
/dev | Device files | /dev/null, /dev/sda |
/mnt, /media | Mount points for extra disks / removable media | Attached volumes |
WHERE SHOULD MY APPLICATION LIVE?
Two defensible conventions:
/home/deploy/apps/myapp— simplest. The deploy user owns everything naturally, no permission gymnastics, backups of/homecover it. This guide uses this./var/www/myapp— traditional and what many tutorials assume. Requireschown -R deploy:deploy /var/www/myappso the deploy user can write there.
Pick one and be consistent. What you must not do is run the app out of /root (only root can read it) or /tmp (wiped on reboot).
Navigation
pwdPrint Working Directory — where am I? The shell prompt usually shows this too, but scripts need it.
ls
ls -l # long format: permissions, owner, size, date
ls -la # also show hidden files (those starting with .)
ls -lh # human-readable sizes (4.0K instead of 4096)
ls -lt # sort by modification time, newest first
ls -ltr # ... reversed, so newest is at the bottom (handy in long lists)Reading ls -la output:
drwxr-xr-x 4 deploy deploy 4096 Aug 11 09:15 apps
-rw-r--r-- 1 deploy deploy 220 Aug 11 09:14 .bashrc
lrwxrwxrwx 1 root root 21 Aug 11 09:20 current -> releases/20260811| Field | Example | Meaning |
|---|---|---|
| Type + permissions | drwxr-xr-x | d=directory, -=file, l=symlink; then three permission triplets |
| Link count | 4 | Hard links (for directories: number of subdirectories + 2) |
| Owner | deploy | The user who owns it |
| Group | deploy | The group that owns it |
| Size | 4096 | Bytes (directories always show block size) |
| Modified | Aug 11 09:15 | Last modification time |
| Name | apps | Filename; -> shows symlink target |
cd /var/log # absolute path — starts from /
cd nginx # relative path — from where you are
cd .. # up one level
cd ~ # your home directory
cd # also your home directory
cd - # back to the previous directory (toggles)~ MEANS DIFFERENT THINGS TO DIFFERENT USERS
~ expands to the current user's home. As deploy it is /home/deploy; under sudo -i it is /root. Countless "the file isn't there" moments come from creating something as root in /root and looking for it in /home/deploy.
Creating and manipulating files
mkdir logs # create a directory
mkdir -p apps/myapp/dist # -p: create parent dirs as needed, no error if it exists-p is what you want in scripts — it is idempotent.
touch app.log # create an empty file, or update its timestamp if it existscp source.txt dest.txt # copy a file
cp -r ./dist /var/www/app/ # -r: recursive, for directories
cp -a ./dist /backup/ # -a: archive — preserve permissions, timestamps, symlinks
cp .env .env.backup-$(date +%F) # a habit worth forming before editing anything importantmv old.txt new.txt # rename
mv app.log /var/log/ # movemv is also how you rename. Within one filesystem it is instant (it only changes a directory entry) — this is what makes symlink-swap deployments atomic (Level 20).
rm file.txt # delete a file
rm -r directory # delete a directory and everything in it
rm -f file # force — no prompt, no error if missing
rm -rf directory # bothrm -rf IS PERMANENT — THERE IS NO TRASH
There is no undo, no recycle bin, no confirmation. Before running any rm -rf:
- Run
lson the same path first. Ifls /path/to/thingshows what you expect, thenrm -rf /path/to/thingwill delete exactly that. - Never use a variable without checking it.
rm -rf "$DIR"/*with an empty$DIRbecomesrm -rf /*. In scripts, alwaysset -u(error on undefined variables) and guard:[ -n "$DIR" ] || exit 1. - Beware the stray space.
rm -rf / home/deploy/olddeletes the entire filesystem. Modernrmrefuses/itself (--preserve-rootis default) but not/home,/var, or/etc. - Never run it as root unless you must. As
deploythe blast radius is limited to whatdeployowns.
Reading files
cat file.txt # print the whole file
cat -n file.txt # with line numbersUse cat only for short files. On a 2 GB log it floods your terminal.
less /var/log/nginx/error.logless is the interactive pager and the right tool for logs:
| Key | Action |
|---|---|
Space / b | Page down / up |
g / G | Jump to start / end |
/pattern | Search forward |
?pattern | Search backward |
n / N | Next / previous match |
F | Follow mode — like tail -f; Ctrl+C to stop |
q | Quit |
head file.txt # first 10 lines
head -n 50 file.txt # first 50 lines
tail file.txt # last 10 lines
tail -n 100 file.txt # last 100 lines
tail -f app.log # FOLLOW — stream new lines as they are written
tail -f -n 200 app.log # show last 200 lines, then followtail -f IS YOUR PRIMARY DEBUGGING TOOL
When something breaks, open a second SSH session and run tail -f on the relevant log while reproducing the problem. Watching an error appear in real time as you click is far faster than searching a static file.
sudo tail -f /var/log/nginx/error.log
pm2 logs api --lines 100
sudo journalctl -u nginx -fSearching
grep — search inside files
grep "error" app.log # lines containing "error"
grep -i "error" app.log # case-insensitive
grep -n "error" app.log # show line numbers
grep -r "DATABASE_URL" ./src # recursive through a directory
grep -v "healthcheck" access.log # INVERT — lines NOT containing it
grep -c "500" access.log # count matching lines
grep -A 5 -B 5 "Exception" app.log # 5 lines of context After and Before
grep -E "40[0-9]|50[0-9]" access.log # extended regexReal-world combinations:
# How many 500 errors today?
grep " 500 " /var/log/nginx/access.log | wc -l
# Which IPs hit us most?
awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -20
# Failed SSH logins
sudo grep "Failed password" /var/log/auth.log | tail -50
# Live-filter a log for errors only
pm2 logs api --raw | grep -i --line-buffered error--line-buffered
When piping a live stream into grep, output is block-buffered by default and appears in bursts. --line-buffered makes it appear immediately.
find — search for files
find /var/log -name "*.log" # by name
find . -name "*.env*" -type f # files only
find /home/deploy -type d -name node_modules # directories only
find /var/log -mtime -1 # modified in the last 24 hours
find /var/log -size +100M # larger than 100 MB
find . -name "*.tmp" -delete # find and delete (check without -delete first!)
find /home/deploy -type f -perm 777 # world-writable files — a security auditALWAYS RUN find WITHOUT THE ACTION FIRST
Run find . -name "*.tmp" and read the list. Then add -delete. The same applies to -exec rm {} \;.
which and whereis
which node # /home/deploy/.nvm/versions/node/v22.11.0/bin/node
which -a node # ALL matches in PATH order — reveals version conflicts
whereis nginx # binary, source, and man page locations
type -a pnpm # shell builtin/alias/function/file — more thorough than `which`which -a node is the fastest way to diagnose "the wrong Node version is running" — it shows you every node on PATH and which one wins.
Disk and memory
df -h # disk free, human-readable — per filesystem
df -i # INODES — a disk can be "full" with free bytes if inodes run outFilesystem Size Used Avail Use% Mounted on
/dev/sda1 38G 12G 25G 33% /Watch the Use% on /. Above 80%, act. At 100% everything breaks in confusing ways: PostgreSQL refuses writes, Nginx cannot log, builds fail, and even SSH logins can fail.
du -sh /var/log # total size of a directory
du -sh /home/deploy/apps/* # size of each app
du -h --max-depth=1 / | sort -rh | head -20 # the standard "what is eating my disk" command| Flag | Meaning |
|---|---|
-s | Summary — one total, not every file |
-h | Human-readable |
--max-depth=1 | Only one level down |
free -h # RAM and swap usage total used free shared buff/cache available
Mem: 3.8Gi 1.9Gi 234Mi 12Mi 1.7Gi 1.7Gi
Swap: 2.0Gi 0B 2.0GiREAD available, NOT free
free looks alarmingly low because Linux uses spare RAM for disk cache — that is good, and the cache is released instantly when a program needs memory. The number that matters is available. If available approaches zero, you are genuinely out of memory and the OOM killer is about to terminate your largest process (usually Node or PostgreSQL).
Processes
ps aux # all processes, all users, detailed
ps aux | grep node # filter
ps -ef --forest # tree view showing parent/child relationships
pgrep -a node # PIDs and command lines matching "node"Columns in ps aux: USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND. The one to watch is RSS — resident set size, actual physical RAM in KB.
top # live process view, always installed
htop # much better; install with: sudo apt install htopIn htop: F6 sorts, F4 filters, F9 kills, F5 toggles tree view, q quits.
kill 1234 # send SIGTERM — polite "please shut down"
kill -9 1234 # send SIGKILL — immediate, uncatchable
kill -HUP 1234 # SIGHUP — many daemons reload config on this
pkill -f "node dist/main.js" # kill by matching the full command line
killall node # kill every process named node — blunt instrumentUSE kill -9 ONLY AS A LAST RESORT
SIGKILL gives the process no chance to flush writes, finish in-flight HTTP requests, or close database connections cleanly. On PostgreSQL it can force crash recovery on next start. Always try plain kill (SIGTERM) first, wait several seconds, and escalate only if the process is genuinely stuck.
Network sockets
sudo ss -tulpn| Flag | Meaning |
|---|---|
-t | TCP |
-u | UDP |
-l | Listening sockets only |
-p | Show the owning process (needs sudo) |
-n | Numeric — don't resolve names (much faster) |
Netid State Local Address:Port Process
tcp LISTEN 127.0.0.1:3000 users:(("node",pid=1234,fd=20))
tcp LISTEN 127.0.0.1:5432 users:(("postgres",pid=890,fd=5))
tcp LISTEN 0.0.0.0:443 users:(("nginx",pid=567,fd=7))This is the most important security-audit command on the server. Read the Local Address column: anything showing 0.0.0.0: or [::]: is reachable from the internet if the firewall permits. Only 22, 80, and 443 should appear that way. Your app, PostgreSQL, and Redis must show 127.0.0.1.
# What is holding port 3000?
sudo ss -tulpn | grep :3000
sudo lsof -i :3000Permissions
Every file has an owner, a group, and three permission triplets.
-rwxr-xr-- 1 deploy developers 1024 Aug 11 10:00 deploy.sh
│└┬┘└┬┘└┬┘ │ │
│ │ │ │ │ └── group
│ │ │ │ └───────── owner (user)
│ │ │ └── others (everyone else)
│ │ └───── group (members of the file's group)
│ └──────── user (the owner)
└────────── type: - file, d directory, l symlinkWhat r, w, x mean
| Permission | On a file | On a directory |
|---|---|---|
r (read, 4) | Read its contents | List the names inside |
w (write, 2) | Modify its contents | Create, delete, rename entries inside |
x (execute, 1) | Run it as a program | Enter it / traverse through it |
THE DIRECTORY x BIT IS THE ONE PEOPLE MISS
x on a directory means "you may pass through this directory to reach things inside". Without it you cannot cd in, and you cannot read a file inside even if that file is world-readable. This is why /home/deploy needs 755 — Nginx (running as www-data) must traverse it to reach static files.
Note also: deleting a file depends on write permission on its containing directory, not on the file itself. You can delete a file you cannot read.
Octal notation
Each triplet is a 3-bit number:
| Symbolic | Binary | Octal | Meaning |
|---|---|---|---|
--- | 000 | 0 | nothing |
--x | 001 | 1 | execute |
-w- | 010 | 2 | write |
-wx | 011 | 3 | write + execute |
r-- | 100 | 4 | read |
r-x | 101 | 5 | read + execute |
rw- | 110 | 6 | read + write |
rwx | 111 | 7 | everything |
Three digits: user, group, others.
The modes you actually need
| Mode | Symbolic | Use for | Why |
|---|---|---|---|
755 | rwxr-xr-x | Directories, executables | Owner full control; everyone else can read and traverse |
644 | rw-r--r-- | Normal files, static assets, configs | Owner edits; everyone reads |
600 | rw------- | .env, private keys, authorized_keys | Only the owner. Nobody else can even read it. |
700 | rwx------ | ~/.ssh, private script directories | Only the owner may enter |
400 | r-------- | Keys you want protected from accidental edits | Read-only, owner only |
775 | rwxrwxr-x | Directories shared by a group | Group members can write |
777 | rwxrwxrwx | Never | Any user on the system can modify it |
NEVER USE 777
chmod 777 is the "turn it off and on again" of permissions — it makes the error go away and creates a vulnerability. Any process on the machine, including one an attacker gets running through a compromised dependency, can now overwrite that file. If it is a script that root runs, you have handed over the server.
When you are tempted by 777, the real fix is almost always chown — the right user should own the file, not everyone should be able to write it.
chmod — change mode
chmod 600 .env # octal — set exact permissions
chmod 755 deploy.sh
chmod +x deploy.sh # symbolic — add execute for everyone
chmod u+x deploy.sh # add execute for the owner only
chmod go-rwx .env # remove all access for group and others
chmod -R 755 /var/www/app # recursiveRECURSIVE chmod IS BLUNT
chmod -R 755 makes every file executable too, which is wrong and looks suspicious in an audit. To set directories and files differently:
find /var/www/app -type d -exec chmod 755 {} \;
find /var/www/app -type f -exec chmod 644 {} \;chown and chgrp — change ownership
sudo chown deploy file.txt # change owner
sudo chown deploy:deploy file.txt # change owner and group
sudo chown -R deploy:deploy /home/deploy/apps # recursive
sudo chgrp www-data /var/www/app # change group onlyOnly root can give a file away to another user. A normal user can change the group of their own file, but only to a group they belong to.
THE MOST COMMON PERMISSION FIX IN DEPLOYMENT
Ran a build as root by accident? Now deploy cannot write to node_modules or .output and your next deploy fails with EACCES. The fix:
sudo chown -R deploy:deploy /home/deploy/apps/myappThe prevention: never run application commands with sudo.
Special bits, briefly
| Bit | Octal prefix | Effect |
|---|---|---|
| setuid | 4--- | File runs with the owner's privileges (e.g. /usr/bin/sudo is setuid root) |
| setgid | 2--- | On a directory: new files inherit the directory's group — useful for shared folders |
| sticky | 1--- | On a directory: only the file's owner can delete it. /tmp is 1777. |
You will rarely set these yourself, but you should recognise rws (setuid) in ls output. Auditing for unexpected setuid-root binaries is a standard compromise check (Level 23).
umask
Default permissions for newly created files are 666 - umask for files and 777 - umask for directories. Ubuntu's default umask is 022, giving 644 files and 755 directories. Check with umask.
Links
ln -s /home/deploy/apps/myapp/releases/20260811 /home/deploy/apps/myapp/currentA symbolic link is a pointer to another path. ln -s target linkname — target first, always.
ls -l current
# current -> releases/20260811Symlinks are the mechanism behind atomic deployments: build the new release in a fresh directory, then repoint the current symlink in one instantaneous operation. If the build fails, current never moved. (Level 20)
They are also how Nginx sites are enabled:
sudo ln -s /etc/nginx/sites-available/app.example.com /etc/nginx/sites-enabled/UPDATING A SYMLINK ATOMICALLY
ln -sfn new current is not atomic — it removes then recreates, leaving a window where current does not exist. The atomic idiom creates a temporary link and renames it over the old one:
ln -sfn releases/20260811 current.tmp && mv -Tf current.tmp currentArchives
tar -czf backup.tar.gz ./myapp # CREATE a gzipped archive
tar -xzf backup.tar.gz # EXTRACT
tar -tzf backup.tar.gz # LIST contents without extracting
tar -xzf backup.tar.gz -C /tmp/ # extract into a specific directory| Flag | Meaning |
|---|---|
-c | Create |
-x | Extract |
-t | List (test) |
-z | gzip compression |
-j | bzip2 |
-J | xz — slower, smaller |
-f | Filename follows (must be last among the grouped flags) |
-v | Verbose |
-C | Change to this directory first |
ALWAYS -t BEFORE -x
Some archives extract into the current directory rather than a subfolder, scattering files everywhere. List first, extract second.
HTTP from the command line
curl https://api.example.com/health # fetch a URL
curl -I https://example.com # HEAD — response headers only
curl -v https://example.com # verbose: TLS handshake, headers
curl -L http://example.com # follow redirects
curl -o file.zip https://.../file.zip # save to a file
curl -s http://127.0.0.1:3001/health | jq # silent + pretty-print JSON
curl -X POST -H "Content-Type: application/json" \
-d '{"email":"a@b.c"}' https://api.example.com/login
curl -w "\n%{time_total}s\n" -o /dev/null -s https://example.com # measure latencyTHE FASTEST WAY TO ISOLATE A 502
# On the server — bypass Nginx entirely and hit the app directly
curl -i http://127.0.0.1:3001/healthIf this works but the public URL 502s, the problem is Nginx configuration. If this also fails, the problem is your application. Two seconds of work that eliminates half the possibilities.
wget https://example.com/file.tar.gz # download to a file (default behaviour)
wget -c https://.../big.iso # continue an interrupted downloadUse curl for API interaction, wget for downloading files.
Editing files
nano — start here
nano /etc/nginx/sites-available/app.example.com| Key | Action |
|---|---|
Ctrl+O, Enter | Save ("write Out") |
Ctrl+X | Exit |
Ctrl+W | Search |
Ctrl+K | Cut line |
Ctrl+U | Paste |
Ctrl+_ | Go to line number |
Nano shows its shortcuts at the bottom of the screen. ^ means Ctrl. For editing config files on a server, nano is entirely sufficient — there is no prize for using vim.
vim — enough to escape
Vim is installed everywhere and is sometimes the default editor (e.g. for visudo), so you need the minimum:
| Key | Action |
|---|---|
i | Enter INSERT mode (now you can type) |
Esc | Back to NORMAL mode |
:w | Save |
:q | Quit |
:wq | Save and quit |
:q! | Quit without saving — the escape hatch |
/text | Search |
dd | Delete line |
u | Undo |
G | End of file |
gg | Start of file |
If you are stuck: press Esc a few times, then type :q! and Enter.
# Set nano as your default editor to avoid vim surprises
sudo update-alternatives --config editor
# or, in ~/.bashrc:
export EDITOR=nanoShell productivity
| Key / syntax | Effect |
|---|---|
Tab | Autocomplete paths and commands — use constantly, it prevents typos |
Ctrl+R | Reverse-search command history. The highest-value shortcut here. |
Ctrl+C | Interrupt the running command |
Ctrl+D | End of input / log out |
Ctrl+L | Clear screen |
Ctrl+A / Ctrl+E | Jump to start / end of line |
!! | Repeat the last command — sudo !! after a permission error |
!$ | Last argument of the previous command |
history | Show command history |
Redirection and pipes
command > file # stdout to file (OVERWRITES)
command >> file # stdout appended
command 2> file # stderr to file
command > file 2>&1 # both stdout and stderr to file
command &> file # same, shorter (bash)
command > /dev/null 2>&1 # discard all output
command1 | command2 # pipe stdout of one into stdin of the next
command | tee file # print to screen AND write to file
command | tee -a file # append version2>&1 means "send file descriptor 2 (stderr) to wherever 1 (stdout) currently goes". Order matters: > file 2>&1 works, 2>&1 > file does not.
Production Checklist — Level 2
- [ ] I know where configs (
/etc), logs (/var/log), and my app (/home/deploy/apps) live - [ ] I can read
ls -laoutput and identify owner, group, and each permission triplet - [ ] I know
600for.envand keys,755for directories,644for normal files - [ ] I understand why
777is never the right answer andchownusually is - [ ] I understand the directory
xbit and why/home/deployneeds755 - [ ] I can run
sudo ss -tulpnand tell which services are publicly exposed - [ ] I use
tail -fon logs while reproducing a problem - [ ] I check
df -hand know that above 80% needs action - [ ] I read
availableinfree -h, notfree - [ ] I run
lsbefore anyrm -rf, and neverrm -rfwith an unchecked variable - [ ] I can edit a file in nano and escape from vim with
:q! - [ ] I use
Ctrl+Rto search history instead of retyping long commands