b25cf0444c
Autoclass Automation · Git · Linux · Python · Ansible · CI/CD · Cloud

The Art of Automation

From no background to expert

A complete, practical path through the tools that run modern infrastructure. Start at level 1 knowing nothing. Finish with systems that configure themselves, ship themselves, watch themselves — and can be recovered by someone else.

6 levels  ·  44 lessons  ·  from zero to expert

Built for a non-programmer. Every concept comes with a plain-English explanation and its business meaning. You are not expected to memorise anything — you are expected to do the exercise and see the result.
Level 1 · 8 lessons

Foundations: Terminal & Git

Learn to drive the machine, and never lose work again.

Why this level matters

Everything else in this course assumes two skills: you can tell a computer what to do from a keyboard, and you can save/undo your work safely. Git is the single most valuable habit in software — it is the difference between 'I broke it and lost everything' and 'I broke it, roll back in 2 seconds'.

8 lessons in this level
L1.1

What automation actually is

In plain English

Automation is writing down a job once so a machine does it forever. The value is not speed — it is that the machine never forgets, never gets bored, and does it identically at 3am.

By the end you can
  • Explain automation in business terms, not technical ones
  • Recognise which of your own tasks are worth automating
  • Understand the three layers: script, schedule, monitor

The idea in one sentence

Automation means: you do a task once, carefully, and write down exactly how — then the computer does it forever, identically, without being reminded.

In business terms

Think of it as hiring an employee who never sleeps, never forgets, costs almost nothing, and follows your written procedure to the letter. The catch: they have zero common sense. If the procedure is wrong, they will do the wrong thing perfectly, forever, very fast.

Why businesses automate

  • Reliability — a human doing a task 400 times makes mistakes. A script does not.
  • Cost — a task that took 30 minutes of a person's day now takes 0.
  • Speed of response — a machine notices a problem at 03:00 and reacts immediately.
  • Institutional memory — the procedure lives in a file, not in someone's head.
  • Auditability — you can read exactly what happened and when.

The three layers

Almost every automation you will ever build is these three layers stacked:

  1. SCRIPT — the actual work: 'fetch the news, format it, send it'.
  2. SCHEDULE — when it runs: 'every day at 07:00'.
  3. MONITOR — proof it worked: 'alert me if it failed or found nothing'.

Beginners build layer 1 and stop. That is why their automations silently die. The professional habit — and the one this course drills hardest — is that layer 3 is not optional. An automation you cannot observe is worse than no automation, because you will trust it.

What NOT to automate

  • Anything you do once. The setup cost exceeds the benefit.
  • Decisions requiring judgement about people or money.
  • A process you do not yet understand — automate a mess and you get a faster mess.
  • Anything where a silent failure is dangerous and you have no monitoring.
Careful. The classic trap: automating something you have done by hand only twice. You will spend 6 hours automating a 10-minute task. Automate what is repetitive AND well-understood.

The mental model to keep

A script is a written procedure. A schedule is an alarm clock. A monitor is a smoke detector. You need all three or the house burns down quietly.

Do this

Write down three tasks you or your team repeat weekly. For each, note: how long it takes, how often it runs, and what happens today if it is forgotten. The one with the worst consequence for being forgotten is your first automation candidate.

Check yourself

You have written a script that runs every night. What is the most important thing to add next?

L1.2

Your terminal: the cockpit

In plain English

The terminal is a text conversation with the computer. You type a command, it answers. Every server you own is operated this way.

By the end you can
  • Read a shell prompt and understand where you are
  • Run commands, read their output and exit codes
  • Navigate the filesystem with confidence

What you are looking at

When you open a terminal you see a prompt. It is waiting for you. You type a command and press Enter; the computer runs it and prints the result. That is the entire interaction model.

ubuntu@web01:~$ whoami
ubuntu
ubuntu@web01:~$ pwd
/home/ubuntu
  • ubuntu — the user you are logged in as
  • web01 — the machine name (hostname)
  • ~ — your home directory, shorthand for /home/ubuntu
  • $ — you are a normal user; # means root (full power, full danger)
In business terms

The terminal is not harder than a graphical interface — it is more precise. A mouse click is a vague instruction ('this thing, roughly here'). A command is exact and repeatable. That precision is exactly why it can be automated later.

The four commands that cover 80% of navigation

pwd                 # print working directory: where am I?
ls -la              # list everything here, including hidden files
cd /var/log         # change directory
cd ..               # go up one level
cd ~                # go home, from anywhere

Exit codes — how computers say yes/no

Every command returns a number when it finishes. 0 means success. Anything else means failure. You never see this number normally — but every automation you build will depend on it.

ls /etc > /dev/null
echo $?          # 0  -> it worked

ls /nonexistent > /dev/null 2>&1
echo $?          # 2  -> it failed
Tip. echo $? prints the exit code of the previous command. This one trick is how you will test whether anything, ever, actually worked.

Getting help without leaving the terminal

man ls              # the full manual for 'ls' (press q to quit)
ls --help           # a quick summary
which python3       # where does this command live?
history             # everything you have typed

Two habits that prevent disasters

  1. Run pwd before anything destructive. Know where you are.
  2. Read a command before you press Enter. Especially with rm (delete) or sudo (become all-powerful).
Careful. rm -rf deletes instantly and permanently. There is no recycle bin. Never run it on a path you have not just checked with pwd and ls.
Do this

Open a terminal and run, in order: pwd, ls -la, cd /etc, pwd, cd -, pwd, whoami, date, echo $?. Say out loud what each one returned and why.

Check yourself

A command finishes and echo $? prints 0. What does that mean?

L1.3

Files, paths and permissions

In plain English

Everything on Linux is a file, organised in one tree. Permissions decide who can read, change or run each one.

By the end you can
  • Read a path and know whether it is absolute or relative
  • Interpret permission strings like -rwxr-xr-x
  • Create, move, copy and safely delete files

One tree, no drives

Windows has C:, D:, E:. Linux has a single tree starting at /. Disks are mounted into that tree — a USB drive might appear at /media/usb. There is no drive letter concept.

PathWhat lives there
/The root of everything
/home/ubuntuYour personal files (also written ~)
/etcSystem configuration files
/var/logLog files — where you look when something broke
/tmpTemporary files, wiped on reboot
/usr/binInstalled programs

Absolute paths start with / and mean the same thing from anywhere. Relative paths do not — logs/x.txt depends on where you currently are.

In business terms

Absolute paths are the difference between 'a file called report.txt' and '/home/ubuntu/reports/report.txt'. In automation, always use absolute paths. A script run by a scheduler has no idea where you were standing when you wrote it.

Reading permissions

ls -l deploy.sh
-rwxr-xr-x 1 ubuntu ubuntu 412 Sep 20 19:19 deploy.sh
#│└┬┘└┬┘└┬┘
#│ │  │  └── others: r-x (read + execute)
#│ │  └───── group:  r-x
#│ └──────── owner:  rwx (read, write, execute)
#└────────── file type: - = file, d = directory, l = symlink
  • r = read, w = write, x = execute (for a script: permission to run it)
  • Three triplets: owner, group, everyone else
  • A file without x cannot be run even if the code is perfect

Making a script runnable

chmod +x deploy.sh      # add execute permission
./deploy.sh             # run it (./ means 'in this directory')
Careful. A script that exists but will not run is almost always a missing x permission. It is the single most common beginner error. Check with ls -l first.

Working with files

cp source.txt backup.txt        # copy
mv old.txt new.txt              # move OR rename
mkdir -p projects/autoclass      # create a directory (and parents)
rm file.txt                     # delete a file
rm -r directory/                # delete a directory and contents
Tip. Before any rm -r, run ls -R on the same path first. Ten seconds of looking prevents an unrecoverable afternoon.
Do this

Create /tmp/lab/, inside it create a file hello.sh containing echo "automation works", make it executable, and run it. Then run ls -l /tmp/lab/hello.sh and confirm the x permissions are present.

Check yourself

You wrote a script and ./script.sh returns 'Permission denied'. What is the fix?

L1.4

Git: save points for your work

In plain English

Git records snapshots of your work so you can always go back. It is version history for any project, not just code.

By the end you can
  • Explain what a repository, commit and staging area are
  • Create a repository and commit changes
  • Read history and understand what changed

The problem Git solves

You have seen this: report_final.docx, report_final_v2.docx, report_final_ACTUAL.docx. Git replaces that mess with one file and a complete history of every version, who changed it, when, and why.

In business terms

Git is an undo button that never expires, for a whole project. Every save point is labelled, attributed and reversible. It is also the industry's standard handover mechanism — 'the work is in Git' means anyone can pick it up.

The three areas

AreaMeaningAnalogy
Working directoryYour files right now, being editedYour desk
Staging areaChanges you have chosen to include nextThe out-tray
RepositoryPermanent recorded historyThe filing cabinet

The workflow is always the same shape: edit → stage → commit.

git init                        # start tracking this folder
git status                      # what has changed?
git add deploy.sh               # stage one file
git add .                       # stage everything changed
git commit -m "Add deploy script"   # record the snapshot

Reading your history

git log --oneline               # one line per commit
git log --oneline -5            # the last five
git show HEAD                   # exactly what changed last time
git diff                        # what have I changed but not staged?

The one-time setup

git config --global user.name "Your Name"
git config --global user.email "you@example.com"
Tip. Commit messages are for your future self at 2am. 'Fix bug' tells them nothing. 'Fix cron job that skipped Sundays' tells them everything.
Careful. Never commit secrets — API keys, passwords, tokens. Once in Git history, they are effectively public forever, even if you delete the file later. Use .gitignore for .env files before your first commit, not after.
Do this

Create /tmp/gitlab/, run git init, create a file notes.txt with three lines, commit it with a clear message, change a line, and run git diff then git log --oneline. Read what Git tells you about your own change.

Check yourself

What does a commit do?

L1.5

Branches: safe experiments

In plain English

A branch is a parallel copy of your project where you can try things without touching the working version. Merge it back when it works; delete it when it does not.

By the end you can
  • Create, switch and merge branches
  • Explain why working on main directly is risky
  • Resolve a simple merge conflict

Why branches exist

If you edit the live version of anything directly, every half-finished experiment is instantly a risk to the working system. A branch gives you a separate timeline: your main version stays clean while you experiment.

In business terms

This is the difference between renovating a shop while trading, and building the new layout in a separate unit then swapping it in one night. Branches make change low-risk, which makes teams willing to change.

The daily commands

git branch                      # list branches
git switch -c feature/new-tiles  # create AND switch to a new branch
git switch main                 # go back to the main line
git merge feature/new-tiles     # bring the branch's work into main
git branch -d feature/new-tiles  # delete it once merged

The working rhythm

  1. Start from main, and make sure it is up to date.
  2. Create a branch with a descriptive name.
  3. Do your work; commit as you go.
  4. Test that it actually works.
  5. Merge back into main.
  6. Delete the branch.
git switch main
git switch -c fix/map-tiles
git add app.js
git commit -m "Switch map tiles to a keyless provider"
git switch main
git merge fix/map-tiles

Merge conflicts, demystified

A conflict happens when the same line changed in both timelines. Git cannot know which is right, so it asks you. It marks the file like this:

<<<<<<< HEAD
referrer-policy: no-referrer
=======
referrer-policy: strict-origin-when-cross-origin
>>>>>>> fix/map-tiles

You pick the correct line, delete the marker lines, save, then git add the file and commit. Nothing is lost — both versions are in the history.

Careful. Conflicts are not errors. They are Git refusing to guess about your intent. The only real mistake is resolving one by deleting the other person's work without understanding it.
Tip. git switch is the modern command; git checkout still works and you will see it in older documentation. Both do the same job here.
Do this

In /tmp/gitlab/, create a branch experiment, change a line in notes.txt, commit it, switch back to main and confirm the change is NOT there, then merge the branch and confirm it is.

Check yourself

Why work on a branch instead of editing main directly?

L1.6

Remotes: GitHub as backup and handover

In plain English

A remote is a copy of your repository on another machine — usually GitHub. Push to back it up and share it; pull to get others' work.

By the end you can
  • Add a remote and push your work to it
  • Pull changes from a remote
  • Explain the difference between commit and push

Local vs remote

Everything so far happened on your own machine. A remote is a second copy, usually on GitHub, which gives you three things: off-site backup, collaboration, and a place your servers can deploy from.

git remote add origin git@github.com:you/project.git
git push -u origin main         # upload your commits
git pull                        # download others' commits
git clone <url>                 # copy an entire project down
CommandDirectionWhat it does
commitlocal onlyRecords a snapshot on your machine
pushoutUploads your commits to the remote
pullinDownloads and applies remote commits
cloneinDownloads a whole project for the first time
In business terms

Think of commit as saving the document, and push as emailing it to the archive. You can save all day without emailing. But a backup that never leaves the building is not really a backup.

Two ways to authenticate

  • SSH key — generate a keypair, add the public half to GitHub. No passwords, most secure, best for servers.
  • Personal access token — a long random string used in place of a password over HTTPS.
ssh-keygen -t ed25519 -C "you@example.com"
cat ~/.ssh/id_ed25519.pub    # paste this into GitHub > Settings > SSH keys
Careful. Never paste a private key or token into a chat, a ticket, or a script you commit. If one leaks, rotate it immediately — assume it is already being used.
Tip. Run git status before git push. It tells you whether you have anything uncommitted, so you never push a half-finished state by accident.
Do this

Create a free private repository on GitHub, add it as a remote to /tmp/gitlab/, push main, then make a change in the GitHub web interface and git pull it down. You have now moved work in both directions.

Check yourself

You committed work but your colleague cannot see it. Why?

L1.7

Undo: recovering from mistakes

In plain English

Almost nothing in Git is truly lost. This lesson is the safety net that makes you willing to experiment.

By the end you can
  • Undo an unstaged edit, a staged change and a commit
  • Recover a deleted branch or a bad merge
  • Know which undo command is safe to use when

Match the undo to the situation

SituationCommandEffect
Edited a file, want it backgit restore file.txtDiscards unsaved edits
Staged but not committedgit restore --staged file.txtUnstages, keeps edits
Committed, not pushedgit reset --soft HEAD~1Undoes commit, keeps changes
Committed and pushedgit revert <sha>New commit that reverses it
Deleted a branchgit reflog then git switch -cRecovers the lost work
In business terms

git revert is the safe one on shared work: it adds a new commit that undoes the old one, so history is never rewritten under anyone's feet. Rewriting published history is how teams lose each other's work.

The reflog: Git's black box recorder

Git records every position HEAD has ever been in, even for commits that are no longer on any branch. This is how you recover 'lost' work.

git reflog
git switch -c recovered-work HEAD@{3}    # jump back to that state
Careful. Never use git reset --hard on work you have not committed. It discards changes permanently with no prompt. When in doubt, commit first — a commit costs nothing and can always be undone.
Tip. If you are unsure which undo to use, run git status and git log --oneline first. Git tells you exactly what state you are in. Acting blind is what loses work.
Do this

In /tmp/gitlab/, make a commit, then use git reset --soft HEAD~1 to undo it, confirm the changes are still present with git status, and commit again. Then delete a branch and recover it via git reflog.

Check yourself

You already pushed a bad commit that others have pulled. What is the correct fix?

L1.8

Level 1 lab: version-control a real project

In plain English

You will put a real folder under Git, make a branch, change something, and merge it — the full loop, end to end.

By the end you can
  • Run the complete Git workflow unaided
  • Write a .gitignore that keeps secrets out
  • Read history to answer 'what changed and when?'

The brief

Pick any folder on your machine that holds something you care about — notes, scripts, a website. You will bring it under version control and make one real change through a branch.

Steps

  1. cd into the folder and run git init.
  2. Create .gitignore containing .env, *.log, node_modules/ and backups/.
  3. Run git status and read carefully what Git wants to track.
  4. git add . then git commit -m "Initial import".
  5. git switch -c tidy-up.
  6. Make one real improvement — fix a typo, add a README line.
  7. git add and git commit it with a message that explains WHY.
  8. git switch main and git merge tidy-up.
  9. Run git log --oneline and read your own history.

Proof you succeeded

git log --oneline
git status          # should say: nothing to commit, working tree clean
git branch          # your work is on main
In business terms

What you have now: every change to this project is recorded, attributed and reversible. If you hand it to someone, they can see the entire story. That is the professional baseline for any project — and it took twenty minutes.

Careful. If git status shows your .env file as trackable, stop and fix .gitignore BEFORE committing. Secrets committed once are compromised permanently.
Do this

Complete all nine steps above, then deliberately break something in a file, run git restore <file> to undo it, and confirm the file is back to its committed state. You have just used the safety net for real.

Check yourself

git status says 'nothing to commit, working tree clean'. What does that mean?

Level 2 · 9 lessons

Linux & Shell

The operating system every server runs, and the language you command it with.

Why this level matters

Every automation you write will run on Linux. You need to understand processes, services, logs and the shell — because when something breaks at 3am, these are the only tools that tell you why.

9 lessons in this level
L2.1

The Linux mental model

In plain English

Linux is built from small single-purpose programs. You chain them together to do big things. Nothing is hidden from you.

By the end you can
  • Describe what a distribution, kernel and shell are
  • Explain 'everything is a file' in practical terms
  • Use pipes to chain commands together

The layers

LayerWhat it is
KernelThe core that talks to hardware and manages processes
DistributionKernel + tools + package manager (Ubuntu, Debian, RHEL)
ShellThe command interpreter you type into (bash, zsh)
UserspaceAll the programs: nginx, python, git, your scripts
In business terms

Linux dominates servers for one reason: it is transparent and scriptable. Every part can be inspected, configured from a text file, and automated. That is why your entire estate runs on it.

Everything is a file

Configuration is files. Logs are files. Even devices are files. This uniformity is why automation works so well: if you can write a file and restart a service, you can automate almost anything.

cat /etc/hostname          # a config file
ls /var/log/               # log files
df -h                      # disk usage (reads filesystem stats)

Pipes: the superpower

The | character feeds one command's output into the next. Small tools, chained, solve problems no single tool was designed for.

# How many failed logins today?
grep "Failed password" /var/log/auth.log | wc -l

# Which processes use the most memory?
ps aux --sort=-%mem | head -5

# Which IPs hit us most in the last 1000 lines of the web log?
tail -1000 /var/log/nginx/access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head
Tip. Read a pipe left to right as a sentence: take the log, keep the failures, count them. You will write automations in exactly this shape.

Redirects: moving output around

command > file      # send output to a file (overwrite)
command >> file     # append to a file
command 2> errors.log   # capture only errors
command > out.log 2>&1  # capture both output and errors
Careful. > overwrites without warning. If you meant to append, use >>. Overwriting a log file you meant to add to is a common and annoying mistake.
Do this

Run ps aux --sort=-%mem | head -5 to see the top memory consumers on your machine, then df -h | sort -k5 -rn | head -3 to find your fullest filesystem. Explain to yourself what each pipe stage did.

Check yourself

What does the | character do?

L2.2

Processes and services (systemd)

In plain English

A process is a running program. A service is a process that runs in the background and starts automatically. systemd manages them.

By the end you can
  • Inspect running processes and find what is using resources
  • Start, stop, enable and inspect a service
  • Read a unit file and explain what it does

Processes: what is running right now

ps aux                  # every process, with user and resource use
top                     # live view (press q to quit)
pgrep -af python        # find python processes
kill 12345              # ask PID 12345 to stop
kill -9 12345           # force it (last resort)
In business terms

A process is a job someone or something started. A service is a job that keeps itself alive across reboots. Your gateway, your tunnel, your databases — all services. If one dies, the system should notice and say so; that is what monitoring is for.

The systemctl commands you will use constantly

systemctl status app-gateway      # is it running? recent logs?
systemctl restart app-gateway     # restart it
systemctl stop edge-tunnel    # stop it
systemctl start edge-tunnel   # start it
systemctl enable edge-tunnel  # start automatically at boot
systemctl is-active app-gateway   # just the state: active/inactive

Reading a unit file

[Unit]
Description=Edge tunnel connector
After=network-online.target

[Service]
ExecStart=/usr/bin/edge-tunnel run
Restart=always
User=ubuntu

[Install]
WantedBy=multi-user.target
  • ExecStart — the command that runs
  • Restart=always — restart automatically if it crashes
  • After= — ordering: wait for networking first
  • WantedBy=multi-user.target — start during normal boot
Tip. systemctl status shows the last few log lines. Nine times out of ten it tells you immediately why a service died.

User services vs system services

System services run as root and start at boot. User services (systemctl --user) run as you, and need loginctl enable-linger to survive logout. That is how a long-running background service keeps working after you close the terminal.

systemctl --user status app-gateway
loginctl show-user ubuntu | grep Linger
Careful. Restarting a service is not free. Restarting your messaging gateway kills the session and any in-flight work. Announce it, do it deliberately, and verify afterwards.
Do this

Run systemctl list-units --type=service --state=running | head -15 to see what is running. Pick one, run systemctl status <name>, and explain what it does from the unit description alone.

Check yourself

What is the difference between systemctl start and systemctl enable?

L2.3

Logs: how you find out what happened

In plain English

Logs are the written record of everything a system did. Reading them is the single most valuable troubleshooting skill.

By the end you can
  • Find logs for a service and filter them
  • Follow a log live while reproducing a problem
  • Spot the difference between a symptom and a cause

Where logs live

LocationContents
/var/log/syslogGeneral system messages
/var/log/auth.logLogins, sudo, authentication
journalctlThe systemd journal — most service logs
/var/log/nginx/Web server access and error logs
/var/log/app/Application-specific logs

The essential commands

journalctl -u app-gateway -n 50       # last 50 lines for a service
journalctl -u app-gateway --since "1 hour ago"
journalctl -u app-gateway -f          # follow live (Ctrl-C to stop)
journalctl -p err -n 30                  # only errors
tail -f /var/log/syslog                  # follow a file live
In business terms

When something breaks, the amateur guess and restart things. The professional read the log. The log names the failing component, the error, and the time — usually in the first five lines you look at.

A worked example

$ systemctl status app-gateway
● app-gateway.service - Application Gateway
     Active: failed (Result: exit-code)
    Process: 4123 ExecStart=/usr/bin/app-gateway (code=exited, status=1)

$ journalctl -u app-gateway -n 20
... ERROR config.yaml: invalid YAML at line 42: mapping values not allowed
... gateway exited with status 1

The log told us exactly what to fix: a YAML syntax error at line 42. No guessing required. That is the difference logs make.

Symptom vs cause

  • Symptom: 'the website is down' — what you noticed.
  • Cause: 'the config file has a typo on line 42' — what actually broke.
  • Chase causes. Restarting on a symptom buys minutes and hides the real fault.
Tip. Filter before you read. journalctl -p err or grep -i error turns a thousand lines into the ten that matter.
Careful. Logs grow without limit and can fill a disk. Check df -h and configure rotation (logrotate) on any service that writes a lot.
Do this

Pick any running service. Run journalctl -u <name> --since "today" and find its most recent error or warning. Then run journalctl -u <name> -f in one terminal and restart the service in another — watch the startup sequence appear.

Check yourself

A service keeps dying. What is the most productive first step?

L2.4

Users, sudo and permissions in practice

In plain English

Linux separates what each user may do. sudo is the controlled way to act as administrator.

By the end you can
  • Explain why running everything as root is dangerous
  • Use sudo deliberately and read what you are approving
  • Set ownership and permissions correctly

Why users exist

If every program ran with unlimited power, one bug in any of them could destroy the whole machine. Users and permissions are the walls that limit the blast radius.

In business terms

This is the same principle as financial controls: nobody gets unlimited authority, because a mistake or a compromise then becomes catastrophic. Least privilege is a security strategy, not an inconvenience.

sudo: acting as administrator, on purpose

sudo systemctl restart nginx      # run one command as root
sudo -i                           # open a root shell (avoid casually)
whoami                            # confirm who you are now
Careful. Run as root only for the single command that needs it, then return to normal. A root shell makes every typo a potential catastrophe — and there is no undo.

Ownership and permissions

chown ubuntu:ubuntu /var/www/site    # change owner and group
chmod 644 file.txt                   # owner writes, everyone reads
chmod 600 secret.key                 # owner only — for keys and secrets
chmod 755 script.sh                  # executable by all
chmod 700 private-dir/               # only owner may enter
ModeMeaningTypical use
644rw- r-- r--Normal files
600rw- --- ---Secrets, keys, credentials
755rwx r-x r-xExecutables, public directories
700rwx --- ---Private directories
Tip. Secrets should be 600 and owned by the user that needs them. If ls -l shows a key file as 644, it is readable by every process on the machine.

Groups

Groups let you grant access to several users at once. sudo usermod -aG docker ubuntu adds ubuntu to the docker group — after which a re-login is needed for it to take effect.

Careful. Adding a user to the docker group is effectively granting root. Do not do it casually; know what you are handing over.
Do this

Create a file /tmp/lab/secret.txt, set it to 600, and confirm with ls -l that only the owner can read it. Then set it to 644 and observe the difference. Explain in one sentence why 600 is correct for credentials.

Check yourself

Why should you avoid running an entire session as root?

L2.5

Networking basics you actually need

In plain English

Ports, DNS and HTTP — the three concepts that explain almost every 'it cannot connect' problem.

By the end you can
  • Explain what a port is and check whether one is listening
  • Trace a DNS lookup and an HTTP request
  • Diagnose a connection failure systematically

Ports

A machine has one network address but many services. A port is the door number that says which service you want. 443 is HTTPS, 22 is SSH, 8080 is a common application port.

ss -ltnp                    # what is listening, on which port
ss -ltnp | grep :443        # is anything serving HTTPS?
curl -s localhost:8080/health   # is that local service alive?
In business terms

An address is a building; a port is the apartment number. 'Connection refused' means you reached the building but nobody was in that apartment. 'Timeout' means you did not even get that far.

DNS: names to addresses

dig example.com +short              # what address does this name resolve to?
dig example.com CNAME +short        # is it an alias?
getent hosts example.com            # system resolver view
cat /etc/resolv.conf                # which DNS servers am I using?

HTTP: the request/response cycle

curl -I https://example.com               # headers only
curl -s -o /dev/null -w "%{http_code}\n" https://example.com
curl -s https://api.example.com/data | head -c 300
CodeMeaningTypical cause
200OKWorking
301/302RedirectMoved; follow the Location header
403ForbiddenAuthenticated but not allowed
404Not foundWrong path
500Server errorThe application itself failed
502/504Bad gateway / timeoutA service behind the proxy is down or slow

Diagnosing systematically

  1. Is the service running? systemctl status <name>
  2. Is it listening? ss -ltnp | grep <port>
  3. Does it answer locally? curl localhost:<port>
  4. Does the name resolve? dig <host>
  5. Does it answer remotely? curl -I https://<host>

This ladder takes you from 'it does not work' to a specific layer in under a minute. Never skip a rung — the answer is usually earlier than you expect.

Careful. A 200 status does not always mean correct. A service can return 200 with an error message in the body. Check content, not just codes.
Do this

Run ss -ltnp and list every listening port on your machine with the process behind it. Then pick one and run the full five-step ladder against it, noting the result at each rung.

Check yourself

'Connection refused' tells you what?

L2.6

Shell scripting fundamentals

In plain English

A shell script is a text file full of commands the computer runs in order. This is the simplest form of automation there is.

By the end you can
  • Write and run a script with variables and arguments
  • Use conditionals and loops
  • Make a script fail loudly instead of silently

Your first script

#!/usr/bin/env bash
# backup-notes.sh - copy my notes somewhere safe
set -euo pipefail

SOURCE="/home/ubuntu/notes"
DEST="/home/ubuntu/backups"
STAMP=$(date +%Y%m%d-%H%M%S)

mkdir -p "$DEST"
tar -czf "$DEST/notes-$STAMP.tar.gz" "$SOURCE"
echo "Backup written: $DEST/notes-$STAMP.tar.gz"
  • #!/usr/bin/env bash — the shebang: which interpreter runs this file
  • set -euo pipefail — fail on error, fail on unset variable, fail on pipe error
  • $(...) — run a command and use its output
  • "$VAR" — always quote variables, so spaces do not break things
In business terms

set -euo pipefail is the single most important line in any script you write. Without it, a script keeps running after a failure and reports success while having done nothing. That is the silent failure that costs businesses money.

Arguments, conditions, loops

#!/usr/bin/env bash
set -euo pipefail

TARGET="${1:?usage: $0 <directory>}"      # fail if no argument given

if [[ ! -d "$TARGET" ]]; then
  echo "Not a directory: $TARGET" >&2
  exit 1
fi

COUNT=0
for f in "$TARGET"/*; do
  [[ -e "$f" ]] || continue
  COUNT=$((COUNT + 1))
done

echo "$COUNT files in $TARGET"

The safety habits

  • Quote every variable: "$FILE" not $FILE
  • Use [[ ]] for tests — safer than [ ]
  • Check a path exists before using it
  • Send errors to stderr (>&2) and exit non-zero
  • Never rm -rf "$VAR" unless you have verified $VAR is set and correct
Careful. The classic disaster: rm -rf $DIR/ where $DIR is empty becomes rm -rf /. Always use ${DIR:?} so the script refuses to run with an unset variable.
Tip. Test scripts with bash -n script.sh (syntax check) and bash -x script.sh (trace each command) before trusting them in a schedule.
Do this

Write /tmp/lab/count.sh that takes a directory as an argument, refuses to run without one, counts the files inside, and prints the total. Make it executable, then run it correctly and incorrectly (with no argument) and confirm it fails loudly the second time.

Check yourself

What does set -euo pipefail do?

L2.7

Text processing: grep, sed, awk, jq

In plain English

Logs and API responses are text. These four tools let you pull exactly the information you need out of thousands of lines.

By the end you can
  • Search and filter text with grep
  • Transform text with sed and awk
  • Parse JSON reliably with jq

grep: find lines

grep "ERROR" app.log                  # lines containing ERROR
grep -i error app.log                 # case-insensitive
grep -c "Failed" auth.log             # count matches
grep -v "DEBUG" app.log               # lines NOT matching
grep -E "error|fail|critical" app.log # extended regex, several patterns

sed: transform text

sed 's/old/new/' file.txt          # replace first occurrence per line
sed 's/old/new/g' file.txt         # replace all
sed -n '10,20p' file.txt           # print lines 10-20
sed -i.bak 's/localhost/127.0.0.1/' config.yaml   # edit in place, keep a backup

awk: pick columns

# Top 5 client IPs in an nginx log
awk '{print $1}' access.log | sort | uniq -c | sort -rn | head -5

# Sum a numeric column
awk '{sum += $5} END {print sum}' data.txt

jq: JSON done properly

Never parse JSON with grep and sed. It breaks on whitespace, ordering and nesting. jq is the correct tool.

curl -s https://api.example.com/items | jq '.'
curl -s https://api.example.com/items | jq '.[] | .name'
curl -s https://api.example.com/items | jq -r '.[] | select(.status=="active") | .id'
jq '.database.host' config.json
jq -r '.items[] | "\(.id) \(.name)"' data.json
In business terms

These four tools are why Linux engineers look fast. A question like 'which IP hammered us most yesterday?' is one line, answered in a second, on a file with a million rows. No spreadsheet, no export, no clicking.

Tip. Learn jq properly — it is the single highest-return tool for working with APIs, and every automation that talks to a modern service will use it.
Careful. sed -i edits files in place with no undo. Always test without -i first, or use -i.bak to keep a backup.
Do this

Create /tmp/lab/data.json containing an array of five objects each with name and score. Use jq to print only names, then only objects with score above 50, then the highest score. You have just done what most API automations do.

Check yourself

Why is jq preferred over grep for parsing JSON?

L2.8

Scheduling: cron and systemd timers

In plain English

Scheduling is what turns a script into an automation. Cron and systemd timers both run things on a timetable.

By the end you can
  • Read and write a crontab expression
  • Choose between cron and a systemd timer
  • Avoid the classic scheduled-job pitfalls

The crontab format

┌── minute (0-59)
│ ┌── hour (0-23)
│ │ ┌── day of month (1-31)
│ │ │ ┌── month (1-12)
│ │ │ │ ┌── day of week (0-6, Sun=0)
│ │ │ │ │
0 7 * * *   /home/ubuntu/scripts/morning-report.sh
*/15 * * * * /home/ubuntu/scripts/health-check.sh
0 2 * * 0   /home/ubuntu/scripts/weekly-backup.sh
  • 0 7 * * * — every day at 07:00
  • */15 * * * * — every 15 minutes
  • 0 2 * * 0 — Sundays at 02:00
crontab -l          # list my scheduled jobs
crontab -e          # edit them

The pitfalls that catch everyone

PitfallWhy it bitesFix
No PATHcron has a minimal environmentUse absolute paths for everything
No output captureYou never learn it failedLog to a file and check the file
Overlapping runsA slow run collides with the nextUse a lock file (flock)
Assuming it ranSilent failureAdd a monitor that alerts on absence
In business terms

The most dangerous automation is one that stopped running three weeks ago and nobody noticed. Scheduling is the easy part — proving it ran is the part that matters. Every scheduled job you own should have something watching it.

A hardened cron entry

*/15 * * * * /usr/bin/flock -n /tmp/health.lock /home/ubuntu/scripts/health-check.sh >> /home/ubuntu/logs/health.log 2>&1

flock -n prevents overlap. The redirect captures both output and errors so you can read what happened. Absolute paths mean the environment cannot surprise you.

systemd timers: the modern alternative

[Unit]
Description=Daily backup

[Timer]
OnCalendar=daily
Persistent=true

[Install]
WantedBy=timers.target

Persistent=true means a missed run (machine was off) executes as soon as possible afterwards. Cron simply skips it. Timers also give you journalctl logging for free.

Tip. Prefer systemd timers when you need missed-run catch-up or real logging. Prefer cron when you want something simple and portable.
Do this

Create a script /tmp/lab/stamp.sh that appends the date to /tmp/lab/stamps.log. Schedule it with cron to run every minute. Wait two minutes, then read the log to confirm it ran twice. Remove the cron entry afterwards.

Check yourself

Your cron job stopped running three weeks ago. What was the real failure?

L2.9

Level 2 lab: a self-monitoring backup job

In plain English

You will build a scheduled backup that logs its work, refuses to run twice at once, and reports failure. This is a complete, production-shaped automation.

By the end you can
  • Combine scripting, scheduling and logging into one system
  • Add locking and failure reporting
  • Verify the automation by inspecting its own evidence

The brief

Build a backup of a real directory that runs nightly, cannot overlap with itself, records what it did, and exits non-zero when it fails.

The script

#!/usr/bin/env bash
# nightly-backup.sh - archive a directory and record the result
set -euo pipefail

SOURCE="${1:?usage: nightly-backup.sh <directory>}"
DEST="/home/ubuntu/backups"
LOG="/home/ubuntu/logs/nightly-backup.log"
STAMP=$(date +%Y%m%d-%H%M%S)

log() { echo "$(date -Is) $*" | tee -a "$LOG"; }

if [[ ! -d "$SOURCE" ]]; then
  log "FAIL source not found: $SOURCE"; exit 1
fi

mkdir -p "$DEST" "$(dirname "$LOG")"
ARCHIVE="$DEST/$(basename "$SOURCE")-$STAMP.tar.gz"

log "START backing up $SOURCE"
if tar -czf "$ARCHIVE" "$SOURCE"; then
  SIZE=$(du -h "$ARCHIVE" | cut -f1)
  log "OK archive=$ARCHIVE size=$SIZE"
else
  log "FAIL tar exited $?"; exit 1
fi

# keep only the 7 most recent archives
ls -1t "$DEST"/*.tar.gz 2>/dev/null | tail -n +8 | xargs -r rm --
log "DONE retention applied, $(ls -1 "$DEST"/*.tar.gz 2>/dev/null | wc -l) archives kept"

The schedule

0 2 * * * /usr/bin/flock -n /tmp/nightly-backup.lock /home/ubuntu/scripts/nightly-backup.sh /srv/www/demo-site >> /home/ubuntu/logs/nightly-backup-cron.log 2>&1

The verification — this is the part most people skip

# Did it run last night?
tail -5 /home/ubuntu/logs/nightly-backup.log

# Is the archive real and complete?
tar -tzf /home/ubuntu/backups/demo-site-*.tar.gz | head

# Would I know if it stopped? (the monitor)
find /home/ubuntu/backups -name '*.tar.gz' -mtime +1 | wc -l   # should be 0
In business terms

You now have the full pattern: a script that does the work, a schedule that triggers it, a log that proves it ran, and a check that catches silence. Everything else in this course is a variation on these four parts.

Careful. An untested backup is not a backup. Restore from your archive at least once. The time to discover your backup is corrupt is not the day you need it.
Tip. Retention matters. Without the tail -n +8 cleanup, a nightly backup fills the disk within months — and a full disk breaks everything else on the machine.
Do this

Implement the script above, schedule it, run it manually once, and confirm: the archive exists, it is non-empty, the log has START/OK/DONE lines, and running it twice at once is blocked by the lock. Then restore one file from the archive to prove it works.

Check yourself

Why does this backup script use a lock file?

Level 3 · 8 lessons

Python for Automation

From reading code to writing tools that do real work.

Why this level matters

Shell scripts are great for gluing existing programs together. Python is for when logic gets complicated: talking to APIs, parsing data, making decisions, handling failures. It is the most widely used automation language on earth and the one your own estate runs on.

8 lessons in this level
L3.1

Python basics: values, variables, flow

In plain English

Python reads almost like English. You store values in named variables and tell the computer what to do step by step.

By the end you can
  • Write and run a Python script
  • Use variables, strings, numbers and f-strings
  • Make decisions with if/elif/else

Running Python

python3 --version
python3 -c "print('hello')"      # run a one-liner
python3 script.py                # run a file

Variables and types

service = "app-gateway"    # str
port = 8081                     # int
cpu_load = 0.42                 # float
is_healthy = True               # bool
hosts = ["web1", "web2"]        # list
config = {"host": "localhost", "port": 8081}   # dict
In business terms

A variable is a labelled box. 'port' holds 8081. In shell scripts you juggle text and hope; Python knows the difference between a number, a word and a true/false, which prevents an entire class of silent bugs.

f-strings: building text

name = "gateway"
port = 8081
print(f"{name} is listening on port {port}")
print(f"load: {cpu_load:.0%}")      # format as a percentage

Decisions

if port == 8081:
    print("gateway")
elif port == 443:
    print("https")
else:
    print("unknown port")

Loops

for host in hosts:
    print(f"checking {host}")

for i in range(3):
    print(f"attempt {i + 1}")

while not is_healthy:
    is_healthy = check_health()     # careful: needs an exit condition
Careful. A while loop with no exit condition hangs forever. Always know what will make it stop.
Tip. Python cares about indentation — the spaces at the start of a line are the structure, not decoration. Use 4 spaces consistently and never mix tabs.
Do this

Write /tmp/lab/inventory.py with a dict describing three servers (name, port, healthy). Loop over it and print one line per server that says whether it is healthy.

Check yourself

What does an f-string do?

L3.2

Functions: reusable blocks

In plain English

A function is a named block of work you can call whenever you need it, with inputs and an output.

By the end you can
  • Define and call functions with parameters and return values
  • Explain why duplication is a bug risk
  • Use default arguments and keyword arguments

The shape of a function

def check_service(name, port, timeout=5):
    """Return True if a TCP port on localhost is accepting connections."""
    import socket
    with socket.socket() as s:
        s.settimeout(timeout)
        try:
            s.connect(("127.0.0.1", port))
            return True
        except OSError:
            return False

if check_service("gateway", 8081):
    print("gateway is up")
else:
    print("gateway is DOWN")
  • def starts the definition
  • Parameters go in the brackets; timeout=5 is a default
  • return hands a value back to the caller
  • The triple-quoted line is a docstring — write it, future you will thank you

Why functions matter

Written once, used everywhere. If the logic needs to change, you change one place instead of hunting through six copies — one of which you will miss.

In business terms

Duplicated logic is a liability. The version of the check you forgot to update is the one that fails silently at the worst moment. Functions are how you guarantee consistency.

Return early, return clearly

def classify(load):
    if load > 0.9:
        return "critical"
    if load > 0.7:
        return "warning"
    return "ok"          # the default case, last
Tip. Handle the failure case first and return early. Deeply nested if/else is where bugs hide.

Keeping concerns separate

# Bad: fetching, deciding and printing all mixed together
def check():
    r = requests.get(URL)
    if r.json()["status"] == "ok":
        print("API OK")

# Good: separate steps, each independently testable
def fetch(url): ...
def is_healthy(payload): ...
def report(result): ...

Separation means you can test is_healthy with fake data, without a network. Anything you cannot test, you cannot trust.

Do this

Write a function summarise(results) that takes a list of booleans and returns a string like '3 ok / 1 failed'. Call it with different lists and print the results.

Check yourself

Why is it better to split fetching, deciding and reporting into separate functions?

L3.3

Errors: handling failure properly

In plain English

Things go wrong constantly. Good code expects failure and handles it deliberately instead of crashing or lying.

By the end you can
  • Catch exceptions and respond appropriately
  • Distinguish recoverable from fatal errors
  • Fail loudly rather than returning a wrong answer

Exceptions

import json, sys

def load_config(path):
    try:
        with open(path) as f:
            return json.load(f)
    except FileNotFoundError:
        print(f"config missing: {path}", file=sys.stderr)
        return None
    except json.JSONDecodeError as e:
        print(f"config is not valid JSON: {e}", file=sys.stderr)
        return None
  • try — code that might fail
  • except — what to do when a specific failure happens
  • Catch specific exceptions, never a bare except:
In business terms

The dangerous code is not the code that crashes — you notice that. It is the code that swallows an error and returns a plausible default. 'We could not read the config, so we assumed everything is fine' is how outages become mysteries.

The golden rule

# BAD: an unparseable answer silently becomes 'keep everything'
keep = decide(item).get("keep", True)

# GOOD: if we cannot tell, say so and let the caller decide
result = decide(item)
if result.status == "unknown":
    log.warning("decision unknown for %s; escalating", item.id)
    raise DecisionUnknown(item.id)

This exact pattern is why typed results matter. An 'unknown' that is visible is safe. An 'unknown' disguised as a default is a time bomb.

Cleaning up

lock = acquire_lock()
try:
    do_work()
finally:
    release_lock()        # runs whether or not do_work() raised
# Better, when the resource supports it:
with open("data.json") as f:
    data = json.load(f)   # file closes automatically, even on error
Careful. except Exception: pass is the anti-pattern of automation. It hides every problem, and you find out weeks later when something important is quietly missing.
Tip. Log the exception, not just a friendly message. log.exception(...) captures the traceback, which tells you the line that failed.
Do this

Write safe_load(path) that returns the file contents or None, handling both a missing file and invalid JSON, printing a distinct message for each. Test it with three inputs: a good file, a missing path, and a file containing broken JSON.

Check yourself

You call a function that cannot determine an answer. What should it do?

L3.4

Files, JSON and APIs

In plain English

Automation is mostly moving data between systems. This lesson is the plumbing: reading files, parsing JSON, calling HTTP APIs.

By the end you can
  • Read and write JSON files safely
  • Call an HTTP API with proper error handling and timeouts
  • Never put secrets in your source code

JSON in and out

import json
from pathlib import Path

# read
data = json.loads(Path("state.json").read_text())

# write atomically: a crash mid-write must not corrupt the original
tmp = Path("state.json.tmp")
tmp.write_text(json.dumps(data, indent=2))
tmp.replace("state.json")
In business terms

Writing to a temporary file and renaming it is how you avoid corrupting data if the process dies mid-save. It is a two-line habit that prevents the worst kind of data loss.

Calling an API

import os, requests

TOKEN = os.environ["CF_API_TOKEN"]      # never hard-code secrets
HEADERS = {"Authorization": f"Bearer {TOKEN}"}

resp = requests.get(
    "https://api.cloudflare.com/client/v4/user/tokens/verify",
    headers=HEADERS,
    timeout=15,                          # ALWAYS set a timeout
)
resp.raise_for_status()                  # turn 4xx/5xx into an exception
payload = resp.json()
if not payload.get("success"):
    raise RuntimeError(f"API refused: {payload.get('errors')}")
  • Always set timeout= — without it a hung connection blocks your job forever
  • Use raise_for_status() so HTTP errors are not silently ignored
  • Check the API's own success flag; HTTP 200 does not guarantee a good result

Retry with backoff

import time

def get_with_retry(url, tries=3, **kw):
    for attempt in range(tries):
        try:
            r = requests.get(url, timeout=15, **kw)
            if r.status_code in (429, 500, 502, 503, 504):
                raise requests.HTTPError(f"transient {r.status_code}")
            r.raise_for_status()
            return r
        except requests.RequestException as e:
            if attempt == tries - 1:
                raise
            wait = 2 ** attempt            # 1, 2, 4 seconds
            log.warning("attempt %s failed (%s); retrying in %ss", attempt + 1, e, wait)
            time.sleep(wait)

Retry only on transient failures (429, 5xx, timeouts). Retrying a 403 just wastes time — the token will not fix itself.

Secrets: the non-negotiable rules

  • Read secrets from the environment or a file with 600 permissions
  • Never commit them to Git
  • Never print them, even partially
  • Rotate immediately if one is ever exposed
Careful. A token pasted into a chat, a ticket or a log is compromised. There is no such thing as a private channel. Rotate it.
Do this

Write a script that reads a URL from an environment variable, fetches it with a 10-second timeout, and prints either the JSON keys or a clear failure message. Point it at a real public API (for example https://api.github.com).

Check yourself

Why must every HTTP request set a timeout?

L3.5

Modules, packages and virtual environments

In plain English

Third-party libraries save you enormous time. Virtual environments keep each project's dependencies separate so they cannot break each other.

By the end you can
  • Create and use a virtual environment
  • Install dependencies and record them
  • Explain why global installs cause problems

The problem

Two projects need different versions of the same library. Install one globally and you break the other. A virtual environment gives each project its own private set of libraries.

python3 -m venv .venv             # create the environment
source .venv/bin/activate        # enter it (prompt changes)
pip install requests             # installs only here
pip freeze > requirements.txt    # record exact versions
deactivate                       # leave it
In business terms

A virtual environment is a clean workshop per project. Everything it needs is in the room; nothing leaks into the next project. This is why 'it works on my machine' stops being an excuse.

Restoring an environment elsewhere

python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt   # exact same versions, every time

Using modules

import json                       # standard library: no install needed
from pathlib import Path          # standard library
import requests                   # third-party: from requirements.txt

from mymodule import my_function  # your own code, same folder

Pin your versions

# requirements.txt — exact versions, so today's working build is tomorrow's working build
requests==2.32.3
PyYAML==6.0.2
Careful. Unpinned dependencies (requests with no version) mean your code can break overnight because someone upstream released a change. Pin versions in anything you rely on.
Tip. Put .venv/ in .gitignore. Never commit an environment — it is large, machine-specific and reproducible from requirements.txt.
Do this

Create /tmp/lab/venvtest/, build a virtual environment, install requests, freeze it to requirements.txt, then delete the environment, recreate it, and reinstall from the file. Confirm the versions match.

Check yourself

Why use a virtual environment per project?

L3.6

Typed data and decisions (the Jev pattern)

In plain English

Make functions return structured answers with an explicit 'unknown' state, so no failure is ever disguised as a normal result.

By the end you can
  • Define a typed result instead of returning loose values
  • Represent 'unknown' as a first-class state
  • Apply the pattern to a real decision

The problem with loose returns

A function that returns True or False has no way to say 'I could not tell'. Callers then treat unparseable input as False — and a system that cannot decide silently behaves as if the answer were no.

# The old shape: three different meanings collapse into one
def should_alert(report):
    if "[SILENT]" in report:      # substring matching on free text!
        return False
    return True                   # a failed parse also lands here

The typed shape

from dataclasses import dataclass
from typing import Literal

@dataclass(frozen=True)
class Decision:
    status: Literal["ok", "unknown"]
    value: bool | None
    confidence: float | None
    reason: str

    def __post_init__(self):
        # An 'ok' decision MUST carry a value. Anything else is a bug, not a default.
        if self.status == "ok" and self.value is None:
            raise ValueError("status ok requires a value")
        if self.status == "unknown" and self.value is not None:
            raise ValueError("status unknown must not carry a value")

def make_ok(value: bool, confidence: float, reason: str) -> Decision:
    return Decision("ok", bool(value), float(confidence), reason)

def make_unknown(reason: str) -> Decision:
    return Decision("unknown", None, None, reason)
In business terms

This is the whole idea in one class: a result is either a real answer with a confidence, or it is explicitly unknown. There is no third path where a broken parse quietly becomes 'no'. The invariant is enforced in code, so it cannot be forgotten.

Using it

def should_alert(report) -> Decision:
    if not isinstance(report, str) or not report.strip():
        return make_unknown("empty or non-text report")
    if "[SILENT]" in report:
        return make_ok(False, 0.95, "model marked silent")
    return make_ok(True, 0.8, "substantive content present")

d = should_alert(model_output)
if d.status == "unknown":
    alert_operator(d.reason)          # visible, actionable
elif d.value:
    send_alert()

Why the threshold belongs in code, not in the prompt

The model produces a judgement and a confidence. Your code decides what confidence is good enough. That way the rule is testable, reviewable, and identical on every run — instead of living inside a prompt and drifting.

THRESHOLD = 0.7

valid = [d for d in decisions if d.status == "ok" and d.confidence >= THRESHOLD]
unknown = [d for d in decisions if d.status == "unknown"]

log.info("answered=%s unknown=%s kept=%s", len(decisions) - len(unknown), len(unknown), len(valid))
Tip. Log the unknown count on every run. If it climbs, your input or your model changed — and you find out before your users do.
Careful. Never let a threshold silently exclude a result with an unparseable confidence. Coerce it to unknown and surface it. A YES dropped for a formatting reason is a missed alert.
Do this

Implement Decision and a classify(text) function that returns ok for clear cases and unknown for empty, non-text or ambiguous input. Then run it over five inputs (including an empty string and None) and print a table of status, value and reason.

Check yourself

What is the benefit of an explicit 'unknown' status?

L3.7

Testing: proving your code works

In plain English

A test is a small piece of code that checks another piece of code. Tests are how you change things without fear.

By the end you can
  • Write tests with pytest
  • Test failure paths, not just success paths
  • Use tests as a safety net for refactoring

The smallest useful test

# test_classify.py
from classify import classify

def test_empty_is_unknown():
    assert classify("").status == "unknown"

def test_clear_yes_is_ok():
    d = classify("System outage in the Singapore region")
    assert d.status == "ok"
    assert d.value is True

def test_none_is_unknown():
    assert classify(None).status == "unknown"
pip install pytest
pytest -v                       # run every test
pytest test_classify.py -v      # run one file
In business terms

Tests are the reason you can improve a system that already works. Without them, every change is a gamble and teams stop improving. With them, you change things in minutes and know within seconds whether you broke anything.

Test the failure paths

Beginners test the happy path. Professionals test what happens when the input is empty, wrong, missing, too large, or hostile. That is where real bugs live.

import pytest

def test_rejects_non_text():
    assert classify(12345).status == "unknown"

def test_decision_invariant():
    with pytest.raises(ValueError):
        Decision(status="ok", value=None, confidence=0.9, reason="bad")

Test-first when fixing a bug

  1. Write a test that reproduces the bug and fails.
  2. Fix the bug.
  3. Run the test again — it passes.
  4. Keep the test forever, so the bug cannot come back.
Tip. Name tests after the behaviour, not the function: test_empty_report_is_unknown tells you what broke when it fails. test_1 does not.
Careful. A test that cannot fail is not a test. If you are not sure your test detects the problem, break the code deliberately and confirm the test goes red.
Do this

Write three tests for your classify function: one passing case, one empty input, one non-text input. Run pytest -v. Then deliberately break classify and confirm at least one test fails — proving your tests actually detect faults.

Check yourself

Why write a test before fixing a bug?

L3.8

Level 3 lab: a real monitoring script

In plain English

You will build a complete monitoring script: checks several services, records results as structured data, exits with a meaningful code, and can be scheduled.

By the end you can
  • Combine functions, error handling, typing and testing
  • Produce machine-readable output
  • Exit with codes a scheduler can act on

The brief

Build service-check.py that reads a list of services (name + port) from a JSON file, checks each one, prints a summary, and exits 0 if all are healthy or 1 if any are not — so a scheduler or monitor can act on it.

Structure it in testable pieces

#!/usr/bin/env python3
"""service-check.py - verify local services are accepting connections."""
import json, socket, sys
from dataclasses import dataclass, asdict
from pathlib import Path

@dataclass(frozen=True)
class Check:
    name: str
    port: int
    ok: bool
    detail: str

def probe(host: str, port: int, timeout: float = 3.0) -> tuple[bool, str]:
    """Return (ok, detail) for a TCP connection attempt."""
    try:
        with socket.create_connection((host, port), timeout=timeout):
            return True, "accepting connections"
    except socket.timeout:
        return False, f"timed out after {timeout}s"
    except ConnectionRefusedError:
        return False, "connection refused (nothing listening)"
    except OSError as e:
        return False, f"error: {e}"

def run(services) -> list[Check]:
    return [Check(s["name"], s["port"], *probe(s.get("host", "127.0.0.1"), s["port"]))
            for s in services]

def main() -> int:
    path = Path(sys.argv[1] if len(sys.argv) > 1 else "services.json")
    try:
        services = json.loads(path.read_text())
    except (OSError, json.JSONDecodeError) as e:
        print(json.dumps({"error": f"cannot read {path}: {e}"}), file=sys.stderr)
        return 2                                     # 2 = we could not even start

    results = run(services)
    failed = [c for c in results if not c.ok]

    print(json.dumps({
        "checked": len(results),
        "healthy": len(results) - len(failed),
        "failed": [asdict(c) for c in failed],
    }, indent=2))

    return 1 if failed else 0

if __name__ == "__main__":
    sys.exit(main())

The three exit codes — and why they matter

CodeMeaningWhat a monitor should do
0All healthyNothing
1Something is downAlert
2The check itself failedAlert differently — the checker is broken
In business terms

Distinguishing 'a service is down' from 'the monitor is broken' is what separates a useful alerting system from a noisy one. If both look the same, you cannot trust either.

The service list

[
  {"name": "gateway",        "port": 8081},
  {"name": "cfhook-receiver", "port": 8080}
]

Schedule it

*/5 * * * * /home/ubuntu/.venv/bin/python /home/ubuntu/scripts/service-check.py /home/ubuntu/services.json >> /home/ubuntu/logs/service-check.log 2>&1
Careful. Note the absolute path to the interpreter. A scheduled job has a minimal environment and may not find python3 the way your shell does.
Do this

Build the script, create the JSON, run it against a real listening port and a deliberately closed one (like 9999), and confirm it exits 1 with both listed. Then run it with a missing JSON file and confirm exit code 2.

Check yourself

Why does the script use exit code 2 for 'could not read the config'?

Level 4 · 7 lessons

Ansible: Configuration at Scale

Describe the state you want; Ansible makes every machine match it.

Why this level matters

Scripts run commands. Ansible describes a desired end state — and it is idempotent, meaning running it twice changes nothing the second time. This is how professionals manage one server or a thousand identically, without ever logging in to do it by hand.

7 lessons in this level
L4.1

What Ansible is and why it wins

In plain English

Ansible connects to machines over SSH and makes them match a description you wrote. No agent to install, no server to run.

By the end you can
  • Explain declarative vs imperative automation
  • Explain idempotence and why it matters
  • Understand Ansible's agentless architecture

Two ways to automate

StyleYou writeExample
ImperativeThe steps to takeInstall nginx, then start it, then copy config
DeclarativeThe result you wantnginx must be installed, running and configured

Scripts are imperative. Ansible is declarative — and that difference is the whole point.

In business terms

Imperative tells a new employee every step. Declarative tells them the finished state and lets them work out the steps. Declarative is safer: if the state is already correct, nothing happens. Re-running is always safe.

Idempotence: the killer feature

Running an Ansible playbook twice has the same effect as running it once. The second run reports 'changed: 0'. That means you can run it again whenever you are unsure — and it will never double-install, duplicate a config line or restart a service needlessly.

$ ansible-playbook site.yml
PLAY RECAP
web1 : ok=6  changed=3  failed=0

$ ansible-playbook site.yml      # run it again
web1 : ok=6  changed=0  failed=0   # nothing to do — state already correct

Agentless architecture

  • You need: SSH access, and Python on the target (almost always already there)
  • No daemon to install, no database, no controller server
  • Works on servers, network devices, cloud APIs, and even your laptop
Careful. Ansible will happily make a change you did not intend, across every host at once. Always test with --check (dry run) and --limit (one host) before a full run.
Tip. Because it is agentless and idempotent, Ansible is the safest first automation tool for infrastructure you depend on.
Do this

Install Ansible on your machine (pip install ansible inside a virtual environment) and run ansible --version. Then run ansible localhost -m ping to confirm it can reach and manage a target — itself.

Check yourself

What does idempotent mean?

L4.2

Inventory, ad-hoc commands and the SSH foundation

In plain English

The inventory is your list of machines. Ad-hoc commands run one task across them instantly — no files needed.

By the end you can
  • Write an inventory of hosts
  • Run ad-hoc commands across hosts
  • Confirm SSH access before relying on it

The inventory file

# inventory.ini
[web]
web1.example.com
web2.example.com

[db]
db1.example.com

[vps]
web01 ansible_host=203.0.113.10 ansible_user=deploy

[all:vars]
ansible_python_interpreter=/usr/bin/python3

Groups ([web], [db]) let you target sets of machines. Groups can contain other groups, so you can model your real estate.

Ad-hoc commands: the fastest way to check something

ansible all -i inventory.ini -m ping                 # can I reach everything?
ansible web -i inventory.ini -a "uptime"              # run a shell command
ansible all -i inventory.ini -a "df -h" --become       # with root privileges
ansible web -i inventory.ini -m setup | head -30        # gather facts
In business terms

Ad-hoc commands are the interview question for any fleet: 'is everything reachable?' becomes one command instead of twelve logins. Adopt this habit before you write your first playbook.

Facts: what Ansible knows about each host

ansible web01 -i inventory.ini -m setup
# -> ansible_os_family, ansible_memtotal_mb, ansible_distribution, ...

ansible all -i inventory.ini -m setup -a 'filter=ansible_*memory*'

Facts let you write configuration that adapts to each machine instead of hard-coding assumptions. You can use them in playbooks as variables.

Careful. Before automating anything, confirm SSH key access works and does not prompt for a password. Ansible cannot answer password prompts in an unattended run.
Tip. ansible-inventory --graph renders your inventory as a tree. Useful once you have nested groups.
Do this

Create an inventory with localhost (using ansible_connection=local) and run ansible all -i inventory.ini -m ping, then -a "hostname", then gather facts with -m setup filtered to memory. You have just managed a machine without logging in.

Check yourself

What is the Ansible inventory?

L4.3

Your first playbook

In plain English

A playbook is a YAML file describing the state you want on a group of hosts. Tasks run in order, each one idempotent.

By the end you can
  • Write and run a playbook
  • Use the key modules: apt, copy, service, template
  • Read the PLAY RECAP to know what changed

A complete playbook

---
- name: Configure web servers
  hosts: web
  become: true

  tasks:
    - name: Install nginx
      ansible.builtin.apt:
        name: nginx
        state: present
        update_cache: true

    - name: Deploy the site configuration
      ansible.builtin.template:
        src: nginx-site.conf.j2
        dest: /etc/nginx/sites-available/default
        owner: root
        group: root
        mode: "0644"
      notify: reload nginx

    - name: Ensure nginx is running and enabled at boot
      ansible.builtin.service:
        name: nginx
        state: started
        enabled: true

  handlers:
    - name: reload nginx
      ansible.builtin.service:
        name: nginx
        state: reloaded

The pieces

ElementMeaning
hostsWhich group from the inventory
becomeRun with sudo
tasksThe list of desired states, in order
notifyTrigger a handler only when this task changes something
handlersDeferred actions, usually service reloads
In business terms

The notify/handler pairing is the elegance of Ansible. The web server reloads only if the config actually changed — not on every run. That is how you avoid restarting production for no reason.

Running it

ansible-playbook -i inventory.ini site.yml --check      # dry run: what WOULD change?
ansible-playbook -i inventory.ini site.yml --limit web1 # just one host
ansible-playbook -i inventory.ini site.yml              # all of them
ansible-playbook -i inventory.ini site.yml --diff       # show the changes

Reading the recap

PLAY RECAP ************************************************
web1 : ok=5  changed=2  unreachable=0  failed=0  skipped=0
#           │       │              │           └── nothing broke
#           │       │              └── host could not be contacted
#           │       └── tasks that actually altered the system
#           └── tasks that ran clean
Careful. Never run a new playbook against all hosts on the first attempt. Use --check, then --limit one host, then widen. Ansible acts fast and in parallel.
Tip. changed=0 on a second run is the proof your playbook is truly idempotent. If it keeps reporting changes, something in it is not declarative.
Do this

Write a playbook against localhost that ensures a package is installed (for example curl), creates a directory with specific permissions, and writes a file from a template. Run it twice and confirm the second run reports changed=0.

Check yourself

What does a handler with notify do?

L4.4

Variables, templates and facts

In plain English

Variables let one playbook work across many machines. Templates build config files that adapt per host.

By the end you can
  • Define variables at multiple levels and explain precedence
  • Write a Jinja2 template
  • Use facts to adapt configuration automatically

Where variables live

# group_vars/web.yml — applies to every host in the 'web' group
nginx_worker_processes: 4
site_name: demo

# host_vars/web1.yml — applies to one host
nginx_worker_processes: 8

Precedence, from weakest to strongest: role defaults → inventory vars → group_vars → host_vars → play vars → extra vars (-e). Extra vars always win, which is why they are the right way to override at run time.

A Jinja2 template

# templates/nginx-site.conf.j2
server {
    listen 80;
    server_name {{ site_name }}.example.com;

    # tuned for a {{ ansible_memtotal_mb }}MB machine
    worker_processes {{ nginx_worker_processes }};

    location / {
        proxy_pass http://127.0.0.1:{{ app_port }};
        proxy_set_header Host $host;
    }
}
In business terms

This is the payoff: one template, and every host gets a config file sized correctly for its own memory and role. No copy-pasting config between machines, no drift.

Using it in a playbook

- name: Deploy config
  ansible.builtin.template:
    src: nginx-site.conf.j2
    dest: /etc/nginx/sites-available/{{ site_name }}
    mode: "0644"

- name: Override at runtime
  ansible.builtin.debug:
    msg: "{{ site_name }} port {{ app_port }}"
# ansible-playbook site.yml -e "app_port=9090"

Adapting with facts

- name: Install the right package manager
  ansible.builtin.package:
    name: nginx
    state: present
  when: ansible_os_family == "Debian"
Careful. Do not hard-code values a fact already provides — memory size, OS family, distribution version. Hard-coded assumptions break the day you add a different machine.
Tip. Keep secrets out of group_vars. Use ansible-vault for credentials so the repository can stay shareable: ansible-vault encrypt_string.
Do this

Create group_vars/all.yml with a service name and port, write a template that renders both into a config file, and a playbook that deploys it. Run it with the default values, then override the port with -e and confirm the file changes.

Check yourself

Why use a template instead of copying a static config file?

L4.5

Roles: organising real projects

In plain English

A role packages tasks, templates, files, variables and handlers into a reusable unit — like a module for a piece of infrastructure.

By the end you can
  • Explain the role directory structure
  • Create and use a role
  • Apply a role to many hosts with variables

The standard structure

roles/nginx/
├── tasks/main.yml        # what to do
├── handlers/main.yml     # deferred actions
├── templates/nginx.conf.j2
├── files/index.html
├── defaults/main.yml     # overridable defaults (lowest precedence)
├── vars/main.yml         # role-internal values (higher precedence)
└── meta/main.yml         # dependencies on other roles

Creating one

ansible-galaxy init roles/nginx

Writing the tasks

# roles/nginx/tasks/main.yml
---
- name: Install nginx
  ansible.builtin.apt:
    name: nginx
    state: present
    update_cache: true

- name: Write configuration
  ansible.builtin.template:
    src: nginx.conf.j2
    dest: /etc/nginx/nginx.conf
    mode: "0644"
  notify: restart nginx

- name: Start and enable
  ansible.builtin.service:
    name: nginx
    state: started
    enabled: true
# roles/nginx/defaults/main.yml
nginx_worker_processes: 2
nginx_client_max_body_size: 1m

Using the role

---
- name: Web tier
  hosts: web
  become: true
  roles:
    - role: nginx
      vars:
        nginx_worker_processes: 4
In business terms

A role is your infrastructure in a box. Once written, a new server becomes one line in a playbook — and it will be configured exactly like the others. This is how a fleet stays consistent as it grows.

Real use: apply two roles to two host groups

---
- name: Common baseline on everything
  hosts: all
  become: true
  roles: [common]

- name: Web servers
  hosts: web
  become: true
  roles: [nginx]

- name: Database servers
  hosts: db
  become: true
  roles: [postgres]
Tip. Write a common role first — timezone, admin user, SSH hardening, monitoring agent. Every machine you own should start from that baseline.
Careful. Roles from Ansible Galaxy are third-party code that runs as root on your servers. Vet them before use; pin versions so an upstream change cannot alter your infrastructure overnight.
Do this

Create a common role that ensures a baseline directory exists, sets the timezone, and writes a /etc/motd greeting. Apply it to localhost and confirm each task reports changed the first time and changed=0 the second.

Check yourself

What is the benefit of packaging tasks into a role?

L4.6

Vault, secrets and safe practice

In plain English

Ansible Vault encrypts sensitive values so your playbooks can live in Git without exposing credentials.

By the end you can
  • Encrypt and decrypt values with ansible-vault
  • Keep secrets out of version control
  • Run a playbook with an encrypted vault password file

Why vault

Your playbook needs database passwords and API tokens. Those cannot live in plain text in a Git repository. Vault encrypts them at rest while keeping the playbook readable and version-controlled.

ansible-vault create group_vars/all/vault.yml        # new encrypted file
ansible-vault edit group_vars/all/vault.yml          # change it
ansible-vault view group_vars/all/vault.yml          # read it
ansible-vault encrypt secrets.yml                    # encrypt an existing file
ansible-vault decrypt secrets.yml                    # remove encryption (careful)

Usage in a playbook

- name: Use a secret without printing it
  ansible.builtin.template:
    src: app.env.j2
    dest: /etc/app.env
    mode: "0600"                        # secrets must not be world-readable
  vars:
    db_password: "{{ vault_db_password }}"

- name: Prefer no_log for secret tasks
  ansible.builtin.command: /usr/local/bin/setup-db
  no_log: true                          # keeps the value out of the output
ansible-playbook site.yml --ask-vault-pass
# or, for unattended automation, a password file with 600 permissions:
ansible-playbook site.yml --vault-password-file ~/.ansible/vault-pass
In business terms

The rule that matters: secrets go into the vault, the vault password never goes into Git, and files holding secrets are mode 0600. Follow those three and your automation can be shared safely.

A safe secrets layout

group_vars/all/vars.yml        # non-secret values, committed
 group_vars/all/vault.yml       # secrets, committed ENCRYPTED
~/.ansible/vault-pass           # the password itself, mode 600, NEVER committed
Careful. If a vault password is committed, the encryption is worthless. Add vault-pass* and *.vault-pass to .gitignore before your first commit, not after.
Tip. Use no_log: true on any task whose output could contain a secret. Ansible prints task output in the recap and in CI logs.
Do this

Create a vault-encrypted file containing a variable vault_api_token, write a playbook that renders it into a file with mode 0600 on localhost, and decrypt-and-view the vault to confirm the plaintext is stored encrypted.

Check yourself

Where should the vault password file live?

L4.7

Level 4 lab: provision and configure a server

In plain English

Bring a fresh machine from bare to production-ready with one command — repeatable, idempotent and safe.

By the end you can
  • Build a multi-role playbook for a real machine
  • Verify idempotence and test the role of check mode
  • Prove the result by inspecting the machine

The brief

Write a playbook that takes a fresh Ubuntu machine to a working web server: hardened baseline, nginx installed and configured, a page deployed, a firewall rule opened for HTTP, and the service enabled at boot.

Structure

site.yml
inventory.ini
roles/
├── common/      # baseline: packages, timezone, user, permissions
├── nginx/       # install, configure from template, enable
└── firewall/    # allow 80/443, deny the rest
---
- name: Baseline
  hosts: all
  become: true
  roles: [common]

- name: Web tier
  hosts: web
  become: true
  roles:
    - nginx
    - firewall

The firewall role, as an example of getting it right

# roles/firewall/tasks/main.yml
---
- name: Allow SSH before anything else, or we lock ourselves out
  community.general.ufw:
    rule: allow
    port: "22"
    proto: tcp

- name: Allow web traffic
  community.general.ufw:
    rule: allow
    port: "{{ item }}"
    proto: tcp
  loop: ["80", "443"]

- name: Enable the firewall
  community.general.ufw:
    state: enabled
    policy: deny
In business terms

Note the order: SSH is permitted first, then everything else, then the default deny. Automating a firewall in the wrong order locks you out of your own server. Ordering is a safety concern, not a style preference.

Proving it worked

# 1. dry run on one host — expect no surprises
ansible-playbook -i inventory.ini site.yml --check --limit web1

# 2. real run on one host
ansible-playbook -i inventory.ini site.yml --limit web1

# 3. idempotence proof: nothing should change now
ansible-playbook -i inventory.ini site.yml --limit web1   # changed=0

# 4. independent verification, not just Ansible's word
ansible web1 -i inventory.ini -a "systemctl is-active nginx"
curl -s -o /dev/null -w "%{http_code}\n" http://web1/
Careful. Never treat failed=0 as proof the system is correct. Verify from outside the tool that configured it — check the service, fetch the page, inspect the firewall. Tools can be enthusiastically wrong.
Do this

Run this against localhost (using ansible_connection=local), skipping the firewall role if you are not certain of your own SSH setup. Then run it a second time and confirm changed=0, and verify the web server independently with curl.

Check yourself

Why does the firewall role allow SSH before enabling the default-deny policy?

Level 5 · 6 lessons

CI/CD: Automating Delivery

From 'it works on my machine' to 'it ships itself, safely'.

Why this level matters

The last mile of automation is delivery. CI/CD means every change is tested automatically and can be released with a button — or no button at all. This is what turns code into a dependable product instead of an event.

6 lessons in this level
L5.1

What CI/CD actually means

In plain English

Continuous Integration tests every change automatically. Continuous Delivery makes releasing it a safe, boring, repeatable act.

By the end you can
  • Define CI and CD without jargon
  • Explain why automated checks beat human review alone
  • Identify the pipeline stages of a typical project

The definitions, plainly

  • Continuous Integration (CI) — every change is automatically built and tested, so broken code is caught in minutes, not at release.
  • Continuous Delivery (CD) — the tested result can be released at any moment, reliably, with one action.
  • Continuous Deployment — the release happens automatically with no human action at all.
In business terms

The business value is not speed for its own sake. It is that the risk of any single change becomes small, so you can change often. Teams that ship weekly fear releases; teams that ship daily find them boring. Boring is the goal.

A typical pipeline

StageWhat happensFails the build when
CheckoutFetch the codeNever
InstallSet up dependenciesA dependency is unavailable
LintStyle and syntax checkCode is malformed
TestRun automated testsAny test fails
BuildProduce the artifactThe build breaks
DeployShip itThe target rejects it
VerifyCheck it is actually liveThe site is not serving

Why automate this

  1. Consistency — the same checks run every time, on every change, identically.
  2. Speed of feedback — a mistake is caught in minutes while the author still remembers what they did.
  3. Trust — a green pipeline is evidence, not an opinion.
  4. Reversibility — a known-good artifact can be redeployed instantly.
Careful. A pipeline that only tests and never verifies the deployed result gives false confidence. 'Deploy succeeded' and 'the site works' are different claims; check both.
Tip. Start with one stage: run the tests automatically on every push. That alone catches most regressions and costs almost nothing.
Do this

Write down the stages of your release process for one project as it happens today (manual and automatic). Mark which steps are judgement calls and which are mechanical — the mechanical ones are your first automation candidates.

Check yourself

What is the main benefit of continuous delivery?

L5.2

GitHub Actions: your first pipeline

In plain English

A workflow is a YAML file in your repository that tells GitHub what to run when something happens — usually a push.

By the end you can
  • Write a workflow triggered on push
  • Interpret a run's logs and status
  • Cache dependencies to speed up runs

Where workflows live

.github/workflows/test.yml      # the path is fixed
.github/workflows/deploy.yml

A complete workflow

name: test

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"
          cache: pip

      - name: Install dependencies
        run: pip install -r requirements.txt

      - name: Run tests
        run: pytest -v

The pieces

KeyMeaning
onThe trigger — push, pull_request, schedule, manual
jobsGroups of steps; jobs run in parallel by default
runs-onThe machine image
stepsSequential commands or reusable actions
usesA prebuilt action from the marketplace
runA shell command
In business terms

This file is your quality gate, written once and applied forever. Every change, from anyone, is checked the same way — no exceptions, no 'I forgot to run the tests'.

Reading a failed run

  1. Open the Actions tab and select the failing run.
  2. Find the step with the red cross — that is where it broke.
  3. Read the log from the bottom up; the actual error is usually the last thing printed.
  4. Reproduce locally with the same command the workflow ran.

Caching to keep runs fast

      - name: Cache pip
        uses: actions/cache@v4
        with:
          path: ~/.cache/pip
          key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
Tip. Pass a version to every uses: action (@v4, not @main). Pinning prevents an upstream change from breaking your pipeline unexpectedly.
Careful. Workflows triggered by pull_request from a fork run untrusted code. Never wire secrets into a workflow that can be triggered by an outside contributor without review.
Do this

Create .github/workflows/test.yml in a repository with a Python file and a test, push it, and watch the run in the Actions tab. Then deliberately break a test, push again, and read the failure log to find the cause.

Check yourself

What does on: push in a workflow mean?

L5.3

Secrets and deployment safety

In plain English

Pipelines need credentials. Handling them correctly is what keeps an automated deployment from becoming an automated breach.

By the end you can
  • Store secrets in the platform, not in the repository
  • Use environments and approvals for production
  • Apply least privilege to deployment credentials

Never in the repository

# WRONG — this token is now public forever
deploy:
  api_token: "cf_live_a1b2c3..."

# RIGHT — stored in repository settings, injected at run time
deploy:
  env:
    CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
In business terms

A secret in a repository is a secret published to everyone with read access, plus anyone who ever forks or scrapes it. Store it in the platform's secret store, and give it the fewest permissions it can work with.

Least privilege for a deploy token

Bad tokenGood token
Account-wide edit on everythingOnly the specific resource it deploys
Never expiresShort expiry, rotated on schedule
Works from any IPRestricted to CI runner ranges where possible

Protecting production with environments

deploy:
  runs-on: ubuntu-latest
  environment:
    name: production
    url: https://demo.example.com
  steps:
    - name: Deploy
      run: ./deploy.sh
      env:
        CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}

An environment with required reviewers turns deployment into a moment that needs a human decision. The pipeline does the work; you keep the authority to release.

Avoiding shell injection

# DANGEROUS — a crafted branch name can run commands
- run: echo "Deploying ${{ github.head_ref }}"

# SAFER — pass it through the environment
- run: echo "Deploying $BRANCH"
  env:
    BRANCH: ${{ github.head_ref }}
Careful. Never interpolate untrusted values directly into a shell command. Branch names, issue titles and PR bodies are attacker-controlled input.
Tip. Rotate deployment credentials on a schedule even if nothing leaked. Rotation is cheap; an unnoticed leak is not.
Do this

Add a dummy secret to a repository's settings, reference it in a workflow with ${{ secrets.MY_TEST_SECRET }}, and confirm the run shows it masked. Then remove it and confirm the workflow fails — proving it was actually required.

Check yourself

Why does storing a token in the repository fail as a security practice?

L5.4

Deploying a static site automatically

In plain English

The simplest and safest deployment: build the site, verify it, publish it, and confirm it is live.

By the end you can
  • Write a deploy workflow for a static site
  • Gate the deploy behind tests
  • Verify the deployed result, not just the deploy step

The workflow

name: deploy-site

on:
  push:
    branches: [main]

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Validate HTML
        run: |
          pip install html5validator
          html5validator --root ./public

      - name: Check no secrets are present
        run: |
          if grep -rIE "(api[_-]?key|token|password)\s*[:=]\s*['\"][A-Za-z0-9]{16,}" ./public; then
            echo "Possible secret committed"; exit 1
          fi

  deploy:
    needs: build-and-test          # do not deploy if checks failed
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4

      - name: Publish
        run: npx wrangler pages deploy ./public --project-name my-site
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}

      - name: Verify it is actually live
        run: |
          sleep 10
          code=$(curl -s -o /dev/null -w "%{http_code}" https://my-site.example.com/)
          echo "live status: $code"
          test "$code" = "200"

The two habits that make this good

  • needs: build-and-test — nothing publishes unless the checks passed.
  • The final verify step — the pipeline fails if the site is not actually serving, so 'deploy succeeded' is never confused with 'it works'.
In business terms

That last step is the difference between a pipeline that reports success and one that guarantees it. Many teams deploy happily for days while serving an error page, because nothing ever checked the result.

Handling caches and stale content

Static hosting caches aggressively. If you change a file without changing its URL, visitors keep the old version. Content-hash the filenames — or stamp a version query — so a new build is always a new URL.

# stamp a content hash so browsers cannot serve a stale build
HASH=$(sha256sum public/app.js | cut -c1-10)
sed -i "s/app\.js?v=[a-f0-9]*/app.js?v=$HASH/" public/index.html
Careful. After deploying, verify with a cache-buster (for example ?cb=$(date +%s)). A plain check can be answered from an edge cache and show you the previous version.
Do this

Add a verify step to any existing deploy workflow that fetches the live URL and fails if the status is not 200. Then trigger a deployment and confirm the verify step runs and passes in the logs.

Check yourself

Why include a post-deploy verification step?

L5.5

Rollback, roll forward

In plain English

Every deployment needs a way back. Planning the retreat is what makes advancing safe.

By the end you can
  • Design a deployment you can reverse in one step
  • Choose between rollback and roll forward
  • Keep immutable, identified artifacts

Two ways to fix production

ApproachWhat you doWhen it is right
RollbackReturn to the previous known-good versionSomething is broken and you need service restored now
Roll forwardShip a fix for the current versionThe cause is understood and the fix is small and quick
In business terms

In an incident, restoring service beats finding the cause. Roll back first, diagnose afterwards — with the broken version still available to inspect. Confusing those two activities is what turns a ten-minute outage into a two-hour one.

Making rollback possible

  • Every release has a unique, recorded identifier (commit SHA, build number, content hash).
  • The previous artifact is retained and reachable — not overwritten.
  • The deploy command can target any previous version.
  • You have done a rollback drill, not just planned one.
# record what is deployed, so rollback is a known target
echo "$GIT_SHA" > public/VERSION

# roll back = deploy the previous recorded version
./deploy.sh --version "$PREVIOUS_SHA"

The drill

  1. Deploy a known change.
  2. Deliberately roll back to the prior version.
  3. Confirm the site serves the earlier content.
  4. Record how long it took.

A rollback you have never performed is a plan, not a capability. The first time you try it should not be during an outage.

Careful. A rollback that depends on someone's laptop having the right files is not a rollback. The previous version must live in the platform, reachable by anyone on call.
Tip. Keep the last few releases addressable (by SHA or ID). Disk and storage are cheap; being unable to retreat is expensive.
Do this

For one of your own sites, record the current version identifier, make a small visible change, deploy it, then return to the recorded previous version. Time both, and note what you would need if you had to do it under pressure.

Check yourself

During a production incident caused by a bad deploy, what is the first priority?

L5.6

Level 5 lab: a full pipeline for your own site

In plain English

Take one of your real sites from manual deploy to a tested, verified, reversible pipeline.

By the end you can
  • Build CI checks that match your actual risks
  • Automate deployment behind a gate
  • Verify the live result and document the rollback

The brief

Pick one site you own. Give it a pipeline that: checks the content for secrets and validity, deploys only when the checks pass, verifies the live URL, and records a version identifier you can roll back to.

The workflow

name: pipeline

on:
  push:
    branches: [main]
  workflow_dispatch:            # allow a manual run when needed

jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: No secrets in the tree
        run: |
          ! grep -rIE "(api[_-]?key|secret|password)\s*[:=]\s*['\"][A-Za-z0-9_-]{16,}" . \
            --exclude-dir=.git

      - name: Check required files exist
        run: |
          test -f public/index.html || { echo "index.html missing"; exit 1; }

      - name: Record the version
        run: echo "${GITHUB_SHA:0:10}" > public/VERSION

  deploy:
    needs: verify
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4

      - name: Deploy
        run: npx wrangler pages deploy ./public --project-name my-project
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}

      - name: Verify live
        run: |
          sleep 10
          url="https://my-site.example.com/?cb=$RANDOM"    # cache-buster
          code=$(curl -s -o /dev/null -w "%{http_code}" "$url")
          echo "status=$code"
          test "$code" = "200"
          # confirm the version we shipped is the version being served
          curl -s "https://my-site.example.com/VERSION?cb=$RANDOM"

Your completion evidence

  1. A pipeline that fails when you deliberately break a check.
  2. A green run that deployed and verified the live site.
  3. A recorded version identifier you can return to.
  4. A timed rollback to the previous version, completed successfully.
In business terms

When this is done, releasing stops being an event you brace for. Push, watch it verify itself, and know you can undo it. That is the entire point of this level.

Careful. Do not wire a production deployment token into a workflow before the verify job is proven. A pipeline that can deploy broken code automatically is a liability, not an improvement.
Do this

Complete the four evidence items above on a site you own. Then deliberately introduce a failing check, confirm the pipeline blocks the deploy, and fix it — proving the gate actually protects production.

Check yourself

What makes a pipeline safe enough to deploy automatically?

Level 6 · 6 lessons

Cloud, Containers & Operating at Scale

Run real infrastructure, watch it properly, and keep it secure.

Why this level matters

You now own automation that runs on a real server, serving real users. This level is what makes it dependable: containers for consistency, cloud services for delivery, monitoring so you hear about problems first, and security by design rather than afterthought.

6 lessons in this level
L6.1

The cloud mental model (and cost control)

In plain English

The cloud rents you computers, storage and services by the second. The discipline is knowing what you are being charged for.

By the end you can
  • Distinguish IaaS, PaaS, serverless and storage
  • Map a real workload to the right service tier
  • Establish cost guards before you scale

The service tiers

TierYou manageExample
IaaSOS, runtime, appA rented VPS
PaaSJust the appCloudflare Pages, Heroku
ServerlessJust the functionCloudflare Workers, AWS Lambda
Storage/CDNJust the filesR2, S3, Cloudflare CDN
In business terms

The higher the tier, the less you administer and the more you pay per unit of work. For small loads, serverless is close to free. For heavy constant load, a rented machine is cheaper. Match the tier to the traffic shape — this single decision drives most of your bill.

Mapping a real workload

  • Static site → Pages/CDN. Effectively free, no server to patch.
  • Occasional API call → Worker/serverless function. Pay per request.
  • Scheduled job every 15 minutes → a small always-on machine or a cron service.
  • Database with modest traffic → managed tier with a free allowance; watch the writes.

Cost guards — set these before you need them

  1. Know every free allowance and when it resets.
  2. Measure usage daily against those allowances, not monthly.
  3. Alert at a threshold well below the limit (for example 70%), not at it.
  4. Never let an unbounded loop, retry storm or bot crawl be able to bill you without limit.
# a minimal usage check, run on a schedule
CURRENT=$(curl -s "$METRICS_URL" | jq -r '.rows_written')
LIMIT=100000
PCT=$(( CURRENT * 100 / LIMIT ))
[ "$PCT" -ge 70 ] && echo "WARNING: ${PCT}% of daily allowance used"
Careful. The most common cloud surprise is an unbounded resource: a function retrying forever, a log growing without rotation, a scanner generating traffic. Bounded by design is the only safe design.
Tip. Meter the things you already run before adding new services. Knowing your current headroom prevents both overspend and premature upgrades.
Do this

List every cloud service you pay for or use, with its free allowance and current usage. Identify the one closest to its limit and write down the single event that could push it over.

Check yourself

Which service tier suits an occasionally-called API?

L6.2

Docker: consistent environments

In plain English

A container packages your application with everything it needs, so it runs identically on your laptop and in production.

By the end you can
  • Explain images vs containers
  • Write a Dockerfile and build an image
  • Apply basic container security practices

Image vs container

  • Image — a read-only template: your code plus its dependencies.
  • Container — a running instance of an image. Start many from one image.
In business terms

The container ends the 'works on my machine' problem permanently, because the machine travels with the code. It also makes rollback trivial: the previous image is already built and tested.

A Dockerfile

FROM python:3.12-slim

# non-root user: a contained compromise
RUN useradd --create-home --uid 10001 appuser

WORKDIR /app

# dependencies first, so code changes do not invalidate the cache
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY --chown=appuser:appuser . .

USER appuser

EXPOSE 8000

HEALTHCHECK --interval=30s --timeout=3s \
  CMD python -c "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8000/health')"

CMD ["python", "-m", "app"]

Building and running

docker build -t myapp:1.0 .
docker run -d --name myapp -p 8000:8000 myapp:1.0
docker logs -f myapp
docker ps                       # what is running
docker exec -it myapp sh        # get a shell inside
docker stop myapp && docker rm myapp

Compose: several containers together

services:
  web:
    build: .
    ports: ["8000:8000"]
    environment:
      DB_HOST: db
    depends_on: [db]

  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    volumes:
      - dbdata:/var/lib/postgresql/data

volumes:
  dbdata:
docker compose up -d
docker compose logs -f
docker compose down

Security basics that are not optional

  • Run as a non-root user inside the container
  • Pin base image versions (python:3.12-slim, never python:latest)
  • Never bake secrets into an image — pass them at run time
  • Scan images for known vulnerabilities
  • Keep images small — fewer packages means less to exploit
Careful. A container is not a security boundary by itself. Running as root inside a container is nearly as dangerous as running as root on the host. Always set USER.
Tip. Order Dockerfile layers from least to most frequently changed. Dependency installation before copying source means rebuilds take seconds, not minutes.
Do this

Write a Dockerfile for a small Python script, build it, run it, and check the logs. Then exec into the container and run whoami — confirm it is NOT root. Finally inspect the image size and try to reduce it by using a smaller base.

Check yourself

What is the difference between an image and a container?

L6.3

Monitoring and alerting that people trust

In plain English

Monitoring is knowing what is normal. Alerting is being told, usefully, when it is not.

By the end you can
  • Distinguish metrics, logs and traces
  • Write an alert that is actionable
  • Avoid alert fatigue

The three signals

SignalAnswersExample
MetricsHow much / how many, over timeCPU 40%, 120 requests/min
LogsWhat exactly happenedConfig error at line 42
TracesWhere time went across services12ms in auth, 800ms in the database

Start with metrics for detection and logs for diagnosis. Traces become valuable once you have several services calling each other.

What makes an alert good

  1. It signals something that actually needs a human.
  2. It names the affected thing specifically.
  3. It includes the evidence — the measurement and the threshold.
  4. It suggests the first action.
  5. It is not a duplicate of an alert already firing.
BAD:  "Something is wrong"
GOOD: "API writes at 78% of the daily quota (78,412/100,000).
       Projection: quota reached ~16:40 UTC. First action: check the sync
       job for a retry loop; disable bulk sync to reduce writes."
In business terms

An alert nobody acts on is worse than no alert, because it teaches the team to ignore the channel. Every alert must either demand an action today or be deleted.

Alert on symptoms, not causes

  • Good: the site is not serving (a symptom users feel).
  • Weaker: disk usage is 80% (a cause that may be harmless).
  • Why: cause-based alerts fire without user impact; symptom-based alerts are always relevant.

Alert fatigue, and how it kills systems

If the channel produces noise, people mute it. Then a real alert arrives into a muted channel and is missed. Every noisy alert permanently degrades the value of every future one.

# A check that only alerts on the condition worth waking someone for
usage_pct=$(curl -s "$URL" | jq -r '.pct')
if [ "$usage_pct" -ge 70 ]; then
  notify "usage ${usage_pct}% of daily allowance — action needed"
fi

Watching for absence

The hardest failure to detect is a job that stopped running — because nothing fails, nothing is produced, and silence looks like health. Monitor for expected output within an expected window.

Careful. Never let a check that cannot read its own data raise alarm. An unreadable meter is an unknown, not a breach — handle it as a separate, clearly labelled condition.
Tip. Test your alerts deliberately. Force the condition, confirm the notification arrives, then confirm it clears. An untested alert is an assumption.
Do this

Write one alert for something you own that states the measurement, the threshold, the impact and the first action. Then deliberately trigger it and confirm the message is comprehensible without any other context.

Check yourself

Why is an alert that fires often but requires no action harmful?

L6.4

Security by design

In plain English

Security is a set of habits applied while building, not a review done at the end. Least privilege, defence in depth, and rotating what leaks.

By the end you can
  • Apply least privilege to every credential
  • Defend in depth rather than relying on one wall
  • Handle an exposure correctly and quickly

Least privilege, concretely

BadGood
Account-wide admin tokenToken scoped to one resource type
No expiryShort-lived, rotated
Usable from anywhereIP-restricted where practical
One token for everythingSeparate tokens per job

A deployment job needs to write files. It does not need to read your database or manage DNS. Scope accordingly — the blast radius of a leak becomes small.

In business terms

Least privilege is the same principle as financial controls: nobody gets signing authority for everything. When something does go wrong, it goes wrong in a contained way.

Defence in depth

  • Encrypt in transit (TLS) and at rest where it matters
  • Authenticate every endpoint, even internal ones
  • Restrict by IP or network where possible
  • Validate all input from outside your system
  • Log access so you can investigate afterwards
  • Keep a service's permissions separate from a human's
# Validate and bound input: never trust what arrives
MAX_LEN = 4096

def handle(payload: dict):
    text = payload.get("text")
    if not isinstance(text, str) or len(text) > MAX_LEN:
        raise ValueError("invalid or oversized input")
    # never interpolate untrusted input into a shell command
    return run_with_argv(["tool", "--input", text])

Command injection — the classic failure

# DANGEROUS: a crafted filename runs arbitrary commands
os.system(f"tar -czf backup.tar.gz {filename}")

# SAFE: an argument list, no shell
subprocess.run(["tar", "-czf", "backup.tar.gz", filename], check=True)

When something is exposed

  1. Rotate the credential immediately — assume it is compromised.
  2. Check its access logs for use you do not recognise.
  3. Replace it everywhere it was referenced.
  4. Reduce its scope so the next one matters less.
  5. Record what happened, so the same mistake is not repeated.
Careful. A secret pasted into a chat, ticket, screenshot or log is compromised. There is no private channel. Detection is not required before rotating — the safe assumption is that it is already gone.
Tip. Separate credentials per job, so revoking one does not break everything, and a leak tells you which system was the source.
Do this

Audit your own credentials: list each one, what it can access, and whether it expires. Identify the most powerful one and reduce its scope or replace it with a narrower alternative.

Check yourself

You discover an API token was pasted into a public chat. What do you do first?

L6.5

Reliability: backups, drills and runbooks

In plain English

Dependability comes from practiced recovery, not from hoping nothing breaks.

By the end you can
  • Specify recovery objectives
  • Test a restore rather than trusting a backup
  • Write a runbook someone else could follow

Two numbers to know

  • RTO — Recovery Time Objective: how long you can be down.
  • RPO — Recovery Point Objective: how much data you can afford to lose.

These decide your design. Losing an hour of data may be fine for a news feed and unacceptable for payments. Without these numbers, backup work has no target to hit.

In business terms

Ask the business question first: 'if this were gone for four hours, what would it cost?' The answer tells you how much engineering the system deserves — and stops you over-building or under-protecting.

Untested backup is not backup

# A restore drill: prove the archive is usable, not just present
LATEST=$(ls -1t /home/ubuntu/backups/*.tar.gz | head -1)
echo "testing $LATEST"

tar -tzf "$LATEST" > /tmp/restore-list.txt     # 1. is it readable?
wc -l /tmp/restore-list.txt                     # 2. does it contain files?

tar -xzf "$LATEST" -C /tmp/restore-test         # 3. extract somewhere safe
diff -r /home/ubuntu/notes /tmp/restore-test/notes  # 4. does it match?

The 3-2-1 rule

  1. 3 copies of anything important.
  2. 2 different media or providers.
  3. 1 copy off-site, so a single disaster cannot take all of them.

Writing a runbook

## Service outage — app-gateway

**Symptom**: no responses, gateway inactive.

**Check**:
1. `systemctl --user status app-gateway`
2. `journalctl --user -u app-gateway -n 50`
3. Look for a config error or a port already in use.

**Restore**:
1. `systemctl --user restart app-gateway`
2. Confirm active and listening: `ss -ltnp | grep 8081`
3. Send a test message and confirm a reply.

**If that fails**: restore `/etc/app/config.yaml` from the newest backup and restart.

**Escalate**: if unresolved in 15 minutes, notify the owner — no silent waiting.

A runbook is written for someone stressed, at night, who did not build the system. Numbered steps, exact commands, and a defined point to escalate.

Careful. A runbook nobody has followed is a document, not a capability. Have someone else work through it once, and fix every place they get stuck.
Tip. Record a rollback drill time — 'we can restore in 4 minutes' is a valuable, provable fact. 'We have backups' is a hope.
Do this

Write a runbook for the single most important service you run. Then have someone else (or yourself, deliberately following only the written text) execute it during a planned restart, and correct any step that was unclear.

Check yourself

What is the first test of a backup?

L6.6

Capstone: design, build, run and document

In plain English

Bring everything together: design a system, automate its setup and delivery, monitor it, secure it, and document its recovery.

By the end you can
  • Combine all six levels into one working system
  • Prove each layer with independent evidence
  • Hand it over with documentation someone else can follow

The brief

Choose a real outcome you want for yourself or your business — a site, a report, a monitored service. Build it so that it configures itself, ships itself, watches itself and can be recovered by someone else.

What the finished system must have

LayerRequirementLevel
Version controlIn Git, with meaningful history1
AutomationScripted, idempotent, scheduled2, 4
ApplicationTyped results, explicit unknowns, tested3
ConfigurationAnsible roles, no manual steps4
DeliveryPipeline with checks, verify step, rollback5
OperationMetriced, alerted, secured, backed up6

The evidence you must produce

  1. A clean run from scratch that produces the working system.
  2. A second run proving idempotence (changed = 0).
  3. A failing check that blocks a bad release.
  4. An alert that fires and then clears when the condition passes.
  5. A restore from backup into a fresh location.
  6. A timed rollback to a previous version.
  7. A runbook another person followed successfully.
In business terms

This list is the difference between 'it works' and 'it is dependable'. Every item is proof produced by the system itself, not by your confidence in it. That standard is what expert means in this field.

The habits to carry forward

  • Automate only what you understand; then read the logs.
  • Make everything idempotent, so re-running is always safe.
  • Never let a failure be silent — unknown must be visible.
  • Least privilege for every credential, rotated on schedule.
  • Verify from outside the tool that configured or deployed it.
  • Test the recovery path, not just the happy path.
  • Write for the tired person at 3am, because that person is you.
Careful. Do not attempt the capstone across many systems at once. One real system, finished and proven end to end, teaches more than five half-built ones — and is far more likely to keep running.
Tip. Keep a docs/ folder in the repository with the runbook, the architecture and the rollback steps. Documentation that travels with the code is documentation that survives.
Do this

Build the capstone system against one real outcome. Produce all seven pieces of evidence. Then hand the runbook to someone else and watch, silently, while they follow it — every hesitation is a gap worth fixing.

Check yourself

What ultimately distinguishes an expert from a competent practitioner?