ansible: monitoring with Prometheus, Loki and Grafana
All checks were successful
deploy / ansible (push) Successful in 7m23s

Nothing watched the servers: tonight's asxpio crash loop was found by
hand. On asxpio-ops, Prometheus (30 days), Loki (14 days) and Grafana
at gf.asxp.io (WireGuard only), with blackbox_exporter probing every
service from outside and postgres_exporter. On both hosts node_exporter
and cAdvisor on the VPC address, and Alloy shipping container logs, the
journal and Traefik's access log to Loki over the VPC.

Traefik and Forgejo expose their metrics; restic and the bucket replica
record their last success for node_exporter. Dashboards: an overview as
home, plus hosts, containers, Traefik, probes, Postgres and logs.
Alerts by email to me@asxp.io: host or service down, certificate under
14 days, disk or memory nearly full, a container restart-looping,
Postgres down, backup or replica stale.

Assisted-by: Claude:opus-5-5
This commit is contained in:
Sergei Poljanski 2026-11-13 04:59:11 +04:00
commit e20f5a7d07
Signed by: asxpi
GPG key ID: 4F8851660FA4121B
28 changed files with 24705 additions and 161 deletions

View file

@ -99,3 +99,8 @@ SHOW_FOOTER_VERSION = false
SHOW_FOOTER_TEMPLATE_LOAD_TIME = false
SHOW_FOOTER_POWERED_BY = false
[metrics]
; scraped by Prometheus on asxpio-ops over the traefik network
ENABLED = true
TOKEN = {{ monitoring_forgejo_metrics_token }}

View file

@ -0,0 +1,24 @@
---
monitoring_base_path: "{{ services_base_path }}/monitoring"
# Hosts with node_exporter, cAdvisor and Alloy, scraped over the VPC
monitoring_hosts:
- { name: asxpio-ops, address: "{{ asxpio_ops_vpc_ip }}" }
- { name: asxpio-services, address: "{{ asxpio_services_vpc_ip }}" }
# Outside-in probes (blackbox_exporter). module: see blackbox.yml.j2
monitoring_probes:
- { name: asxp.io, target: "https://asxp.io/", module: http_2xx }
- { name: blog, target: "https://blog.asxp.io/", module: http_2xx }
- { name: czsk.it, target: "https://czsk.it/", module: http_2xx }
- { name: datacenterbingo, target: "https://datacenterbingo.xyz/", module: http_2xx }
- { name: datacenterbingo data.json, target: "https://datacenterbingo.xyz/data.json", module: http_2xx }
- { name: forgejo, target: "https://git.asxp.io/", module: http_2xx }
- { name: s3, target: "https://s3.asxp.io/", module: http_up }
- { name: sillytavern, target: "http://{{ asxpio_services_wireguard_ip }}:8000/", module: http_up }
- { name: xmpp c2s starttls, target: "xmpp.asxp.io:5222", module: xmpp_starttls }
- { name: xmpp c2s tls, target: "xmpp.asxp.io:5223", module: tls_connect }
- { name: xmpp s2s, target: "xmpp.asxp.io:5269", module: tcp_connect }
- { name: turn, target: "xmpp.asxp.io:3478", module: tcp_connect }
- { name: xray, target: "{{ hostvars['asxpio-services'].public_ip }}:443", module: tcp_connect }
- { name: minecraft, target: "mc.asxp.io:25565", module: tcp_connect }

View file

@ -0,0 +1,781 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": "-- Grafana --",
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"gnetId": 14282,
"graphTooltip": 0,
"id": null,
"iteration": 1617715580880,
"links": [],
"panels": [
{
"collapsed": false,
"datasource": "prometheus",
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 0
},
"id": 8,
"panels": [],
"title": "CPU",
"type": "row"
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "prometheus",
"fieldConfig": {
"defaults": {
"custom": {}
},
"overrides": []
},
"fill": 1,
"fillGradient": 0,
"gridPos": {
"h": 7,
"w": 24,
"x": 0,
"y": 1
},
"hiddenSeries": false,
"id": 15,
"legend": {
"alignAsTable": true,
"avg": true,
"current": false,
"max": true,
"min": false,
"rightSide": true,
"show": true,
"total": false,
"values": true
},
"lines": true,
"linewidth": 1,
"nullPointMode": "null as zero",
"options": {
"alertThreshold": true
},
"percentage": false,
"pluginVersion": "7.4.5",
"pointradius": 2,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": true,
"steppedLine": false,
"targets": [
{
"expr": "sum(rate(container_cpu_usage_seconds_total{instance=~\"$host\",name=~\"$container\",name=~\".+\"}[5m])) by (name) *100",
"hide": false,
"interval": "",
"legendFormat": "{{name}}",
"refId": "A"
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "CPU Usage",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"$$hashKey": "object:606",
"format": "percent",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"$$hashKey": "object:607",
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"collapsed": false,
"datasource": "prometheus",
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 8
},
"id": 11,
"panels": [],
"title": "Memory",
"type": "row"
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "prometheus",
"fieldConfig": {
"defaults": {
"custom": {}
},
"overrides": []
},
"fill": 1,
"fillGradient": 0,
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 9
},
"hiddenSeries": false,
"id": 9,
"legend": {
"alignAsTable": true,
"avg": true,
"current": false,
"max": true,
"min": false,
"rightSide": true,
"show": true,
"total": false,
"values": true
},
"lines": true,
"linewidth": 1,
"nullPointMode": "null as zero",
"options": {
"alertThreshold": true
},
"percentage": false,
"pluginVersion": "7.4.5",
"pointradius": 2,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": true,
"steppedLine": false,
"targets": [
{
"expr": "sum(container_memory_rss{instance=~\"$host\",name=~\"$container\",name=~\".+\"}) by (name)",
"hide": false,
"interval": "",
"legendFormat": "{{name}}",
"refId": "A"
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Memory Usage",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"$$hashKey": "object:606",
"format": "bytes",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"$$hashKey": "object:607",
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "prometheus",
"fieldConfig": {
"defaults": {
"custom": {}
},
"overrides": []
},
"fill": 1,
"fillGradient": 0,
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 9
},
"hiddenSeries": false,
"id": 14,
"legend": {
"alignAsTable": true,
"avg": true,
"current": false,
"max": true,
"min": false,
"rightSide": true,
"show": true,
"total": false,
"values": true
},
"lines": true,
"linewidth": 1,
"nullPointMode": "null as zero",
"options": {
"alertThreshold": true
},
"percentage": false,
"pluginVersion": "7.4.5",
"pointradius": 2,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": true,
"steppedLine": false,
"targets": [
{
"expr": "sum(container_memory_cache{instance=~\"$host\",name=~\"$container\",name=~\".+\"}) by (name)",
"hide": false,
"interval": "",
"legendFormat": "{{name}}",
"refId": "A"
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Memory Cached",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"$$hashKey": "object:606",
"format": "bytes",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"$$hashKey": "object:607",
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"collapsed": false,
"datasource": "prometheus",
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 17
},
"id": 2,
"panels": [],
"title": "Network",
"type": "row"
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "prometheus",
"fieldConfig": {
"defaults": {
"custom": {}
},
"overrides": []
},
"fill": 1,
"fillGradient": 0,
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 18
},
"hiddenSeries": false,
"id": 4,
"legend": {
"alignAsTable": true,
"avg": true,
"current": false,
"hideEmpty": false,
"hideZero": false,
"max": true,
"min": false,
"rightSide": true,
"show": true,
"sideWidth": null,
"total": false,
"values": true
},
"lines": true,
"linewidth": 1,
"nullPointMode": "null",
"options": {
"alertThreshold": true
},
"percentage": false,
"pluginVersion": "7.4.5",
"pointradius": 2,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "sum(rate(container_network_receive_bytes_total{instance=~\"$host\",name=~\"$container\",name=~\".+\"}[5m])) by (name)",
"hide": false,
"interval": "",
"legendFormat": "{{name}}",
"refId": "A"
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Received Network Traffic",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"$$hashKey": "object:674",
"format": "Bps",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"$$hashKey": "object:675",
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"aliasColors": {},
"bars": false,
"dashLength": 10,
"dashes": false,
"datasource": "prometheus",
"fieldConfig": {
"defaults": {
"custom": {}
},
"overrides": []
},
"fill": 1,
"fillGradient": 0,
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 18
},
"hiddenSeries": false,
"id": 6,
"legend": {
"alignAsTable": true,
"avg": true,
"current": false,
"max": true,
"min": false,
"rightSide": true,
"show": true,
"total": false,
"values": true
},
"lines": true,
"linewidth": 1,
"nullPointMode": "null",
"options": {
"alertThreshold": true
},
"percentage": false,
"pluginVersion": "7.4.5",
"pointradius": 2,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "sum(rate(container_network_transmit_bytes_total{instance=~\"$host\",name=~\"$container\",name=~\".+\"}[5m])) by (name)",
"interval": "",
"legendFormat": "{{name}}",
"refId": "A"
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "Sent Network Traffic",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"$$hashKey": "object:832",
"format": "Bps",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
},
{
"$$hashKey": "object:833",
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": true
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"collapsed": false,
"datasource": "prometheus",
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 26
},
"id": 19,
"panels": [],
"title": "Misc",
"type": "row"
},
{
"datasource": "prometheus",
"fieldConfig": {
"defaults": {
"custom": {
"align": null,
"filterable": false
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "id"
},
"properties": [
{
"id": "custom.width",
"value": 260
}
]
},
{
"matcher": {
"id": "byName",
"options": "Running"
},
"properties": [
{
"id": "unit",
"value": "d"
},
{
"id": "decimals",
"value": 1
},
{
"id": "custom.displayMode",
"value": "color-text"
},
{
"id": "color",
"value": {
"fixedColor": "dark-green",
"mode": "fixed"
}
}
]
}
]
},
"gridPos": {
"h": 10,
"w": 24,
"x": 0,
"y": 27
},
"id": 17,
"options": {
"showHeader": true,
"sortBy": []
},
"pluginVersion": "7.4.5",
"targets": [
{
"expr": "(time() - container_start_time_seconds{instance=~\"$host\",name=~\"$container\",name=~\".+\"})/86400",
"format": "table",
"instant": true,
"interval": "",
"legendFormat": "{{name}}",
"refId": "A"
}
],
"timeFrom": null,
"timeShift": null,
"title": "Containers Info",
"transformations": [
{
"id": "filterFieldsByName",
"options": {
"include": {
"names": [
"container_label_com_docker_compose_project",
"container_label_com_docker_compose_project_working_dir",
"image",
"instance",
"name",
"Value",
"container_label_com_docker_compose_service"
]
}
}
},
{
"id": "organize",
"options": {
"excludeByName": {},
"indexByName": {},
"renameByName": {
"Value": "Running",
"container_label_com_docker_compose_project": "Label",
"container_label_com_docker_compose_project_working_dir": "Working dir",
"container_label_com_docker_compose_service": "Service",
"image": "Registry Image",
"instance": "Instance",
"name": "Name"
}
}
}
],
"type": "table"
}
],
"schemaVersion": 27,
"style": "dark",
"tags": [
"cadvisor",
"docker"
],
"templating": {
"list": [
{
"allValue": ".*",
"current": {},
"datasource": "prometheus",
"definition": "label_values({__name__=~\"container.*\"},instance)",
"description": null,
"error": null,
"hide": 0,
"includeAll": true,
"label": "Host",
"multi": false,
"name": "host",
"options": [],
"query": {
"query": "label_values({__name__=~\"container.*\"},instance)",
"refId": "Prometheus-host-Variable-Query"
},
"refresh": 1,
"regex": "",
"skipUrlSync": false,
"sort": 5,
"tagValuesQuery": "",
"tags": [],
"tagsQuery": "",
"type": "query",
"useTags": false
},
{
"allValue": ".*",
"current": {},
"datasource": "prometheus",
"definition": "label_values({__name__=~\"container.*\", instance=~\"$host\"},name)",
"description": null,
"error": null,
"hide": 0,
"includeAll": true,
"label": "Container",
"multi": false,
"name": "container",
"options": [],
"query": {
"query": "label_values({__name__=~\"container.*\", instance=~\"$host\"},name)",
"refId": "Prometheus-container-Variable-Query"
},
"refresh": 1,
"regex": "",
"skipUrlSync": false,
"sort": 0,
"tagValuesQuery": "",
"tags": [],
"tagsQuery": "",
"type": "query",
"useTags": false
}
]
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "Cadvisor exporter",
"uid": "gnet-14282",
"version": 1,
"description": "Simple exporter for cadvisor only"
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,248 @@
{
"annotations": {
"list": [
{
"$$hashKey": "object:75",
"builtIn": 1,
"datasource": "-- Grafana --",
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"description": "Log Viewer Dashboard for Loki",
"editable": false,
"gnetId": 13639,
"graphTooltip": 0,
"id": null,
"iteration": 1608932746420,
"links": [
{
"$$hashKey": "object:59",
"icon": "bolt",
"includeVars": true,
"keepTime": true,
"tags": [],
"targetBlank": true,
"title": "View In Explore",
"type": "link",
"url": "/explore?orgId=1&left=[\"now-1h\",\"now\",\"Loki\",{\"expr\":\"{job=\\\"$app\\\"}\"},{\"ui\":[true,true,true,\"none\"]}]"
},
{
"$$hashKey": "object:61",
"icon": "external link",
"tags": [],
"targetBlank": true,
"title": "Learn LogQL",
"type": "link",
"url": "https://grafana.com/docs/loki/latest/logql/"
}
],
"panels": [
{
"aliasColors": {},
"bars": true,
"dashLength": 10,
"dashes": false,
"datasource": "loki",
"fieldConfig": {
"defaults": {
"custom": {},
"links": []
},
"overrides": []
},
"fill": 1,
"fillGradient": 0,
"gridPos": {
"h": 3,
"w": 24,
"x": 0,
"y": 0
},
"hiddenSeries": false,
"id": 6,
"legend": {
"avg": false,
"current": false,
"max": false,
"min": false,
"show": false,
"total": false,
"values": false
},
"lines": false,
"linewidth": 1,
"nullPointMode": "null",
"percentage": false,
"pluginVersion": "7.1.0",
"pointradius": 2,
"points": false,
"renderer": "flot",
"seriesOverrides": [],
"spaceLength": 10,
"stack": false,
"steppedLine": false,
"targets": [
{
"expr": "sum(count_over_time({job=\"$app\"} |= \"$search\" [$__interval]))",
"legendFormat": "",
"refId": "A"
}
],
"thresholds": [],
"timeFrom": null,
"timeRegions": [],
"timeShift": null,
"title": "",
"tooltip": {
"shared": true,
"sort": 0,
"value_type": "individual"
},
"type": "graph",
"xaxis": {
"buckets": null,
"mode": "time",
"name": null,
"show": true,
"values": []
},
"yaxes": [
{
"$$hashKey": "object:168",
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": false
},
{
"$$hashKey": "object:169",
"format": "short",
"label": null,
"logBase": 1,
"max": null,
"min": null,
"show": false
}
],
"yaxis": {
"align": false,
"alignLevel": null
}
},
{
"datasource": "loki",
"fieldConfig": {
"defaults": {
"custom": {}
},
"overrides": []
},
"gridPos": {
"h": 25,
"w": 24,
"x": 0,
"y": 3
},
"id": 2,
"maxDataPoints": "",
"options": {
"showLabels": false,
"showTime": true,
"sortOrder": "Descending",
"wrapLogMessage": false
},
"targets": [
{
"expr": "{job=\"$app\"} |= \"$search\" | logfmt",
"hide": false,
"legendFormat": "",
"refId": "A"
}
],
"timeFrom": null,
"timeShift": null,
"title": "",
"transparent": true,
"type": "logs"
}
],
"refresh": false,
"schemaVersion": 26,
"style": "dark",
"tags": [],
"templating": {
"list": [
{
"allValue": null,
"current": {},
"datasource": "loki",
"definition": "label_values(job)",
"hide": 0,
"includeAll": false,
"label": "App",
"multi": false,
"name": "app",
"options": [],
"query": "label_values(job)",
"refresh": 1,
"regex": "",
"skipUrlSync": false,
"sort": 0,
"tagValuesQuery": "",
"tags": [],
"tagsQuery": "",
"type": "query",
"useTags": false
},
{
"current": {
"selected": false,
"text": "",
"value": ""
},
"hide": 0,
"label": "String Match",
"name": "search",
"options": [
{
"selected": true,
"text": "",
"value": ""
}
],
"query": "",
"skipUrlSync": false,
"type": "textbox"
}
]
},
"time": {
"from": "now-1h",
"to": "now"
},
"timepicker": {
"hidden": false,
"refresh_intervals": [
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d"
]
},
"timezone": "",
"title": "Logs / App",
"uid": "gnet-13639",
"version": 13
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,37 @@
---
# Prometheus, Loki, Grafana, blackbox_exporter and postgres_exporter on
# asxpio-ops. Grafana: https://gf.asxp.io, WireGuard only.
- name: Create the provisioning directories
ansible.builtin.file:
path: "{{ monitoring_base_path }}/{{ item }}"
state: directory
mode: "0755"
loop:
- grafana/provisioning/datasources
- grafana/provisioning/dashboards
- grafana/provisioning/alerting
- grafana/dashboards
- name: Deploy the dashboards
ansible.builtin.copy:
src: dashboards/
dest: "{{ monitoring_base_path }}/grafana/dashboards/"
mode: "0644"
- name: Deploy the monitoring stack
ansible.builtin.include_role:
name: docker_service
vars:
service_name: monitoring
service_base_path: "{{ monitoring_base_path }}"
service_templates:
- { src: docker-compose.yml.j2, dest: docker-compose.yml }
- { src: prometheus.yml.j2, dest: prometheus.yml }
- { src: blackbox.yml.j2, dest: blackbox.yml }
- { src: loki.yml.j2, dest: loki.yml }
- { src: grafana.env.j2, dest: grafana.env, mode: "0600" }
- { src: datasources.yaml.j2, dest: grafana/provisioning/datasources/datasources.yaml }
- { src: dashboards.yaml.j2, dest: grafana/provisioning/dashboards/dashboards.yaml }
- { src: alerting.yaml.j2, dest: grafana/provisioning/alerting/alerting.yaml }
- { src: alert-rules.yaml.j2, dest: grafana/provisioning/alerting/rules.yaml }
health_check_url: "http://{{ asxpio_ops_vpc_ip }}:{{ monitoring_loki_port }}/ready"

View file

@ -0,0 +1,170 @@
# Managed by Ansible (role monitoring). Generated; see the commit adding it.
{% raw %}
apiVersion: 1
groups:
- orgId: 1
name: asxpio
folder: Alerts
interval: 1m
rules:
- uid: host-down
title: "Host down"
condition: C
data:
- refId: A
relativeTimeRange: { from: 900, to: 0 }
datasourceUid: prometheus
model: { refId: A, expr: "1 - up{job=\"node\"}", instant: true, intervalMs: 30000, maxDataPoints: 43200 }
- refId: C
datasourceUid: __expr__
model: { refId: C, type: threshold, expression: A, conditions: [ { evaluator: { type: gt, params: [0] } } ] }
noDataState: OK
execErrState: Error
for: 2m
labels: { severity: critical }
annotations: { summary: "{{ $labels.host }} stopped answering (node_exporter over the VPC)" }
- uid: target-down
title: "Scrape target down"
condition: C
data:
- refId: A
relativeTimeRange: { from: 900, to: 0 }
datasourceUid: prometheus
model: { refId: A, expr: "up{job!~\"node|probe_.*\"} == 0", instant: true, intervalMs: 30000, maxDataPoints: 43200 }
- refId: C
datasourceUid: __expr__
model: { refId: C, type: threshold, expression: A, conditions: [ { evaluator: { type: gt, params: [-1] } } ] }
noDataState: OK
execErrState: Error
for: 5m
labels: { severity: warning }
annotations: { summary: "{{ $labels.job }} on {{ $labels.instance }} is not answering metrics scrapes" }
- uid: probe-down
title: "Service down (outside-in probe)"
condition: C
data:
- refId: A
relativeTimeRange: { from: 900, to: 0 }
datasourceUid: prometheus
model: { refId: A, expr: "1 - probe_success", instant: true, intervalMs: 30000, maxDataPoints: 43200 }
- refId: C
datasourceUid: __expr__
model: { refId: C, type: threshold, expression: A, conditions: [ { evaluator: { type: gt, params: [0] } } ] }
noDataState: OK
execErrState: Error
for: 2m
labels: { severity: critical }
annotations: { summary: "{{ $labels.probe }} ({{ $labels.instance }}) is not reachable" }
- uid: cert-expiry
title: "Certificate expires soon"
condition: C
data:
- refId: A
relativeTimeRange: { from: 900, to: 0 }
datasourceUid: prometheus
model: { refId: A, expr: "(probe_ssl_earliest_cert_expiry - time()) / 86400", instant: true, intervalMs: 30000, maxDataPoints: 43200 }
- refId: C
datasourceUid: __expr__
model: { refId: C, type: threshold, expression: A, conditions: [ { evaluator: { type: lt, params: [14] } } ] }
noDataState: OK
execErrState: Error
for: 1h
labels: { severity: warning }
annotations: { summary: "{{ $labels.probe }}: certificate expires in {{ $values.A.Value | printf \"%.0f\" }} days" }
- uid: disk-full
title: "Disk almost full"
condition: C
data:
- refId: A
relativeTimeRange: { from: 900, to: 0 }
datasourceUid: prometheus
model: { refId: A, expr: "100 * (1 - node_filesystem_avail_bytes{fstype!~\"tmpfs|overlay|squashfs|fuse.*\"} / node_filesystem_size_bytes{fstype!~\"tmpfs|overlay|squashfs|fuse.*\"})", instant: true, intervalMs: 30000, maxDataPoints: 43200 }
- refId: C
datasourceUid: __expr__
model: { refId: C, type: threshold, expression: A, conditions: [ { evaluator: { type: gt, params: [85] } } ] }
noDataState: OK
execErrState: Error
for: 10m
labels: { severity: warning }
annotations: { summary: "{{ $labels.host }} {{ $labels.mountpoint }} is {{ $values.A.Value | printf \"%.0f\" }}% full" }
- uid: memory-low
title: "Memory almost exhausted"
condition: C
data:
- refId: A
relativeTimeRange: { from: 900, to: 0 }
datasourceUid: prometheus
model: { refId: A, expr: "100 * (1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)", instant: true, intervalMs: 30000, maxDataPoints: 43200 }
- refId: C
datasourceUid: __expr__
model: { refId: C, type: threshold, expression: A, conditions: [ { evaluator: { type: gt, params: [92] } } ] }
noDataState: OK
execErrState: Error
for: 10m
labels: { severity: warning }
annotations: { summary: "{{ $labels.host }} memory is {{ $values.A.Value | printf \"%.0f\" }}% used" }
- uid: container-restarting
title: "Container restart-looping"
condition: C
data:
- refId: A
relativeTimeRange: { from: 900, to: 0 }
datasourceUid: prometheus
model: { refId: A, expr: "changes(container_start_time_seconds{name!=\"\"}[15m])", instant: true, intervalMs: 30000, maxDataPoints: 43200 }
- refId: C
datasourceUid: __expr__
model: { refId: C, type: threshold, expression: A, conditions: [ { evaluator: { type: gt, params: [2] } } ] }
noDataState: OK
execErrState: Error
for: 0m
labels: { severity: critical }
annotations: { summary: "{{ $labels.name }} on {{ $labels.host }} restarted {{ $values.A.Value | printf \"%.0f\" }} times in 15 minutes" }
- uid: postgres-down
title: "Postgres down"
condition: C
data:
- refId: A
relativeTimeRange: { from: 900, to: 0 }
datasourceUid: prometheus
model: { refId: A, expr: "pg_up", instant: true, intervalMs: 30000, maxDataPoints: 43200 }
- refId: C
datasourceUid: __expr__
model: { refId: C, type: threshold, expression: A, conditions: [ { evaluator: { type: lt, params: [1] } } ] }
noDataState: OK
execErrState: Error
for: 1m
labels: { severity: critical }
annotations: { summary: "Postgres (storage) is not answering" }
- uid: backup-stale
title: "Backup stale"
condition: C
data:
- refId: A
relativeTimeRange: { from: 900, to: 0 }
datasourceUid: prometheus
model: { refId: A, expr: "(time() - restic_backup_last_success_timestamp_seconds) / 3600", instant: true, intervalMs: 30000, maxDataPoints: 43200 }
- refId: C
datasourceUid: __expr__
model: { refId: C, type: threshold, expression: A, conditions: [ { evaluator: { type: gt, params: [26] } } ] }
noDataState: OK
execErrState: Error
for: 0m
labels: { severity: warning }
annotations: { summary: "restic on {{ $labels.host }}: last good backup {{ $values.A.Value | printf \"%.0f\" }} hours ago" }
- uid: replica-stale
title: "Bucket replica stale"
condition: C
data:
- refId: A
relativeTimeRange: { from: 900, to: 0 }
datasourceUid: prometheus
model: { refId: A, expr: "(time() - s3_replicate_last_success_timestamp_seconds) / 3600", instant: true, intervalMs: 30000, maxDataPoints: 43200 }
- refId: C
datasourceUid: __expr__
model: { refId: C, type: threshold, expression: A, conditions: [ { evaluator: { type: gt, params: [26] } } ] }
noDataState: OK
execErrState: Error
for: 0m
labels: { severity: warning }
annotations: { summary: "hel1 -> fsn1 bucket replica: last good run {{ $values.A.Value | printf \"%.0f\" }} hours ago" }
{% endraw %}

View file

@ -0,0 +1,17 @@
apiVersion: 1
contactPoints:
- orgId: 1
name: email
receivers:
- uid: email-me
type: email
settings:
addresses: {{ monitoring_alert_email }}
singleEmail: true
policies:
- orgId: 1
receiver: email
group_by: [alertname, host]
group_wait: 1m
group_interval: 5m
repeat_interval: 4h

View file

@ -0,0 +1,38 @@
# Managed by Ansible (role monitoring).
modules:
http_2xx:
prober: http
timeout: 10s
http:
preferred_ip_protocol: ip4
follow_redirects: true
# Up means "answers": 401/403/404 are fine (s3 root, allowlisted UIs)
http_up:
prober: http
timeout: 10s
http:
preferred_ip_protocol: ip4
valid_status_codes: [200, 301, 302, 401, 403, 404]
tls_connect:
prober: tcp
timeout: 10s
tcp:
preferred_ip_protocol: ip4
tls: true
tcp_connect:
prober: tcp
timeout: 10s
tcp:
preferred_ip_protocol: ip4
# XMPP client port: stream header, STARTTLS, then the certificate
xmpp_starttls:
prober: tcp
timeout: 10s
tcp:
preferred_ip_protocol: ip4
query_response:
- send: "<?xml version='1.0'?><stream:stream to='asxp.io' xmlns='jabber:client' xmlns:stream='http://etherx.jabber.org/streams' version='1.0'>"
- expect: "urn:ietf:params:xml:ns:xmpp-tls"
- send: "<starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls'/>"
- expect: "<proceed"
- starttls: true

View file

@ -0,0 +1,9 @@
apiVersion: 1
providers:
- name: asxpio
type: file
disableDeletion: true
allowUiUpdates: false
options:
path: /var/lib/grafana/dashboards
foldersFromFilesStructure: true

View file

@ -0,0 +1,14 @@
apiVersion: 1
datasources:
- name: Prometheus
uid: prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
jsonData: { timeInterval: 30s }
- name: Loki
uid: loki
type: loki
access: proxy
url: http://loki:3100

View file

@ -0,0 +1,87 @@
# Managed by Ansible (role monitoring).
services:
prometheus:
image: prom/prometheus:{{ monitoring_prometheus_version }}
container_name: prometheus
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time={{ monitoring_metrics_retention }}
- --web.enable-lifecycle
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus-data:/prometheus
networks: [monitoring, traefik, storage]
restart: unless-stopped
mem_limit: 640m
loki:
image: grafana/loki:{{ monitoring_loki_version }}
container_name: loki
command: ["-config.file=/etc/loki/loki.yml"]
ports:
# Alloy on both hosts pushes here over the VPC
- "{{ asxpio_ops_vpc_ip }}:{{ monitoring_loki_port }}:3100"
volumes:
- ./loki.yml:/etc/loki/loki.yml:ro
- loki-data:/loki
networks: [monitoring]
restart: unless-stopped
mem_limit: 512m
grafana:
image: grafana/grafana:{{ monitoring_grafana_version }}
container_name: grafana
env_file: [grafana.env]
volumes:
- grafana-data:/var/lib/grafana
- ./grafana/provisioning:/etc/grafana/provisioning:ro
- ./grafana/dashboards:/var/lib/grafana/dashboards:ro
networks: [monitoring, traefik]
restart: unless-stopped
mem_limit: 384m
labels:
- traefik.enable=true
- traefik.docker.network=traefik
- traefik.http.routers.grafana.rule=Host(`{{ monitoring_grafana_host }}`)
- traefik.http.routers.grafana.entrypoints=websecure
- traefik.http.routers.grafana.tls=true
- traefik.http.routers.grafana.tls.certresolver=letsencrypt
- traefik.http.routers.grafana.middlewares=grafana-wireguard-only
- traefik.http.middlewares.grafana-wireguard-only.ipallowlist.sourcerange={{ wireguard_network_cidr }}
- traefik.http.services.grafana.loadbalancer.server.port=3000
blackbox:
image: prom/blackbox-exporter:{{ monitoring_blackbox_version }}
container_name: blackbox
command: ["--config.file=/etc/blackbox/blackbox.yml"]
volumes:
- ./blackbox.yml:/etc/blackbox/blackbox.yml:ro
networks: [monitoring]
restart: unless-stopped
mem_limit: 64m
postgres-exporter:
image: quay.io/prometheuscommunity/postgres-exporter:{{ monitoring_postgres_exporter_version }}
container_name: postgres-exporter
# The password comes from the storage stack's own .env (same host)
env_file: [{{ services_base_path }}/storage/.env]
environment:
DATA_SOURCE_URI: storage-postgres:5432/postgres?sslmode=disable
DATA_SOURCE_USER: postgres
entrypoint: ["/bin/sh", "-c", "DATA_SOURCE_PASS=\"$$POSTGRES_PASSWORD\" exec /bin/postgres_exporter"]
networks: [monitoring, storage]
restart: unless-stopped
mem_limit: 64m
volumes:
prometheus-data:
loki-data:
grafana-data:
networks:
monitoring:
traefik:
external: true
storage:
external: true

View file

@ -0,0 +1,17 @@
GF_SERVER_ROOT_URL=https://{{ monitoring_grafana_host }}/
GF_SECURITY_ADMIN_USER=admin
GF_SECURITY_ADMIN_PASSWORD={{ monitoring_grafana_admin_password }}
GF_SECURITY_COOKIE_SECURE=true
GF_USERS_ALLOW_SIGN_UP=false
GF_AUTH_ANONYMOUS_ENABLED=false
GF_ANALYTICS_REPORTING_ENABLED=false
GF_ANALYTICS_CHECK_FOR_UPDATES=false
GF_NEWS_NEWS_FEED_ENABLED=false
GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH=/var/lib/grafana/dashboards/overview.json
GF_SMTP_ENABLED=true
GF_SMTP_HOST=smtp.fastmail.com:587
GF_SMTP_USER={{ vault_forgejo_smtp_user }}
GF_SMTP_PASSWORD={{ vault_forgejo_smtp_password }}
GF_SMTP_FROM_ADDRESS={{ vault_forgejo_smtp_user }}
GF_SMTP_FROM_NAME=asxpio monitoring
GF_SMTP_STARTTLS_POLICY=MandatoryStartTLS

View file

@ -0,0 +1,37 @@
# Managed by Ansible (role monitoring).
auth_enabled: false
server:
http_listen_port: 3100
log_level: warn
common:
path_prefix: /loki
replication_factor: 1
ring:
kvstore:
store: inmemory
storage:
filesystem:
chunks_directory: /loki/chunks
rules_directory: /loki/rules
schema_config:
configs:
- from: "2026-11-01"
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
limits_config:
retention_period: {{ monitoring_logs_retention }}
reject_old_samples: true
reject_old_samples_max_age: 168h
ingestion_rate_mb: 8
ingestion_burst_size_mb: 16
volume_enabled: true
compactor:
working_directory: /loki/compactor
retention_enabled: true
delete_request_store: filesystem
analytics:
reporting_enabled: false

View file

@ -0,0 +1,75 @@
# Managed by Ansible (role monitoring).
global:
scrape_interval: 30s
evaluation_interval: 30s
scrape_configs:
- job_name: node
static_configs:
{% for h in monitoring_hosts %}
- targets: ["{{ h.address }}:{{ monitoring_node_exporter_port }}"]
labels: { host: {{ h.name }} }
{% endfor %}
- job_name: cadvisor
scrape_interval: 60s
static_configs:
{% for h in monitoring_hosts %}
- targets: ["{{ h.address }}:{{ monitoring_cadvisor_port }}"]
labels: { host: {{ h.name }} }
{% endfor %}
metric_relabel_configs:
# Short-lived CI job containers would bloat the series count
- source_labels: [name]
regex: FORGEJO-ACTIONS-TASK-.*
action: drop
- job_name: alloy
static_configs:
{% for h in monitoring_hosts %}
- targets: ["{{ h.address }}:12345"]
labels: { host: {{ h.name }} }
{% endfor %}
- job_name: traefik
static_configs:
- targets: ["traefik:8082"]
labels: { host: asxpio-ops }
- job_name: forgejo
metrics_path: /metrics
authorization:
credentials: "{{ monitoring_forgejo_metrics_token }}"
static_configs:
- targets: ["forgejo:3000"]
labels: { host: asxpio-ops }
- job_name: postgres
static_configs:
- targets: ["postgres-exporter:9187"]
labels: { host: asxpio-ops }
- job_name: monitoring
static_configs:
- targets: ["localhost:9090", "loki:3100", "grafana:3000", "blackbox:9115"]
labels: { host: asxpio-ops }
{% for module in monitoring_probes | map(attribute='module') | unique %}
- job_name: probe_{{ module }}
metrics_path: /probe
params:
module: [{{ module }}]
static_configs:
{% for p in monitoring_probes if p.module == module %}
- targets: ["{{ p.target }}"]
labels: { probe: "{{ p.name }}" }
{% endfor %}
relabel_configs:
- source_labels: [__address__]
target_label: __param_target
- source_labels: [__param_target]
target_label: instance
- target_label: __address__
replacement: blackbox:9115
{% endfor %}

View file

@ -0,0 +1,22 @@
---
# node_exporter, cAdvisor and Alloy on every host. Metrics listen on the
# host's VPC address only; Alloy ships Docker logs and the journal to Loki
# on asxpio-ops over the VPC.
- name: Create the node_exporter textfile directory
ansible.builtin.file:
path: "{{ monitoring_textfile_dir }}"
state: directory
mode: "0755"
- name: Deploy the monitoring agents
ansible.builtin.include_role:
name: docker_service
vars:
service_name: monitoring-agent
service_base_path: "{{ services_base_path }}/monitoring-agent"
service_templates:
- { src: docker-compose.yml.j2, dest: docker-compose.yml }
- { src: config.alloy.j2, dest: config.alloy }
health_check_enabled: true
health_check_host: "{{ vpc_ip }}"
health_check_port: "{{ monitoring_node_exporter_port }}"

View file

@ -0,0 +1,78 @@
// Managed by Ansible (role monitoring_agent): logs of {{ inventory_hostname }} to Loki.
discovery.docker "containers" {
host = "unix:///var/run/docker.sock"
}
discovery.relabel "containers" {
targets = discovery.docker.containers.targets
rule {
source_labels = ["__meta_docker_container_name"]
regex = "/(.*)"
target_label = "container"
}
rule {
source_labels = ["__meta_docker_container_label_com_docker_compose_project"]
target_label = "compose_project"
}
rule {
source_labels = ["__meta_docker_container_label_com_docker_compose_service"]
target_label = "compose_service"
}
}
loki.source.docker "containers" {
host = "unix:///var/run/docker.sock"
targets = discovery.docker.containers.targets
relabel_rules = discovery.relabel.containers.rules
labels = { job = "docker" }
forward_to = [loki.process.containers.receiver]
}
loki.process "containers" {
// CI job containers are many, short-lived and noisy: the runner logs suffice
stage.match {
selector = "{container=~\"FORGEJO-ACTIONS-TASK-.*\"}"
action = "drop"
}
forward_to = [loki.write.default.receiver]
}
loki.relabel "journal" {
forward_to = []
rule {
source_labels = ["__journal__systemd_unit"]
target_label = "unit"
}
rule {
source_labels = ["__journal_priority_keyword"]
target_label = "level"
}
}
loki.source.journal "journal" {
max_age = "12h"
relabel_rules = loki.relabel.journal.rules
labels = { job = "journal" }
forward_to = [loki.write.default.receiver]
}
{% if inventory_hostname == 'asxpio-ops' %}
local.file_match "traefik" {
path_targets = [{ "__path__" = "/traefik-logs/access.log", job = "traefik-access" }]
}
loki.source.file "traefik" {
targets = local.file_match.traefik.targets
forward_to = [loki.write.default.receiver]
}
{% endif %}
loki.write "default" {
endpoint {
url = "http://{{ asxpio_ops_vpc_ip }}:{{ monitoring_loki_port }}/loki/api/v1/push"
}
external_labels = { host = "{{ inventory_hostname }}" }
}

View file

@ -0,0 +1,66 @@
# Managed by Ansible (role monitoring_agent).
services:
node-exporter:
image: prom/node-exporter:{{ monitoring_node_exporter_version }}
container_name: node-exporter
command:
- --path.rootfs=/host
- --web.listen-address={{ vpc_ip }}:{{ monitoring_node_exporter_port }}
- --collector.textfile.directory=/textfile
- --collector.filesystem.mount-points-exclude=^/(dev|proc|run|sys|var/lib/docker/.+)($|/)
network_mode: host
pid: host
volumes:
- /:/host:ro,rslave
- {{ monitoring_textfile_dir }}:/textfile:ro
restart: unless-stopped
mem_limit: 64m
cadvisor:
image: ghcr.io/google/cadvisor:{{ monitoring_cadvisor_version }}
container_name: cadvisor
command:
- --listen_ip={{ vpc_ip }}
- --port={{ monitoring_cadvisor_port }}
- --docker_only=true
- --housekeeping_interval=30s
- --store_container_labels=false
- --whitelisted_container_labels=com.docker.compose.project,com.docker.compose.service
- --disable_metrics=percpu,sched,tcp,udp,hugetlb,referenced_memory,cpu_topology,resctrl,advtcp,process
network_mode: host
privileged: true
devices:
- /dev/kmsg
volumes:
- /:/rootfs:ro
- /var/run:/var/run:ro
- /sys:/sys:ro
- /var/lib/docker/:/var/lib/docker:ro
- /dev/disk/:/dev/disk:ro
restart: unless-stopped
mem_limit: 192m
alloy:
image: grafana/alloy:{{ monitoring_alloy_version }}
container_name: alloy
command:
- run
- --server.http.listen-addr={{ vpc_ip }}:12345
- --storage.path=/var/lib/alloy/data
- /etc/alloy/config.alloy
network_mode: host
volumes:
- ./config.alloy:/etc/alloy/config.alloy:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
- /var/log/journal:/var/log/journal:ro
- /run/log/journal:/run/log/journal:ro
- /etc/machine-id:/etc/machine-id:ro
{% if inventory_hostname == 'asxpio-ops' %}
- /opt/traefik/logs:/traefik-logs:ro
{% endif %}
- alloy-data:/var/lib/alloy/data
restart: unless-stopped
mem_limit: 256m
volumes:
alloy-data:

View file

@ -22,6 +22,12 @@ declare -A REPOS=(
log() { echo "restic-backup: $*"; }
# Last good backup per repository, for Prometheus (node_exporter textfile)
metric() {
local f={{ monitoring_textfile_dir }}/restic_$1.prom
printf '# HELP restic_backup_last_success_timestamp_seconds Last successful restic backup.\n# TYPE restic_backup_last_success_timestamp_seconds gauge\nrestic_backup_last_success_timestamp_seconds{repo="%s"} %s\n' "$1" "$(date +%s)" > "$f.tmp" && mv "$f.tmp" "$f"
}
case "${1:-}" in
init)
for name in "${!REPOS[@]}"; do
@ -56,6 +62,7 @@ run)
[ "$(date +%a)" = "{{ restic_prune_weekday }}" ] && prune="--prune"
log "$name: forget $prune"
restic forget --host {{ inventory_hostname }} {{ restic_keep }} $prune || failed=1
metric "$name"
done
rm -rf "$STAGE"

View file

@ -16,4 +16,8 @@ rclone sync hel1:{{ p.src }} fsn1:{{ p.dst }} \
{% endfor %}
--stats-one-line -v || failed=1
{% endfor %}
if [ $failed = 0 ]; then
f={{ monitoring_textfile_dir }}/s3_replicate.prom
printf '# HELP s3_replicate_last_success_timestamp_seconds Last fully successful hel1 to fsn1 replica.\n# TYPE s3_replicate_last_success_timestamp_seconds gauge\ns3_replicate_last_success_timestamp_seconds %s\n' "$(date +%s)" > "$f.tmp" && mv "$f.tmp" "$f"
fi
exit $failed

View file

@ -16,6 +16,15 @@ entryPoints:
scheme: https
websecure:
address: ":443"
# Prometheus metrics, reached on the traefik network only (not published)
metrics:
address: ":8082"
metrics:
prometheus:
entryPoint: metrics
addRoutersLabels: true
addServicesLabels: true
# Docker Provider
providers: