In the three previous lessons you have written if, case and [[ ]] guided by their resemblance to other languages. It worked, but you did not know why. Time to fix that, starting with the idea that governs everything in Bash and that hardly any tutorial states clearly: here you do not branch on a boolean value, you branch on a command's exit code. if does not expect an expression: it expects a command, runs it and checks whether it returned 0; everything else — [ ], [[ ]], (( )) — are commands that exist in order to produce that 0 or 1. With that piece in place, the rest falls into position on its own. By the end, health_check.sh will be able to say OK, WARN or CRITICAL, and purge_releases.sh will be born.
Contents
- The condition is an exit code
if,elif,else- The three kinds of test and which to use
- Complete table of operators
- Regular expressions with
=~andBASH_REMATCH caseand its three terminators- Loops:
for,while,until break,continueandselect- Short circuits
&&and||, and their trap - Loops and performance: when you want
awk - Application: alert levels and
purge_releases.sh
- The condition is an exit code
operator@srv-tramontana:~$ if grep -q 'db_timeout' /var/log/tramontana/errors.log
> then echo "there are database timeouts"; fi
there are database timeoutsThere is no == true anywhere. if ran grep -q, grep returned 0 because it found matches, and 0 means success, so the then was entered. Had it found nothing it would have returned 1 and the block would have been skipped.
Three consequences worth keeping in mind from now on:
- Any command can be a condition:
if ping -c1 -W2 10.0.2.15,if [[ -f file ]],if my_function. They are all the same thing. And the classic[is a real command, with its executable in/usr/bin/[; that is why[ $a = $b ]needs spaces: they are separate arguments. - The logic is inverted compared with languages that have booleans: 0 is true. If you write a function that checks something, it must return 0 when the answer is "yes".
if, elif, else
if, elif, elsedisk_usage=$(df --output=pcent / | tail -1 | tr -dc '0-9')
if (( disk_usage >= 90 )); then
level="CRITICAL"
elif (( disk_usage >= 80 )); then
level="WARN"
else
level="OK"
fiThe syntax requires then and fi; the ; before then lets you put it on the same line. The elifs are evaluated in order and the first one that matches wins, so the conditions go from the most restrictive to the loosest: if you swapped the first two blocks, a disk at 95% would say WARN. To negate, put ! in front: if ! systemctl is-active --quiet tramontana; then .... And beware of if [ $? -eq 0 ], almost always redundant: put the command directly in the if.
- The three kinds of test and which to use
| Form | What it is | When to use it |
|---|---|---|
[ ... ] or test ... |
POSIX command | Only if the script must run under dash |
[[ ... ]] |
Bash reserved word | Always, by default |
(( ... )) |
Arithmetic evaluation | Numeric comparisons and calculations |
The difference between [ ] and [[ ]] is not cosmetic. [[ ]] is shell syntax, not a command, so Bash does not do word splitting or glob expansion inside it:
operator@srv-tramontana:~$ f="august report.txt"
operator@srv-tramontana:~$ [ -f $f ] && echo it exists
-bash: [: august: binary operator expected
operator@srv-tramontana:~$ [[ -f $f ]] && echo it exists || echo "does not exist, but no error"
does not exist, but no errorWith [ ] the unquoted variable was split into two arguments and the command could not even be evaluated. With [[ ]] there is no need to quote, and it also accepts &&, ||, patterns and =~, which [ ] does not have. For numbers, (( )) is far more readable: compare if (( errors > 20 && connections >= 200 )) with [ "$errors" -gt 20 -a "$connections" -ge 200 ]. Remember from 04-02 that inside (( )) the variables go without $.
- Complete table of operators
| Group | Operator | True if… |
|---|---|---|
| Files | -e / -f / -d / -L |
It exists / is a regular file / is a directory / is a link |
| Files | -s / -r / -w / -x |
It is not empty / read, write, execute permission |
| Files | a -nt b / a -ot b |
a is newer / older than b |
| Strings | -z "$s" / -n "$s" |
It is empty / it is not empty |
| Strings | $s == pattern |
Matches the globbing pattern (no quotes on the right) |
| Strings | == / != / < / > |
Equal / different / lexicographic order according to the locale |
| Strings | $s =~ regex |
Matches the regular expression (section 5) |
| Numbers | -eq -ne -lt -le -gt -ge |
Equal, different, less, less or equal, greater, greater or equal |
| Numbers | Inside (( )) |
== != < <= > >=, and && || ! |
The classic confusion deserves a warning in bold: -eq is for numbers and == is for strings. [[ "01" -eq "1" ]] is true (numerically equal) but [[ "01" == "1" ]] is false (different strings). Using the wrong operator gives correct results for months and then fails with a leading zero or with a version like 3.10.
Another surprising detail: inside [[ ]], the right-hand side of == is not quoted if you want it to be a pattern:
operator@srv-tramontana:~$ v="3.2.1"; [[ $v == 3.2.* ]] && echo "branch 3.2"
branch 3.2
operator@srv-tramontana:~$ [[ $v == "3.2.*" ]] && echo "branch 3.2" || echo "no match"
no matchThe quotes turn the pattern into literal text: it is the number one source of "my comparison does not work and I do not know why".
- Regular expressions with
=~ and BASH_REMATCH
=~ and BASH_REMATCH=~ compares against an extended regular expression (the ERE from 03-02) and, as well as saying yes or no, leaves the captures in the BASH_REMATCH array: position 0 is the whole match and the following ones are the parenthesised groups.
validate_date() {
local date_value="$1"
if [[ $date_value =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})$ ]]; then
printf 'valid: year %s, month %s, day %s\n' \
"${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}"
return 0
fi
printf 'invalid format: %s\n' "$date_value" >&2
return 1
}
operator@srv-tramontana:~$ validate_date "2026-08-18"
valid: year 2026, month 08, day 18
operator@srv-tramontana:~$ validate_date "18/08/2026"; echo "code: $?"
invalid format: 18/08/2026
code: 1With this you validate the date column of bookings.csv before trusting it. Two golden rules: the regex does not go in quotes — quote it and it becomes literal text, just as with == — and if you store it in a variable, use that unquoted too: [[ $f =~ $PATTERN ]].
case and its three terminators
case and its three terminatorscase compares a string against globbing patterns — not regular expressions — and runs the first branch that matches.
classify_code() {
case "$1" in
2*) echo "success" ;; 3*) echo "redirection" ;;
404) echo "not found" ;; 4*) echo "client error" ;;
500|503) echo "server error" ;; *) echo "unknown" ;;
esac
}
operator@srv-tramontana:~$ for c in 200 404 418 503 999; do
> printf '%s->%s ' "$c" "$(classify_code "$c")"; done; echo
200->success 404->not found 418->client error 503->server error 999->unknownThe order matters: 404 comes before 4* because the first match wins. | separates alternatives and *) is the default case, which goes last and is always worth including. The three terminators:
| Terminator | Effect |
|---|---|
;; |
Ends the case. The one you will use 99% of the time |
;& |
Falls into the next branch without checking its pattern (C's fallthrough) |
;;& |
Keeps checking the remaining patterns and runs the ones that match |
;;& is rare but useful for cumulative labels; ;& is dangerous because a distracted reader does not see it. Use them only with a comment justifying it.
- Loops:
for, while, until
for, while, untilfor over lists and globs
for release in 3.1.0 3.2.0 3.2.1; do printf 'release %s\n' "$release"; done
for file in /var/log/tramontana/*.log; do
[[ -e $file ]] || continue # protects against a glob with no matches
printf '%-40s %s lines\n' "$file" "$(wc -l < "$file")"
doneThe second form — iterating over a glob — is the correct one for walking through files, and it brings two warnings. The first: if the glob finds nothing, Bash leaves the literal pattern and the loop runs once with /var/log/tramontana/*.log as the value; hence the [[ -e ... ]] || continue, or else shopt -s nullglob from 03-02. The second, more important:
Never write
for f in $(ls).
You will see it on the internet constantly and it is wrong for three cumulative reasons: ls returns text that the shell splits on spaces, so "august report.txt" becomes two elements; special characters in the name are expanded as globs; and ls can colour or align its output depending on options and aliases. The glob has none of those problems because Bash works with real file names, not with text. And there is the arithmetic form, for (( i = 0; i < ${#versions[@]}; i++ )), for when the index matters.
while and until
while repeats while the condition returns 0; until repeats until it returns 0. They are the same structure with the condition negated, and until wins when the natural phrasing is "wait until":
attempts=0
until curl -sf -o /dev/null --max-time 3 "http://10.0.2.15:8080/health"; do
(( ++attempts ))
(( attempts >= 10 )) && { echo "the application is not starting" >&2; exit 69; }
sleep 2
done
printf 'responded after %d attempts\n' "$attempts"That loop is the one a deployment needs in order to wait for the service to be ready, and we will reuse it in 04-07. You also already know the while read from 04-03, which is the third common use.
break, continue and select
break, continue and selectbreak leaves the loop and continue jumps to the next iteration. Both accept a number saying how many levels they affect: break 2 leaves two nested loops in one go.
for dir in /opt/tramontana/releases/*/; do
for file in "$dir"*.conf; do
[[ -e $file ]] || continue
grep -q 'db_password' "$file" || continue
printf 'credential in %s\n' "$file" >&2
break 2 # stop searching across all releases
done
done
PS3="Choose a release to deploy: " # select: a numbered menu for free
select v in 3.2.1 3.3.0 cancel; do
[[ -n $v ]] || { echo "invalid option" >&2; continue; }
[[ $v == cancel ]] && break
printf 'deploying %s\n' "$v"; break
doneIf you find yourself with three levels of nesting, that is not a loop problem: it is a function you have not written yet, and we will look at it in 04-05. As for select, it is a loop and without break it never ends; PS3 is the prompt text and $REPLY holds what the user typed.
- Short circuits
&& and ||, and their trap
&& and ||, and their trapYou have been using them since 02-01: A && B runs B only if A succeeded; A || B runs B only if A failed. As a one-line conditional they are unbeatable: [[ -d $DIR ]] || mkdir -p "$DIR", or command -v shellcheck >/dev/null || { echo "shellcheck is missing" >&2; exit 69; }. But a lot of people write condition && action || something_else believing it is an if/else, and it is not:
The key: || does not look at the condition, it looks at the result of the previous action. If the action fails for any reason — a mkdir without permission, a grep with no matches — the || branch runs as well as the && one. The rule: chain && and || only when the action cannot fail; as soon as there is a genuine else, use if.
- Loops and performance: when you want
awk
awkThis section will save you hours of waiting. Let us count the 14 responses with code 500 in access.log in two ways:
operator@srv-tramontana:~$ time (n=0; while read -r l; do
> grep -q ' 500 ' <<<"$l" && (( ++n )); done < /var/log/tramontana/access.log
> echo "$n")
14
real 0m1.873s
operator@srv-tramontana:~$ time awk '$5 == 500 { n++ } END { print n+0 }' \
> /var/log/tramontana/access.log
14
real 0m0.006sThree hundred times faster, and that is with only 412 lines. The cause is not that Bash interprets slowly: it is that the loop launches a grep process for every line, and creating a process costs a few milliseconds that multiply up. With the 10,000 lines access.log will have in a month, the loop would take almost a minute and awk would still be in hundredths. The rule:
- If the loop processes text line by line, you probably want
awk(orgrep,sed,sort), which do a single pass inside a single process. - If the loop has to call an external command on every turn —
curl,systemctl,tar— then it really is Bash's work, because there is no alternative. And if you need Bash over text, at least do not launch processes inside:readwith several names,caseand parameter expansion do a great deal without leaving the shell.
- Application: alert levels and
purge_releases.sh
purge_releases.shhealth_check.sh finally decides. We replace the printing block with checks that carry a level:
# --- Evaluation -----------------------------------------------------------
overall_level="OK"
escalate() { # raises the overall level if the new one is more serious
case "$1" in
CRITICAL) overall_level="CRITICAL" ;;
WARN) [[ $overall_level == OK ]] && overall_level="WARN" ;;
esac
}
case "$http_code" in
200) http_status="OK" ;;
000) http_status="CRITICAL" ;; # curl did not even manage to connect
*) http_status="WARN" ;;
esac
if (( disk_usage >= 90 )); then disk_status="CRITICAL"
elif (( disk_usage >= DISK_THRESHOLD )); then disk_status="WARN"
else disk_status="OK"; fi
if (( errors_today >= 50 )); then err_status="CRITICAL"
elif (( errors_today >= 10 )); then err_status="WARN"
else err_status="OK"; fi
escalate "$http_status"; escalate "$disk_status"; escalate "$err_status"
LC_ALL=C printf '%-10s %-8s %s\n' \
"HTTP" "$http_status" "code $http_code" \
"Disk" "$disk_status" "${disk_usage}% used (threshold ${DISK_THRESHOLD}%)" \
"Errors" "$err_status" "$errors_today today"
printf '\nOverall status: %s\n' "$overall_level"
case "$overall_level" in OK) exit 0 ;; WARN) exit 1 ;; CRITICAL) exit 2 ;; esac
operator@srv-tramontana:~$ ~/scripts/health_check.sh; echo "code: $?"
HTTP OK code 200
Disk OK 30% used (threshold 80%)
Errors OK 6 today
Overall status: OK
code: 0
operator@srv-tramontana:~$ ~/scripts/health_check.sh -u 25 | tail -1
Overall status: WARNWith those codes — 0 correct, 1 warning, 2 critical — the script is now fit for monitoring: Nagios, Zabbix and company use exactly that convention. And now the new script:
#!/usr/bin/env bash
#
# purge_releases.sh - Removes old releases, keeping the active one,
# the previous one and the newer ones.
# Author : Systems operator <operator@srv-tramontana> Date: 2026-08-18
# Usage : purge_releases.sh [-n] [-v] Exit: 0 ok | 2 usage | 66 no releases
set -euo pipefail
readonly BASE="/opt/tramontana"
readonly RELEASES_DIR="$BASE/releases"
DRY_RUN=0; VERBOSE=0
# log(), error() and run() come from 04-03; getopts with ":hnv" sets -n and -v.
active=$(basename "$(readlink -f "$BASE/app")") # where the link points
log "active release: $active"
# We iterate over the glob (never over ls) and sort with sort -V, which knows
# that 3.10.0 comes after 3.9.0, something plain sort ignores. mapfile dumps
# each line into an array element, without splitting on spaces.
list=()
for d in "$RELEASES_DIR"/*/; do [[ -d $d ]] && list+=("$(basename "$d")"); done
(( ${#list[@]} )) || { error "no releases in $RELEASES_DIR"; exit 66; }
mapfile -t versions < <(printf '%s\n' "${list[@]}" | sort -V)
active_index=-1 # position of the active one, to know which is "the previous"
for (( i = 0; i < ${#versions[@]}; i++ )); do
[[ ${versions[i]} == "$active" ]] && active_index=$i
done
(( active_index >= 0 )) || { error "the active $active is not in the list"; exit 66; }
for (( i = 0; i < ${#versions[@]}; i++ )); do
v="${versions[i]}"
(( i >= active_index - 1 )) && { log "keeping $v"; continue; }
printf 'purging %s (%s)\n' "$v" "$(du -sh "$RELEASES_DIR/$v" | cut -f1)"
run rm -rf -- "${RELEASES_DIR:?}/$v"
done
exit 0operator@srv-tramontana:~$ ~/scripts/purge_releases.sh -n -v
[2026-08-18 11:04:12] active release: 3.2.1
purging 3.1.0 (97M)
[dry-run] rm -rf -- /opt/tramontana/releases/3.1.0
[2026-08-18 11:04:12] keeping 3.2.0
[2026-08-18 11:04:12] keeping 3.2.1
[2026-08-18 11:04:12] keeping 3.3.0Exactly what we wanted: only 3.1.0 is purgeable. Note two defensive details: rm -rf -- "${RELEASES_DIR:?}/$v" uses ${VAR:?} from 04-02 so that, if the variable were left empty by a bug, the command aborts instead of turning into rm -rf /; and -- stops a directory name with a leading hyphen being read as an option.
Common Mistakes and Tips
- Forgetting the spaces inside
[ ]or[[ ]].[[$a == $b]]givescommand not found: the brackets are commands and reserved words, not syntax glued to the operands. - Mixing
-eqwith strings or==with numbers.[[ "3.10" > "3.9" ]]is false lexicographically: for versions,sort -V; for numbers,(( )). And do not quote the right-hand side of==or=~, or the pattern becomes literal text. for f in $(ls). It breaks with spaces and with special characters; always use the glob. And protect the glob with no matches using[[ -e $f ]] || continueorshopt -s nullglob, or the loop will run once with the literal pattern.- Trusting
cond && action || otheras anif/else. Ifactionfails, both branches run. And nesting three loops is the unmistakable sign that a function is missing. - Tip: always compare numbers with
(( )): it is more readable and you will not confuse the operator. Always include the*)branch in acase, even if only to log "unexpected value". And before writing a loop that processes text, ask yourself whetherawkwould do it in a single pass: the answer is nearly always yes.
Exercises
Exercise 1. Write is_valid_version(), which returns 0 if its argument has the format N.N.N (one or more digits per part) and 1 if not, using =~, and prints the three numbers separately. Test it with 3.2.1, 3.10.0, 3.2 and v3.2.1.
Exercise 2. Write ~/scripts/time_bands_summary.sh, which over access.log classifies each request as night (00-05), morning (06-13), afternoon (14-21) or evening (22-23) with case, and shows requests and errors per band. Do it without launching processes inside the loop and compare the time with awk.
Exercise 3. Luis has written this fragment to decide whether to purge. Find the four flaws and rewrite it.
free=`df / | tail -1 | awk '{print $5}'`
if [ $free > 80 ]; then
echo "purging" && rm -rf /opt/tramontana/releases/* || echo "error"
fiSolutions
Solution 1.
is_valid_version() {
local v="$1"
# ^ and $ anchor the match to the WHOLE string: without them, "v3.2.1x"
# would match too. The parentheses create the BASH_REMATCH groups.
[[ $v =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]] || return 1
printf 'major=%s minor=%s patch=%s\n' \
"${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}"
}
for v in 3.2.1 3.10.0 3.2 v3.2.1; do
if is_valid_version "$v"; then printf ' %-8s valid\n' "$v"
else printf ' %-8s NOT valid\n' "$v"; fi
done
operator@srv-tramontana:~$ bash /tmp/versions.sh
major=3 minor=2 patch=1
3.2.1 valid
major=3 minor=10 patch=0
3.10.0 valid
3.2 NOT valid # a part is missing
v3.2.1 NOT valid # the ^ anchor rejects the 'v'The dots are escaped (\.) because in a regex . means "any character"; unescaped, 3x2y1 would be valid too. And notice how the function is used directly in the if: that is section 1 in action. (With no explicit return 0, the function returns the code of the printf, which is 0.)
Solution 2.
#!/usr/bin/env bash
#
# time_bands_summary.sh - Requests and errors per time band in access.log.
# Author : Systems operator <operator@srv-tramontana> Date: 2026-08-18
set -euo pipefail
readonly LOG="${1:-/var/log/tramontana/access.log}"
declare -A requests errors
# read chops the line up without launching processes: the time is the second
# field and the code the fifth. ${hour%%:*} leaves only HH, and the 10#
# forces base ten because "08" and "09" would be invalid octal (04-02).
while read -r _date hour _method _path code _rest; do
case "$(( 10#${hour%%:*} ))" in
[0-5]) band="night" ;;
[6-9]|1[0-3]) band="morning" ;;
1[4-9]|2[01]) band="afternoon" ;;
*) band="evening" ;;
esac
requests["$band"]=$(( ${requests["$band"]:-0} + 1 ))
[[ $code == [45]* ]] && errors["$band"]=$(( ${errors["$band"]:-0} + 1 ))
done < "$LOG"
LC_ALL=C printf '%-12s %12s %8s\n' BAND REQUESTS ERRORS
for b in night morning afternoon evening; do
LC_ALL=C printf '%-12s %12d %8d\n' "$b" "${requests[$b]:-0}" "${errors[$b]:-0}"
done
operator@srv-tramontana:~$ time ~/scripts/time_bands_summary.sh
BAND REQUESTS ERRORS
night 88 15
morning 147 3
afternoon 141 4
evening 36 1
real 0m0.048sThe 15 errors during the night are the ones from the 03:00 band you already know about, and the 23 in total add up. The awk version — one line with substr($2,1,2)+0 and a ternary for the band — takes 0.007 s: seven times less, even with no processes inside the loop, purely because of the interpreter's overhead. But look at the scale: hundredths against hundredths. The catastrophic difference appears when there is a $(...) inside the loop, not because of the loop itself. And note the [45]* pattern in the case: globbing, not regex.
Solution 3. The four flaws:
` `instead of$( ). Backticks do not nest, they have different escaping rules and they are unreadable. Besides,df's$5includes the%, so the numeric comparison receives30%.[ $free > 80 ]. Inside[ ],>does not compare: it is a redirection, so that command creates a file called80in the current directory and the test is always true. You have to use-gtor, better,(( )).rm -rf /opt/tramontana/releases/*deletes everything, including the active release theapplink points to: the application goes down instantly.echo ... && rm ... || echo "error". If thermpartially fails, both messages are printed and the script returns no useful code. On top of that there is no--dry-runand no record of what was deleted.
disk_usage=$(df --output=pcent / | tail -1 | tr -dc '0-9')
if (( disk_usage > 80 )); then
printf 'disk at %d%%, purging old releases\n' "$disk_usage" >&2
if ~/scripts/purge_releases.sh -v; then printf 'purge completed\n' >&2
else printf 'the purge failed (code %d)\n' "$?" >&2; exit 1; fi
fiThe destructive logic is delegated to purge_releases.sh, which already knows what to keep, has a --dry-run and logs what it does. Reusing instead of rewriting is also a control structure.
Conclusion
You no longer write conditionals by imitation: you know what happens underneath.
- You are clear that the condition is an exit code, that 0 is success, and that any command — including a function of your own — can go in an
if. - You use
if/elif/elseordering from the most restrictive condition to the loosest, and you choose between the three kinds of test:[[ ]]by default because it does not split words and it accepts patterns,(( ))for anything numeric, and[ ]only if you need POSIX. - You handle the complete table of operators for files, strings and numbers; you do not confuse
-eqwith==and you do not quote the right-hand side of a pattern; you validate with=~andBASH_REMATCH, with the regex unquoted and anchored with^and$; and you writecasewith globbing patterns, the mandatory*)and the;;,;∧;&terminators. - You iterate with
forover globs, never over$(ls), with the arithmetic form when the index matters, and withwhile/untilfor waits and reads; you control the flow withbreak N,continue Nandselect. You know the trap ofcond && action || otherand the performance rule: if the loop processes text, you probably wantawk, measured on a real case. - And your scripts decide:
health_check.shreturns OK, WARN or CRITICAL with codes fit for monitoring, andpurge_releases.shwalks the releases keeping the active one, the previous one and the newer ones.
Now look at the scripts side by side: log() is copied three times, error() as well, the run() wrapper is on its second copy and the getopts block is nearly identical in all of them. Every time you improve one you will have to remember to improve the others, and one day you will not remember. In the next lesson, Functions and Libraries, we solve that: when to extract a function and why the name is documentation, Bash's dynamic scope and why every variable must be local, the three real ways of returning data when return only accepts a number from 0 to 255, and how to build lib/common.sh with robust loading, include guards and even a small test that checks the library works. By the end of it, those duplicated functions will live in a single place.
Linux Course: From Beginner to System Administrator
Module 1: Introduction to Linux
- What Is Linux?
- History of Linux
- Linux Distributions
- Installing Linux
- First Contact with the System
- The Linux File System Structure
Module 2: Basic Linux Commands
- Introduction to the Command Line
- Getting Help and System Documentation
- Navigating the File System
- File and Directory Operations
- Viewing and Editing Files
- Hard and Symbolic Links
- File Permissions and Ownership
Module 3: Advanced Command-Line Skills
- The Shell Environment: Variables, Aliases and History
- Using Wildcards and Regular Expressions
- Searching Files and Content: find, locate and grep
- Pipes and Redirection
- Text Processing: cut, sort, uniq, sed and awk
- Process Management
- Scheduling Tasks with Cron
- Networking Commands
Module 4: Shell Scripting
- Introduction to Shell Scripting
- Variables and Data Types
- Script Input, Output and Arguments
- Control Structures
- Functions and Libraries
- Debugging and Error Handling
- Production Scripts: Best Practices
Module 5: System Administration
- User and Group Management
- sudo and Special Permissions
- Package Management
- Disk Management
- systemd and Service Management
- System Logs: journald and syslog
- System Monitoring and Performance Tuning
- Backup and Restore
Module 6: Networking and Security
- Network Configuration
- SSH and Remote Access
- Firewalls and Perimeter Security
- Intrusion Detection Systems
- Secrets Management and TLS Certificates
- Securing Linux Systems
Module 7: Advanced Topics
- The Boot Process and System Recovery
- Advanced Diagnostics: strace, perf and eBPF
- Linux Kernel Tuning
- Virtualization with Linux
- Linux Containers and Docker
- Automation with Ansible
- High Availability and Load Balancing
