Netdata → n8n Enriched Alert Emails¶
Status: Retired 2026-10-10 — kept as reference (was live across the fleet from 2026-03-21)
Retired 2026-10-10 — this pipeline is switched off
Netdata now sends only its plain native alert email. SEND_CUSTOM is "NO" on every agent host and the Netdata Enriched Alerts n8n workflow is deactivated, because the enriched mail duplicated the plain mail (55 of 68 enriched mails had a plain twin within 10 minutes) and the plain format is the one kept. Everything below describes how it worked and how to bring it back.
To re-enable: set SEND_CUSTOM="YES" in the Netdata notification config on the hosts (deployed via Ansible, which restarts the agent), then reactivate the workflow in n8n (a CLI change needs an n8n restart to take effect).
Replaces Netdata's plain-text alert emails with rich HTML emails that include a plain-English explanation, a suggested remediation command, and a direct link to the relevant MajorDocs article.
How It Works¶
Netdata alarm fires
→ custom_sender() in health_alarm_notify.conf
→ POST JSON payload to n8n webhook
→ Code node enriches with suggestion + wiki link
→ Send Email node sends HTML email via SMTP
→ Respond node returns 200 OK
n8n Workflow¶
Name: Netdata Enriched Alerts
URL: https://n8n.majorshouse.com
Webhook endpoint: POST https://n8n.majorshouse.com/webhook/netdata-alert
Workflow ID: a1b2c3d4-aaaa-bbbb-cccc-000000000001
Nodes¶
- Netdata Webhook — receives POST from Netdata's
custom_sender() - Enrich Alert — Code node; matches alarm/chart/family to enrichment table, builds HTML email body in
$json.emailBody - Send Enriched Email — sends via SMTP port 465 (SMTP account 2), from
netdata@majorshouse.comtomarcus@majorshouse.com - Respond OK — returns
okwith HTTP 200 to Netdata
Enrichment Keys¶
The Code node matches each key against the alarm, chart, or family field, case-insensitively and on token boundaries (matchesKey()), so net matches net_packets but not netdata or cabinet. First match wins, so specific keys still come before generic ones:
| Key | Title | Wiki Article |
|---|---|---|
reboot |
Reboot Required | ansible-unattended-upgrades-fleet (majorlab: docker-caddy-selinux-post-reboot-recovery) |
selinux |
SELinux Denial Alert | netdata-selinux-avc-chart |
disk_space |
Disk Space Alert | diagnosing-disk-space-alerts |
ram |
Memory Alert | managing-linux-services-systemd-ansible |
cpu |
CPU Alert | managing-linux-services-systemd-ansible |
load |
Load Average Alert | managing-linux-services-systemd-ansible |
net |
Network Alert | tailscale-homelab-remote-access |
docker |
Docker Container Alert | debugging-broken-docker-containers |
web_log |
Web Log Alert | tuning-netdata-web-log-alerts |
health |
Docker Health Alarm | netdata-docker-health-alarm-tuning |
mdstat |
RAID Array Alert | mdadm-usb-hub-disconnect-recovery |
systemd |
Systemd Service Alert | managing-linux-services-systemd-ansible |
| (no match) | Server Alert | netdata-new-server-setup |
The reboot key catches Netdata's system_post_update_reboot_status alarm ("System requires reboot after package updates"). It fires before a reboot, not after one, so its suggestion is a plan: see which packages need it (sudo dnf needs-restarting -r on Fedora, cat /var/run/reboot-required.pkgs on Debian/Ubuntu), check for in-flight work, then ansible-playbook reboot.yml -l <host>. Before this key existed (added 2026-09-23), the alarm fell through to the generic Server Alert text.
The selinux key covers the Fedora hosts' AVC denial alarm, which was the most common alert in the workflow's history (25 of 74 runs, 2026-09-11 to 09-24) and had previously fallen through to the generic Server Alert. Its first command groups denials by permission, process and SELinux domain straight from /var/log/audit/audit.log. It deliberately uses grep rather than ausearch, which has returned no matches on majorlab while the log held denials. The systemd key pulls the real unit name out of the chart id (…unit_restic-backup_service_state → restic-backup.service), so the email says systemctl status restic-backup.service instead of a placeholder.
Suggestion layout¶
Single-line suggestions are a plain suggestion string. Multi-step ones declare intro, steps (each an optional text and an optional cmd) and note, and render as a numbered list with each command in its own monospace block, so long commands wrap inside the block instead of mid-sentence. reboot, selinux and systemd use this. Every inline <code> in the email also gets an inline monospace style, because many mail clients drop <style> blocks. The returned suggestion field stays a flat string for the tests and any downstream use.
Test against real alerts, not just made-up ones. roles/n8n_alerts/files/test_enrich_alert.js runs before every deploy. Cases built from real payloads caught what synthetic ones missed. The workflow's history can be decoded read-only inside the container: execution_data is stored in flatted format, and n8n bundles the flatted package.
Host names: Netdata FQDN vs inventory name¶
Netdata reports each host's own hostname -f, which is not always the Ansible inventory name — majorlab.majorshouse.com, home.majorshouse.com (majorhome), temptedparadise.com (teelia), majorpi.majorshouse.com. The Code node maps these to inventory names (invHost) before any per-host check. Without that map, a check like dockerHosts.includes(hostname) silently never matches, and the host quietly gets the generic advice instead. That happened here until 2026-09-23. When adding a host-specific branch, key it on invHost, and add the host to the alias map if its FQDN differs.
Netdata Configuration¶
Config File Locations¶
| Server | Path |
|---|---|
| majorhome, majormail, majordiscord, tttpod, teelia | /etc/netdata/health_alarm_notify.conf |
| majorlinux, majortoot, dca | /usr/lib/netdata/conf.d/health_alarm_notify.conf |
Required Settings¶
custom_sender() Function¶
custom_sender() {
local to="${1}"
local payload
payload=$(jq -n \
--arg hostname "${host}" \
--arg alarm "${name}" \
--arg chart "${chart}" \
--arg family "${family}" \
--arg status "${status}" \
--arg old_status "${old_status}" \
--arg value "${value_string}" \
--arg units "${units}" \
--arg info "${info}" \
--arg alert_url "${goto_url}" \
--arg severity "${severity}" \
--arg raised_for "${raised_for}" \
--arg total_warnings "${total_warnings}" \
--arg total_critical "${total_critical}" \
'{hostname:$hostname,alarm:$alarm,chart:$chart,family:$family,status:$status,old_status:$old_status,value:$value,units:$units,info:$info,alert_url:$alert_url,severity:$severity,raised_for:$raised_for,total_warnings:$total_warnings,total_critical:$total_critical}')
local httpcode
httpcode=$(docurl -s -o /dev/null -w "%{http_code}" \
-X POST \
-H "Content-Type: application/json" \
-d "${payload}" \
"https://n8n.majorshouse.com/webhook/netdata-alert")
if [ "${httpcode}" = "200" ]; then
info "sent enriched notification to n8n for ${status} of ${host}.${name}"
sent=$((sent + 1))
else
error "failed to send notification to n8n, HTTP code: ${httpcode}"
fi
}
jq required
The custom_sender() function requires jq to be installed. Verify with which jq on each server.
Deploying to a New Server¶
# 1. Find the config file
find /etc/netdata /usr/lib/netdata -name health_alarm_notify.conf 2>/dev/null
# 2. Edit it — add the two lines and the custom_sender() function above
# 3. Test connectivity from the server
curl -s -o /dev/null -w "%{http_code}" \
-X POST https://n8n.majorshouse.com/webhook/netdata-alert \
-H "Content-Type: application/json" \
-d '{"hostname":"test","alarm":"disk_space._","status":"WARNING"}'
# Expected: 200
# 4. Restart Netdata
systemctl restart netdata
# 5. Send a test alarm
/usr/libexec/netdata/plugins.d/alarm-notify.sh test custom
Troubleshooting¶
Emails not arriving — check n8n execution log:
Go to https://n8n.majorshouse.com → open "Netdata Enriched Alerts" → Executions tab. Look for error status entries.
Email body empty:
The Send Email node's HTML field must be ={{ $json.emailBody }}. Shell variable expansion can silently strip $json if the workflow is patched via inline SSH commands — always use a Python script file.
000 curl response from a server:
Usually a timeout, not a DNS or connection failure. Re-test with --max-time 30.
custom_sender() syntax error in Netdata logs:
Bash heredocs don't work inside sourced config files. Use jq -n --arg ... as shown above — no heredocs.
n8n N8N_TRUST_PROXY must be set:
Without N8N_TRUST_PROXY=true in the Docker environment, Caddy's X-Forwarded-For header causes n8n's rate limiter to abort requests before parsing the body. Set in /opt/n8n/compose.yml.