DevOps.Academy
CoursesCurriculumPricing

    Loading…

    ↑ ↓ move · ↵ open · Ctrl K anywhere

    ◔My learningSign in
    DevOps.Academy

    Free, hands-on DevOps lessons. Made with care in India.

    Learn

    All coursesLinux roadmapCurriculumMy learning

    Academy

    PricingCertificates

    Legal

    PrivacyTermsContact

    devops.rajeev.pro

    Linux & BashLesson 4 of 16
    Your progress · 0%
    On this page
    1. Reading permissions
    2. Changing permissions and owners
    3. Copying, moving and selecting in bulk
    4. Finding files with find
    5. locate: fast answers from a database
    6. xargs: turning a list into arguments
    7. Archives: tar, gzip and xz
    8. Hard links and symlinks
    ↖ Course roadmap
    Lesson 4 · Working with files

    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.

    35 min read · beginner · hands-on

    By the end of this lesson you'll be able to

    0 of 7 complete

    Reading 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.sh
    • lslist files
    • -llong format: permissions, owner, size, date

    Three sets of three. r = 4, w = 2, x = 1: add them per set to get the number.© Diagram: DevOps Academy

    Three sets of three. r = 4, w = 2, x = 1: add them per set to get the number.© Diagram: DevOps Academy
    BitOn a fileOn a folder
    rread the contentslist the names inside (ls)
    wchange the contentscreate, delete and rename files inside
    xrun it as a programenter 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.sh
    • chmodchange permissions
    sudo chown deploy:devops /srv/app
    • sudorun the next command as root
    • chownchange owner
    NumberLettersTypical use
    755rwxr-xr-xscripts and programs everyone may run, folders
    644rw-r--r--normal files, web pages, most configs
    640rw-r-----configs a service's group must read
    600rw-------secrets: private keys, .env files
    700rwx------your private folders, like ~/.ssh
    Never chmod 777

    777 lets every user and every process on the machine change the file. It "fixes" a permission error by removing all protection. Find out which user needs access, then give exactly that: the right owner, group, or one extra bit.

    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 b
    • touchcreate an empty file, or update its time
    • &&run the next command only if this one worked
    • mkdirmake a directory
    • lslist files

    Special bits: setuid, setgid, sticky

    BitShows asMeans
    setuid on a programrws in the owner setruns as the file's owner (passwd runs as root to update /etc/shadow)
    setgid on a folderrws in the group setnew files inside get the folder's group: perfect for shared team folders
    sticky on a foldert 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/shared
    • chmodchange permissions
    find / -perm -4000 -type f 2> /dev/null
    • findsearch 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.bak
    • cpcopy files
    mv report.txt reports/2026-10.txt
    • mvmove or rename
    ls *.log
    • lslist files
    find /var/log -name "*.gz" -mtime +30 -delete
    • findsearch 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
    Check before you act

    Run every find … -delete or rm with a wildcard without the action first. find /var/log -name "*.gz" -mtime +30 shows exactly what would go. Add -delete only when the list is right.

    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.

    TestMatches
    -name, -inamethe file name, with wildcards; -iname ignores case
    -type f, -type dfiles, folders (l for symlinks)
    -size +1M, -size -10kbigger than 1 MiB, smaller than 10 KiB
    -mtime +30, -mmin -15changed more than 30 days ago, less than 15 minutes ago
    -newer FILEchanged more recently than FILE
    -user, -group, -permowner, group, permission bits (-perm -o=r: others can read)
    -maxdepth Ngo 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" -print
    • findsearch 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 updatedb
    • sudorun 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.log
    • touchcreate an empty file, or update its time
    sudo updatedb
    • sudorun 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.

    findlocate
    Searchesthe disk, nowa database from the last updatedb
    Speed on a big diskslowinstant
    New or deleted filealways rightwrong until the next updatedb
    Can testsize, age, owner, permissionsthe name only
    Use it foranything 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 rm
    • findsearch 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 v2
    • findsearch for files
    • -namematch the file name (wildcards allowed)
    ls logs/*.log | xargs -I {} cp {} {}.bak
    • lslist files
    OptionDoes
    -0items are separated by null bytes (pair it with find -print0)
    -n Nat most N items per command
    -rdon'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 Nrun 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 command
    • headshow the first lines
    zcat big.log.gz | grep ERROR
    • grepfind lines that match a pattern
    FormatSpeedSizeSeen as
    gzip (z)fastgood.tar.gz, .tgz: logs, everyday backups
    xz (J)slowsmallest.tar.xz: release downloads
    bzip2 (j)middlemiddle.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

    A hard link is another name for the same data. A symlink is a signpost to a path.© Diagram: DevOps Academy

    A hard link is another name for the same data. A symlink is a signpost to a path.© Diagram: DevOps Academy
    $ 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 -li
    • lslist files

    The same inode number (1311) and a link count of 2 show the hard link. The l and the -> show the symlink.

    Hard linkSymlink
    Points tothe data (inode)a path
    Works across disksnoyes
    Works for foldersnoyes
    Original deletedstill worksbreaks ("dangling")
    Used forrarely, by handconfig 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.

    In production: releases and a current symlink

    Many deploy tools work like this: each release goes into its own folder, and a symlink called current points at the live one. The web server serves current/. Deploying is changing the link, rolling back is changing it back, and the old release is still on disk if you need it.

    ⌘
    Mini mission

    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.

    Input: ~/lab4/site-v2 (a folder with index.html)Output: ~/lab4/app/current → releases/v1
    Reveal 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-v2
    • mkdirmake a directory
    • -pcreate missing parents, no error if it exists
    echo "version 1" > ~/lab4/app/releases/v1/index.html
    • echoprint text
    • >write output to a file, replacing it
    cd ~/lab4
    • cdchange 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.html
    • catprint a file
    ls -l app/current
    • lslist 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 done

    Run each task in your own account or terminal, then tick it off.

    Knowledge check

    Quick check

    Question 1 of 8

    A file shows -rw-r-----. Who can read it?

    Go deeper

    Official reading for this lesson

    • Docschmod(1) manualUbuntu manpages ↗
    • Docstar(1) manualUbuntu manpages ↗
    • Docsln(1) manualUbuntu manpages ↗
    • Docsfind(1) manualUbuntu manpages ↗
    • GuideLPIC-1 exam 101, objectives 103.3, 104.5 and 104.6Linux Professional Institute ↗

    Or get every quiz answer right and it completes itself.

    ← PreviousShell basics: PATH, variables, streams and sudoNext lesson →Text processing: get answers out of logs