selfhostedops-stack/automation/b-incident-responder.json
grimm bb2b5b5e22 Episode 8: n8n as the automation hub, with the editor kept off the public internet
Three workflows that connect the services from episodes 1 to 7: a Forgejo push
becomes a phone notification, an Uptime Kuma outage writes and closes its own
incident page in Outline, and a nightly schedule writes one ops digest.

The point of the episode is the split between webhooks and the editor. Caddy
publishes only the webhook path prefixes under the n8n host and answers an
empty 404 for everything else, so the editor stays reachable over the mesh from
episode 5 and never gets a login page on the open internet.

Also in here:
- n8n pinned to a version instead of :latest, after episode 7 showed that every
  restore is otherwise an upgrade
- the 2.x environment that actually matters, including the file access
  restriction pointed at one directory rather than the root
- the Forgejo allowed host list, without which a webhook to a private address
  is silently dropped
- fixed subnets for both docker networks, after a restore handed them out in
  the other order and quietly broke the mesh route
- a note on the encryption key: it decrypts every stored credential, it lives in
  the n8n volume, and a backup without it restores a hub that cannot log in

Claude-Session: https://claude.ai/code/session_01Md8yhBKp5e7xMmCyxoEgdm
2026-09-05 12:54:11 +02:00

358 lines
11 KiB
JSON

{
"name": "B - Incident responder",
"nodes": [
{
"id": "b1",
"name": "Kuma incident",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
-220,
0
],
"webhookId": "REPLACE_ON_IMPORT",
"parameters": {
"httpMethod": "POST",
"path": "kuma-incident",
"responseMode": "onReceived",
"options": {}
}
},
{
"id": "b2",
"name": "Read event",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
0,
0
],
"parameters": {
"jsCode": "\n// Uptime Kuma posts one JSON body for every state change. Reduce it to the\n// four fields both branches need, so nothing below has to know Kuma's shape.\nconst b = $json.body ?? $json;\nconst hb = b.heartbeat ?? {};\nconst mon = b.monitor ?? {};\nconst down = Number(hb.status) === 0;\nreturn [{\n json: {\n monitor: mon.name ?? 'unknown monitor',\n monitorUrl: mon.url ?? '',\n down,\n at: hb.time ? new Date(hb.time.replace(' ', 'T') + 'Z').toISOString() : new Date().toISOString(),\n reason: hb.msg ?? b.msg ?? '',\n },\n}];\n"
}
},
{
"id": "b3",
"name": "Down?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
220,
0
],
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.down }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
}
},
{
"id": "b4",
"name": "Build incident doc",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
460,
-140
],
"parameters": {
"jsCode": "\n// One incident document per outage. \"Status: open\" is the marker the UP\n// branch searches for later, so it stays machine readable on purpose.\nconst e = $json;\nconst stamp = e.at.replace('T', ' ').replace(/\\..*/, ' UTC');\nconst title = 'Incident: ' + e.monitor + ' (' + stamp + ')';\n// A markdown list, not plain lines: Outline collapses single newlines inside\n// a paragraph, which would turn the whole record into one unreadable blob.\nconst text = [\n '- Monitor: ' + e.monitor,\n '- Status: open',\n '- Down since: ' + e.at,\n '- Reason: ' + (e.reason || 'no detail from the monitor'),\n e.monitorUrl ? '- Checked URL: ' + e.monitorUrl : '',\n '',\n '---',\n '',\n 'Opened automatically by n8n. The resolution is appended when the monitor reports UP again.',\n].filter(Boolean).join('\\n');\nreturn [{ json: { title, payload: { title, text, collectionId: 'REPLACE_WITH_YOUR_OUTLINE_COLLECTION_ID', publish: true } } }];\n"
}
},
{
"id": "13d2a02f",
"name": "Create incident doc",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
680,
-140
],
"parameters": {
"method": "POST",
"url": "http://outline:3000/api/documents.create",
"options": {},
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"contentType": "json",
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify($json.payload) }}"
},
"credentials": {
"httpHeaderAuth": {
"id": "REPLACE_WITH_YOUR_CREDENTIAL_ID",
"name": "Outline API"
}
}
},
{
"id": "e28ddc1d",
"name": "Push alert",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
900,
-140
],
"parameters": {
"method": "POST",
"url": "http://ntfy/alerts",
"options": {},
"authentication": "genericCredentialType",
"genericAuthType": "httpBasicAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "X-Title",
"value": "=DOWN: {{ $('Read event').first().json.monitor }}"
},
{
"name": "X-Priority",
"value": "5"
}
]
},
"sendBody": true,
"contentType": "raw",
"rawContentType": "text/plain",
"body": "={{ $('Read event').first().json.reason || 'monitor reports down' }}\nIncident doc: https://wiki.ops.example.com{{ $json.data.url }}"
},
"credentials": {
"httpBasicAuth": {
"id": "REPLACE_WITH_YOUR_CREDENTIAL_ID",
"name": "ntfy (n8n user)"
}
}
},
{
"id": "0ac14924",
"name": "List Ops Log",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
460,
140
],
"parameters": {
"method": "POST",
"url": "http://outline:3000/api/documents.list",
"options": {},
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"contentType": "json",
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ collectionId: \"REPLACE_WITH_YOUR_OUTLINE_COLLECTION_ID\", limit: 25 }) }}"
},
"credentials": {
"httpHeaderAuth": {
"id": "REPLACE_WITH_YOUR_CREDENTIAL_ID",
"name": "Outline API"
}
}
},
{
"id": "b8",
"name": "Find open incident",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
680,
140
],
"parameters": {
"jsCode": "\n// Find the still open incident for this monitor and close it out.\nconst e = $('Read event').first().json;\nconst docs = $input.first().json.data ?? [];\nconst open = docs.find(d =>\n String(d.text ?? '').includes('Monitor: ' + e.monitor) &&\n String(d.text ?? '').includes('Status: open'));\n\nif (!open) {\n return [{ json: { found: false, monitor: e.monitor, duration: 'unknown',\n note: 'no open incident document for this monitor' } }];\n}\n\nconst m = String(open.text).match(/Down since:\\s*(\\S+)/);\nconst since = m ? new Date(m[1]) : null;\nconst back = new Date(e.at);\nconst secs = since ? Math.max(0, Math.round((back - since) / 1000)) : null;\nconst human = secs === null ? 'unknown'\n : secs < 90 ? secs + ' s'\n : Math.floor(secs / 60) + ' min ' + (secs % 60) + ' s';\n\nconst text = String(open.text).replace('Status: open', 'Status: resolved')\n + '\\n- Back up: ' + e.at + '\\n- Downtime: ' + human + '\\n';\n\nreturn [{ json: {\n found: true, monitor: e.monitor, duration: human, seconds: secs,\n docUrl: open.url ?? '',\n payload: { id: open.id, title: String(open.title).replace('Incident:', 'Resolved:'), text, publish: true },\n}}];\n"
}
},
{
"id": "e402dfe1",
"name": "Close incident doc",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
900,
140
],
"parameters": {
"method": "POST",
"url": "http://outline:3000/api/documents.update",
"options": {},
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"contentType": "json",
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify($json.payload || {}) }}"
},
"credentials": {
"httpHeaderAuth": {
"id": "REPLACE_WITH_YOUR_CREDENTIAL_ID",
"name": "Outline API"
}
},
"onError": "continueRegularOutput"
},
{
"id": "7b4981c4",
"name": "Push all clear",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1120,
140
],
"parameters": {
"method": "POST",
"url": "http://ntfy/alerts",
"options": {},
"authentication": "genericCredentialType",
"genericAuthType": "httpBasicAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "X-Title",
"value": "=UP again: {{ $('Read event').first().json.monitor }}"
},
{
"name": "X-Priority",
"value": "3"
}
]
},
"sendBody": true,
"contentType": "raw",
"rawContentType": "text/plain",
"body": "=Downtime {{ $('Find open incident').first().json.duration }}. Incident closed."
},
"credentials": {
"httpBasicAuth": {
"id": "REPLACE_WITH_YOUR_CREDENTIAL_ID",
"name": "ntfy (n8n user)"
}
}
}
],
"connections": {
"Kuma incident": {
"main": [
[
{
"node": "Read event",
"type": "main",
"index": 0
}
]
]
},
"Read event": {
"main": [
[
{
"node": "Down?",
"type": "main",
"index": 0
}
]
]
},
"Down?": {
"main": [
[
{
"node": "Build incident doc",
"type": "main",
"index": 0
}
],
[
{
"node": "List Ops Log",
"type": "main",
"index": 0
}
]
]
},
"Build incident doc": {
"main": [
[
{
"node": "Create incident doc",
"type": "main",
"index": 0
}
]
]
},
"Create incident doc": {
"main": [
[
{
"node": "Push alert",
"type": "main",
"index": 0
}
]
]
},
"List Ops Log": {
"main": [
[
{
"node": "Find open incident",
"type": "main",
"index": 0
}
]
]
},
"Find open incident": {
"main": [
[
{
"node": "Close incident doc",
"type": "main",
"index": 0
}
]
]
},
"Close incident doc": {
"main": [
[
{
"node": "Push all clear",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
},
"active": false
}