Working with files: permissions, archives and links
Read and set permissions with confidence, copy and find files in bulk, pack and unpack archives, and use links the way real deployments do.
By the end of this lesson you'll be able to
0 of 7 completeReading permissions
Every file has an owner, a group, and three sets of permissions. ls -l shows them:
$ ls -l deploy.sh -rwxr-x--- 1 rajeev devops 412 Oct 13 10:42 deploy.sh
ls -l deploy.shlslist files-llong format: permissions, owner, size, date
| Bit | On a file | On a folder |
|---|---|---|
r | read the contents | list the names inside (ls) |
w | change the contents | create, delete and rename files inside |
x | run it as a program | enter it (cd) and reach the files |
The folder rules surprise people: to delete a file you need w on the folder, not the file.
Changing permissions and owners
$ chmod u+x deploy.sh # letters: add execute for the user (owner) $ chmod g-w,o-rwx notes.txt # remove write for the group, everything for others $ chmod 640 app.env # numbers: owner rw, group r, others nothing $ chmod -R g+rX shared/ # recursive; capital X adds x only to folders $ sudo chown deploy:devops /srv/app # change owner and group $ sudo chgrp devops report.csv # change only the group
chmod u+x deploy.shchmodchange permissions
sudo chown deploy:devops /srv/appsudorun the next command as rootchownchange owner
| Number | Letters | Typical use |
|---|---|---|
755 | rwxr-xr-x | scripts and programs everyone may run, folders |
644 | rw-r--r-- | normal files, web pages, most configs |
640 | rw-r----- | configs a service's group must read |
600 | rw------- | secrets: private keys, .env files |
700 | rwx------ | your private folders, like ~/.ssh |
umask: the defaults for new files
New files start from 666 and folders from 777, minus the umask. With the common umask
022, new files are 644 and folders 755.
$ umask 0022 $ touch a.txt && mkdir b && ls -ld a.txt b -rw-r--r-- 1 rajeev rajeev 0 Oct 13 10:50 a.txt drwxr-xr-x 2 rajeev rajeev 4096 Oct 13 10:50 b
touch a.txt && mkdir b && ls -ld a.txt btouchcreate an empty file, or update its time&&run the next command only if this one workedmkdirmake a directorylslist files
Special bits: setuid, setgid, sticky
| Bit | Shows as | Means |
|---|---|---|
| setuid on a program | rws in the owner set | runs as the file's owner (passwd runs as root to update /etc/shadow) |
| setgid on a folder | rws in the group set | new files inside get the folder's group: perfect for shared team folders |
| sticky on a folder | t at the end (/tmp is drwxrwxrwt) | anyone may create files, only the owner may delete them |
$ chmod g+s /srv/shared # team folder: files inherit the group $ find / -perm -4000 -type f 2> /dev/null # audit: every setuid program
chmod g+s /srv/sharedchmodchange permissions
find / -perm -4000 -type f 2> /dev/nullfindsearch for files-typef for files, d for directories
A setuid program owned by root that you didn't expect is a security finding. Check the list on any new server.
Copying, moving and selecting in bulk
$ cp -a /srv/app /srv/app.bak # -a: keep permissions, owners, times and links $ mv report.txt reports/2026-10.txt # move and rename in one step $ ls *.log # wildcard: every name ending in .log $ ls access.log.? # ? = exactly one character $ ls app-[0-9].conf # [ ] = one character from the set $ cp config.{yml,yml.bak} # braces expand: cp config.yml config.yml.bak $ find /var/log -name "*.gz" -mtime +30 -delete # act on what find matches $ find . -name "*.log" -size +100M -exec ls -lh {} \; # run a command on each match $ find . -name "*.tmp" -print0 | xargs -0 rm # safe even with spaces in names
cp -a /srv/app /srv/app.bakcpcopy files
mv report.txt reports/2026-10.txtmvmove or rename
ls *.loglslist files
find /var/log -name "*.gz" -mtime +30 -deletefindsearch for files-namematch the file name (wildcards allowed)-mtimeby days since last change (-1 = last 24 hours)
find . -name "*.log" -size +100M -exec ls -lh {} \;.this folder-sizeby size, like +10M
find . -name "*.tmp" -print0 | xargs -0 rm|pipe: send output to the next command
Finding files with find
find walks a folder tree right now and checks every file against your tests. It's slow on a huge disk,
but it's always correct, and it can act on what it finds. The outputs below come from a small practice tree,
~/lab4, with an app and some logs.
A find command reads left to right: where to start, tests that must all be true, then an action
(-print if you give none).
$ find . -name "*.gz" ./logs/access.log.2.gz ./logs/error.log.3.gz $ find . -iname "*.LOG" ./logs/access.log ./logs/app.log $ find . -type f -size +1M ./logs/app.log $ find logs -name "*.gz" -mtime +30 logs/access.log.2.gz logs/error.log.3.gz
find . -name "*.gz"findsearch for files.this folder-namematch the file name (wildcards allowed)
find . -type f -size +1M-typef for files, d for directories-sizeby size, like +10M
find logs -name "*.gz" -mtime +30-mtimeby days since last change (-1 = last 24 hours)
Always quote the pattern. Without quotes, the shell expands *.gz in your current folder before find
ever sees it.
| Test | Matches |
|---|---|
-name, -iname | the file name, with wildcards; -iname ignores case |
-type f, -type d | files, folders (l for symlinks) |
-size +1M, -size -10k | bigger than 1 MiB, smaller than 10 KiB |
-mtime +30, -mmin -15 | changed more than 30 days ago, less than 15 minutes ago |
-newer FILE | changed more recently than FILE |
-user, -group, -perm | owner, group, permission bits (-perm -o=r: others can read) |
-maxdepth N | go at most N folders down |
Tests next to each other mean AND. Use -o for OR, with escaped brackets around the group:
$ find . -type f \( -name "*.tmp" -o -name "*.bak" \) ./app/releases/v1/build.tmp ./app/cache.tmp ./logs/old reports/draft.tmp $ find . -newer app/releases/v1/index.html -name "*.html" ./app/releases/v2/index.html $ find app -type f -perm -o=r -name ".env" app/.env
find . -type f \( -name "*.tmp" -o -name "*.bak" \)findsearch for files.this folder-typef for files, d for directories
find . -newer app/releases/v1/index.html -name "*.html"-namematch the file name (wildcards allowed)
That last one is a real audit: a .env file with a password in it that every user on the server can read.
Skip whole folders with -prune. Searching node_modules or .git wastes time and floods the output:
$ find . -name node_modules -prune -o -name "*.js" -print ./app/server.js
find . -name node_modules -prune -o -name "*.js" -printfindsearch for files.this folder-namematch the file name (wildcards allowed)
Read it as: if the name is node_modules, don't go in; otherwise, if it ends in .js, print it. The
explicit -print matters; without it, the pruned folder's name is printed too.
Act on the matches. -printf formats each line, -exec … {} + runs one command with all the matches,
and -delete removes them:
$ find logs -name "*.gz" -mtime +30 -printf "%TY-%Tm-%Td %6s %p\n" 2026-09-03 53 logs/access.log.2.gz 2026-09-08 9000 logs/error.log.3.gz $ find . -name "*.tmp" -exec ls -l {} + -rw-r--r-- 1 rajeev rajeev 0 Oct 18 12:08 ./app/cache.tmp -rw-r--r-- 1 rajeev rajeev 0 Oct 18 12:08 ./app/releases/v1/build.tmp -rw-r--r-- 1 rajeev rajeev 0 Oct 18 12:08 ./logs/old reports/draft.tmp $ find . -type f -name "*.tmp" -delete -print ./app/releases/v1/build.tmp ./app/cache.tmp ./logs/old reports/draft.tmp
find logs -name "*.gz" -mtime +30 -printf "%TY-%Tm-%Td %6s %p\n"findsearch for files-namematch the file name (wildcards allowed)-mtimeby days since last change (-1 = last 24 hours)
find . -name "*.tmp" -exec ls -l {} +.this folder
find . -type f -name "*.tmp" -delete -print-typef for files, d for directories
{} + packs many files into one command, like xargs. {} \; runs the command once per file: fine for a
handful, slow for thousands. Names with spaces, like old reports, are safe with both, because find
passes each name as one argument.
locate: fast answers from a database
locate doesn't search the disk. It searches a list of every file name, built by updatedb (on Ubuntu the
package is plocate, refreshed by a daily timer). That makes it instant on any size of disk, and it also
means it can be out of date:
$ locate access.log $ sudo updatedb $ locate access.log /home/rajeev/lab4/logs/access.log /home/rajeev/lab4/logs/access.log.1 /home/rajeev/lab4/logs/access.log.2.gz /home/rajeev/lab4/logs/access.log.bak /home/rajeev/lab4/logs/access.log.bak.gz /home/rajeev/lab4/logs/access.log.gz
sudo updatedbsudorun the next command as root
The first search found nothing: the files were created after the last updatedb. The same happens to a file
made a minute ago:
$ touch /home/rajeev/lab4/logs/new-today.log $ locate new-today.log $ sudo updatedb $ locate new-today.log /home/rajeev/lab4/logs/new-today.log $ locate -c index.html 2 $ locate -b '\index.html' /home/rajeev/lab4/app/releases/v1/index.html /home/rajeev/lab4/app/releases/v2/index.html
touch /home/rajeev/lab4/logs/new-today.logtouchcreate an empty file, or update its time
sudo updatedbsudorun the next command as root
-c counts, -i ignores case, and -b '\name' matches the exact base name instead of any path that contains
it.
find | locate | |
|---|---|---|
| Searches | the disk, now | a database from the last updatedb |
| Speed on a big disk | slow | instant |
| New or deleted file | always right | wrong until the next updatedb |
| Can test | size, age, owner, permissions | the name only |
| Use it for | anything you'll act on, any audit | "where is that config file again?" on a dev host |
Use locate to find where something lives. Use find before you change or delete anything.
xargs: turning a list into arguments
Many commands take file names as arguments, not as input on stdin. xargs bridges the two: it reads a list
and runs a command with the items as arguments. Its classic trap is spaces in names:
$ find . -name "*.tmp" | xargs rm rm: cannot remove './logs/old': No such file or directory rm: cannot remove 'reports/draft.tmp': No such file or directory $ find . -name "*.tmp" -print0 | xargs -0 rm -v removed './logs/old reports/draft.tmp'
find . -name "*.tmp" | xargs rmfindsearch for files.this folder-namematch the file name (wildcards allowed)|pipe: send output to the next command
xargs split old reports/draft.tmp at the space and asked rm for two files that don't exist. With
-print0 and -0, names are separated by a null byte, which can't appear in a file name. Use that pair every
time a list of files goes into xargs.
$ printf "web-01\nweb-02\nweb-03\nweb-04\n" | xargs -n 2 echo ping ping web-01 web-02 ping web-03 web-04 $ printf "" | xargs echo "running with:" running with: $ printf "" | xargs -r echo "running with:" $ find app/releases -name index.html | xargs grep -l v2 app/releases/v2/index.html $ ls logs/*.log | xargs -I {} cp {} {}.bak
printf "web-01\nweb-02\nweb-03\nweb-04\n" | xargs -n 2 echo ping|pipe: send output to the next command
find app/releases -name index.html | xargs grep -l v2findsearch for files-namematch the file name (wildcards allowed)
ls logs/*.log | xargs -I {} cp {} {}.baklslist files
| Option | Does |
|---|---|
-0 | items are separated by null bytes (pair it with find -print0) |
-n N | at most N items per command |
-r | don't run the command at all when the list is empty (GNU runs it once) |
-I {} | run once per item and put it where {} is, even in the middle of the command |
-P N | run up to N commands at the same time |
Note the empty-input line: without -r, xargs still ran echo once. With rm -f or a deploy script,
"ran once with nothing" can do real damage, so add -r to anything destructive.
-P uses more than one CPU. Compressing four log files one at a time, then four at a time:
$ time (ls logs/*.log logs/*.bak | xargs -n 1 gzip -k -9) real 0m0.270s $ time (ls logs/*.log logs/*.bak | xargs -n 1 -P 4 gzip -k -9) real 0m0.123s
Tiny files, so tiny numbers, but less than half the time. On gigabytes of logs the difference is minutes.
find -exec {} + or xargs? For files, find -exec … {} + is simpler and has no splitting trap. Reach
for xargs when the list comes from somewhere else (a grep, a file, another command) or when you want -P.
Archives: tar, gzip and xz
tar packs many files into one; gzip or xz compresses it. Most of the time you use them
together:
$ tar czf site.tar.gz site/ # c create, z gzip, f file name $ tar tzf site.tar.gz | head # t list what's inside, without extracting $ tar xzf site.tar.gz -C /tmp/restore # x extract, -C into this folder $ tar cJf logs.tar.xz /var/log/myapp # J = xz: smaller, slower $ gzip big.log # compress one file → big.log.gz (the original goes) $ gunzip big.log.gz # and back $ zcat big.log.gz | grep ERROR # read a compressed file without unpacking it
tar czf site.tar.gz site/tarpack or unpack archives
tar tzf site.tar.gz | head|pipe: send output to the next commandheadshow the first lines
zcat big.log.gz | grep ERRORgrepfind lines that match a pattern
| Format | Speed | Size | Seen as |
|---|---|---|---|
gzip (z) | fast | good | .tar.gz, .tgz: logs, everyday backups |
xz (J) | slow | smallest | .tar.xz: release downloads |
bzip2 (j) | middle | middle | .tar.bz2: older projects |
Always list an archive (tar tzf) before extracting one you didn't make: some archives contain a
folder, others spill files straight into your current directory.
dd copies raw bytes, such as a whole disk to an image file. It's powerful and unforgiving:
dd to the wrong of= device erases it. Use it only when you've double-checked both names.
Hard links and symlinks
$ ln data.txt data-hard.txt # hard link: same inode, same data $ ln -s /srv/app/releases/v2 current # symlink: a pointer to a path $ ls -li 1311 -rw-r--r-- 2 rajeev rajeev 12 Oct 13 11:02 data-hard.txt 1311 -rw-r--r-- 2 rajeev rajeev 12 Oct 13 11:02 data.txt 1318 lrwxrwxrwx 1 rajeev rajeev 20 Oct 13 11:03 current -> /srv/app/releases/v2
ls -lilslist files
The same inode number (1311) and a link count of 2 show the hard link. The l and the ->
show the symlink.
| Hard link | Symlink | |
|---|---|---|
| Points to | the data (inode) | a path |
| Works across disks | no | yes |
| Works for folders | no | yes |
| Original deleted | still works | breaks ("dangling") |
| Used for | rarely, by hand | config switches, releases, /etc/alternatives |
Copying makes an independent duplicate: change the copy, the original stays. Linking gives you one thing with two names: change it through either, both see the change.
Ship a release with a symlink switch
Package version 2 of a small site, unpack it next to version 1, switch the live link to v2 in one step, then roll back to v1. Prove which version is live after each step.
~/lab4/app/current → releases/v1Reveal one safe solution
$ mkdir -p ~/lab4/app/releases/v1 ~/lab4/site-v2 $ echo "version 1" > ~/lab4/app/releases/v1/index.html $ echo "version 2" > ~/lab4/site-v2/index.html $ cd ~/lab4 $ tar czf site-v2.tar.gz -C site-v2 . # package v2 $ mkdir app/releases/v2 && tar xzf site-v2.tar.gz -C app/releases/v2 $ ln -sfn releases/v1 app/current && cat app/current/index.html version 1 $ ln -sfn releases/v2 app/current && cat app/current/index.html # deploy version 2 $ ln -sfn releases/v1 app/current && cat app/current/index.html # roll back version 1 $ ls -l app/current lrwxrwxrwx 1 rajeev rajeev 11 Oct 13 11:20 app/current -> releases/v1
mkdir -p ~/lab4/app/releases/v1 ~/lab4/site-v2mkdirmake a directory-pcreate missing parents, no error if it exists
echo "version 1" > ~/lab4/app/releases/v1/index.htmlechoprint text>write output to a file, replacing it
cd ~/lab4cdchange directory
tar czf site-v2.tar.gz -C site-v2 .tarpack or unpack archives.this folder
mkdir app/releases/v2 && tar xzf site-v2.tar.gz -C app/releases/v2&&run the next command only if this one worked
ln -sfn releases/v1 app/current && cat app/current/index.htmlcatprint a file
ls -l app/currentlslist files-llong format: permissions, owner, size, date
-s makes a symlink, -f replaces the existing one, and -n treats an existing link to a folder
as the link itself, so ln replaces it instead of creating a new link inside the old release.
Try it yourself
0 of 7 steps doneRun each task in your own account or terminal, then tick it off.
Quick check
A file shows -rw-r-----. Who can read it?
Official reading for this lesson
Or get every quiz answer right and it completes itself.