Fifty-five lessons. srv-tramontana is installed, hardened, monitorable, backed up, automated, encrypted in transit and running a database that no longer uses the factory configuration. You know how to rebuild it in fifty minutes, recover the database to a specific second, diagnose a latency problem with eBPF and decide with numbers whether high availability is needed.
And even so something is missing, because "it works" and "it is in production" are not the same thing. A production system is one that other people depend on without knowing it exists: it has an owner, an agreed service objective, somebody who looks at it every morning, alerts that get attended to, a procedure for when it breaks and a record of why it is the way it is. None of that is software; all of it is operations.
This lesson closes the course by putting that layer in place. The complete checklist with its evidence, the monitoring with Prometheus and Grafana that has been postponed since 05-07, alerts you can attend to without hating them, the daily routine, the blameless post-incident review, and the error budget that turns the 99.8 % from 07-07 into an executable decision. And the three debts still open since Module 5, which are closed today.
Contents
- What being in production means
- The going-to-production checklist
- Monitoring and alerting: they are not the same thing
- Prometheus and node_exporter
- The essential PromQL
- Metrics of your own: the ones you already have without knowing it
- Grafana and the minimum dashboard
- Alerts you can act on
- Centralised logs: the debt from 05-06
- Daily, weekly and monthly operations
- The duty log and the change log
- The blameless post-incident review
- Change management and deployment windows
- SLIs, SLOs and the error budget
- Closing the outstanding debts
- Compliance: GDPR and data retention
- Closing the course
What being in production means
| "It works on my machine" | In production | |
|---|---|---|
| Who depends on it | You | People who do not know it exists |
| When it has to work | When you look at it | Always |
| If it breaks at 3 a.m. | It gets fixed tomorrow | Somebody finds out and acts |
| Configuration | In your head | In code, versioned |
| Changes | Whenever you feel like it | With a window and approval |
| Data | Reproducible | Irreplaceable |
| Availability objective | None | Agreed and measured |
| When somebody asks "is it all right?" | "I think so" | A number |
The last row is the one that sums the lesson up. The difference between an amateur system and a professional one is not the technology: it is that when somebody asks "is it all right?" there is a figure, not an impression.
And there is a practical test, more useful than any definition:
Could you go away for two weeks' holiday without a computer?
To answer yes you need six things: that the system heals itself for the anticipated failures, that somebody receives the alerts, that that somebody has written procedures they can follow without being you, that the backups verify themselves, that there is an escalation path, and that a record exists of what has been changed and why. This lesson builds all six.
The going-to-production checklist
Every row carries verifiable evidence: a command that returns a result, not a box ticked in good conscience. Without evidence, a checklist is a list of good intentions.
Infrastructure
| # | Requirement | Evidence | Status |
|---|---|---|---|
| 1 | An operating system with long-term support | lsb_release -d → Ubuntu 24.04 LTS |
✅ 01-04 |
| 2 | Reproducible configuration in code | ansible-playbook --check --diff with no changes |
✅ 07-06 |
| 3 | A measured full rebuild | Record of the last rehearsal: 50 min | ✅ 07-06 |
| 4 | Services managed by systemd, not loose scripts | systemctl list-units --failed empty |
✅ 05-05 |
| 5 | Boot verified after a restart | systemd-analyze critical-chain with no failures |
✅ 07-01 |
| 6 | Resources with measured headroom | health_check.sh → 0 |
✅ 05-07 |
| 7 | An equivalent test environment | srv-tramontana-test operational |
✅ 07-04 |
Security
| # | Requirement | Evidence | Status |
|---|---|---|---|
| 8 | An allowlist firewall | ufw status verbose |
✅ 06-03 |
| 9 | SSH without passwords, without root, with fail2ban |
sshd -T | grep -E 'permitroot|passwordauth' |
✅ 06-02 |
| 10 | Encryption in transit active | curl -sI https://... | grep strict-transport |
✅ 08-01 |
| 11 | A valid certificate renewing itself | certbot certificates; check_certificate.sh |
✅ 06-05 |
| 12 | Secrets outside the code and encrypted | pass ls; systemd-creds list |
✅ 06-05 |
| 13 | The service running with least privilege | systemd-analyze security tramontana → 1.6 |
✅ 05-05 |
| 14 | Mandatory confinement active | aa-status | grep tramontana (enforce) |
✅ 06-06 |
| 15 | Detection of file changes | aide --check; the DB outside the server |
✅ 06-04 |
| 16 | Auditing of sensitive events | auditctl -l with rules loaded |
✅ 06-04 |
| 17 | Security updates up to date | apt list --upgradable | grep -c security → 0 |
✅ 06-06 |
| 18 | A review of external exposure | Lynis index: 82 | ✅ 06-06 |
| 19 | Remote access without exposing services | wg show with active peers |
✅ 08-04 |
Data
| # | Requirement | Evidence | Status |
|---|---|---|---|
| 20 | RPO and RTO agreed in writing | RPO 15 min, RTO 2 h | ✅ 07-06, 08-02 |
| 21 | Automatic, verified backups | check_backup.sh → 0; restic check |
✅ 05-08 |
| 22 | A copy off the server (the 3-2-1 rule) | restic snapshots in a remote repository |
✅ 05-08 |
| 23 | An immutable off-site copy (append-only) |
An append-only token | ⚠️ Closed today |
| 24 | A rehearsed restore with a measured time | Runbook RB-DB-02: 24 min | ✅ 08-02 |
| 25 | Point-in-time recovery | WAL archiving: failed_count = 0 |
✅ 08-02 |
| 26 | Encryption at rest for the backup | LUKS + restic encryption |
✅ 05-04, 06-05 |
| 27 | A retention policy in line with GDPR | Defined and applied | ⚠️ Pending: audit |
Observability
| # | Requirement | Evidence | Status |
|---|---|---|---|
| 28 | Persistent, rotated logs | journalctl --disk-usage; logrotate -d |
✅ 05-06 |
| 29 | Continuous monitoring with history | up{job="tramontana"} = 1 |
⚠️ Closed today |
| 30 | Alerts that reach a person | The Alertmanager route tested | ⚠️ Closed today |
| 31 | A dashboard with the four golden signals | Grafana operational | ⚠️ Closed today |
| 32 | A meaningful health endpoint | curl -s /health | jq .status |
✅ 07-07 |
Operations
| # | Requirement | Evidence | Status |
|---|---|---|---|
| 33 | A formal availability objective | An SLO agreed with Marta | ⚠️ Closed today |
| 34 | Runbooks outside the server | The filing cabinet + the repository | ✅ 05-08, 08-02 |
| 35 | A daily routine defined and carried out | The table in section 10 | ⚠️ Closed today |
| 36 | A change log | The git log of ~/tramontana-infra |
✅ 07-06 |
| 37 | A reversible, tested deployment | deploy.sh with rollback |
✅ 04-07 |
| 38 | Escalation defined: who and when | Section 8 | ⚠️ Closed today |
Documentation and compliance
| # | Requirement | Evidence | Status |
|---|---|---|---|
| 39 | An inventory of systems and owners | docs/inventory.md |
⚠️ Pending |
| 40 | Every access justified | authorized_keys reviewed |
⚠️ authorized_keys2: today |
| 41 | A record of processing activities | A GDPR document | ⚠️ Pending |
| 42 | An up-to-date architecture diagram | docs/architecture.md |
✅ 07-07 |
Summary: 30 rows of 42 satisfied. Nine are closed in this lesson; three remain as planned work with a date. That figure is in itself evidence number 43: knowing exactly what is missing.
Monitoring and alerting: they are not the same thing
It is the distinction that avoids the most expensive mistake in this area.
| Monitoring | Alerting | |
|---|---|---|
| The question it answers | "What is happening and what happened?" | "Does somebody have to do something now?" |
| When it is consulted | When somebody looks | It comes looking for you |
| Appropriate volume | Everything that can be measured | Very little |
| The cost of too much | Disk | The important ones get ignored |
| Tool | Prometheus, Grafana | Alertmanager |
Monitor everything you can; alert on almost nothing. It is counter-intuitive and it is the only way for alerts to be of any use. A system that sends forty notifications a day is a system without alerts, because nobody reads them.
The four golden signals
From 05-07, now with real instrumentation:
| Signal | What it measures | At Tramontana |
|---|---|---|
| Latency | How long a request takes | The $upstream_response_time of 08-01 |
| Traffic | How much demand there is | Requests per second |
| Errors | What proportion fails | 5xx responses |
| Saturation | How full the system is | CPU, memory, PgBouncer connections |
Two nuances that make the difference between measuring and measuring well:
Latency is measured in percentiles, never as an average. An average of 80 ms can hide the fact that 1 % of users wait 4 seconds. And you have to separate the latency of successful requests from that of failed ones: a 500 error returned in 2 ms improves the average and makes the service worse.
The latency of errors is excluded from the SLI. Otherwise a total outage — where everything fails quickly — would show up as a performance improvement.
And the USE method, complementary, for each resource: Utilisation, Saturation and Errors.
| Golden signals | The USE method | |
|---|---|---|
| Point of view | The user's | The resources' |
| It answers | "Is the service all right?" | "Which resource is the bottleneck?" |
| When it is used | Alerting | Diagnosing |
Prometheus and node_exporter
Prometheus is a time-series database that scrapes metrics: instead of services sending it data, it asks HTTP endpoints for them periodically.
| Scraping (Prometheus) | Pushing (StatsD, Graphite) | |
|---|---|---|
| Who initiates it | The server | The client |
| Detecting that a service has died | Trivial: up == 0 |
Hard: an absence of data |
| Configuration | Centralised | On every client |
| Ephemeral targets | Needs discovery | Natural |
That second row is an enormous advantage: with scraping, a service that is down immediately produces up == 0, which is an explicit signal.
$ sudo apt install prometheus prometheus-node-exporter
$ prometheus --version
prometheus, version 2.48.1 (branch: HEAD)The package's units are reasonable but they do not meet the course's standard. Drop-ins, as always:
# /etc/systemd/system/prometheus-node-exporter.service.d/override.conf
[Service]
# Listen ONLY on localhost: Prometheus runs on the same machine.
# Without this, anybody on 10.0.2.0/24 reads metrics that reveal the
# kernel version, the file systems and the processes.
ExecStart=
ExecStart=/usr/bin/prometheus-node-exporter \
--web.listen-address=127.0.0.1:9100 \
--collector.systemd \
--collector.textfile.directory=/var/lib/node_exporter/textfile \
--no-collector.wifi --no-collector.hwmon --no-collector.infiniband
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
NoNewPrivileges=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources
CapabilityBoundingSet=
MemoryMax=256M
CPUQuota=20%$ sudo mkdir -p /var/lib/node_exporter/textfile
$ sudo chown prometheus:prometheus /var/lib/node_exporter/textfile
$ sudo systemctl daemon-reload && sudo systemctl restart prometheus-node-exporter
$ systemd-analyze security prometheus-node-exporter
→ Overall exposure level: 2.1 OK# /etc/prometheus/prometheus.yml
global:
scrape_interval: 15s # how often the metrics are requested
evaluation_interval: 15s # how often the rules are evaluated
external_labels:
environment: production
server: srv-tramontana
rule_files:
- /etc/prometheus/rules/*.yml
alerting:
alertmanagers:
- static_configs:
- targets: ['127.0.0.1:9093']
scrape_configs:
# Prometheus itself: if it fails, you have to know
- job_name: prometheus
static_configs:
- targets: ['127.0.0.1:9090']
# System metrics: CPU, memory, disk, network, systemd
- job_name: node
static_configs:
- targets: ['127.0.0.1:9100']
labels: {instance: srv-tramontana}
# The application (it requires Luis to expose /metrics)
- job_name: tramontana
metrics_path: /metrics
static_configs:
- targets: ['127.0.0.1:8080']
# Discard high-cardinality metrics: a label with the booking ID
# would create one series per booking and blow up the memory.
metric_relabel_configs:
- source_labels: [__name__]
regex: 'tramontana_booking_detail.*'
action: drop
- job_name: nginx
static_configs:
- targets: ['127.0.0.1:9113']
- job_name: postgres
static_configs:
- targets: ['127.0.0.1:9187']$ sudo promtool check config /etc/prometheus/prometheus.yml
Checking /etc/prometheus/prometheus.yml
SUCCESS: 1 rule files found
$ sudo systemctl reload prometheus
$ curl -s 'http://127.0.0.1:9090/api/v1/targets' | \
jq -r '.data.activeTargets[] | "\(.labels.job)\t\(.health)"'
prometheus up
node up
tramontana up
nginx up
postgres uppromtool check config is Prometheus' nginx -t, and it is used the same way: always before reloading.
About retention and disk, which is the question that always comes up:
$ sudo du -sh /var/lib/prometheus/metrics2
412M /var/lib/prometheus/metrics2
# Estimate: bytes ≈ series x (time / interval) x ~2 bytes per sample
# 4,000 series x (30 days / 15 s) x 2 B ≈ 1.4 GB# /etc/default/prometheus
ARGS="--storage.tsdb.retention.time=30d --storage.tsdb.retention.size=4GB \
--web.listen-address=127.0.0.1:9090"Thirty days is enough to investigate incidents and see monthly trends. For years of history there are Thanos or Mimir, and for Tramontana they are unnecessary.
The essential PromQL
PromQL is intimidating at first and it boils down to five patterns that cover 90 % of the cases.
1. up: the most important and the simplest.
2. rate() over counters. A counter only goes up. Its absolute value says nothing; what matters is the rate at which it grows.
rate() handles restarts correctly: if the counter goes back to zero because the process restarted, it detects it and does not produce an absurd negative value.
Rule:
rate()only over counters (_total). For gauges — memory, temperature, connections — the value is used directly.
3. Aggregations and ratios.
# The proportion of 5xx errors over the total: the ERROR SIGNAL
sum(rate(nginx_http_requests_total{status=~"5.."}[5m]))
/
sum(rate(nginx_http_requests_total[5m]))
# CPU usage: 'idle' is what is left over, so 1 minus that
1 - avg(rate(node_cpu_seconds_total{mode="idle"}[5m]))
# Percentage of memory available
node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes * 1004. histogram_quantile(): the latency percentiles.
# p95 latency, in seconds
histogram_quantile(0.95,
sum(rate(tramontana_request_duration_seconds_bucket[5m])) by (le))The le label (less or equal) defines the histogram's buckets and must be kept in the by. It is the most common PromQL mistake: aggregating without by (le) and getting meaningless results.
5. Prediction, which is what makes an alert useful.
# At this rate, will the disk fill up in the next 4 hours?
predict_linear(node_filesystem_avail_bytes{mountpoint="/"}[6h], 4*3600) < 0Alerting on "the disk is 90 % full" is alerting too late or alerting for nothing — a disk sitting stable at 91 % is not a problem. Alerting on "at this rate it fills up in four hours" is actionable, which is the property that defines a good alert.
Metrics of your own: the ones you already have without knowing it
node_exporter and friends give you technical metrics. The ones that really matter are those that answer business questions, and most of them you are already calculating in the course's scripts — they just have to be exposed.
The mechanism is the textfile collector: any script leaves a .prom file in a directory and node_exporter publishes it.
#!/usr/bin/env bash
#
# tramontana_metrics.sh - Exposes our own metrics to Prometheus
#
# It collects what the course's scripts already calculate and publishes
# it in Prometheus exposition format. It runs every 5 min.
#
set -euo pipefail
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/lib/common.sh"
readonly OUTPUT=/var/lib/node_exporter/textfile/tramontana.prom
readonly DOMAIN=bookings.tramontana.example
umask 022 # node_exporter has to be able to READ it
main() {
# ATOMIC write: if the script dies halfway through, node_exporter
# would read a truncated file and discard every metric in it.
local tmp; tmp="$(mktemp "${OUTPUT}.XXXXXX")"
trap 'rm -f "$tmp"' EXIT
{
# --- 1. Age of the last successful backup (05-08) ---
# The most useful backup metric is NOT "did the timer run?"
# but "how long ago was there a VERIFIED backup?".
echo '# HELP tramontana_backup_age_seconds Seconds since the last verified backup'
echo '# TYPE tramontana_backup_age_seconds gauge'
if [[ -f /var/lib/tramontana/last-successful-backup ]]; then
printf 'tramontana_backup_age_seconds %d\n' \
$(( $(date +%s) - $(stat -c %Y /var/lib/tramontana/last-successful-backup) ))
else
printf 'tramontana_backup_age_seconds %d\n' 999999
fi
# --- 2. Days until the certificate expires (06-05) ---
echo '# HELP tramontana_certificate_days_left Days until the TLS certificate expires'
echo '# TYPE tramontana_certificate_days_left gauge'
local end days
if end="$(echo | timeout 10 openssl s_client -connect "${DOMAIN}:443" \
-servername "$DOMAIN" 2>/dev/null | \
openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)"; then
days=$(( ( $(date -d "$end" +%s) - $(date +%s) ) / 86400 ))
printf 'tramontana_certificate_days_left %d\n' "$days"
fi
# --- 3. The Lynis index (06-06) ---
echo '# HELP tramontana_lynis_index The Lynis hardening index'
echo '# TYPE tramontana_lynis_index gauge'
if [[ -f /var/log/lynis-report.dat ]]; then
printf 'tramontana_lynis_index %s\n' \
"$(awk -F= '/^hardening_index=/{print $2}' /var/log/lynis-report.dat)"
fi
# --- 4. PostgreSQL replica lag (08-02) ---
# How many bytes would be lost if the primary went down NOW.
echo '# HELP tramontana_replica_lag_bytes The replica lag in bytes'
echo '# TYPE tramontana_replica_lag_bytes gauge'
printf 'tramontana_replica_lag_bytes %s\n' \
"$(sudo -u postgres psql -tAc \
"SELECT coalesce(max(pg_wal_lsn_diff(sent_lsn,replay_lsn)),0)::bigint
FROM pg_stat_replication" 2>/dev/null || echo 0)"
# --- 5. WAL archiving failures (08-02) ---
echo '# HELP tramontana_wal_archive_failures Cumulative WAL archiving failures'
echo '# TYPE tramontana_wal_archive_failures gauge'
printf 'tramontana_wal_archive_failures %s\n' \
"$(sudo -u postgres psql -tAc \
"SELECT failed_count FROM pg_stat_archiver" 2>/dev/null || echo 0)"
# --- 6. The status of health_check.sh (0/1/2) ---
echo '# HELP tramontana_health_status 0 OK, 1 warning, 2 critical'
echo '# TYPE tramontana_health_status gauge'
local status=0
"${SCRIPT_DIR}/health_check.sh" >/dev/null 2>&1 || status=$?
printf 'tramontana_health_status %d\n' "$status"
# --- 7. BUSINESS metrics: the ones Marta cares about ---
echo '# HELP tramontana_bookings_total Bookings recorded today'
echo '# TYPE tramontana_bookings_total gauge'
printf 'tramontana_bookings_total %s\n' \
"$(sudo -u postgres psql -tAc \
"SELECT count(*) FROM app.bookings WHERE date = CURRENT_DATE" \
-d tramontana 2>/dev/null || echo 0)"
# --- 8. A freshness mark: it detects that THIS script has died ---
echo '# HELP tramontana_metrics_generated_seconds Generation timestamp'
echo '# TYPE tramontana_metrics_generated_seconds gauge'
printf 'tramontana_metrics_generated_seconds %d\n' "$(date +%s)"
} > "$tmp"
chmod 0644 "$tmp"
mv "$tmp" "$OUTPUT" # atomic
trap - EXIT
}
main "$@"$ sudo ~/scripts/tramontana_metrics.sh
$ curl -s http://127.0.0.1:9100/metrics | grep '^tramontana_'
tramontana_backup_age_seconds 21840
tramontana_certificate_days_left 71
tramontana_lynis_index 82
tramontana_replica_lag_bytes 0
tramontana_wal_archive_failures 0
tramontana_health_status 0
tramontana_bookings_total 14
tramontana_metrics_generated_seconds 1755518402Four design decisions in that script:
- The atomic write with
mktempandmv.node_exportercan read the file at any moment; a truncated one makes it discard the whole contents. umask 022, against the course's general convention:node_exporterruns as a different user and needs to read it. It is a justified exception, and that is why it is commented.- Metric 8, the freshness mark, is what makes the other seven trustworthy. Without it, if this script stops running, Prometheus would carry on publishing the last known values indefinitely and everything would look fine for ever. With it, you can alert that the metrics have gone stale.
- The business metric,
tramontana_bookings_total, is the one Marta cares about and the one that detects the most dangerous failure: the one that breaks nothing. If the system answers 200 to everything but nobody manages to book, no technical metric will reveal it.
And for the application, what Luis has to expose on /metrics:
# HELP tramontana_requests_total HTTP requests served
# TYPE tramontana_requests_total counter
tramontana_requests_total{method="GET",path="/houses",code="200"} 41822
# HELP tramontana_request_duration_seconds Request duration
# TYPE tramontana_request_duration_seconds histogram
tramontana_request_duration_seconds_bucket{le="0.05"} 38120
tramontana_request_duration_seconds_bucket{le="0.1"} 40911
tramontana_request_duration_seconds_bucket{le="0.5"} 41780
tramontana_request_duration_seconds_bucket{le="+Inf"} 41822
tramontana_request_duration_seconds_sum 1284.41
tramontana_request_duration_seconds_count 41822
# HELP tramontana_db_active_connections Database connections in use
# TYPE tramontana_db_active_connections gauge
tramontana_db_active_connections 12Grafana and the minimum dashboard
$ sudo install -m 0755 -d /etc/apt/keyrings
$ curl -fsSL https://apt.grafana.com/gpg.key | \
sudo gpg --dearmor -o /etc/apt/keyrings/grafana.gpg
$ echo "deb [signed-by=/etc/apt/keyrings/grafana.gpg] https://apt.grafana.com stable main" | \
sudo tee /etc/apt/sources.list.d/grafana.list
$ sudo apt update && sudo apt install grafana# /etc/grafana/grafana.ini (fragment)
[server]
http_addr = 127.0.0.1 # NOT exposed: you reach it via Nginx or the VPN
http_port = 3000
root_url = https://panel.tramontana.example/
[security]
admin_user = operator
# The password is injected with systemd-creds (06-05), not here
disable_gravatar = true
cookie_secure = true
cookie_samesite = strict
content_security_policy = true
[users]
allow_sign_up = falseAnd the access, making use of what is already built: over the VPN from 08-04, without publishing anything new.
The minimum dashboard, six panels and not one more:
| Panel | Query | Why |
|---|---|---|
| Availability (30 d) | avg_over_time(up{job="tramontana"}[30d]) * 100 |
The number Marta asks about |
| Latency p50/p95/p99 | histogram_quantile(...) |
Golden signal: latency |
| Requests per second | sum(rate(nginx_http_requests_total[5m])) |
Golden signal: traffic |
| Error ratio | 5xx over the total | Golden signal: errors |
| Saturation | CPU, memory, disk, connections | Golden signal: saturation |
| System status | Backup age, certificate days, replica lag, Lynis | What you look at every morning |
A piece of advice that goes against instinct: a dashboard with forty charts does not get looked at. The main dashboard has to fit on one screen and answer "is it all right?" in three seconds. The detail lives on secondary dashboards you go to when you need them.
Alerts you can act on
The rule
Every alert must require immediate human action. If the recipient cannot do anything, or it can wait until tomorrow, it is not an alert: it is a dashboard or a report.
Alert fatigue is the characteristic failure mode of monitoring, and its progression is always the same:
- Alerts are configured for everything, "just in case".
- Ten a day arrive, almost all of them irrelevant.
- People start ignoring them.
- A mail filter is created that archives them.
- The important alert arrives and nobody sees it.
The system ends up worse than with no alerts at all, because there is a false sense of vigilance. And the remedy is counter-intuitive: delete alerts.
Symptom versus cause
| A symptom alert | A cause alert | |
|---|---|---|
| What it detects | That the service is not working | That a component is failing |
| Example | "The error rate is above 5 %" | "The CPU is at 90 %" |
| False positives | Few | Many: 90 % CPU can be normal |
| Coverage | It detects unforeseen failures | Only the foreseen ones |
| Helps to diagnose | Not much | A great deal |
Rule: alert on symptoms, diagnose with causes. A for: 5m on the error rate detects any failure that affects the user, including the ones nobody anticipated. A 90 % CPU alert fires when the nightly backup runs and the service is working perfectly.
With two legitimate exceptions, which are cause alerts and must exist because their symptom arrives too late:
- The disk is going to fill up. By the time the symptom appears, the service is already down.
- The certificate is going to expire. By the time the symptom appears, the site no longer loads.
Both share the key property: they warn hours or days in advance and the action is obvious.
# /etc/prometheus/rules/tramontana.yml
groups:
- name: symptoms
interval: 30s
rules:
# --- CRITICAL: they wake somebody up in the middle of the night ---
- alert: ServiceDown
expr: up{job="tramontana"} == 0
for: 2m # 2 min avoids the noise of a restart
labels: {severity: critical, team: operations}
annotations:
summary: "Tramontana Bookings is not responding"
description: "Target {{ $labels.instance }} has not responded for 2 min."
action: "See runbook RB-OPS-01. systemctl status tramontana; journalctl -u tramontana -n50"
runbook: "https://docs.tramontana.example/RB-OPS-01"
- alert: HighErrorRate
expr: |
sum(rate(nginx_http_requests_total{status=~"5.."}[5m]))
/ sum(rate(nginx_http_requests_total[5m])) > 0.05
for: 5m
labels: {severity: critical, team: operations}
annotations:
summary: "More than 5 % of requests are failing"
description: "Current rate: {{ $value | humanizePercentage }}."
action: "journalctl -t nginx_error -n 50; check PostgreSQL"
- alert: LatencyDegraded
expr: |
histogram_quantile(0.95,
sum(rate(tramontana_request_duration_seconds_bucket[5m])) by (le)) > 2
for: 10m
labels: {severity: warning, team: operations}
annotations:
summary: "p95 latency above 2 s"
action: "pg_stat_statements by total_exec_time; latency_diagnosis.sh"
- name: causes_that_warn_in_advance
rules:
# --- The two justified exceptions ---
- alert: DiskWillFill
expr: predict_linear(node_filesystem_avail_bytes{mountpoint="/"}[6h], 4*3600) < 0
for: 15m
labels: {severity: critical, team: operations}
annotations:
summary: "/ will fill up in less than 4 hours at the current rate"
action: "du -sh /var/log/* /srv/*; review rotation and purging"
- alert: CertificateExpiringSoon
expr: tramontana_certificate_days_left < 20
for: 1h
labels: {severity: warning, team: operations}
annotations:
summary: "The certificate expires in {{ $value }} days"
action: "certbot renew --dry-run; check certbot.timer"
- name: data
rules:
- alert: BackupTooOld
# Threshold at 2x the agreed RPO: it does not alert on a normal delay
expr: tramontana_backup_age_seconds > 2*4*3600
for: 30m
labels: {severity: critical, team: operations}
annotations:
summary: "No verified backup for {{ $value | humanizeDuration }}"
action: "See runbook RB-DB-01. journalctl -u tramontana-backup"
- alert: WALArchivingFailing
# A deliberate cause alert: the symptom (PostgreSQL stopped)
# appears hours later and by then there is no easy way out.
expr: tramontana_wal_archive_failures > 0
for: 5m
labels: {severity: critical, team: operations}
annotations:
summary: "WAL archiving is failing"
action: "df -h /srv/tramontana/backups; see the incident of 2026-08-18"
- alert: ReplicaLagging
expr: tramontana_replica_lag_bytes > 100*1024*1024
for: 10m
labels: {severity: warning, team: operations}
annotations:
summary: "The replica is {{ $value | humanize1024 }}B behind"
- name: meta_monitoring
rules:
# The alert that watches the monitoring. Without it, a dead
# metrics script makes EVERYTHING look fine for ever.
- alert: StaleMetrics
expr: time() - tramontana_metrics_generated_seconds > 1800
for: 5m
labels: {severity: warning, team: operations}
annotations:
summary: "Our own metrics have not updated for more than 30 min"
action: "systemctl status tramontana-metrics.timer"$ sudo promtool check rules /etc/prometheus/rules/tramontana.yml
SUCCESS: 10 rules found
# Test a rule WITHOUT waiting for it to happen
$ sudo promtool test rules /etc/prometheus/tests/rules_test.yml
Unit Testing: SUCCESSAlertmanager: routes and severities
# /etc/alertmanager/alertmanager.yml
global:
resolve_timeout: 5m
route:
group_by: ['alertname', 'severity']
group_wait: 30s # wait 30 s in case related alerts arrive
group_interval: 5m
repeat_interval: 4h # a reminder every 4 h if it is still active
receiver: ops-email
routes:
# Critical: email and, out of hours, the phone
- matchers: [severity="critical"]
receiver: on-call
group_wait: 10s
repeat_interval: 1h
# Warnings: email only, no hurry
- matchers: [severity="warning"]
receiver: ops-email
repeat_interval: 12h
# Silence derived alerts when the root cause has already alerted
inhibit_rules:
- source_matchers: [alertname="ServiceDown"]
target_matchers: [severity=~"warning|critical"]
equal: [instance]
receivers:
- name: ops-email
email_configs:
- to: [email protected]
headers: {Subject: '[{{ .Status | toUpper }}] {{ .CommonLabels.alertname }}'}
- name: on-call
email_configs:
- to: [email protected]
webhook_configs:
- url: 'http://127.0.0.1:9095/phone'The inhibit_rules deserve attention: when the service goes down, the latency, error and database-connection alerts would fire as well. Receiving five notifications about the same incident is alert fatigue in its purest form. Inhibition sends one: the cause.
Severities and escalation
| Severity | Criterion | Channel | Response |
|---|---|---|---|
| Critical | The service is down or there is a risk of data loss | Phone, 24×7 | Immediate |
| Warning | Degraded, or something will break within hours | Working hours | |
| Informational | Worth knowing | Dashboard | Weekly review |
Level 1: operator (you) -> 15 min without a response Level 2: Luis (development) -> 30 min without a response Level 3: Marta (business decisions) -> always informed on critical ones
And the scheduled silence during maintenance, without which the deployment window generates an avalanche of notifications:
$ amtool silence add alertname=~".*" --duration=1h \
--author=operator --comment="Maintenance window: deploying 3.3.0"
b3f19c8d-4a2e-4f91-b7c2-1e9d0f8a3b45
$ amtool silence expire b3f19c8d-4a2e-4f91-b7c2-1e9d0f8a3b45 # when finishedSilences are always set with an expiry. An indefinite silence set on a Tuesday is an alert that will never sound again, and nobody will remember.
Centralised logs: the debt from 05-06
In 05-06 one piece was left pending: the journal is persistent and local. With a single server that works; as soon as there are two — the application nodes from 07-07, the load balancer, the replica — diagnosing means logging into each machine and correlating by hand.
Why centralising is needed:
| Problem | With local logs | Centralised |
|---|---|---|
| Correlating across machines | By hand, with timestamps | One query |
| A compromised server | The attacker erases their tracks | They are already elsewhere |
| A destroyed server | The logs are lost | Preserved |
| Legal retention | Per machine | One policy |
| Searching 30 days of logs | zgrep over rotated files |
An index |
That second row is pure security and it links back to 06-04: if the logs only live on the attacked machine, the attacker edits them. It is the same reasoning by which the AIDE database lives outside the server.
Loki as the lightweight option:
| Loki | Elasticsearch/OpenSearch | Remote journald | |
|---|---|---|---|
| What it indexes | Only the labels, not the text | All the text | Nothing |
| Resources | Very low | High: 4-8 GB of RAM minimum | Minimal |
| Querying | LogQL, similar to PromQL | Very powerful | journalctl |
| Grafana integration | Native: the same dashboard | Good | No |
| Storage cost | Low: compressed objects | High | Low |
Loki is the choice for Tramontana for two reasons: it does not index the full text — which is what makes Elasticsearch expensive — and it shares labels and syntax with Prometheus, so that you can jump from a latency chart to the logs of that same instant on the same dashboard. That correlation is what makes a logging system useful.
# /etc/promtail/config.yml (the agent that ships the logs)
clients:
- url: http://10.0.2.15:3100/loki/api/v1/push
scrape_configs:
- job_name: journal
journal:
max_age: 12h
labels: {job: systemd-journal, server: srv-tramontana}
relabel_configs:
- source_labels: ['__journal__systemd_unit']
target_label: unit
- source_labels: ['__journal_priority_keyword']
target_label: level# A LogQL query: application errors in the last hour
{unit="tramontana.service", level=~"err|crit"} |= "ERROR"
# Error rate per minute, which can be charted next to the metrics
sum(rate({unit="tramontana.service"} |= "ERROR" [5m]))And the obligatory warning: logs contain personal data — IP addresses, user identifiers, sometimes email addresses. Centralising them multiplies the reach of a leak. Bounded retention, restricted access and the same treatment as the database.
Daily, weekly and monthly operations
The five minutes every morning
| # | What you look at | Where | Warning sign |
|---|---|---|---|
| 1 | Active alerts | Alertmanager | Any that is unattended |
| 2 | The main dashboard | Grafana | Anything anomalous compared with yesterday |
| 3 | The age of the last backup | The dashboard | > 24 h |
| 4 | Failed units | systemctl --failed |
Any |
| 5 | New errors in the log | Loki or journalctl -p err |
An unfamiliar pattern |
#!/usr/bin/env bash
# morning_check.sh — the five minutes, in one command
set -euo pipefail
source "$(dirname "${BASH_SOURCE[0]}")/lib/common.sh"
main() {
printf '\n=== MORNING CHECK · %s ===\n\n' "$(date '+%F %T')"
printf '── Active alerts ──\n'
amtool alert query --output=extended 2>/dev/null | head -10 || echo " (none)"
printf '\n── Failed units ──\n'
systemctl --failed --no-legend || echo " (none)"
printf '\n── Overall status ──\n'
curl -s 'http://127.0.0.1:9090/api/v1/query?query=tramontana_health_status' | \
jq -r '.data.result[0].value[1] as $e |
" health_check: " + (if $e=="0" then "OK"
elif $e=="1" then "WARN" else "CRITICAL" end)'
curl -s 'http://127.0.0.1:9090/api/v1/query?query=tramontana_backup_age_seconds' | \
jq -r '" last backup: " + ((.data.result[0].value[1]|tonumber/3600|floor)|tostring) + " h ago"'
curl -s 'http://127.0.0.1:9090/api/v1/query?query=tramontana_certificate_days_left' | \
jq -r '" certificate: " + .data.result[0].value[1] + " days"'
printf '\n── Errors in the last 24 h ──\n'
journalctl -p err --since "24 hours ago" --no-pager -q | \
awk '{$1=$2=$3=""; print}' | sort | uniq -c | sort -rn | head -5
printf '\n── Availability (7 d) ──\n'
curl -s --data-urlencode 'query=avg_over_time(up{job="tramontana"}[7d])*100' \
'http://127.0.0.1:9090/api/v1/query' | \
jq -r '" " + (.data.result[0].value[1]|tonumber|.*100|round/100|tostring) + " %"'
printf '\n'
}
main "$@"$ ~/scripts/morning_check.sh
=== MORNING CHECK · 2026-08-18 08:04:11 ===
── Active alerts ──
(none)
── Failed units ──
(none)
── Overall status ──
health_check: OK
last backup: 6 h ago
certificate: 71 days
── Errors in the last 24 h ──
3 tramontana[1204]: ERROR slow query on /reports/billing
1 nginx_error: upstream timed out
── Availability (7 d) ──
99.94 %Weekly (30 minutes) and monthly (2 hours)
| Frequency | Task | Why |
|---|---|---|
| Weekly | Review the 7-day trends | To see slow growth |
| Weekly | Outstanding security updates | The exposure window |
| Weekly | The top 5 of pg_stat_statements |
Queries that are degrading |
| Weekly | Disk space and growth | Anticipate, do not react |
| Weekly | The week's change log | Context for incidents |
| Monthly | Restore a file from the backup | Verify it for real |
| Monthly | Review and prune alerts | Alert fatigue |
| Monthly | Lynis and AIDE | Configuration drift |
| Monthly | Review access: users, SSH keys, VPN peers | They accumulate |
| Monthly | Error budget consumption | Decide the pace of change |
| Quarterly | A full PITR rehearsal (RB-DB-02) | An unrehearsed backup is a file |
| Twice a year | A rebuild drill | Validate the RTO |
| Annually | Review the SLO, the threat model and the architecture | The context changes |
The duty log and the change log
The duty log is a text file where you note down what happens. It sounds trivial and it is the tool that saves the most time during an incident, because it answers "has this happened to us before?".
# Duty log · srv-tramontana
## 2026-08-18
**08:04** Morning check. All fine. Availability 7 d: 99.94 %.
**11:20** [CHANGE] Nginx as a reverse proxy with TLS. The encryption-in-transit
debt open since 06-05 is closed. HSTS with max-age=300 on purpose; raise it to
2 years on 25/08 after verifying the renewal. Measured: -78 % of bytes
transferred, +19 ms on the first handshake.
**14:32** [INCIDENT] Accidental `DELETE` on app.bookings (40,218 rows).
Detected 14:51. Recovered by PITR on the test machine and reimported.
Total time 34 min. No data lost. Post-incident review: 20/08.
**16:10** [OBSERVATION] p95 rises to 1.4 s on Tuesdays between 16:00 and 18:00.
It coincides with Marta's billing report. Not a problem today; keep an eye on it.
## 2026-08-17
**03:14** [ALERT] Space on /var at 91 %. Cause: lv-backups full →
archive_command fails → pg_wal grows. Resolved by purging WAL with
pg_archivecleanup and extending the LV by 10 GiB. **New alert created:
WALArchivingFailing (failed_count > 0).**Four rules to make it work:
- It is written at the time, not at the end of the day. What gets postponed does not get written.
- Timestamps always, so that it can be correlated with the charts.
- The observations are worth as much as the incidents. The 16:10 entry is the one that will explain an alert two months from now.
- It lives outside the server — in the Ansible repository — because it will be needed precisely when the server is not there.
The change log already exists: it is the git log of ~/tramontana-infra. The discipline is that every configuration change goes through it, so that "what changed before it started failing?" can be answered.
$ cd ~/tramontana-infra && git log --oneline --since="7 days ago"
a4f19c8 web role: HSTS to 2 years after verifying the renewal
3e2b1d5 db role: memory tuning for 3.8 GB + PgBouncer
9c8a7f2 WALArchivingFailing alert after the incident of 17/08
1b4d6e3 vpn role: add marta-tablet
# The key question during an incident
$ git log --since="6 hours ago" --statThe blameless post-incident review
An incident is a failure of the system, not of a person. If somebody was able to delete 40,000 rows with one command, the problem is that the system allowed it without friction, not that that person made a mistake.
This is not corporate kindness: it is the only way to get truthful information. In a culture where culprits are sought, people hide their mistakes, and then incidents repeat themselves because nobody knows they happened. The blameless review is an engineering decision.
Post-incident review: accidental deletion of bookings
Incident: INC-2026-003 · Date: 2026-08-18 · Severity: High Duration of impact: 34 min · Data lost: none Written by: Operations · Reviewed with: Marta, Luis
1. What happened
During a routine cleanup of old data, a
DELETEwhoseWHEREclause was not the intended one was run against the production database, removing 40,218 historical bookings. It was detected 19 minutes later and the rows were recovered by point-in-time recovery. There was no data loss and no interruption of service.2. Impact
Dimension Impact Service availability None: the site carried on working Data permanently lost None Data temporarily inaccessible 40,218 historical bookings, 34 min Customers affected None (data for internal consultation) Error budget consumed 0 % 3. Timeline
Time Event 14:30 The cleanup of data prior to 2024 begins 14:32 The DELETEis run with the wrong condition14:51 A report returns anomalous figures: it is detected 14:53 All further writing is stopped. The incident is opened 14:58 The exact timestamp is located in pg_stat_statements15:02 The PITR restore begins on srv-tramontana-test15:19 Recovery completed and verified on the test machine 15:24 Data reimported into production with psql -115:26 Final verification. Incident closed 4. Root cause: the five whys
# Question Answer 1 Why were 40,218 bookings deleted? A DELETEwith the wrongWHERE2 Why was it run with the wrong condition? It was typed by hand, with no prior SELECT3 Why was it typed by hand? There is no procedure for data cleanups 4 Why is there none? A bulk cleanup had never been done before 5 Why was one needed now? The audit table grew with no retention policy Root cause: the absence of a data retention policy forced an improvised manual cleanup, with no procedure and no safeguards.
And an observation about the method: the five whys cut through the easy answer — "somebody mistyped something" — until they reach a systemic and actionable cause. Stopping at why number 2 would have produced the useless conclusion that "we have to be more careful".
5. What went well
This section is compulsory and it is always forgotten:
- Point-in-time recovery worked exactly as rehearsed (RB-DB-02, 08-02). The quarterly rehearsal proved its worth three days later.
- Restoring on a separate machine preserved the later transactions. Restoring over production would have lost 19 minutes of real bookings.
- Detection in 19 minutes was reasonable, though it can be improved.
- It was reported immediately, with no concealment. That made it possible to act quickly.
6. What changes
# Action Owner Date Status 1 A retention policy for auditagreed with MartaMarta + Ops 25/08 Pending 2 A cleanup runbook: a mandatory SELECT count(*)before anyDELETEOps 22/08 In progress 3 An svc_cleanuprole withstatement_timeoutand no bulkDELETEpermissionOps 25/08 Pending 4 Purging scheduled as an idempotent, tested timer, not manual Ops 31/08 Pending 5 An alert if a table loses more than 10 % of its rows in 5 min Ops 25/08 Pending 6 Production psqlwith\set ON_ERROR_ROLLBACK offand a warning in the promptOps 22/08 Done No action is "be more careful". A corrective action that depends on human attention is not a corrective action: it is a wish.
7. Lessons
- Recovery rehearsals pay for themselves. The one in 08-02 was done on the 15th; on the 18th it was genuinely needed, and nobody had to improvise.
- A table that grows with no retention policy ends up forcing a risky operation. Silent growth is a debt that gets collected all at once.
- Detecting in 19 minutes is a long time for a bulk deletion. Action 5 will bring it down to under 5.
Change management and deployment windows
Most interruptions do not come from hardware failures: they come from changes. And yet change is necessary. Managing it means putting friction on it in proportion to the risk, not preventing it.
| Type of change | Examples | Approval | Window |
|---|---|---|---|
| Standard | Security updates, adding a VPN peer | None: pre-approved | Any time |
| Normal | A version deployment, a configuration change | A colleague's review | An agreed window |
| Major | A schema change, a major PostgreSQL version | Marta + a rollback plan | A planned window |
| Emergency | A patch for a critical vulnerability | Afterwards | Immediate |
Tramontana's window: Tuesdays and Wednesdays, from 10:00 to 12:00. And the reasons why you do not deploy on a Friday afternoon, which is the industry's most famous rule:
- Problems appear under real load, hours later. On a Friday at 18:00, that load arrives on Monday.
- People go home. If something fails at 20:00 on a Friday, whoever deployed it is no longer around.
- The weekend is when most bookings are made at Tramontana: the worst possible moment for a failure.
- A Friday incident drags through the weekend, with the person on call working on a change they did not make.
The rule, correctly formulated: you do not deploy if there is not enough time ahead to detect and revert the problem with the available team. Tuesday morning satisfies that; Friday afternoon does not.
The pre-deployment checklist:
| # | Check |
|---|---|
| 1 | Has it been tested on srv-tramontana-test? |
| 2 | Is there a recent, verified backup? |
| 3 | Is the rollback plan written down? |
| 4 | Are the schema migrations backwards-compatible? |
| 5 | Has everybody who needs to know been told? |
| 6 | Is there an alert silence scheduled, with an expiry? |
| 7 | Is there error budget left? |
| 8 | Is there time ahead to revert calmly? |
SLIs, SLOs and the error budget
Three concepts that get confused and are different:
| SLI | SLO | SLA | |
|---|---|---|---|
| What it is | A measured indicator | An internal objective | A contractual agreement |
| Example | 99.94 % of requests successful | ≥ 99.8 % monthly | 99.5 %, with a penalty |
| Who sets it | The measurement | The team together with the business | Legal and sales |
| If it is not met | — | The pace of work changes | There are financial consequences |
The SLO is always set stricter than the SLA, so that there is headroom before the contract is breached.
Tramontana's SLO
Taking the 99.8 % proposed in 07-07 and turning it into concrete numbers:
SLI: the proportion of HTTP requests to
bookings.tramontana.exampleanswered with a code below 500 and in under 2 seconds.SLO: ≥ 99.8 % of those requests, measured over a rolling 30-day window.
# The SLI, exactly as it is measured
(
sum(rate(nginx_http_requests_total{status!~"5.."}[30d]))
- sum(rate(tramontana_request_duration_seconds_count{le="2"}[30d]))
) / sum(rate(nginx_http_requests_total[30d]))The error budget
Here is the idea that changes the way you work:
Error budget = 100 % − SLO. It is the amount of failure you can afford. It is not a leftover: it is a resource that gets spent.
| SLO | Budget | Over 30 days |
|---|---|---|
| 99.9 % | 0.1 % | 43 min 12 s |
| 99.8 % | 0.2 % | 1 h 26 min 24 s |
| 99.5 % | 0.5 % | 3 h 36 min |
Tramontana can be down for 1 h 26 min every 30 days and still meet its objective. That time is a budget that can be spent deliberately: on risky deployments, on upgrades, on experiments.
And out of that comes a management rule that replaces the endless arguments between "we have to move faster" and "we have to be more stable":
| Budget consumed | Decision |
|---|---|
| < 50 % | You can deploy normally. There is headroom |
| 50-80 % | Normal deployments, more care with the major ones |
| 80-100 % | Only standard changes and fixes. New features are frozen |
| Exhausted | A total freeze. All effort goes into stabilising |
# Budget consumed over the last 30 days, as a percentage
(1 - (
sum(rate(nginx_http_requests_total{status!~"5.."}[30d]))
/ sum(rate(nginx_http_requests_total[30d]))
)) / 0.002 * 100$ curl -s --data-urlencode 'query=(1 - (sum(rate(nginx_http_requests_total{status!~"5.."}[30d])) / sum(rate(nginx_http_requests_total[30d])))) / 0.002 * 100' \
http://127.0.0.1:9090/api/v1/query | jq -r '.data.result[0].value[1]'
31.431.4 % consumed: there is headroom. That number goes on the main dashboard, and it is what turns "shall we deploy?" from an argument of opinions into a query against data.
The most valuable thing about the error budget, and it usually comes as a surprise: exhausting it is as bad as not spending it. A team that ends the month with 5 % consumed is being too conservative — it could have deployed more, delivered more value and taken more risk within what was agreed. Excessive reliability has a cost too: the features that never got built.
Closing the outstanding debts
Debt 1: the off-site copy is not append-only
The problem. restic uploads the backups with a token that can also delete. An attacker with access to srv-tramontana can run restic forget --prune and destroy the whole history. It is exactly how modern ransomware operates: first it deletes the backups, then it encrypts the data.
# The check that reveals the debt
$ restic forget --keep-last 1 --dry-run
# If this does NOT give a permissions error, the token can delete.The solution: an append-only token, plus a restic in --append-only mode on the repository side.
# 1. A new token at the provider, WITHOUT delete permission
# (Backblaze B2: "Write Only"; S3: a policy without s3:DeleteObject)
$ pass insert tramontana/restic-append-only
# 2. Object lock at the provider: nothing can be deleted for 30 days,
# not even with administrator credentials
$ b2 update-bucket --defaultRetentionMode compliance \
--defaultRetentionPeriod "30 days" tramontana-backups
# 3. The server uses the restricted token
$ sudo sed -i 's|^RESTIC_TOKEN=.*|RESTIC_TOKEN_CMD="pass tramontana/restic-append-only"|' \
/etc/tramontana/backup.env
# 4. VERIFY that it cannot delete
$ RESTIC_PASSWORD=$(pass restic/tramontana) restic forget --keep-last 1 --dry-run
Fatal: unable to remove files: AccessDenied
# 5. The purge is done from ANOTHER MACHINE with different credentials,
# monthly, with a token that can delete and that is NEVER on the
# backed-up server.The general principle, applicable far beyond this: the backed-up system must not be able to destroy its own backups. If it can, they are not backups: they are a replica with a delay.
| Before | After | |
|---|---|---|
| The server can delete backups | Yes | No |
| Survives ransomware | No | Yes, 30 days |
Survives an accidental rm |
No | Yes |
| Who purges | The server itself | Another machine, monthly |
Checklist row 23: closed.
Debt 2: the unexplained authorized_keys2
$ ls -la /home/operator/.ssh/
-rw------- 1 operator operator 742 Jan 12 2025 authorized_keys
-rw------- 1 operator operator 381 Mar 3 2025 authorized_keys2 # <-- ???
$ ssh-keygen -lf /home/operator/.ssh/authorized_keys2
2048 SHA256:Xk9m2pQ7... support@old-provider (RSA)What it is. authorized_keys2 is an obsolete file from OpenSSH 2.x, when there were separate files for SSH-1 and SSH-2 keys. Modern versions of OpenSSH ignore it entirely, unless AuthorizedKeysFile mentions it explicitly.
$ sudo sshd -T | grep -i authorizedkeysfile
authorizedkeysfile .ssh/authorized_keys .ssh/authorized_keys2And there is the problem: Ubuntu's default configuration does include it. That 2048-bit RSA key, belonging to a provider you no longer work with, gives access to the server today.
It is a perfect example of a real risk: it is not a software vulnerability, it is a file nobody looked at for eighteen months.
# 1. Investigate before deleting: has it been used?
$ sudo journalctl -u ssh --since "90 days ago" | \
grep 'Accepted publickey' | grep -o 'SHA256:[A-Za-z0-9+/]*' | sort -u
SHA256:aB3cD4eF5g... # the operator's
# The authorized_keys2 fingerprint does NOT appear: unused in 90 days
# 2. Ask Marta whether the provider is still in a relationship with us
# -> Answer: contract ended in March 2025
# 3. Withdraw it, keeping the evidence (06-04)
$ sudo cp /home/operator/.ssh/authorized_keys2 \
/var/log/incidents/authorized_keys2.withdrawn-$(date +%F)
$ sudo shred -u /home/operator/.ssh/authorized_keys2
# 4. And the structural fix: make sure it cannot exist again
$ sudo tee /etc/ssh/sshd_config.d/60-authorized-keys.conf <<'EOF'
# Only one authorised keys file. authorized_keys2 is a leftover from
# OpenSSH 2.x that Ubuntu still includes by default, and that lets a
# forgotten third-party key give access without anybody seeing it.
AuthorizedKeysFile .ssh/authorized_keys
EOF
$ sudo sshd -t && sudo systemctl reload ssh
$ sudo sshd -T | grep -i authorizedkeysfile
authorizedkeysfile .ssh/authorized_keysAnd in Ansible, so that it is permanent and verified:
- name: Do not allow authorized_keys2
ansible.builtin.template:
src: 60-authorized-keys.conf.j2
dest: /etc/ssh/sshd_config.d/60-authorized-keys.conf
validate: '/usr/sbin/sshd -t -f %s'
notify: Reload ssh
- name: Check that no out-of-policy key files exist
ansible.builtin.find:
paths: /home
patterns: 'authorized_keys2'
recurse: true
hidden: true
register: extra_keys
failed_when: extra_keys.matched > 0The lesson of method: in an access inventory, a file that cannot be explained is a file that gets withdrawn. And the underlying mistake was never having a periodic access review — which is now the monthly row in the table in section 10.
Checklist row 40: closed.
Debt 3: there is no formal availability objective
It is closed by formalising what was proposed in 07-07:
Internal service level agreement · Tramontana Bookings Agreed on 18/08/2026 between Operations and Management (Marta Vidal). Review: August 2027.
Item Value SLI Requests with a code below 500 and latency under 2 s SLO 99.8 % over a rolling 30-day window Error budget 1 h 26 min every 30 days Maintenance window Tuesdays and Wednesdays, 10:00-12:00 RPO 15 minutes RTO 2 hours On-call hours Working days 8:00-20:00; critical 24×7 Compliance review Monthly, on the dashboard
Checklist row 33: closed.
Compliance: GDPR and data retention
Tramontana holds customers' personal data: name, contact details and dates of stay. That triggers specific obligations under the General Data Protection Regulation, and some of them are technical and they are yours.
| Obligation | Status at Tramontana |
|---|---|
| Record of processing activities (art. 30) | ⚠️ Pending |
| Appropriate technical measures (art. 32) | ✅ Encryption in transit (08-01) and at rest (LUKS) |
| Minimisation: only the necessary data | ⚠️ Review with Luis |
| Storage limitation | ⚠️ audit with no policy |
| The right to erasure | ⚠️ Procedure not written |
| The right of access and portability | ⚠️ Not automated |
| Breach notification within 72 h | ✅ Detection (06-04); the procedure still to be written |
| A log of access to personal data | ✅ auditd (06-04) |
And the point that connects directly with the incident in section 12: the audit table takes up 1,204 MB, more than all the booking data put together, and nobody has decided how long it is kept for. That is not only a space problem: keeping personal data with no defined time limit is a breach of the storage limitation principle.
-- A proposed retention policy, to be decided with Marta and with legal advice
-- Bookings: 6 years (a commercial obligation to keep accounting records)
-- Technical audit: 12 months
-- Logs containing IP addresses: 12 months
-- Contact details of cancelled bookings: 1 year
CREATE OR REPLACE PROCEDURE app.purge_retention() LANGUAGE plpgsql AS $$
BEGIN
DELETE FROM app.audit WHERE created_at < now() - interval '12 months';
UPDATE app.guests SET phone = NULL, email = NULL, anonymised = true
WHERE id IN (SELECT guest_id FROM app.bookings
WHERE status = 'cancelled' AND date < now() - interval '1 year')
AND anonymised = false;
RAISE NOTICE 'retention purge completed';
END $$;Anonymising instead of deleting preserves the statistical usefulness — how many bookings there were, for which cottages — without keeping the personal data. It is the technically correct solution and the one most often overlooked.
The final warning, and it is important: GDPR is not a technical matter, it is a legal matter with technical implications. Nothing in this section replaces professional advice. What is your responsibility as an administrator is: that the technical measures exist, that the retention is enforceable with one command, that there is traceability of access, and that whoever decides — Marta — has the information to decide. An administrator who implements a purge before anybody has decided the time limits is taking a legal decision without the authority to do so.
Common Mistakes and Tips
- Confusing monitoring with alerting. Monitor everything; alert on almost nothing.
- Alerting on causes instead of symptoms. "CPU at 90 %" fires when the backup runs. "More than 5 % errors" detects any real failure.
- Not pruning alerts. Ten notifications a day is equivalent to none. The monthly alert review is as important as creating them.
- Alerts with no action. If the recipient cannot do anything, it is a dashboard, not an alert. Every alert carries its
actionand its link to the runbook. - Silences with no expiry. An indefinite silence is an alert switched off for ever.
- Not monitoring the monitoring. A dead metrics script makes everything look fine indefinitely. The freshness mark solves it.
- Average latency instead of percentiles. An average of 80 ms hides the fact that 1 % waits 4 seconds.
- Including errors in the latency. A total outage, where everything fails quickly, would show up as an improvement in performance.
rate()over a gauge. It only makes sense over counters (_total).- Aggregating histograms without
by (le). It is the classic PromQL mistake: the percentiles come out meaningless. - High-cardinality labels. A label with the booking ID creates one series per booking and blows up Prometheus' memory.
- A dashboard with forty charts. Nobody looks at it. The main one has to fit on one screen.
- Post-incident reviews that look for culprits. They guarantee that the next incident gets hidden.
- Corrective actions of the "be more careful" kind. They are not actions: they are wishes. They have to change the system.
- Deploying on a Friday afternoon. The problems appear under real load and by then there is nobody around.
- An SLO with no error budget. An objective without its operational consequence changes no decisions.
- Spending 5 % of the error budget. You are being too conservative: you could have delivered more value.
- Backups the server itself can delete. They are not backups: they are a replica with a delay, and ransomware knows it.
- Not reviewing access periodically. An
authorized_keys2from a provider of eighteen months ago gives access today. - Keeping personal data with no defined time limit. It is a GDPR breach, not just a space problem.
- A tip on method. When somebody asks "is the system all right?", if the answer is not a number, you are not in production.
Exercises
Exercise 1
At 03:47 HighErrorRate fires. Document the complete incident response — from the notification to the closure — and write the resulting post-incident review.
Exercise 2
Design the monthly alert review: what is measured about each alert, the criteria for deciding whether to delete it, adjust it or keep it, and apply the method to the alerts configured in this lesson.
Exercise 3
Marta asks for a system status report for the annual meeting with the partners: what has been done this year, where things stand and what is needed. Write it.
Solutions
Solution 1
03:47 — The notification.
[CRITICAL] HighErrorRate Summary: More than 5 % of requests are failing Description: Current rate: 34.2 %. Action: journalctl -t nginx_error -n 50; check PostgreSQL Runbook: https://docs.tramontana.example/RB-OPS-02
03:49 — Confirm the problem is real, before touching anything.
$ curl -sI https://bookings.tramontana.example/houses | head -1
HTTP/2 502
$ curl -s --data-urlencode 'query=sum(rate(nginx_http_requests_total{status=~"5.."}[5m])) / sum(rate(nginx_http_requests_total[5m]))' \
http://127.0.0.1:9090/api/v1/query | jq -r '.data.result[0].value[1]'
0.342Confirmed: one user in three is getting an error. It is not a false alarm.
03:51 — Narrow the scope down with the dashboard, not by guessing.
| Panel | Reading |
|---|---|
| Errors | 502s since 03:41 |
| Traffic | Normal for the hour |
| p95 latency | Rose to 8 s at 03:38, before the errors |
| CPU / memory | Normal |
| PgBouncer connections | cl_waiting = 47 |
tramontana_health_status |
2 (critical) |
The latency rose three minutes before the errors, and there are 47 clients waiting for a connection. The bottleneck is in the database, not in the application and not in Nginx.
03:53 — The change log, which is the obligatory question.
No recent changes. It is not a deployment: it is something that has happened on its own.
03:55 — Diagnosis in the database.
$ sudo -u postgres psql -x -c "
SELECT pid, state, wait_event_type, wait_event,
now()-xact_start AS duration, left(query,60) AS query
FROM pg_stat_activity WHERE state != 'idle'
ORDER BY xact_start LIMIT 3;"
-[ RECORD 1 ]---+--------------------------------------------
pid | 12844
state | active
wait_event_type | Lock
wait_event | transactionid
duration | 00:19:12
query | UPDATE app.availability SET status = 'occupied'
$ sudo -u postgres psql -c "
SELECT blocked.pid AS waiting, blocker.pid AS blocking,
left(blocker_act.query, 50) AS blocking_query
FROM pg_locks blocked
JOIN pg_locks blocker ON blocker.transactionid = blocked.transactionid
AND blocker.granted
JOIN pg_stat_activity blocker_act ON blocker_act.pid = blocker.pid
WHERE NOT blocked.granted;"
waiting | blocking | blocking_query
---------+----------+--------------------------------------------
12844 | 11902 | VACUUM FULL app.availabilityRoot cause found in eight minutes. A VACUUM FULL on app.availability takes an ACCESS EXCLUSIVE lock that prevents all reading and writing of that table. The requests pile up waiting, they exhaust the PgBouncer pool, and the application returns 502s.
$ sudo -u postgres psql -c "SELECT pid, backend_start, application_name
FROM pg_stat_activity WHERE pid = 11902;"
pid | backend_start | application_name
-------+-------------------------------+------------------
11902 | 2026-08-19 03:30:12.441+02 | psqlapplication_name = psql and started at 03:30: somebody launched it by hand. Checking the duty log confirms it — a note from the previous afternoon: "availability at 31.7 % dead tuples, look into it".
03:58 — Mitigation.
# Cancel the query (SIGINT). NOT pg_terminate_backend, which cuts the
# connection dead: cancelling is cleaner and it is usually enough.
$ sudo -u postgres psql -c "SELECT pg_cancel_backend(11902);"
pg_cancel_backend
-------------------
t
$ sleep 20 && curl -sI https://bookings.tramontana.example/houses | head -1
HTTP/2 200
$ sudo -u postgres psql -tAc "SELECT count(*) FROM pg_stat_activity
WHERE wait_event_type = 'Lock';"
004:00 — Verify and close.
$ curl -s --data-urlencode 'query=sum(rate(nginx_http_requests_total{status=~"5.."}[5m])) / sum(rate(nginx_http_requests_total[5m]))' \
http://127.0.0.1:9090/api/v1/query | jq -r '.data.result[0].value[1]'
0.0021
$ amtool alert query alertname=HighErrorRate
(no active alerts)04:05 — Note it in the duty log while it is fresh.
**03:47** [INCIDENT] HighErrorRate, 34 % of 502s. Cause: a manual
VACUUM FULL on app.availability launched at 03:30, ACCESS EXCLUSIVE
lock. Mitigated with pg_cancel_backend at 03:58. Duration of impact:
17 min. Post-incident review on 21/08.Post-incident review: 502 errors caused by a VACUUM FULL lock
Incident: INC-2026-004 · Date: 2026-08-19 · Severity: Critical Duration of impact: 17 min (03:41-03:58) · Detection: 6 min
1. What happened
A
VACUUM FULLrun manually onapp.availabilityacquired an exclusive lock that prevented all reading and writing of the table for 28 minutes. The requests piled up, the connection pool was exhausted and the application returned 502 errors to 34 % of requests for 17 minutes.2. Impact
Dimension Value Duration of impact 17 min Failed requests ~2,100 (34 % of 6,200) Estimated bookings lost 1-2 (early morning, low traffic) Error budget consumed 19.7 % of the month (17 of 86 min) Data lost None 3. Timeline
Time Event 03:30 VACUUM FULL app.availabilityis launched manually03:38 p95 latency rises to 8 s (an early symptom, with no alert) 03:41 The first 502s: the PgBouncer pool is exhausted 03:47 HighErrorRatefires and the notification arrives03:49 The problem is confirmed 03:51 It is narrowed down with the dashboard: the database 03:55 The lock and the VACUUM FULLare identified03:58 pg_cancel_backend. Service restored04:00 Verified. Incident closed 4. Root cause: the five whys
# Question Answer 1 Why were there 502s? The application could not query the database 2 Why could it not? An ACCESS EXCLUSIVElock onapp.availability3 Why was that lock there? A manual VACUUM FULL4 Why was it run by hand in production? 31.7 % dead tuples were spotted and somebody wanted to fix it quickly 5 Why was there so much bloat? autovacuumis not tuned for that tableRoot cause:
autovacuumwith the default values (a 20 % threshold) does not keep up with the write rate ofapp.availability. The resulting bloat prompted a manual intervention with a tool whose locking was not anticipated.Contributing cause: there was no control preventing a blocking operation being run in production, and no alert to warn before users were affected.
5. What went well
- The alert was a symptom alert and it worked: it detected a failure nobody had anticipated. A cause alert on
VACUUMdid not exist and would not have been needed.- The dashboard narrowed the problem down in two minutes. Without it, the diagnosis would have been blind.
- The change log ruled out a deployment in thirty seconds.
- The mitigation was reversible: cancelling rather than terminating the connection.
- The duty log gave the context — the previous day's note — that explained who and why.
6. What changes
# Action Owner Date Status 1 Tune autovacuumforapp.availabilityto 2 % (08-02)Ops 19/08 Done 2 lock_timeout = 5sby default in the productionpsqlsessionOps 20/08 Done 3 Install pg_repackand document it as the tool for bloatOps 26/08 Pending 4 A LongLockWaitalert (> 30 s) — it warns before the symptomOps 22/08 Pending 5 Runbook RB-DB-03: database maintenance, with the list of blocking operations Ops 26/08 Pending 6 A dashboard with dead tuples per table, so it is not discovered by chance Ops 22/08 Pending 7 No manual maintenance operations in production outside the window The team 20/08 Agreed # Action 2: /etc/postgresql/16/main/conf.d/50-protection.conf # If a query does not get a lock within 5 s, it fails instead of # waiting. A query that fails fast is infinitely preferable to one # that blocks the entire service. lock_timeout = 5s7. Lessons
- The latency rose 9 minutes before the errors. That is the margin we lost by not having an early alert. Action 4 recovers it.
- The intention was good and the result was an outage. It is not a failure of the person: it is a failure of the system, which allowed a blocking operation to be run in production with no friction at all. Actions 2, 5 and 7 add that friction.
- 19.7 % of the error budget in a 17-minute incident demonstrates why the SLO changes decisions: with two incidents like that, the month enters the caution zone and deployments are moderated.
VACUUM FULLis not "a bigger vacuum". It locks the whole table. It deserves to be in a runbook with that warning in capitals.
Solution 2
Why this review exists. Alerts are created after every incident and they are almost never removed. In a year, a system accumulates thirty alerts of which five are useful. The monthly review is the counterweight.
The four things measured about each alert:
| Metric | How it is obtained | What it reveals |
|---|---|---|
| Frequency | Times it fired in 30 days | Noise or silence |
| Precision | (Firings that required action) / (total firings) | False positives |
| Sensitivity | Incidents it detected / real incidents | Coverage gaps |
| Reaction time | Minutes until the first human action | Whether it is attended to or ignored |
# Frequency and duration of each alert over 30 days, from Prometheus
$ curl -s --data-urlencode \
'query=sum by (alertname) (count_over_time(ALERTS{alertstate="firing"}[30d]))' \
http://127.0.0.1:9090/api/v1/query | \
jq -r '.data.result[] | "\(.metric.alertname)\t\(.value[1])"' | sort -k2 -rn
BackupTooOld 412
LatencyDegraded 38
DiskWillFill 4
HighErrorRate 2
CertificateExpiringSoon 0
ServiceDown 0
WALArchivingFailing 0
StaleMetrics 0
ReplicaLagging 0The decision tree, applied to each alert:
Did it fire in 30 days?
├── NO
│ ├── Would it have detected a real incident that happened? → KEEP
│ ├── Does it cover a serious though rare risk? → KEEP
│ └── Neither of the two? → CANDIDATE FOR DELETION
└── YES
├── Precision < 50 %? → ADJUST the threshold or 'for', or DELETE
├── Frequency > 10/month? → It is noise: ADJUST
├── Nobody ever acted? → IT IS NOT AN ALERT: move it to a dashboard
└── High precision and low frequency? → KEEPApplied to this lesson's alerts:
| Alert | Freq. | Precision | Reaction | Decision |
|---|---|---|---|---|
BackupTooOld |
412 | 0.2 % | Ignored | ADJUST: broken |
LatencyDegraded |
38 | 21 % | 45 min | ADJUST: too sensitive |
DiskWillFill |
4 | 100 % | 12 min | KEEP |
HighErrorRate |
2 | 100 % | 2 min | KEEP: exemplary |
ServiceDown |
0 | — | — | KEEP: maximum risk |
WALArchivingFailing |
0 | — | — | KEEP: it detected the incident of 17/08 |
CertificateExpiringSoon |
0 | — | — | KEEP: zero cost, high risk |
ReplicaLagging |
0 | — | — | KEEP |
StaleMetrics |
0 | — | — | KEEP: it watches the watchers |
Analysis and correction of the two problematic ones:
BackupTooOld: 412 firings, 0.2 % precision. It is the textbook case of alert fatigue: it fired every 30 minutes, every night, between the backup running and it being verified. Four hundred and twelve notifications nobody read, and which on top of that trained the team to ignore the channel.
# Diagnosis: when exactly does it fire?
$ curl -s --data-urlencode \
'query=ALERTS{alertname="BackupTooOld",alertstate="firing"}' \
http://127.0.0.1:9090/api/v1/query_range... | jq ...
# -> All of them between 02:30 and 03:10 # BEFORE: too tight a threshold and no room for the run itself
# expr: tramontana_backup_age_seconds > 2*4*3600 # 8 h
# for: 30m
# AFTER: 26 h covers a full daily cycle plus room for the run;
# 'for: 1h' avoids the noise of the backup window.
- alert: BackupTooOld
expr: tramontana_backup_age_seconds > 26*3600
for: 1h
labels: {severity: critical}
annotations:
summary: "No verified backup for {{ $value | humanizeDuration }}"
action: "See runbook RB-DB-01"LatencyDegraded: 38 firings, 21 % precision, 45 minutes until a reaction. The 45 minutes are the revealing figure: the team was already ignoring it. It fires every afternoon during Marta's billing report, which is slow and expected.
# BEFORE: p95 > 2 s for 10 min -> it caught the reports
# AFTER: two changes that make it useful again
# 1. Exclude the reports path, which is slow by design
# 2. A looser threshold and window: 3 s for 15 min
- alert: LatencyDegraded
expr: |
histogram_quantile(0.95, sum(rate(
tramontana_request_duration_seconds_bucket{path!~"/reports/.*"}[5m]
)) by (le)) > 3
for: 15m
labels: {severity: warning}And for the reports, which are worth watching but with a different threshold:
- alert: ReportsVerySlow
expr: |
histogram_quantile(0.95, sum(rate(
tramontana_request_duration_seconds_bucket{path=~"/reports/.*"}[5m]
)) by (le)) > 15
for: 30m
labels: {severity: warning}The result of the review:
| Before | After | |
|---|---|---|
| Notifications per month | 456 | ~8 |
| Average precision | 2 % | ~85 % |
| Active alerts | 9 | 10 (one new, two adjusted) |
| Average reaction time | 45 min | Estimated < 5 min |
Four principles this review sums up:
- An alert with low precision is worse than none at all, because it trains the team to ignore the channel the good ones also arrive on.
- Reaction time measures whether the alert is attended to. Forty-five minutes means it is being ignored, and that figure is more honest than any opinion.
- An alert that never fires is not useless, if it covers a serious risk and its cost is zero.
ServiceDownhas not fired in a year and it must stay there. - Adjust before deleting. Both problematic ones covered real risks; the problem was in the threshold, not in the idea.
And a final note on method: this review is written into the duty log, with the before and after of each modified alert. Six months from now, when somebody wonders why the BackupTooOld threshold is 26 hours and not 8, the answer will be written down.
Solution 3
Annual systems report · Tramontana Bookings
For: Marta Vidal and partners · From: Systems Operations Year: 2026 · Date: 18 August 2026
Executive summary
Over the last year, the system that holds up Tramontana Bookings has gone from being a hand-configured machine that nobody but me knew how to rebuild to a documented, automated, monitored infrastructure with written procedures that somebody else could follow.
The three most important changes, one line each:
- Our customers' data now travels encrypted. Until June it did not.
- We can recover the database to any specific instant, and we have rehearsed it: 24 minutes.
- Rebuilding the whole server went from 8 hours to 50 minutes, and that is measured, not estimated.
Availability measured over the last 30 days: 99.94 %, above the 99.8 % objective we are proposing to formalise.
1. Where we were and where we are
A year ago Today The server's configuration In my head In code, versioned Rebuilding after a disaster 8 h estimated 50 min measured Data we could lose Up to 24 h 15 minutes Recovering an accidental deletion Impossible without losing a day To the second before Have the backups been tested? Never A documented quarterly rehearsal Encryption of web traffic No Yes Secure remote access Only from the office From anywhere, over an encrypted tunnel Do we know whether it is all right? "I think so" 99.94 %, on a dashboard If something fails at 3 a.m. We find out in the morning An alert to the phone Operational documentation None 6 written and rehearsed procedures
2. What has been done, grouped
Reliability. Complete automation of the configuration: the server is rebuilt from scratch with one command, and that has cut disaster recovery time from eight hours to fifty minutes. It is the improvement that has increased availability the most, and it has not cost a euro in equipment.
Data protection. Automatic verified backups, encrypted, stored off the server and — since this month — impossible to delete from the server itself for 30 days, which is the concrete defence against a ransomware attack. And the ability to rewind the database to any instant, rehearsed.
Security. A restrictive firewall, intrusion detection, access auditing, passwords and certificates stored encrypted, and the service running with the fewest possible permissions. An independent automated assessment scores our hardening level at 82 out of 100, where a default installation is around 60.
Performance. Tuning the database to the actual machine, which has reduced disk access by 93 %. And a report query that used to take 3.8 seconds now takes 0.18.
Visibility. This is the most recent change and the one that most changes the day to day: a dashboard that answers "is it all right?" in three seconds, with 30 days of history, and alerts that warn before the problem affects customers.
3. This year's incidents
Date What happened Impact Status 17/08 The backup disk filled up and blocked the database log None: detected beforehand Resolved + a new alert 18/08 Accidental deletion of 40,218 historical bookings None: recovered in 34 min Resolved + 6 measures 19/08 Errors for 17 min caused by a maintenance operation ~2,100 failed requests Resolved + 7 measures No incident caused data loss. All three were analysed with the same method — what happened, why, what changes — and generated fifteen concrete improvements, all of them applied or with a date.
I want to highlight the one on 18 August: 40,000 bookings were recovered without losing anything, using a procedure we had rehearsed three days earlier. That rehearsal, which looked like bureaucracy, paid for itself in 72 hours.
4. What we propose agreeing
A service objective. Until now we had none, and without one it is impossible to decide how much to invest. We propose:
Item Proposal What it means in practice Availability 99.8 % monthly At most 1 h 26 min of failure every 30 days Recoverable data 15 minutes In the worst case we would lose 15 min of bookings Recovery time 2 hours From a total disaster to the service restored Change window Tuesday and Wednesday mornings Never Friday afternoon or the weekend We are above that objective today (99.94 %). We are proposing it that way deliberately: an objective that is already met comfortably allows improvements to be deployed calmly, and one that is too demanding would force us to slow the product's development.
And one idea I want to explain because it changes how we decide. That 0.2 % of margin is a budget: we can "spend" it on changes and improvements. If in a given month we use less than half of it, we carry on deploying normally. If we get close to the limit, new features are frozen and the effort goes into stabilising. It turns the argument "are we going too fast?" into a query against a number. This month we have used 31 % of it.
5. What is needed
High priority, a management decision:
- A data retention policy. We keep the application's activity history with no defined time limit, and it already takes up more space than all the bookings put together. As well as being a space problem, keeping personal data with no defined period is not defensible under the Data Protection Regulation. I need a decision on how long we keep each thing; I can apply it in a week. I recommend legal advice to set the periods.
- A record of processing activities. It is a document GDPR requires and that we do not have. It is not technical work, but it needs my input.
Medium priority, moderate investment:
- A second application server with load balancing. It is the recommendation I analysed in detail: it would eliminate interruptions caused by deployments and by a machine failing, which are the majority. Cost: two machines and a week of work, plus three or four weeks of prior development with Luis. It would take us from the current 99.8 % to a much more comfortable margin.
- A database replica. Justified above all because it would let us run the heavy reports without affecting the website, and incidentally upgrade without cutting the service.
What I do NOT recommend, and I want it in writing:
- A full high-availability architecture (five machines, automatic server failover). Analysed with numbers: it costs several times more than option 3 and delivers barely 670 € of additional annual saving. And it would add a complexity that with the current staffing we cannot watch properly — a sophisticated system badly maintained is less reliable than a simple one well looked after.
- Kubernetes, which is the fashionable technology. I have set it up and evaluated it in the test environment: at our size it would consume between two and four weeks of work a year just to keep it running, in order to solve problems we do not have.
6. Risks we are consciously accepting
I would rather they were written down than discovered on the bad day:
Risk Consequence Why we accept it A single application machine A hardware failure = the service down for up to 2 h Proposal 3 would resolve it Manual database failover 5-15 additional minutes in that case Automating it requires 5 machines and would create a greater risk A single person with operational knowledge Holidays or illness = a slow response Partly mitigated: written procedures Dependence on one provider An outage of theirs affects all of us Normal at our scale The third is the one that worries me most in the medium term, and it is the reason I have put so much effort this year into writing procedures: today, somebody with general technical knowledge could follow most of them without ever having worked on our system. A year ago, they could not.
7. Conclusion
The system is in a considerably better position than a year ago and — what matters most — it is in a position I can demonstrate with data rather than assert with confidence. We have gone from "I think it is all right" to "99.94 %, here is the dashboard".
What remains outstanding is mostly decisions, not technical work: how long we keep the data, what level of service we commit to providing, and whether we invest in the second server. With those three decisions taken, we have a system that can be depended on.
Conclusion
The course ends where it had to end: not with one more tool, but with the layer that turns a server that works into a service that can be depended on. You have a checklist of forty-two rows with verifiable evidence on each one, and you know exactly which ones are missing, which is a way of knowing that is worth more than believing they are all there. You have monitoring with thirty days of history, alerts that can be acted on because they are few and they are symptom alerts, and a dashboard that answers the only question that matters in three seconds.
Above all you have operational judgement. You know that monitoring and alerting are not the same thing and that you have to do a great deal of the first and very little of the second. You know that an alert with 2 % precision is worse than none, because it trains the team to ignore the channel the good ones also arrive on. You know that a post-incident review that looks for culprits guarantees that the next one gets hidden, and that a corrective action of the "be more careful" kind is not an action but a wish. And you know that the error budget turns "shall we deploy?" into a query against a number, with the conclusion that comes as a surprise: spending only 5 % of the budget means having been too conservative.
The three debts are closed. The off-site copy can no longer be deleted by the server it backs up, because a system that can destroy its own backups does not have backups. The authorized_keys2 turned out to be what these things usually are: not an exotic vulnerability, but a file from a provider of eighteen months ago that still gave access because nobody had looked at it — and what has been corrected is not the file, it is the absence of periodic review that let it sit there. And the availability objective finally exists, with its number, its window and its budget.
And with that the whole course closes. Eight modules.
You began in Module 1 not knowing what a kernel was, and you built the system from an installation image. In 2 you stopped fearing the terminal: permissions, inodes, links and man as the first port of call. In 3 the shell stopped being an interpreter of isolated orders and became a language: pipes, find, awk, processes, signals. In 4 you wrote scripts that do not break — set -euo pipefail, trap, flock, idempotence — and deploy.sh with its atomic rollback is still in production fifty lessons later. 5 was real administration: users, packages with pinning, LVM, hardened systemd, a persistent journal, and backups with an agreed RPO and RTO instead of improvised ones. 6 made security a discipline and not a list of tricks: a threat model, an allowlist firewall, AIDE with its database elsewhere, encrypted secrets and that TLS certificate that waited two modules. 7 looked under the bonnet — boot, strace, perf, eBPF, the kernel, KVM, containers, Ansible — and finished with the honest analysis of whether high availability was needed. And 8 applied all of it to complete builds, from beginning to end.
What you take away from here is not a list of commands. It is a method: measure before and after, because a tuning change without measurement is superstition. Test on the test machine before production. --dry-run before acting. A copy and a diff -u before editing. Validate before reloading, whether that is nginx -t, promtool check, testparm -s or pg_hba_file_rules. Never close the door you are coming in through. Silence if all is well. And always say what a measure protects against and what it does not, which is what separates an honest report from a sales pitch.
And an idea that has appeared in every module wearing different clothes: the professional answer is not always yes. No to the full high-availability architecture, no to Kubernetes, no to raising max_connections, no to copying six terabytes to the cloud. Knowing how to say no, with numbers and with an alternative, is worth as much as knowing how to build it.
On how to carry on. The LPIC-1 and LPIC-2 certifications or the RHCSA organise and accredit what you already know; the RHCSA is practical and demanding, and preparing for it genuinely teaches you something. The natural specialisations from here are four: reliability engineering, which is this last lesson taken to scale; platform and cloud, where Kubernetes and infrastructure as code are the day job; cybersecurity, extending Module 6; and databases, extending 08-02. Take part in the community — mailing lists, forums, a well-written bug report, a documentation fix — because explaining something is the best way to discover whether you understand it. And read the release notes of the kernel and of your distribution: it is the cheapest way of not falling behind.
But if you can keep only one thing, let it be this: keep the lab. That test VM, or the media server from 08-03, or the k3s cluster you decided not to take to production. A place of your own where you can break things on purpose, cause failures during working hours instead of waiting for them, and try out what you do not know yet. Everything you have learned on this course you have learned by doing it, and that is the only way there is. The difference between somebody who reads about systems and somebody who administers them is not what they have studied: it is what they have broken and put back together. Switch the machine on and carry on.
Linux Course: From Beginner to System Administrator
Module 1: Introduction to Linux
- What Is Linux?
- History of Linux
- Linux Distributions
- Installing Linux
- First Contact with the System
- The Linux File System Structure
Module 2: Basic Linux Commands
- Introduction to the Command Line
- Getting Help and System Documentation
- Navigating the File System
- File and Directory Operations
- Viewing and Editing Files
- Hard and Symbolic Links
- File Permissions and Ownership
Module 3: Advanced Command-Line Skills
- The Shell Environment: Variables, Aliases and History
- Using Wildcards and Regular Expressions
- Searching Files and Content: find, locate and grep
- Pipes and Redirection
- Text Processing: cut, sort, uniq, sed and awk
- Process Management
- Scheduling Tasks with Cron
- Networking Commands
Module 4: Shell Scripting
- Introduction to Shell Scripting
- Variables and Data Types
- Script Input, Output and Arguments
- Control Structures
- Functions and Libraries
- Debugging and Error Handling
- Production Scripts: Best Practices
Module 5: System Administration
- User and Group Management
- sudo and Special Permissions
- Package Management
- Disk Management
- systemd and Service Management
- System Logs: journald and syslog
- System Monitoring and Performance Tuning
- Backup and Restore
Module 6: Networking and Security
- Network Configuration
- SSH and Remote Access
- Firewalls and Perimeter Security
- Intrusion Detection Systems
- Secrets Management and TLS Certificates
- Securing Linux Systems
Module 7: Advanced Topics
- The Boot Process and System Recovery
- Advanced Diagnostics: strace, perf and eBPF
- Linux Kernel Tuning
- Virtualization with Linux
- Linux Containers and Docker
- Automation with Ansible
- High Availability and Load Balancing
