You have four scripts in ~/scripts and log() is written four times. error() too. run() is on its third copy. The day you decide the log should carry the level as well as the date you will have to edit four files and remember all four; and the day you forget one, you will have a script that logs differently without anybody finding out until it matters. Functions solve that, but not only that: a well-named function turns ten cryptic lines into one line that reads like a sentence. This lesson is about when to extract them, about the two traps Bash reserves for people coming from other languages — dynamic scope and a return that only returns numbers — and about how to build a real library, with robust loading, guards and tests. By the end, lib/common.sh will exist and the earlier scripts will use it.
Contents
- When to extract a function
- Syntax, and why we do not use
function - A function's arguments are its own
- Scope:
localand Bash's dynamic scope - Returning values:
returnonly returns a number - Predicate functions
- Recursion, and overriding commands
- Libraries:
lib/common.sh - What a library should and should not do
- Minimal tests without frameworks
- Application: refactoring the scripts
- When to extract a function
Three criteria, and it is enough for one of them to hold:
- The rule of three. You have written the same thing for the third time: the first may be coincidence, the second impatience; the third is a function.
- The block needs a comment to be understood. If you were about to write
# checks whether the release exists and is not empty, that comment is in fact the name:release_exists(). And if there are more than two levels of nesting, as we warned in 04-04, a third level is nearly always a missing function.
The name matters as much as the code: a good name is a verb or a predicate, it describes what it does and not how, and it makes the call read without comments. Compare if [[ -d /opt/tramontana/releases/$v && -f /opt/tramontana/releases/$v/app.jar ]] with if release_exists "$v". And a note on balance: a one-line function used exactly once is usually noise. The measure is not "the more the better", but "let each name save an explanation".
- Syntax, and why we do not use
function
functionBash accepts name() { body; } (POSIX), function name { body; } (a Bash/ksh extension) and the mixture function name() { ... }. We always use the first: it is the only one that works in any POSIX shell, it is the one shellcheck expects by default, and saving the parentheses gains you nothing. Syntax details that cause mysterious errors:
- The body between
{ }needs spaces after{and before}, because{is a reserved word, not a symbol, and the last statement before}needs a;or a newline. - Functions must be defined before they are used: Bash reads the file from top to bottom. Hence why in 04-07 we put
main "$@"as the script's last line. - A function and an alias with the same name clash, and the alias wins: another reason not to overuse them.
declare -f name shows the code of an already-defined function and declare -F lists the names: they are the equivalent of declare -p for variables, very useful when you are unsure which version has been loaded.
- A function's arguments are its own
Inside a function, $1, $@ and $# are not the script's: they are the call's. $0, by contrast, is still the script's name.
operator@srv-tramontana:~$ cat /tmp/args_fn.sh
show() { printf 'fn: $#=%d $1=%s $0=%s\n' "$#" "${1:-empty}" "${0##*/}"; }
printf 'script: $#=%d $1=%s\n' "$#" "${1:-empty}"
show alpha beta
show # no arguments
operator@srv-tramontana:~$ bash /tmp/args_fn.sh 3.2.1
script: $#=1 $1=3.2.1
fn: $#=2 $1=alpha $0=args_fn.sh
fn: $#=0 $1=empty $0=args_fn.shIt is the number one confusion for beginners: calling the function expecting it to "see" the script's arguments. If you want to pass them, do it explicitly with show "$@", which you know from 04-03 is the only form that respects spaces. And notice ${1:-empty}: with set -u active, reading $1 in a function with no arguments aborts the script, so a default value is mandatory if the argument can be missing.
- Scope:
local and Bash's dynamic scope
local and Bash's dynamic scopeEvery variable created inside a function must be declared local. Without local, the variable is global and outlives the function, with entertaining consequences:
operator@srv-tramontana:~$ count() { for i in 1 2 3; do :; done; }
operator@srv-tramontana:~$ for i in a b c; do count; printf '%s ' "$i"; done; echo
3 3 3
operator@srv-tramontana:~$ count() { local i; for i in 1 2 3; do :; done; }
operator@srv-tramontana:~$ for i in a b c; do count; printf '%s ' "$i"; done; echo
a b cIn the first case count trampled the caller's i and the outer loop printed 3 three times instead of a b c. A local i fixes it. With generic names such as i, n, tmp or line this happens constantly.
Now the part that surprises people coming from other languages. Bash has dynamic scope, not lexical: a local variable is visible inside the functions that function calls too, even if they are defined in another file.
operator@srv-tramontana:~$ inner() { echo "inner sees: ${secret:-nothing}"; }
operator@srv-tramontana:~$ outer() { local secret="visible"; inner; }
operator@srv-tramontana:~$ outer; inner
inner sees: visible
inner sees: nothingIn a lexically scoped language — Python, Java, C — inner would never see that variable, because it only sees what is in the text surrounding it; in Bash it sees whatever is in the call stack. It has a legitimate use — passing context without arguments — but it is above all invisible coupling: do not rely on it, declare local everywhere and pass the data as arguments.
- Returning values:
return only returns a number
return only returns a numberoperator@srv-tramontana:~$ add() { return $(( $1 + $2 )); }
operator@srv-tramontana:~$ add 200 100; echo "$?"
44300 turned into 44, because return accepts only an integer from 0 to 255 and the value is truncated modulo 256. return is not "return a result": it is "return an exit code", exactly the same concept as in 04-01 and 04-04. To return data there are three routes:
| Form | How | Advantage | Drawback |
|---|---|---|---|
stdout + $( ) |
printf inside, x=$(fn) outside |
Composable, no side effects | Creates a subshell: slow in large loops |
| Agreed global | The function assigns RESULT=... |
Fast, no subshell | Pollutes the namespace |
Nameref (local -n) |
The caller passes the name of its variable | Explicit and no subshell | Bash ≥ 4.3; collides if the names match |
# stdout: the preferred form, and the only genuinely composable one
release_path() { printf '%s\n' "/opt/tramontana/releases/$1"; }
r=$(release_path 3.2.1)
# nameref: when you return several values or you are in a tight loop
split_version() {
local -n _major="$2" _minor="$3" # _major becomes the caller's variable
local rest="${1#*.}"
_major="${1%%.*}"; _minor="${rest%%.*}"
}
split_version "3.10.2" maj min
printf 'major=%s minor=%s\n' "$maj" "$min" # -> major=3 minor=10The underscore in _major is not a whim: if the caller had a variable named exactly the same as the nameref, Bash would give a circular name reference error. Prefixing namerefs with _ reduces that collision to a practical impossibility.
The course's rule: stdout by default, and a nameref only when you return several values or the subshell is a measured problem, not an imagined one.
- Predicate functions
A function that only answers yes or no must return 0 for "yes" and be used directly in an if, with no == true and no intermediate variables.
# is_number STRING -> 0 if it is a non-negative integer, 1 if not.
is_number() { [[ ${1:-} =~ ^[0-9]+$ ]]; }
# release_exists VERSION -> 0 if the release exists and is not empty.
release_exists() {
local dir="/opt/tramontana/releases/${1:-}"
[[ -d $dir ]] && [[ -n "$(ls -A "$dir" 2>/dev/null)" ]]
}
operator@srv-tramontana:~$ is_number 80 && echo yes; is_number "8O" || echo no
yes
no
operator@srv-tramontana:~$ if release_exists 3.2.1; then echo "ready"; fi
readyNeither of them has a return: a function returns the code of its last statement, and the last statement is precisely the test. Writing if ...; then return 0; else return 1; fi is four times longer and does the same thing.
A warning that links to 04-06: if the last statement can legitimately fail — a (( counter )) that is 0, a grep with no matches — the function will return 1 and, with set -e, will bring the script down. That is why log() ends with an explicit return 0.
- Recursion, and overriding commands
Bash supports recursion, but it is rarely the answer: there is no tail-call optimisation, FUNCNEST limits the depth, and almost everything recursive an administrator does — walking a directory tree — is solved better and faster by find. What is useful is overriding a command with a function, typically to add safety or logging; the key is being able to call the original:
rm() {
error "use 'run rm' instead of a bare rm, so that --dry-run is respected"
command rm "$@" # command skips functions and calls the real program
}command ignores functions and aliases and runs the binary or builtin; builtin forces the shell's builtin (useful if you wrap cd or echo). Without one of the two, the function would call itself for ever. And a warning: do not override commands in a shared library; having rm do odd things ten metres away from where it is written is an excellent way of ruining somebody's afternoon.
- Libraries:
lib/common.sh
lib/common.shWe create ~/scripts/lib/common.sh with the functions that repeat:
#!/usr/bin/env bash
#
# common.sh - Functions shared by the Tramontana scripts.
# Author : Systems operator <operator@srv-tramontana> Date: 2026-08-18
# NOT executed: it is loaded with 'source'. No top-level code beyond
# defining functions and the include guard.
# Multiple-inclusion guard: if two scripts load the library, 'return 0'
# aborts this reading without aborting the script that did the source.
[[ -n ${TRAMONTANA_COMMON_LOADED:-} ]] && return 0
readonly TRAMONTANA_COMMON_LOADED=1
TRAMONTANA_VERBOSE="${TRAMONTANA_VERBOSE:-0}"
# log MESSAGE... -> stderr with a timestamp, only if TRAMONTANA_VERBOSE=1.
# Returns: always 0 (so as not to break 'set -e' when VERBOSE=0).
log() {
(( TRAMONTANA_VERBOSE )) && printf '[%s] %s\n' "$(date '+%F %T')" "$*" >&2
return 0
}
# error MESSAGE... -> error message on stderr. Returns 0.
error() { printf '[%s] ERROR: %s\n' "$(date '+%F %T')" "$*" >&2; }
# die CODE MESSAGE... -> logs the error and ends the SCRIPT with CODE.
die() { local code="$1"; shift; error "$@"; exit "$code"; }
# require_command NAME...
# Returns: 0 if all are available; 1 if any is missing (and says which).
require_command() {
local cmd missing=0
for cmd in "$@"; do
command -v "$cmd" >/dev/null 2>&1 || { error "missing command: $cmd"; missing=1; }
done
return "$missing"
}
# confirm QUESTION
# Returns: 0 if the answer is y/yes. With no terminal (cron), 1 without asking.
confirm() {
local answer
[[ -t 0 ]] || { error "no interactive terminal: not confirming"; return 1; }
read -r -p "${1:-Continue?} [y/N] " answer
[[ ${answer,,} == y || ${answer,,} == yes ]]
}
# format_bytes N -> prints N bytes in the nearest readable unit.
format_bytes() {
LC_ALL=C awk -v b="${1:-0}" 'BEGIN {
split("B KiB MiB GiB TiB", u, " "); i = 1
while (b >= 1024 && i < 5) { b /= 1024; i++ }
printf (i == 1 ? "%d %s\n" : "%.1f %s\n"), b, u[i]
}'
}Loading it robustly
source lib/common.sh only works if the working directory is ~/scripts, and cron runs from $HOME. The canonical solution:
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"Taken apart from the inside out:
${BASH_SOURCE[0]}is the path of the file being read. It is used instead of$0because if the file is loaded withsource,$0would be the parent script's, not its own.dirnameextracts its directory, which may be relative.cd ... && pwdturns that relative path into an absolute one and resolves the.., all inside$( ), that is, in a subshell: your real working directory does not change. The&&guarantees that if thecdfails thepwdis not run (SC2164 from 04-01).- The comment
# shellcheck source=...tells ShellCheck where the file is so that it can analyse it; without it, it warns with SC1091.
- What a library should and should not do
| Should | Should not |
|---|---|
| Define functions and, at most, constants | Do work when it is loaded |
| Carry a multiple-inclusion guard | Call exit at the top level |
| Document every function: parameters, output, return | Write to stdout unless that is its result |
Use local in all its variables |
Redefine system commands |
| Prefix anything that is not a public function | Depend on the working directory |
The ban on exit deserves a nuance: a library must not call it at its top level — that would kill the shell of whoever loads it with source, including your terminal — but it may do so inside a function whose declared purpose is to terminate, such as die(). The difference is who decides: not the library's author, but the caller.
On prefixes: in a project of your own, log and error are convenient and acceptable; in a library meant for others to load, prefix everything (tram_log, tram_error), because the day somebody loads your library and another one that also defines log, the last one read wins, silently. We already prefix the variables (TRAMONTANA_*), which is where the clash would be hardest to diagnose.
- Minimal tests without frameworks
A library without tests is a library you will break without noticing. You do not need a framework: it is enough to run each function with known inputs and count the failures.
#!/usr/bin/env bash
#
# test_common.sh - Tests for lib/common.sh.
# Usage: test_common.sh -> 0 if everything passes, 1 if there is any failure.
# No -e on purpose: we want to run ALL the cases, not stop at the first.
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
failures=0
check() { # check DESCRIPTION EXPECTED ACTUAL
if [[ $2 == "$3" ]]; then printf ' ok %s\n' "$1"
else printf ' FAIL %s: expected [%s], got [%s]\n' "$1" "$2" "$3" >&2
(( ++failures ))
fi
}
check "bytes 0" "0 B" "$(format_bytes 0)"
check "bytes 2048" "2.0 KiB" "$(format_bytes 2048)"
check "bytes release" "97.0 MiB" "$(format_bytes 101711872)"
require_command bash awk 2>/dev/null
check "require_command present" "0" "$?"
require_command nonexistent_command_xyz 2>/dev/null
check "require_command absent" "1" "$?"
TRAMONTANA_VERBOSE=0; log "must not be seen" 2>/dev/null
check "quiet log returns 0" "0" "$?"
printf '\n%d failure(s)\n' "$failures"
(( failures == 0 ))
operator@srv-tramontana:~$ ~/scripts/test_common.sh; echo "code: $?"
ok bytes 0
ok bytes 2048
ok bytes release
ok require_command present
ok require_command absent
ok quiet log returns 0
0 failure(s)
code: 0Thirty lines and you already have a safety net. The quiet log returns 0 case is not decorative: it checks the return 0 from section 6, the one that stops set -e killing the script when verbose mode is off. That is what tests are mainly good for: pinning down the subtle decisions before somebody "simplifies" them.
- Application: refactoring the scripts
With the library ready, the header of health_check.sh and bookings_report.sh becomes identical and the duplicated functions disappear:
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
require_command curl df awk || die 69 "missing dependencies"
while getopts ":hvu:" option; do
case "$option" in
h) usage; exit 0 ;;
v) TRAMONTANA_VERBOSE=1 ;; # now the library controls it
u) is_number "$OPTARG" || die 2 "the threshold must be a number: $OPTARG"
DISK_THRESHOLD="$OPTARG" ;;
\?) die 2 "unknown option: -$OPTARG" ;;
:) die 2 "option -$OPTARG needs a value" ;;
esac
done
shift $(( OPTIND - 1 ))
operator@srv-tramontana:~$ ~/scripts/health_check.sh -u eighty; echo "code: $?"
[2026-08-18 12:03:44] ERROR: the threshold must be a number: eighty
code: 2Three concrete gains. die 2 "..." replaces error ...; exit 2 in five places, so no error path can forget the exit. The threshold validation appears for the first time, and it appears because is_number already existed and using it cost one line. And TRAMONTANA_VERBOSE is a single variable in a single file, instead of four VERBOSEs that could drift out of sync. Add purge_releases.sh too: its run() moves into the library as it is, and its check of the active release becomes release_exists "$active" || die 66 "the active release does not exist".
Common Mistakes and Tips
- Forgetting
local. The variable becomes global and tramples the caller's; with names such asi,tmporlineit is only a matter of time. local x=$(command)and believing you have checked the error. The exit code you see islocal's, not the command's: always 0. Declare first (local x) and assign afterwards (x=$(command)). That is SC2155, and we will look at it in depth in 04-06.- Expecting
returnto give back a piece of data. It only returns 0-255, and 300 becomes 44: data goes out on stdout. And beware of using the script's arguments inside a function: in there,$1is the function's, so pass them with"$@". Define the function before using it, because Bash reads from top to bottom and the call would fail withcommand not found. - Putting executable code at the top level of a library. Loading it will have effects nobody asked for, and an
exitthere will close the terminal of whoever loads it by hand. And do not load it with a relative path: it works in your terminal and fails in cron, which starts from another directory; useSCRIPT_DIR. - Tip: document every function with three lines of contract — what it receives, what it prints, what it returns — right above it: that is what lets it be used without reading the body. And run
test_common.shbefore every commit of the library: ten seconds that stop you breaking four scripts at once. - Tip: if a function needs more than five arguments or more than thirty lines, it is probably two functions.
Exercises
Exercise 1. Explain what this fragment prints and why, then fix it so that it prints what its author expected.
process() {
total=0
for n in "$@"; do total=$(( total + n )); done
return "$total"
}
total=1000
process 7 6 5 4 3
echo "bookings: $total"Exercise 2. Add to lib/common.sh a function house_summary CSV HOUSE that prints house;bookings;nights;amount for one house from bookings.csv, with code 0 if it exists and 1 if not. Add two cases to test_common.sh: mas-figueres (must give mas-figueres;7;21;2450.00) and a non-existent house.
Exercise 3. Luis has written this library. Find the five problems and rewrite it.
#!/bin/sh
echo "loading library..."
LOGFILE=/tmp/tramontana.log
function log { echo "$1" >> $LOGFILE; }
function check_disk {
usage=`df / | tail -1 | awk '{print $5}' | tr -d %`
if [ $usage -gt 80 ]; then log "disk full"; exit 1; fi
}Solutions
Solution 1. It prints bookings: 25, and that is a lucky coincidence that hides two errors.
What happens: total inside process is not local, so the function tramples the caller's global variable; the sum 7+6+5+4+3 is 25 and that is what gets printed, not the 1000 assigned earlier. The return "$total" is also useless here and dangerous in general: if the bookings added up to 300, return would give back 44 (300 modulo 256) and whoever used it would get a false figure with no warning at all.
# Returns the sum on stdout, the correct channel for a piece of data. 'local'
# stops it trampling the caller's variables (including the loop's 'n', which
# is the most frequently forgotten one).
process() {
local sum=0 n
for n in "$@"; do sum=$(( sum + n )); done
printf '%d\n' "$sum"
}
total=1000
bookings=$(process 7 6 5 4 3)
printf 'previous: %d bookings: %d\n' "$total" "$bookings"
# -> previous: 1000 bookings: 25Now total keeps its value and the result arrives through its proper channel.
Solution 2.
# house_summary CSV_FILE HOUSE -> "house;bookings;nights;amount".
# Returns: 0 if the house appears in the file; 1 if not, or if it is unreadable.
house_summary() {
local csv="${1:-}" house="${2:-}" output
[[ -r $csv ]] || { error "cannot read $csv"; return 1; }
output=$(LC_ALL=C awk -F';' -v h="$house" '
NR > 1 && $3 == h { r++; n += $5; a += $6 }
END { if (r) printf "%s;%d;%d;%.2f\n", h, r, n, a }' "$csv")
[[ -n $output ]] || return 1
printf '%s\n' "$output"
}
# Cases added to test_common.sh
CSV="/home/operator/data/bookings.csv"
check "summary mas-figueres" "mas-figueres;7;21;2450.00" \
"$(house_summary "$CSV" mas-figueres)"
house_summary "$CSV" nonexistent-house >/dev/null 2>&1
check "summary of a non-existent house returns 1" "1" "$?"
operator@srv-tramontana:~$ ~/scripts/test_common.sh | tail -2
ok summary mas-figueres
ok summary of a non-existent house returns 1The design respects the rules from section 9: the data goes out on stdout, the error on stderr, the return code distinguishes the cases, and the function neither calls exit nor assumes a working directory. awk printing nothing when there are no matches is what makes it possible to tell "does not exist" apart from "exists with zeros".
Solution 3. The five problems:
#!/bin/shwithfunction. Contradictory: dash does not knowfunction. A library loaded withsourcecarries a shebang by convention and for the editors, but it must not be executable.echo "loading library..."at the top level. It does work when it is loaded and writes to stdout, contaminating the output of any script that uses it. A library loads in silence.exit 1inside the library. It kills the caller's script without giving it a choice, and if somebody loads it in their terminal, it closes it. The function should report and return a code.- No
local, no inclusion guard and no documentation.usageis global and will trample any variable of the same name, loading the file twice redefines everything, and no function says what it returns. ` `,$usagewithout quotes andLOGFILEfixed in/tmp. A predictable log file in/tmpis a classic security risk (04-06), and[ $usage -gt 80 ]fails ifusageends up empty.
#!/usr/bin/env bash
# common_disk.sh - Disk utilities. Loaded with 'source'.
[[ -n ${TRAMONTANA_DISK_LOADED:-} ]] && return 0
readonly TRAMONTANA_DISK_LOADED=1
# disk_usage [MOUNT_POINT] -> prints the usage percentage (the number only).
# Returns: 1 if it could not be measured.
disk_usage() {
local mount="${1:-/}" pct
pct=$(df --output=pcent "$mount" 2>/dev/null | tail -1 | tr -dc '0-9') || return 1
[[ -n $pct ]] || return 1
printf '%s\n' "$pct"
}
# disk_above THRESHOLD [MOUNT_POINT]
# Returns: 0 if usage exceeds THRESHOLD, 1 if not, 2 if it could not be measured.
disk_above() {
local threshold="${1:?the threshold is missing}" mount="${2:-/}" pct
pct=$(disk_usage "$mount") || { error "cannot measure $mount"; return 2; }
(( pct > threshold ))
}Now whoever uses it decides: disk_above 80 || exit 0, or disk_above 80 && purge_releases.sh. The library reports, the script acts.
Conclusion
Your scripts have stopped repeating themselves, and along the way you have understood the two Bash oddities that most confuse people coming from other languages.
- You extract functions by the rule of three, because the block was asking for a comment or because there was a third level of nesting, and you name them so that the call reads like a sentence. You use the
name() { ... }syntax, you define before you use, and you know that inside a function$1and$@are the function's, not the script's. - You declare
localon all function variables, and you understand that Bash has dynamic scope: what you declare is visible in the functions you call, with the invisible coupling that implies. - You know that
returnonly returns a code from 0 to 255 and you know the three real ways of returning data, with stdout by default andlocal -nprefixed with_when needed. - You write predicate functions with no explicit
return, taking advantage of the fact that a function returns the code of its last statement, and you addreturn 0when that last statement could legitimately fail. You knowcommandandbuiltinfor calling the original when you override a name, and the reason not to do it in a shared library. - You have
lib/common.shwithlog(),error(),die(),require_command(),confirm()andformat_bytes(), loaded withSCRIPT_DIR, protected by an inclusion guard, documented function by function and tested bytest_common.sh.
And now an exercise in honesty. health_check.sh has carried set -euo pipefail since the first lesson and you have never seen exactly what it does. log() ends with a return 0 that has turned up twice "because otherwise the script dies", with no real explanation. bookings_report.sh creates the report's directory and, if something fails halfway, leaves it half written. And backup_tramontana.sh, promised since Module 2, still does not exist. The next lesson, Debugging and Error Handling, closes all of that: bash -x with a readable PS4, the shellcheck codes that matter most, strict mode pulled apart — including the five places where set -e does not work — trap with EXIT, ERR and INT so that no temporary file and no lock survives an interruption, idempotency as a design goal, and finally the complete, hardened backup script.
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
