A production server accumulates tens of thousands of files. On srv-tramontana there are four releases, backups in three folders, .bak- files scattered wherever somebody edited something, and logs that grow every day. When Marta asks "how much space do the old releases take up?" or when you have to find out whether a credential has slipped into some file, navigating with cd and ls will not do: you have to ask the system.

This lesson gives you the three search tools and, more importantly, the judgement to choose between them. locate answers in milliseconds but it can lie to you. find really walks the tree and accepts criteria no other tool offers. grep does not search for files: it searches inside them. They are three different problems, and confusing them costs you time or errors.

Contents

  1. Three tools, three problems
  2. locate: the pre-computed database
  3. find: the anatomy of a search
  4. find criteria: name, type, size, time, ownership, depth
  5. Combining criteria with logic
  6. Actions: -print, -delete, -exec and -execdir
  7. Names with spaces and the -print0 | xargs -0 pattern
  8. grep in depth
  9. ripgrep, the modern alternative
  10. Real Tramontana cases

  1. Three tools, three problems

Question Tool Why
Where is the file called X? locate Instantaneous; enough if the file is not recent
Which files meet these conditions? find The only one that filters by size, date, permissions, owner
Which files contain this text? grep -r It is the only one that opens and reads the content

The typical confusion is using find for what grep does or the other way round. A mnemonic rule: find searches by the file's properties, grep searches by its content. And they are combined constantly, because the usual request is "search in the files that meet X to see whether Y appears".

  1. locate: the pre-computed database

locate does not walk the disk: it queries an index generated periodically. On Ubuntu 24.04 the implementation is plocate.

operator@srv-tramontana:~$ sudo apt install -y plocate
operator@srv-tramontana:~$ locate confirmation.html
/opt/tramontana/releases/3.2.1/templates/confirmation.html
/opt/tramontana/releases/3.3.0/templates/confirmation.html
operator@srv-tramontana:~$ touch /home/operator/data/locate-test.txt
operator@srv-tramontana:~$ locate locate-test.txt      # no output: not indexed yet
operator@srv-tramontana:~$ sudo updatedb && locate locate-test.txt
/home/operator/data/locate-test.txt

The database is updated once a day by a scheduled task. Until updatedb runs, what you have just created does not exist as far as locate is concerned, and what you have just deleted still shows up.

Useful options: -i (ignore case), -c (count), -b '\name' (match the base name only), -l 5 (limit results), -e (verify that the file still exists). When to use it: to locate a system or configuration file whose name you half remember. When not to: in any serious procedure, and never as the input to a destructive command. An rm fed by locate may try to delete obsolete paths or overlook what really should have been deleted.

  1. find: the anatomy of a search

find <paths> <criteria> <actions>

The three blocks go in that order, and that is the source of half the errors. find processes the arguments sequentially: everything before the first criterion is interpreted as a path.

operator@srv-tramontana:~$ find /opt/tramontana/releases -maxdepth 1 -type d
/opt/tramontana/releases
/opt/tramontana/releases/3.1.0
/opt/tramontana/releases/3.2.0
/opt/tramontana/releases/3.2.1
/opt/tramontana/releases/3.3.0

If you give no action, find assumes -print. If you give no path, it uses the current directory; always writing it is a good habit, because a find with no path launched from / walks the whole system.

  1. find criteria

By name

Criterion Matches
-name '*.log' the base name, case-sensitive
-iname '*.LOG' the same but case-insensitive
-path '*/templates/*' the full path
-regex '.*/3\.[12]\..*' the full path, with a regex

The pattern goes in single quotes, without exception: otherwise Bash expands it against the current directory before find sees it, and you search for something else.

By type

-type f regular file, d directory, l symbolic link, s socket, b/c devices, p named pipe.

find /opt/tramontana -maxdepth 1 -type l -ls returns a single entry: the deployment link app -> releases/3.2.1 from Module 2, with its relative target.

By size

-size accepts suffixes: c bytes, k KiB, M MiB, G GiB. With no suffix, the unit is 512-byte blocks, which is almost never what you want. The sign delimits: +10M greater than, -10M less than, 10M exactly (rounded up, so it is rarely useful).

operator@srv-tramontana:~$ find /opt/tramontana -type f -size +10M -exec ls -lh {} +
-rwxr-x--- 1 root tramontana 48M Aug 10 09:12 /opt/tramontana/releases/3.2.1/executable
-rwxr-x--- 1 root tramontana 48M Aug 15 11:03 /opt/tramontana/releases/3.3.0/executable

By time

Here is the detail you have to understand properly because it silently produces wrong results.

Criterion Unit Meaning
-mtime n days content modified
-mmin n minutes content modified
-atime / -amin days / minutes last access
-ctime / -cmin days / minutes metadata changed
-newer FILE — more recent than that file

Why -mtime +7 means neither "modified in the last 7 days" nor "more than 7 days ago" as you would say it out loud. find calculates the days elapsed and discards the decimal part. A file that is 7 days and 20 hours old has an age of 7.83 days, which truncated is 7. And +7 means "strictly greater than 7", so that file does not match. In practice, -mtime +7 selects what is more than 8 days old.

Expression Selects files aged
-mtime 0 less than 24 h
-mtime -7 less than 7 days
-mtime 7 between 7 and 8 days exactly
-mtime +7 8 days or more

When precision matters — and in an automatic purge it matters — use -mmin or, better, compare against a reference file:

operator@srv-tramontana:~$ touch -d '2026-08-01 00:00' /tmp/ref
operator@srv-tramontana:~$ find /srv/tramontana/backups -type f ! -newer /tmp/ref

That is "earlier than 1 August", unambiguously and with no mental arithmetic. It is the form we use at Tramontana.

By ownership and permissions

operator@srv-tramontana:~$ find /var/log/tramontana -type f ! -group adm
operator@srv-tramontana:~$ find /opt/tramontana -type f -perm /u+s

The first verifies that all the logs have adm as their owning group; no output means the convention is being followed. The second is a security audit: it looks for binaries with SUID, that bit you learned to recognise in 02-07. The syntax of -perm has three forms:

Form Means
-perm 640 exactly 640 permissions
-perm -640 at least those bits (it may have more)
-perm /640 any of those bits

There is also -user operator, -group tramontana, -uid 997, -nouser and -nogroup (orphans, typical after deleting an account).

By depth

operator@srv-tramontana:~$ find /opt/tramontana -maxdepth 2 -type d

-maxdepth and -mindepth are positional options: they must go before any criterion. If you write find /opt -name '*.log' -maxdepth 2, find works but warns you that the result is not what you expect, because by then it has already decided to descend. Always put them right after the path.

  1. Combining criteria with logic

Consecutive criteria are combined with an implicit AND. For the rest:

Operator Meaning
-a AND (implicit, almost never written)
-o OR
! or -not negation
\\( \\) grouping (the parentheses must be escaped)
operator@srv-tramontana:~$ find /home/operator -type f \( -name '*.bak-*' -o -name '*.tmp' \) -mtime +30
/home/operator/.bashrc.bak-2026-07-02
/home/operator/data/bookings.csv.bak-2026-07-11

The parentheses are indispensable: without them, precedence makes -mtime +30 apply only to the last alternative, and you would take out every .bak-* regardless of its age. Whenever you mix -o with anything else, group it. The parentheses are escaped because to Bash they are metacharacters; '(' and ')' work too.

  1. Actions

Action What it does
-print prints the path (the default)
-print0 the same, separating with a null byte
-ls like ls -dils
-delete deletes (it is evaluated before other criteria: careful)
-exec CMD {} \; runs CMD once per file
-exec CMD {} + runs CMD grouping files together
-execdir CMD {} \; the same, but from the file's directory
-ok CMD {} \; like -exec, asking for confirmation

{} is the placeholder that find replaces with the path. The \; terminator is escaped so that Bash does not keep it.

\; against +: the performance difference

With \; one process is launched per file. With +, find accumulates paths and launches the smallest possible number of processes. Over 5,000 system files:

operator@srv-tramontana:~$ time find /usr/share/doc -type f -exec basename {} \; > /dev/null
real    0m11.482s
operator@srv-tramontana:~$ time find /usr/share/doc -type f -exec basename {} + > /dev/null
real    0m0.213s

Fifty times faster. The difference is not the command: it is the cost of creating thousands of processes. Use + whenever the command accepts several arguments. You only need \; when the command takes a single argument or when {} appears in the middle of the command (for example -exec mv {} {}.old \;).

-execdir and why it is safer

-execdir runs the command from the directory containing the file, and passes it ./name instead of the full path. That eliminates a whole class of problems: paths starting with a hyphen that the command would interpret as options, and the race condition in which somebody replaces an intermediate directory with a symbolic link between the moment find locates it and the moment the command acts. If you are going to run something destructive over directories other users write to, -execdir is the correct option.

-delete, sensibly

operator@srv-tramontana:~$ find /srv/tramontana/backups/temp -type f -name '*.tmp' -mtime +7
/srv/tramontana/backups/temp/export-1.tmp
/srv/tramontana/backups/temp/export-2.tmp
operator@srv-tramontana:~$ find /srv/tramontana/backups/temp -type f -name '*.tmp' -mtime +7 -delete

Always in two steps: first without -delete to see the list, then with it. It is the same convention you already apply to rm. And a real warning: -delete implies -depth, and if you place it before the criteria, find evaluates it first and deletes everything it finds. The order matters.

  1. Names with spaces and the -print0 | xargs -0 pattern

File names on Linux can contain spaces, tabs and even newlines. Any pipeline that separates paths with spaces breaks on them.

operator@srv-tramontana:~$ touch '/tmp/report august.txt'
operator@srv-tramontana:~$ find /tmp -name '*.txt' | xargs ls -l
ls: cannot access '/tmp/report': No such file or directory
ls: cannot access 'august.txt': No such file or directory

The solution is a separator that cannot appear in a name: the null byte. find emits it with -print0 and xargs expects it with -0.

operator@srv-tramontana:~$ find /tmp -name '*.txt' -print0 | xargs -0 ls -l
-rw-r----- 1 operator operator 0 Aug 18 11:20 '/tmp/report august.txt'

find ... -print0 | xargs -0 command is the canonical pattern. Memorise it exactly as it is.

xargs itself

xargs builds command lines from its standard input. The options that matter:

Option Effect
-0 input separated by null bytes
-n 5 at most 5 arguments per invocation
-I{} replaces {} with each line; implies one argument per invocation
-P 4 runs up to 4 processes in parallel
-r, --no-run-if-empty do not run anything if the input is empty
-t shows the command before running it (debugging)

--no-run-if-empty avoids a classic error: without it, xargs rm with empty input runs rm with no arguments. In GNU it is the default behaviour, but writing it explicitly makes the command portable and documents the intention.

  1. grep in depth

Option Effect
-i ignore case
-v invert: lines that do not match
-n line number
-c count matches only
-l / -L only the names of files with / without a match
-r / -R recursive (-R follows symbolic links)
-w / -x whole word / whole line
-o print only the part that matches
-A n / -B n / -C n n lines after / before / of context
-e P1 -e P2 several patterns
-f file patterns read from a file
-E / -F / -P ERE / fixed strings / PCRE
--include / --exclude filters which files are read in recursive mode
-q quiet; only $? matters
operator@srv-tramontana:~$ grep -n -A2 'ERROR 500' /var/log/tramontana/errors.log | head -6
41:2026-08-18 03:12:44 ERROR 500 /bookings db_timeout
42-2026-08-18 03:12:44 TRACE active_connections=200
43-2026-08-18 03:12:45 WARN pool exhausted
--
58:2026-08-18 03:19:02 ERROR 500 /bookings db_timeout

The context turns an isolated line into a story: the 500 error comes with active_connections=200, which is exactly the max_connections value in app.conf. There is a diagnostic hypothesis there.

-F against -E. With -F, grep treats the pattern as literal text and uses a multi-pattern search algorithm that is much faster than a regular expression engine. If you are searching for a fixed string, -F is the correct option: faster and with no surprises from metacharacters. Searching for 10.0.2.15 with -E would also match 10x0y2z15; with -F, only the IP.

operator@srv-tramontana:~$ grep -rFl '10.0.2.15' /etc/tramontana/
/etc/tramontana/app.conf

-q as a test. It prints nothing; it only returns 0 if it found something. It is the correct way to ask "does this appear?" and to chain with &&:

operator@srv-tramontana:~$ grep -q 'log_level=debug' /etc/tramontana/app.conf \
    && echo 'WARNING: the app is in debug mode'
WARNING: the app is in debug mode

A real finding: leaving log_level=debug in production bloats the logs and can dump sensitive data into the file.

  1. ripgrep, the modern alternative

rg does the same as grep -r and is several times faster: it walks in parallel, respects .gitignore, ignores binaries and numbers the lines by default.

operator@srv-tramontana:~$ rg -n 'db_timeout' /var/log/tramontana/
errors.log:41:2026-08-18 03:12:44 ERROR 500 /bookings db_timeout
errors.log:58:2026-08-18 03:19:02 ERROR 500 /bookings db_timeout

It is installed with sudo apt install -y ripgrep. It is excellent for your day-to-day work. With one professional caveat: grep is on every machine and rg is not. On somebody else's server, in a minimal container or in a documented procedure, write grep.

  1. Real Tramontana cases

a) Old releases to purge. How much space they take up and which ones are not the active one:

operator@srv-tramontana:~$ readlink /opt/tramontana/app
releases/3.2.1
operator@srv-tramontana:~$ find /opt/tramontana/releases -maxdepth 1 -mindepth 1 -type d \
    ! -name '3.2.1' -exec du -sh {} +
97M     /opt/tramontana/releases/3.1.0
98M     /opt/tramontana/releases/3.2.0
99M     /opt/tramontana/releases/3.3.0

-mindepth 1 excludes /opt/tramontana/releases itself, which would otherwise appear in the list. Before deleting anything: 3.3.0 is the next release, not an old one. The rule we have set is to keep the active one and the one immediately before it, so the purge candidate is 3.1.0, and only that one. This is the criterion we will automate in 03-07.

b) All the scattered .bak-* files.

operator@srv-tramontana:~$ sudo find /etc /home /srv /opt -name '*.bak-*' -type f -printf '%TY-%Tm-%Td %10s %p\n' | sort
2026-07-02 3771 /home/operator/.bashrc.bak-2026-07-02
2026-07-11 1284 /home/operator/data/bookings.csv.bak-2026-07-11
2026-08-18 512 /etc/tramontana/app.conf.bak-2026-08-18

-printf lets you compose exactly the output you want: %TY-%Tm-%Td the modification date, %10s the size, aligned, %p the path. It is cleaner than chaining ls and awk.

c) Searching the system for a credential. Marta asks whether the database password might be somewhere it should not:

operator@srv-tramontana:~$ sudo grep -rIl --exclude-dir='.git' -e 'db_password' /etc /home /srv /opt
/etc/tramontana/app.conf
/srv/tramontana/backups/temp/app.conf.copy

It appears where it should... and where it should not. -I skips binaries (without it, the 48 MB executable is analysed pointlessly and can make a mess of the terminal), -l gives just the file name, and --exclude-dir avoids noise. The second path is a serious discovery:

operator@srv-tramontana:~$ ls -l /srv/tramontana/backups/temp/app.conf.copy
-rw-r--r-- 1 operator operator 512 Aug 12 17:40 /srv/tramontana/backups/temp/app.conf.copy

Permissions 644: readable by any user on the system, when the original is 640 owned by root:tramontana. It is a copy Luis left behind while debugging a connection problem. It gets deleted, and the report to Marta says what the measure protects (nobody else can read that credential from the server) and what it does not (the password was exposed for six days; it has to be rotated, because deleting the copy does not undo the exposure). That distinction is what is expected of you.

d) Counting errors by type.

operator@srv-tramontana:~$ grep -c '' /var/log/tramontana/errors.log
87
operator@srv-tramontana:~$ grep -oE 'ERROR [0-9]{3}' /var/log/tramontana/errors.log | sort | uniq -c | sort -rn
     41 ERROR 500
     28 ERROR 503
     18 ERROR 404

grep -c '' counts every line — the empty pattern always matches: 87, as we expected. Of those, 41 are 500 server errors. That is already a report.

Common Mistakes and Tips

  • Not quoting the -name pattern. Bash expands it against the current directory and find receives something else.
  • Putting -maxdepth after the criteria. It has to go immediately after the path.
  • Mixing -o without parentheses. Precedence will make the last criterion apply to one branch only.
  • Reading -mtime +7 literally. It is 8 days or more. If it matters, use ! -newer reference-file.
  • Chaining find | xargs without -print0/-0. It breaks on the first name with a space in it.
  • Using -exec ... \; over thousands of files. Switch to + and you gain an order of magnitude.
  • Putting -delete before the criteria. It is evaluated first and deletes too much. And do not trust locate for making decisions: it is yesterday's photograph, verify with find or with ls.
  • Tip: find mixes warnings about directories you have no permission for into its output. Add 2>/dev/null to silence them — you will see this in detail in 03-04 — but only when you know they are of no interest to you. And faced with a complex find, run it first with -ls: that is its --dry-run.

Exercises

Exercise 1. Locate in /home/operator all the regular files larger than 1 MiB modified more than two weeks ago, showing date, size and path sorted by date. Explain why you did not use a plain -mtime +14.

Exercise 2. Find out which system configuration files mention Tramontana's database port (5432), excluding binaries and without names containing spaces breaking anything. Then count how many times it appears in total.

Exercise 3. Prepare — without running it — the purge of release 3.1.0 and of all the .tmp files more than 7 days old in /srv/tramontana/backups/temp. Show exactly what would be deleted, how much space would be freed, and justify the prior verification you would carry out so as not to delete the active release.

Solutions

Solution 1.

operator@srv-tramontana:~$ touch -d '2026-08-04 00:00' /tmp/ref-14d
operator@srv-tramontana:~$ find /home/operator -type f -size +1M ! -newer /tmp/ref-14d \
    -printf '%TY-%Tm-%Td %8s %p\n' | sort
2026-07-11 2097152 /home/operator/data/export-june.tar.gz
2026-07-29 5242880 /home/operator/work/2026/07/data/dump.sql

-mtime +14 is avoided because its real meaning is "15 days or more", because of the truncation of the decimal part that we saw in section 4. With a reference file created with touch -d the boundary is a specific, verifiable date, not the result of arithmetic you have to remember. In an automatic purge that one-day difference can mean deleting the only copy that was left. -printf and sort replace ls -l, which cannot sort an arbitrary list of paths by date.

Solution 2.

operator@srv-tramontana:~$ sudo grep -rIlF --exclude-dir=.git '5432' /etc 2>/dev/null
/etc/tramontana/app.conf
/etc/postgresql/16/main/postgresql.conf
operator@srv-tramontana:~$ sudo grep -rIhoF '5432' /etc 2>/dev/null | wc -l
7

-r handles the traversal internally, so there is no pipeline to break: the problem of names with spaces does not even arise. That is the first lesson of the solution: if grep -r is enough for you, do not build a find | xargs. -I discards binaries, -F treats 5432 as a literal (it is a number, there are no metacharacters, but it is faster and more explicit), and 2>/dev/null silences the permission denied warnings in subdirectories of /etc that are of no interest even with sudo. To count, -h suppresses the file name and -o emits one line per match, so that wc -l counts occurrences and not lines containing at least one.

Solution 3. First the verification you cannot skip:

operator@srv-tramontana:~$ readlink /opt/tramontana/app
releases/3.2.1

The active one is 3.2.1, so 3.1.0 is not the active one. This check goes always first, because the app link is the only source of truth about which release is being served: trusting your memory or the highest number is how a release gets deleted in production. And it has to be done now, not yesterday.

operator@srv-tramontana:~$ du -sh /opt/tramontana/releases/3.1.0
97M     /opt/tramontana/releases/3.1.0
operator@srv-tramontana:~$ find /srv/tramontana/backups/temp -type f -name '*.tmp' -mtime +7 \
    -printf '%TY-%Tm-%Td %8s %p\n'
2026-08-02  4096 /srv/tramontana/backups/temp/export-1.tmp
2026-08-05  8192 /srv/tramontana/backups/temp/export-2.tmp

Total to be freed: 97 MiB plus 12 KiB. The commands we would run, already validated by the lists above:

# sudo rm -rf /opt/tramontana/releases/3.1.0
# find /srv/tramontana/backups/temp -type f -name '*.tmp' -mtime +7 -delete

They are commented out on purpose: the exercise asked you to prepare them. Note that the second command is literally the same as the verification one with -delete added at the end. That is the correct procedure: you do not write a new command to delete, you add the action to the command you have already seen work. Any change in the pattern between the check and the deletion invalidates the check.

Conclusion

You no longer navigate a server: you interrogate it.

  • You choose between the three: locate for "where is it", find to filter by the file's properties, grep to search content. And you know locate's flaw: it is yesterday's photograph until the next updatedb.
  • You have mastered find <paths> <criteria> <actions> and the criteria by name, type, size, time, owner, permissions and depth, with the three forms of -perm and the positional options placed where they belong.
  • You genuinely understand -mtime +7 and you know that the robust alternative is ! -newer over a reference file.
  • You combine criteria with -o, ! and escaped parentheses, knowing why the grouping is not optional.
  • You use -exec ... + instead of \; because the difference is an order of magnitude; you know -execdir, and you apply -delete only after having seen the list.
  • You have internalised the canonical pattern find ... -print0 | xargs -0 and you handle xargs with -n, -I{}, -P and --no-run-if-empty.
  • You handle grep with context, recursion, -o, -F for literals and -q as a chainable test, and you know rg as a fast alternative that will not always be installed.
  • And you have solved four real problems: quantifying the purgeable releases without touching the active one, taking an inventory of the .bak-* files, finding an exposed credential in a forgotten copy — with the honest conclusion that deleting it is not enough and the password has to be rotated — and counting the errors in errors.log by type.

In several of those commands you have used 2>/dev/null, | and > without anybody having fully explained to you what they are. That ends in the next lesson. Pipes and Redirection takes the mechanism apart: the three standard streams and why they exist, how to see them in /proc, what a pipe really is, the difference between &>file, >file 2>&1 and the classic mistake 2>&1 >file that almost everybody makes, and the | sudo tee pattern for writing to protected files. By the end of it you will have stopped chaining commands by imitation and will start composing them knowing exactly where every byte travels.

Linux Course: From Beginner to System Administrator

Module 1: Introduction to Linux

Module 2: Basic Linux Commands

Module 3: Advanced Command-Line Skills

Module 4: Shell Scripting

Module 5: System Administration

Module 6: Networking and Security

Module 7: Advanced Topics

Module 8: Practical Projects

© Copyright 2026. All rights reserved