Skip to content

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.

PathWhat lives thereYou will use it for
/etcSystem-wide configuration. Text files, no binaries./etc/nginx/, /etc/ssh/sshd_config, /etc/fstab, /etc/hosts
/varData that varies at runtime: logs, caches, databases, spoolsLogs, PostgreSQL data, web roots
/var/logAll system and service logsnginx/access.log, auth.log, syslog
/var/wwwConventional web rootStatic sites, Nuxt static output
/var/libPersistent service statepostgresql/, docker/, redis/
/homeRegular users' home directories/home/deploy — where your app lives
/rootThe root user's home. Note: not /home/rootRarely — you should not be working as root
/optSelf-contained third-party softwareVendor apps that ship as one directory
/usrInstalled programs and their data/usr/bin, /usr/lib, /usr/share
/usr/localSoftware you compiled/installed manuallyKeeps your stuff separate from apt's
/tmpTemporary files, wiped on reboot, world-writableScratch space. Never store anything you need.
/srvData served by this systemAn alternative to /var/www; less common
/procVirtual filesystem exposing kernel/process state/proc/meminfo, /proc/<pid>/environ
/devDevice files/dev/null, /dev/sda
/mnt, /mediaMount points for extra disks / removable mediaAttached 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 /home cover it. This guide uses this.
  • /var/www/myapp — traditional and what many tutorials assume. Requires chown -R deploy:deploy /var/www/myapp so 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).

bash
pwd

Print Working Directory — where am I? The shell prompt usually shows this too, but scripts need it.

bash
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
FieldExampleMeaning
Type + permissionsdrwxr-xr-xd=directory, -=file, l=symlink; then three permission triplets
Link count4Hard links (for directories: number of subdirectories + 2)
OwnerdeployThe user who owns it
GroupdeployThe group that owns it
Size4096Bytes (directories always show block size)
ModifiedAug 11 09:15Last modification time
NameappsFilename; -> shows symlink target
bash
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 ​

bash
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.

bash
touch app.log              # create an empty file, or update its timestamp if it exists
bash
cp 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 important
bash
mv old.txt new.txt          # rename
mv app.log /var/log/        # move

mv 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).

bash
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   # both

rm -rf IS PERMANENT — THERE IS NO TRASH

There is no undo, no recycle bin, no confirmation. Before running any rm -rf:

  1. Run ls on the same path first. If ls /path/to/thing shows what you expect, then rm -rf /path/to/thing will delete exactly that.
  2. Never use a variable without checking it. rm -rf "$DIR"/* with an empty $DIR becomes rm -rf /*. In scripts, always set -u (error on undefined variables) and guard: [ -n "$DIR" ] || exit 1.
  3. Beware the stray space. rm -rf / home/deploy/old deletes the entire filesystem. Modern rm refuses / itself (--preserve-root is default) but not /home, /var, or /etc.
  4. Never run it as root unless you must. As deploy the blast radius is limited to what deploy owns.

Reading files ​

bash
cat file.txt                     # print the whole file
cat -n file.txt                  # with line numbers

Use cat only for short files. On a 2 GB log it floods your terminal.

bash
less /var/log/nginx/error.log

less is the interactive pager and the right tool for logs:

KeyAction
Space / bPage down / up
g / GJump to start / end
/patternSearch forward
?patternSearch backward
n / NNext / previous match
FFollow mode — like tail -f; Ctrl+C to stop
qQuit
bash
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 follow

tail -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.

bash
sudo tail -f /var/log/nginx/error.log
pm2 logs api --lines 100
sudo journalctl -u nginx -f

Searching ​

grep — search inside files ​

bash
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 regex

Real-world combinations:

bash
# 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 ​

bash
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 audit

ALWAYS 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 ​

bash
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 ​

bash
df -h              # disk free, human-readable — per filesystem
df -i              # INODES — a disk can be "full" with free bytes if inodes run out
Filesystem      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.

bash
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
FlagMeaning
-sSummary — one total, not every file
-hHuman-readable
--max-depth=1Only one level down
bash
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.0Gi

READ 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 ​

bash
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.

bash
top                # live process view, always installed
htop               # much better; install with: sudo apt install htop

In htop: F6 sorts, F4 filters, F9 kills, F5 toggles tree view, q quits.

bash
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 instrument

USE 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 ​

bash
sudo ss -tulpn
FlagMeaning
-tTCP
-uUDP
-lListening sockets only
-pShow the owning process (needs sudo)
-nNumeric — 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.

bash
# What is holding port 3000?
sudo ss -tulpn | grep :3000
sudo lsof -i :3000

Permissions ​

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 symlink

What r, w, x mean ​

PermissionOn a fileOn a directory
r (read, 4)Read its contentsList the names inside
w (write, 2)Modify its contentsCreate, delete, rename entries inside
x (execute, 1)Run it as a programEnter 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:

SymbolicBinaryOctalMeaning
---0000nothing
--x0011execute
-w-0102write
-wx0113write + execute
r--1004read
r-x1015read + execute
rw-1106read + write
rwx1117everything

Three digits: user, group, others.

The modes you actually need ​

ModeSymbolicUse forWhy
755rwxr-xr-xDirectories, executablesOwner full control; everyone else can read and traverse
644rw-r--r--Normal files, static assets, configsOwner edits; everyone reads
600rw-------.env, private keys, authorized_keysOnly the owner. Nobody else can even read it.
700rwx------~/.ssh, private script directoriesOnly the owner may enter
400r--------Keys you want protected from accidental editsRead-only, owner only
775rwxrwxr-xDirectories shared by a groupGroup members can write
777rwxrwxrwxNeverAny 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 ​

bash
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         # recursive

RECURSIVE 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:

bash
find /var/www/app -type d -exec chmod 755 {} \;
find /var/www/app -type f -exec chmod 644 {} \;

chown and chgrp — change ownership ​

bash
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 only

Only 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:

bash
sudo chown -R deploy:deploy /home/deploy/apps/myapp

The prevention: never run application commands with sudo.

Special bits, briefly ​

BitOctal prefixEffect
setuid4---File runs with the owner's privileges (e.g. /usr/bin/sudo is setuid root)
setgid2---On a directory: new files inherit the directory's group — useful for shared folders
sticky1---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.

bash
ln -s /home/deploy/apps/myapp/releases/20260811 /home/deploy/apps/myapp/current

A symbolic link is a pointer to another path. ln -s target linkname — target first, always.

bash
ls -l current
# current -> releases/20260811

Symlinks 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:

bash
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:

bash
ln -sfn releases/20260811 current.tmp && mv -Tf current.tmp current

Archives ​

bash
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
FlagMeaning
-cCreate
-xExtract
-tList (test)
-zgzip compression
-jbzip2
-Jxz — slower, smaller
-fFilename follows (must be last among the grouped flags)
-vVerbose
-CChange 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 ​

bash
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 latency

THE FASTEST WAY TO ISOLATE A 502

bash
# On the server — bypass Nginx entirely and hit the app directly
curl -i http://127.0.0.1:3001/health

If 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.

bash
wget https://example.com/file.tar.gz        # download to a file (default behaviour)
wget -c https://.../big.iso                 # continue an interrupted download

Use curl for API interaction, wget for downloading files.

Editing files ​

nano — start here ​

bash
nano /etc/nginx/sites-available/app.example.com
KeyAction
Ctrl+O, EnterSave ("write Out")
Ctrl+XExit
Ctrl+WSearch
Ctrl+KCut line
Ctrl+UPaste
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:

KeyAction
iEnter INSERT mode (now you can type)
EscBack to NORMAL mode
:wSave
:qQuit
:wqSave and quit
:q!Quit without saving — the escape hatch
/textSearch
ddDelete line
uUndo
GEnd of file
ggStart of file

If you are stuck: press Esc a few times, then type :q! and Enter.

bash
# Set nano as your default editor to avoid vim surprises
sudo update-alternatives --config editor
# or, in ~/.bashrc:
export EDITOR=nano

Shell productivity ​

Key / syntaxEffect
TabAutocomplete paths and commands — use constantly, it prevents typos
Ctrl+RReverse-search command history. The highest-value shortcut here.
Ctrl+CInterrupt the running command
Ctrl+DEnd of input / log out
Ctrl+LClear screen
Ctrl+A / Ctrl+EJump to start / end of line
!!Repeat the last command — sudo !! after a permission error
!$Last argument of the previous command
historyShow command history

Redirection and pipes ​

bash
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 version

2>&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 -la output and identify owner, group, and each permission triplet
  • [ ] I know 600 for .env and keys, 755 for directories, 644 for normal files
  • [ ] I understand why 777 is never the right answer and chown usually is
  • [ ] I understand the directory x bit and why /home/deploy needs 755
  • [ ] I can run sudo ss -tulpn and tell which services are publicly exposed
  • [ ] I use tail -f on logs while reproducing a problem
  • [ ] I check df -h and know that above 80% needs action
  • [ ] I read available in free -h, not free
  • [ ] I run ls before any rm -rf, and never rm -rf with an unchecked variable
  • [ ] I can edit a file in nano and escape from vim with :q!
  • [ ] I use Ctrl+R to search history instead of retyping long commands

Next: Level 3 — Users, sudo and Permissions →