Technical lab demonstrating Linux administration, Ansible-driven deployment, an Nginx reverse proxy in front of a Python application, and an observability stack (Prometheus, Grafana, Loki, Alloy) capable of detecting and helping diagnose a real incident: a backend outage and its recovery.
Grafana dashboard provisioned automatically through Ansible.
- A Python application (Flask + gunicorn) managed as a systemd service.
- Nginx as a reverse proxy, with verified behavior on backend outage
(
502) and recovery (200). - A complete, idempotent deployment with Ansible (roles, handlers, templates, variables separated from tasks).
- Metrics via Prometheus + Node Exporter, with a basic alert rule.
- Version-controlled Grafana dashboards, provisioned automatically.
- Centralized Nginx logs via Grafana Alloy → Loki, queryable from Grafana.
- A real incident reproduced and documented with log evidence (see
docs/incidents/).
flowchart LR
Client["curl / browser"] -->|":8080"| N["nginx :8080\nreverse proxy"]
N -->|"proxy_pass"| G["gunicorn :8000\n(linux-ops-app.service)"]
N -->|"access/error log"| A["Grafana Alloy"]
A -->|push| L["Loki :3100"]
P["Prometheus :9090"] -->|scrape| NE["Node Exporter :9100"]
P -->|scrape self| P
P -->|rule_files| Alert["InstanceDown rule"]
GF["Grafana :3000"] -->|datasource| P
GF -->|datasource| L
Linux (systemd), Python (Flask, gunicorn), Nginx, Ansible, Prometheus, Node Exporter, Grafana, Loki, Grafana Alloy.
app/ Flask application source code
ansible/ Deployment playbook and roles
roles/common/ Base system packages
roles/application/ systemd service for the Python app
roles/nginx/ Reverse proxy on :8080
roles/node_exporter/ System metrics
roles/prometheus/ Scraping + alert rules
roles/grafana_repo/ Official Grafana APT repository
roles/loki/ Log storage
roles/alloy/ Nginx log collection
roles/grafana/ Provisioned datasources and dashboard
docs/incidents/ Documented evidence of real incidents
scripts/ Failure simulation and recovery
screenshots/ Dashboard screenshots (pending)
- Ubuntu 24.04 (or similar, with
aptandsystemd). - Ansible core ≥ 2.13 (tested with 2.16.3) on the control host.
sudoaccess on the target host (the playbook usesbecome: true).- Connectivity to
apt.grafana.comto install Grafana, Loki and Alloy.
cd ansible
ansible-playbook site.yml --ask-become-passansible.cfg already points to the inventory/local.ini inventory (a single
local host). To deploy to another machine, replace the inventory with one
pointing to the real host and its SSH credentials.
cd ansible
ansible-playbook --syntax-check site.yml
ansible-playbook site.yml --check --diff --ask-become-passA second run should report no changes, except for a known warning from the
ansible.builtin.pip module in --check mode (see "Known limitations").
curl -i http://127.0.0.1:8000/ # app directly -> 200
curl -i http://127.0.0.1:8080/ # through Nginx -> 200
curl -i http://127.0.0.1:8000/health # -> 200 {"status":"ok"}./scripts/simulate-failure.sh # stops linux-ops-app and confirms HTTP 502
./scripts/recover.sh # starts linux-ops-app and confirms HTTP 200simulate-failure.sh saves the service's initial state and, if the script
fails or is interrupted (Ctrl+C), restores it automatically. If the
simulation completes successfully, it deliberately leaves the service
stopped so that recover.sh performs the recovery explicitly.
Evidence of a real run of this flow, with timestamps and Nginx logs:
docs/incidents/2026-07-20-app-down.md.
- Logs:
journalctl -u linux-ops-app -n 50andtail -f /var/log/nginx/linux-ops-error.logshow theconnect() failed (111: Connection refused)error when the backend is down. - Grafana → Loki: the "Nginx Access Logs" panel on the
linux-ops-overviewdashboard shows502lines in real time. - Prometheus: the
InstanceDownrule (up == 0for 30s) is evaluated against the scraped targets (Prometheus, Node Exporter).
| Symptom | Likely cause | Verification |
|---|---|---|
Nginx returns 502 |
linux-ops-app is not active |
systemctl status linux-ops-app |
nginx -t fails |
Syntax error in the vhost | nginx -t shows the exact file and line |
| Grafana shows no Loki data | Alloy is not reading the logs | systemctl status alloy and journalctl -u alloy |
Prometheus target down |
The corresponding exporter is not active | curl 127.0.0.1:9100/metrics (Node Exporter) |
- The application does not expose
/metrics; Prometheus does not monitor the app directly, only the host (Node Exporter) and Prometheus itself. - The
InstanceDownalert rule does not cover a direct HTTP probe against:8080; see "Next improvements". - Loki storage uses
/tmp/loki(Loki's default configuration), so logs do not persist across a host restart. - The observability services (Prometheus, Grafana, Loki, Node Exporter)
listen on all interfaces (
0.0.0.0), which is acceptable for a local lab but should be restricted to127.0.0.1or protected with a firewall in any shared environment. - There is no Alertmanager: alerts are evaluable in Prometheus (
/alerts) but are not routed to any notification channel.
- Ansible with per-component roles instead of a single flat playbook, so each piece (app, nginx, prometheus, grafana, loki, alloy) is independent and has its own handlers.
- Handlers instead of unconditional restarts: each service is only restarted or reloaded when its configuration actually changes.
- gunicorn instead of Flask's development server:
app.run()is not suitable for production; thesystemdunit runs gunicorn with 2 workers. docs/incidents/with real log evidence instead of a purely descriptive simulation.
- Add
blackbox_exporterto alert directly on the HTTP availability of:8080(would detect the502without relying on logs). - Dashboard screenshots taken during the incident, in
screenshots/. - Loki persistence outside of
/tmp. - Restrict Prometheus/Grafana/Loki/Node Exporter binding to
127.0.0.1when remote access is not needed.
