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

  1. What being in production means
  2. The going-to-production checklist
  3. Monitoring and alerting: they are not the same thing
  4. Prometheus and node_exporter
  5. The essential PromQL
  6. Metrics of your own: the ones you already have without knowing it
  7. Grafana and the minimum dashboard
  8. Alerts you can act on
  9. Centralised logs: the debt from 05-06
  10. Daily, weekly and monthly operations
  11. The duty log and the change log
  12. The blameless post-incident review
  13. Change management and deployment windows
  14. SLIs, SLOs and the error budget
  15. Closing the outstanding debts
  16. Compliance: GDPR and data retention
  17. 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	up

promtool 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.

up{job="tramontana"}
# 1 = the target answered the last scrape; 0 = it did not answer

2. rate() over counters. A counter only goes up. Its absolute value says nothing; what matters is the rate at which it grows.

# Requests per second, averaged over the last 5 minutes
rate(tramontana_requests_total[5m])

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 * 100

4. 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) < 0

Alerting 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 1755518402

Four design decisions in that script:

  1. The atomic write with mktemp and mv. node_exporter can read the file at any moment; a truncated one makes it discard the whole contents.
  2. umask 022, against the course's general convention: node_exporter runs as a different user and needs to read it. It is a justified exception, and that is why it is commented.
  3. 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.
  4. 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 12

Grafana 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 = false

And the access, making use of what is already built: over the VPN from 08-04, without publishing anything new.

$ sudo ufw allow from 10.8.0.0/24 to any port 3000 proto tcp comment 'Grafana via VPN'

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:

  1. Alerts are configured for everything, "just in case".
  2. Ten a day arrive, almost all of them irrelevant.
  3. People start ignoring them.
  4. A mail filter is created that archives them.
  5. 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:  SUCCESS

Alertmanager: 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 Email 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 finished

Silences 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:

  1. It is written at the time, not at the end of the day. What gets postponed does not get written.
  2. Timestamps always, so that it can be correlated with the charts.
  3. 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.
  4. 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" --stat

The 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 DELETE whose WHERE clause 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 DELETE is run with the wrong condition
14: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_statements
15:02 The PITR restore begins on srv-tramontana-test
15:19 Recovery completed and verified on the test machine
15:24 Data reimported into production with psql -1
15:26 Final verification. Incident closed

4. Root cause: the five whys

# Question Answer
1 Why were 40,218 bookings deleted? A DELETE with the wrong WHERE
2 Why was it run with the wrong condition? It was typed by hand, with no prior SELECT
3 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 audit agreed with Marta Marta + Ops 25/08 Pending
2 A cleanup runbook: a mandatory SELECT count(*) before any DELETE Ops 22/08 In progress
3 An svc_cleanup role with statement_timeout and no bulk DELETE permission Ops 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 psql with \set ON_ERROR_ROLLBACK off and a warning in the prompt Ops 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

  1. 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.
  2. 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.
  3. 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:

  1. Problems appear under real load, hours later. On a Friday at 18:00, that load arrives on Monday.
  2. People go home. If something fails at 20:00 on a Friday, whoever deployed it is no longer around.
  3. The weekend is when most bookings are made at Tramontana: the worst possible moment for a failure.
  4. 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.example answered 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.4

31.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_keys2

And 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_keys

And 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 > 0

The 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 action and 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_keys2 from 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.342

Confirmed: 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.

$ cd ~/tramontana-infra && git log --since="12 hours ago" --oneline
(no results)

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.availability

Root 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    | psql

application_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';"
0

04: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 FULL run manually on app.availability acquired 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.availability is launched manually
03: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 HighErrorRate fires and the notification arrives
03:49 The problem is confirmed
03:51 It is narrowed down with the dashboard: the database
03:55 The lock and the VACUUM FULL are identified
03:58 pg_cancel_backend. Service restored
04: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 EXCLUSIVE lock on app.availability
3 Why was that lock there? A manual VACUUM FULL
4 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? autovacuum is not tuned for that table

Root cause: autovacuum with the default values (a 20 % threshold) does not keep up with the write rate of app.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 VACUUM did 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 autovacuum for app.availability to 2 % (08-02) Ops 19/08 Done
2 lock_timeout = 5s by default in the production psql session Ops 20/08 Done
3 Install pg_repack and document it as the tool for bloat Ops 26/08 Pending
4 A LongLockWait alert (> 30 s) — it warns before the symptom Ops 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 = 5s

7. Lessons

  1. The latency rose 9 minutes before the errors. That is the margin we lost by not having an early alert. Action 4 recovers it.
  2. 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.
  3. 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.
  4. VACUUM FULL is 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	0

The 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? → KEEP

Applied 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:

  1. 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.
  2. 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.
  3. An alert that never fires is not useless, if it covers a serious risk and its cost is zero. ServiceDown has not fired in a year and it must stay there.
  4. 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:

  1. 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.
  2. 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:

  1. 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.
  2. 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

Module 2: Basic Linux Commands

Module 3: Advanced Command-Line Skills

Module 4: Shell Scripting

Module 5: System Administration

Module 6: Networking and Security

Module 7: Advanced Topics

Module 8: Practical Projects

© Copyright 2026. All rights reserved