Zabbix homelab setup

This document describes the current Zabbix setup used in the homelab, including:

  • Zabbix Server deployed with Docker Compose
  • External PostgreSQL database hosted on the central PostgreSQL server
  • Linux host monitoring using zabbix-agent2
  • Docker monitoring via zabbix-agent2
  • Alert forwarding to n8n using a Zabbix webhook media type

Architecture overview

graph TD
    A[Linux / Docker Hosts] -->|Active checks TCP/10051| B[Zabbix Server]
    B -->|SQL| C[Central PostgreSQL Server]
    B -->|Webhook POST| D[n8n Webhook]
    D -->|Telegram Bot| E[Telegram]

    A -->|Optional passive checks TCP/10050| B
    A -->|Docker socket| F[Docker Engine]

Components

ComponentPurpose
Zabbix ServerMain monitoring engine
Zabbix WebWeb UI for monitoring/configuration
PostgreSQLExternal central database
zabbix-agent2Linux host monitoring agent
Docker pluginContainer discovery and monitoring through Agent 2
n8nAlert workflow/notification processing
TelegramFinal alert destination

1. PostgreSQL database setup

The Zabbix database is hosted on the central PostgreSQL server rather than as a Docker container.

The database was created manually through Adminer.

Example database name:

zabbix

Create Zabbix PostgreSQL user

Run the following in Adminer while connected as a PostgreSQL admin user.

Replace the password with a long random value.

CREATE USER zabbix WITH PASSWORD 'CHANGE_THIS_TO_A_LONG_RANDOM_PASSWORD';
 
GRANT CONNECT ON DATABASE zabbix TO zabbix;
 
ALTER DATABASE zabbix OWNER TO zabbix;

Then connect/select the zabbix database in Adminer and run:

GRANT USAGE, CREATE ON SCHEMA public TO zabbix;
ALTER SCHEMA public OWNER TO zabbix;
 
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO zabbix;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO zabbix;
GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA public TO zabbix;
 
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT ALL PRIVILEGES ON TABLES TO zabbix;
 
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT ALL PRIVILEGES ON SEQUENCES TO zabbix;
 
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT ALL PRIVILEGES ON FUNCTIONS TO zabbix;

PostgreSQL access requirements

Ensure the central PostgreSQL server allows connections from the Docker host running Zabbix.

Check:

  • PostgreSQL is listening on the required interface
  • pg_hba.conf allows the Zabbix Docker host
  • Firewall allows PostgreSQL TCP/5432 from the Zabbix host only

Recommended security rule:

Zabbix Docker Host -> PostgreSQL Server TCP/5432

Avoid allowing all homelab VLANs to access PostgreSQL.


2. Zabbix server Docker Compose

The Zabbix server uses an external PostgreSQL database, so there is no PostgreSQL container in the Compose stack.

docker-compose.yml

services:
  zabbix-server:
    image: zabbix/zabbix-server-pgsql:alpine-7.4.12
    container_name: zabbix-server
    restart: unless-stopped
    environment:
      DB_SERVER_HOST: ${DB_SERVER_HOST}
      DB_SERVER_PORT: ${DB_SERVER_PORT}
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
 
      ZBX_CACHESIZE: 128M
      ZBX_HISTORYCACHESIZE: 64M
      ZBX_HISTORYINDEXCACHESIZE: 32M
      ZBX_TRENDCACHESIZE: 32M
      ZBX_VALUECACHESIZE: 128M
 
      ZBX_STARTPINGERS: 5
      ZBX_STARTDISCOVERERS: 2
      ZBX_STARTHTTPPOLLERS: 5
    ports:
      - "10051:10051"
    networks:
      - zabbix
 
  zabbix-web:
    image: zabbix/zabbix-web-nginx-pgsql:alpine-7.4.12
    container_name: zabbix-web
    restart: unless-stopped
    depends_on:
      - zabbix-server
    environment:
      DB_SERVER_HOST: ${DB_SERVER_HOST}
      DB_SERVER_PORT: ${DB_SERVER_PORT}
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
 
      ZBX_SERVER_HOST: zabbix-server
      ZBX_SERVER_PORT: 10051
      ZBX_SERVER_NAME: ${ZBX_SERVER_NAME}
      PHP_TZ: ${PHP_TZ}
    ports:
      - "8080:8080"
    networks:
      - zabbix
 
networks:
  zabbix:
    name: zabbix

.env

DB_SERVER_HOST=postgres.example.internal
DB_SERVER_PORT=5432
POSTGRES_DB=zabbix
POSTGRES_USER=zabbix
POSTGRES_PASSWORD=CHANGE_THIS_TO_THE_REAL_PASSWORD
ZBX_SERVER_NAME=Homelab Zabbix
PHP_TZ=Europe/Lisbon

Deploy

docker compose pull
docker compose up -d

Open the web UI:

http://<docker-host-ip>:8080

Default login:

User: Admin
Password: zabbix

Change the default password immediately.


3. Linux agent installation

For Linux hosts, install zabbix-agent2 directly on the host.

This is preferred over running the agent as a container because it gives better visibility into:

  • Host CPU/RAM/disk
  • Filesystems and mounts
  • Network interfaces
  • systemd services
  • Docker containers, when Docker is present

Ubuntu 24.04 agent installation

Install the Zabbix 7.4 repository package:

wget https://repo.zabbix.com/zabbix/7.4/release/ubuntu/pool/main/z/zabbix-release/zabbix-release_latest_7.4+ubuntu24.04_all.deb
sudo dpkg -i zabbix-release_latest_7.4+ubuntu24.04_all.deb
sudo apt update

Install Agent 2:

sudo apt install zabbix-agent2

Proxmox VE agent installation

For Proxmox hosts based on Debian 12, install the Zabbix 7.4 repository package:

wget https://repo.zabbix.com/zabbix/7.4/release/debian/pool/main/z/zabbix-release/zabbix-release_latest_7.4+debian12_all.deb
sudo dpkg -i zabbix-release_latest_7.4+debian12_all.deb
sudo apt update
sudo apt install zabbix-agent2

Then edit the agent configuration:

sudo vi /etc/zabbix/zabbix_agent2.conf

Enable and restart the service:

sudo systemctl enable --now zabbix-agent2
sudo systemctl restart zabbix-agent2
sudo systemctl status zabbix-agent2

Agent configuration

Edit:

sudo vim /etc/zabbix/zabbix_agent2.conf

Set the following values:

Server=zabbix-server.example.internal
ServerActive=zabbix-server.example.internal
Hostname=uat
HostMetadata=linux,docker

Explanation:

SettingPurpose
ServerZabbix server allowed to perform passive checks
ServerActiveZabbix server used for active checks and auto-registration
HostnameHostname Zabbix will use for this agent
HostMetadataMetadata used by Zabbix auto-registration rules

In this example, the host metadata is:

linux,docker

This can be matched in Zabbix auto-registration actions to automatically assign groups, templates, and tags.

Enable and restart agent

sudo systemctl enable --now zabbix-agent2
sudo systemctl restart zabbix-agent2

Check status:

systemctl status zabbix-agent2

Check logs:

sudo journalctl -u zabbix-agent2 -n 100 --no-pager

Expected behaviour:

  • Agent starts successfully
  • No repeated connection errors to zabbix-server.example.internal
  • Host appears in Zabbix via auto-registration once the action is configured

4. Docker monitoring with Agent 2

Zabbix Agent 2 can monitor Docker containers using the Docker plugin.

The agent talks to Docker through:

/var/run/docker.sock

To allow the zabbix user to read Docker data:

sudo usermod -aG docker zabbix
sudo systemctl restart zabbix-agent2

Test access:

sudo -u zabbix docker ps

Expected result:

Docker containers are listed

If permission is denied, Docker monitoring will not work.

Security note

Adding the zabbix user to the docker group gives the user high privileges on the host. In a homelab this may be acceptable, but access to the Zabbix agent should be restricted.

Recommended firewalling:

Linux Host -> Zabbix Server TCP/10051   # active checks
Zabbix Server -> Linux Host TCP/10050   # only if passive checks are used

Do not expose agent port TCP/10050 broadly across VLANs.


5. Zabbix auto-registration

Auto-registration uses active checks and host metadata.

The agent must have:

ServerActive=zabbix-server.example.internal
Hostname=uat
HostMetadata=linux,docker

Example auto-registration matching logic:

Metadata containsAction
linuxAdd host to Linux group and link Linux Agent template
dockerLink Docker by Zabbix Agent 2 template

Suggested templates:

Linux by Zabbix agent active
Docker by Zabbix agent 2

6. n8n webhook alerting

Zabbix sends alerts to n8n using a custom webhook media type.

The flow is:

Zabbix Trigger Action -> Zabbix Webhook Media Type -> n8n Webhook -> Telegram

Create n8n webhook

In n8n:

  1. Create a workflow
  2. Add a Webhook node
  3. Method: POST
  4. Path: zabbix
  5. Copy the production webhook URL

Example:

https://n8n.example.internal/webhook/zabbix

Use the production URL for real alerts, not the test URL.


7. Zabbix media type for n8n

The n8n media type was created by cloning the built-in Telegram media type.

This is useful because the Telegram media type already has good Zabbix event handling logic for:

  • Problem events
  • Recovery events
  • Update events
  • Event source validation
  • HTTP proxy support
  • Message templates
  • Zabbix alert tags

Create the media type

In Zabbix UI:

Alerts -> Media types

Clone the existing:

Telegram

Rename the cloned media type to:

n8n Webhook

Then remove the Telegram-specific parameters and logic.

Remove parameters such as:

api_token
api_chat_id
api_parse_mode
message_thread_id

The n8n media type only needs to POST the Zabbix alert payload to the n8n webhook URL.

Media type parameters

Use these parameters.

NameValue
webhook_urlhttps://n8n.example.internal/webhook/zabbix
alert_subject{ALERT.SUBJECT}
alert_message{ALERT.MESSAGE}
event_source{EVENT.SOURCE}
event_value{EVENT.VALUE}
event_update_status{EVENT.UPDATE.STATUS}
event_id{EVENT.ID}
event_name{EVENT.NAME}
event_status{EVENT.STATUS}
event_severity{EVENT.SEVERITY}
event_nseverity{EVENT.NSEVERITY}
event_opdata{EVENT.OPDATA}
event_date{EVENT.DATE}
event_time{EVENT.TIME}
event_duration{EVENT.DURATION}
event_update_message{EVENT.UPDATE.MESSAGE}
event_recovery_status{EVENT.RECOVERY.STATUS}
event_recovery_date{EVENT.RECOVERY.DATE}
event_recovery_time{EVENT.RECOVERY.TIME}
host_name{HOST.NAME}
host_ip{HOST.IP}
host_conn{HOST.CONN}
trigger_id{TRIGGER.ID}
trigger_name{TRIGGER.NAME}
trigger_url{TRIGGER.URL}
zabbix_urlhttps://zabbix.yourdomain.local
http_proxyleave empty

Message templates

The message templates are important.

Without message templates, n8n may receive incomplete alert data such as:

Zabbix EVENT
Severity: N/A
Problem: No event name received
Event ID: N/A

Add these templates under:

Alerts -> Media types -> n8n Webhook -> Message templates

Problem template

Type:

Problem

Subject:

Problem: {EVENT.NAME}

Message:

Problem started at {EVENT.TIME} on {EVENT.DATE}
Problem name: {EVENT.NAME}
Host: {HOST.NAME}
Severity: {EVENT.SEVERITY}
Operational data: {EVENT.OPDATA}
Original problem ID: {EVENT.ID}

Example Telegram output from a real Zabbix alert:

Problem: Linux: uat01 has been restarted (uptime < 10m)
 
Problem started at 15:16:10 on 2026.07.26
Problem name: Linux: uat01 has been restarted (uptime < 10m)
Host: uat01
Severity: Warning
Operational data: 00:01:18
Original problem ID: 257

Problem recovery template

Type:

Problem recovery

Subject:

Resolved: {EVENT.NAME}

Message:

Problem resolved at {EVENT.RECOVERY.TIME} on {EVENT.RECOVERY.DATE}
Problem name: {EVENT.NAME}
Host: {HOST.NAME}
Original severity: {EVENT.SEVERITY}
Duration: {EVENT.DURATION}
Original problem ID: {EVENT.ID}

Problem update template

Type:

Problem update

Subject:

Update: {EVENT.NAME}

Message:

Problem updated at {EVENT.UPDATE.TIME} on {EVENT.UPDATE.DATE}
Problem name: {EVENT.NAME}
Host: {HOST.NAME}
Update message: {EVENT.UPDATE.MESSAGE}
Updated by: {USER.FULLNAME}
Original problem ID: {EVENT.ID}

Webhook script

The cloned Telegram script can be simplified to send a JSON payload to n8n instead of sending a Telegram message directly.

Use this JavaScript in the Zabbix webhook media type:

const CLogger = function(serviceName) {
	this.serviceName = serviceName;
	this.INFO = 4;
	this.WARN = 3;
	this.ERROR = 2;
 
	this.log = function(level, msg) {
		Zabbix.log(level, '[' + this.serviceName + '] ' + msg);
	};
};
 
const CWebhook = function(value) {
	try {
		params = JSON.parse(value);
 
		if (['0', '1', '2', '3', '4'].indexOf(params.event_source) === -1) {
			throw 'Incorrect "event_source" parameter given: ' + params.event_source + '. Must be 0-4.';
		}
 
		if (['0', '3', '4'].indexOf(params.event_source) !== -1 && ['0', '1'].indexOf(params.event_value) === -1) {
			throw 'Incorrect "event_value" parameter given: ' + params.event_value + '. Must be 0 or 1.';
		}
 
		if (['0', '3', '4'].indexOf(params.event_source) !== -1) {
			if (params.event_source === '0' && ['0', '1'].indexOf(params.event_update_status) === -1) {
				throw 'Incorrect "event_update_status" parameter given: ' + params.event_update_status + '. Must be 0 or 1.';
			}
 
			if (params.event_source === '4') {
				if (['0', '1', '2', '3', '4', '5'].indexOf(params.event_update_nseverity) !== -1
					&& params.event_update_nseverity != params.event_nseverity) {
					params.event_nseverity = params.event_update_nseverity;
					params.event_severity = params.event_update_severity;
					params.event_update_status = '1';
				}
			}
		}
 
		this.runCallback = function(name, params) {
			if (typeof this[name] === 'function') {
				return this[name].apply(this, [params]);
			}
		};
 
		this.handleEvent = function(source, event) {
			const alert = { source: source, event: event };
			return [
				this.runCallback('on' + source + event, alert),
				this.runCallback('on' + event, alert),
				this.runCallback('onEvent', alert)
			];
		};
 
		this.handleEventless = function(source) {
			const alert = { source: source, event: null };
			return [
				this.runCallback('on' + source, alert),
				this.runCallback('onEvent', alert)
			];
		};
 
		this.run = function() {
			var results = [];
 
			if (typeof this.httpProxy === 'string' && this.httpProxy.trim() !== '') {
				this.request.setProxy(this.httpProxy);
			}
 
			const types = {
				'0': 'Trigger',
				'1': 'Discovery',
				'2': 'Autoreg',
				'3': 'Internal',
				'4': 'Service'
			};
 
			if (['0', '3', '4'].indexOf(this.params.event_source) !== -1) {
				var event = (this.params.event_update_status === '1')
					? 'Update'
					: ((this.params.event_value === '1') ? 'Problem' : 'Resolve');
 
				results = this.handleEvent(types[this.params.event_source], event);
			}
			else if (typeof types[this.params.event_source] !== 'undefined') {
				results = this.handleEventless(types[this.params.event_source]);
			}
			else {
				throw 'Unexpected "event_source": ' + this.params.event_source;
			}
 
			for (idx in results) {
				if (typeof results[idx] !== 'undefined') {
					return JSON.stringify(results[idx]);
				}
			}
		};
 
		this.httpProxy = params.http_proxy;
		this.params = params;
		this.runCallback('onCheckParams', {});
	}
	catch (error) {
		throw 'Webhook processing failed: ' + error;
	}
};
 
const CParamValidator = {
	isType: function(value, type) {
		if (type === 'array') {
			return Array.isArray(value);
		}
		return (typeof value === type);
	},
 
	isDefined: function(value) {
		return !CParamValidator.isType(value, 'undefined');
	},
 
	isEmpty: function(value) {
		if (!CParamValidator.isType(value, 'string')) {
			throw 'Value "' + value + '" must be a string to be checked for emptiness.';
		}
		return (value.trim() === '');
	},
 
	ifMatch: function(value, regex) {
		return (new RegExp(regex)).test(value);
	},
 
	checkURL: function(value) {
		if (CParamValidator.isEmpty(value)) {
			throw 'URL value must be a non-empty string.';
		}
		if (!CParamValidator.ifMatch(value, '^(http|https):\\/\\/.+')) {
			throw 'URL value must contain a schema.';
		}
		return value;
	},
 
	check: function(key, rule, params) {
		if (!CParamValidator.isDefined(params[key])) {
			throw 'Required parameter "' + key + '" was not found.';
		}
 
		var value = params[key];
 
		switch (rule.type) {
			case 'string':
				if (!CParamValidator.isType(value, 'string')) {
					throw 'Value "' + key + '" must be a string.';
				}
				if (rule.required === true && CParamValidator.isEmpty(value)) {
					throw 'Value "' + key + '" must be a non-empty string.';
				}
				if (rule.url === true) {
					value = CParamValidator.checkURL(value);
				}
				break;
 
			default:
				throw 'Unexpected validation type "' + rule.type + '" for "' + key + '".';
		}
 
		params[key] = value;
		return this;
	},
 
	validate: function(rules, params) {
		if (!CParamValidator.isType(params, 'object') || CParamValidator.isType(params, 'array')) {
			throw 'Incorrect parameters value. The value must be an object.';
		}
 
		for (var key in rules) {
			CParamValidator.check(key, rules[key], params);
		}
	}
};
 
const CHttpRequest = function(logger) {
	this.request = new HttpRequest();
	this.logger = (typeof logger === 'object' && logger !== null) ? logger : Zabbix;
 
	this.addHeaders = function(value) {
		var headers = [];
 
		if (typeof value === 'object' && value !== null) {
			if (!Array.isArray(value)) {
				Object.keys(value).forEach(function(key) {
					headers.push(key + ': ' + value[key]);
				});
			}
			else {
				headers = value;
			}
		}
		else if (typeof value === 'string') {
			value.split('\r\n').forEach(function(header) {
				headers.push(header);
			});
		}
 
		for (var idx in headers) {
			this.request.addHeader(headers[idx]);
		}
	};
 
	this.setProxy = function(proxy) {
		this.request.setProxy(proxy);
	};
 
	this.plainRequest = function(method, url, data) {
		var resp = null;
		method = method.toLowerCase();
 
		this.logger.log(4, 'Sending ' + method + ' request to: ' + url);
		this.logger.log(4, 'Payload: ' + JSON.stringify(data));
 
		if (['get', 'post', 'put', 'patch', 'delete', 'trace'].indexOf(method) !== -1) {
			resp = this.request[method](url, data);
		}
		else if (['connect', 'head', 'options'].indexOf(method) !== -1) {
			resp = this.request[method](url);
		}
		else {
			throw 'Unexpected method. Method ' + method + ' is not supported.';
		}
 
		this.logger.log(4, 'HTTP status: ' + this.request.getStatus());
		this.logger.log(4, 'Response: ' + resp);
 
		return resp;
	};
 
	this.jsonRequest = function(method, url, data) {
		this.addHeaders('Content-Type: application/json');
		return this.plainRequest(method, url, JSON.stringify(data));
	};
 
	this.getStatus = function() {
		return this.request.getStatus();
	};
};
 
var serviceLogName = 'n8n Webhook',
	Logger = new CLogger(serviceLogName),
	N8N = CWebhook;
 
N8N.prototype.onCheckParams = function () {
	CParamValidator.validate(
		{
			webhook_url: {type: 'string', required: true, url: true},
			alert_subject: {type: 'string', required: false},
			alert_message: {type: 'string', required: false},
			event_source: {type: 'string', required: true},
			event_value: {type: 'string', required: true},
			event_update_status: {type: 'string', required: false}
		},
		this.params
	);
 
	this.params.webhook_url = CParamValidator.checkURL(this.params.webhook_url);
};
 
N8N.prototype.buildPayload = function(alert) {
	return {
		source: 'zabbix',
		alert: {
			source: alert.source,
			event: alert.event
		},
		status: alert.event,
		subject: this.params.alert_subject,
		message: this.params.alert_message,
		event: {
			id: this.params.event_id,
			name: this.params.event_name,
			status: this.params.event_status,
			value: this.params.event_value,
			source: this.params.event_source,
			severity: this.params.event_severity,
			nseverity: this.params.event_nseverity,
			opdata: this.params.event_opdata,
			date: this.params.event_date,
			time: this.params.event_time,
			duration: this.params.event_duration,
			update_status: this.params.event_update_status,
			update_message: this.params.event_update_message,
			recovery_status: this.params.event_recovery_status,
			recovery_date: this.params.event_recovery_date,
			recovery_time: this.params.event_recovery_time
		},
		host: {
			name: this.params.host_name,
			ip: this.params.host_ip,
			conn: this.params.host_conn
		},
		trigger: {
			id: this.params.trigger_id,
			name: this.params.trigger_name,
			url: this.params.trigger_url
		},
		zabbix: {
			url: this.params.zabbix_url
		}
	};
};
 
N8N.prototype.onEvent = function(alert) {
	Logger.log(Logger.INFO, 'Source: ' + alert.source + '; Event: ' + alert.event);
	Logger.log(Logger.INFO, 'Webhook URL: ' + this.params.webhook_url);
 
	var payload = this.buildPayload(alert);
	var response = this.request.jsonRequest('POST', this.params.webhook_url, payload);
	var status = this.request.getStatus();
 
	if (status < 200 || status >= 300) {
		Logger.log(Logger.WARN, 'HTTP code: ' + status);
		throw 'n8n webhook failed with HTTP status ' + status + ': ' + response;
	}
 
	return {
		tags: {
			'__n8n_last_status': String(status)
		}
	};
};
 
try {
	var hook = new N8N(value);
	hook.request = new CHttpRequest(Logger);
	return hook.run();
}
catch (error) {
	Logger.log(Logger.WARN, 'notification failed: ' + error);
	throw 'Sending failed: ' + error;
}

8. Enable alert delivery in Zabbix

For Zabbix to actually send alerts to n8n, three things must be enabled:

  1. The n8n webhook media type must be enabled.
  2. The Zabbix user must have the n8n media type assigned and enabled.
  3. A trigger action must be enabled to send messages to that user/media type.

Enable the media type

In Zabbix UI:

Alerts -> Media types

Open the cloned n8n media type and make sure it is enabled.

Example:

Name: n8n Webhook
Type: Webhook
Status: Enabled

If the media type is disabled, the action can match but no notification will be sent.

Add media to Zabbix user

Create a dedicated Zabbix user for n8n alerts or use an existing alerting user.

Example user:

n8n-alerts

Then add the media type to that user:

Users -> Users -> n8n-alerts -> Media

Media settings:

Type: n8n Webhook
Send to: n8n
When active: 1-7,00:00-24:00
Use if severity: select desired severities
Enabled: yes

The Send to value is mandatory in Zabbix, but for this custom webhook it can be a dummy value such as:

n8n

The real destination is controlled by the media type parameter:

webhook_url

Enable trigger action

In Zabbix UI:

Alerts -> Actions -> Trigger actions

Make sure this built-in action is enabled:

Report problems to Zabbix administrators

This is important because Zabbix will not send problem notifications unless a trigger action matches the event and sends a message to a user/media type.

For the homelab n8n setup, either update the built-in action to send to the n8n alert user/media type, or create a dedicated action.


9. Create dedicated trigger action for n8n

A dedicated action keeps the n8n alert flow separate from the default administrator notifications.

In Zabbix UI:

Alerts -> Actions -> Trigger actions

Create an action:

Name: Send problems to n8n
Status: Enabled

Suggested condition:

Trigger severity >= Warning

Operations

Send message
Send to users: n8n-alerts
Send only to: n8n Webhook

Recovery operations

Also configure recovery operations so n8n receives resolved events:

Recovery operations -> Send message
Send to users: n8n-alerts
Send only to: n8n Webhook

Update operations

Also configure update operations if manual acknowledgement/update comments should be sent to n8n:

Update operations -> Send message
Send to users: n8n-alerts
Send only to: n8n Webhook

Quick alert checklist

If Telegram/n8n does not receive alerts, check these first:

Alerts -> Media types -> n8n Webhook -> Enabled
Users -> Users -> n8n-alerts -> Media -> Enabled
Alerts -> Actions -> Trigger actions -> Report problems to Zabbix administrators -> Enabled
Alerts -> Actions -> Trigger actions -> Send problems to n8n -> Enabled

10. n8n Telegram message

The n8n workflow receives the Zabbix payload under:

$json.body

Since Zabbix already formats the alert subject and message through the media type message templates, the Telegram node can stay simple.

Example Telegram message:

{{ $json.body.subject }}
 
{{ $json.body.message }}
 
This message was sent automatically with n8n

This keeps formatting in Zabbix and leaves n8n responsible only for routing and delivery.

11. Troubleshooting

Zabbix webhook error: Could not resolve host: undefined

This means the webhook script tried to call an undefined URL.

Most likely cause:

The media type parameter webhook_url is missing or misspelled.

Fix:

Alerts -> Media types -> n8n Webhook -> Parameters

Ensure there is a parameter named exactly:

webhook_url

Lowercase.

n8n receives unresolved macros

Example:

{
  "host": {
    "name": "{HOST.NAME}",
    "ip": "{HOST.IP}"
  }
}

This can happen when using the Zabbix media type test because there is no real trigger event context.

To properly test:

  1. Trigger a real Zabbix problem
  2. Let the Trigger Action send the alert
  3. Check the payload received in n8n

Agent does not auto-register

Check on the Linux host:

sudo journalctl -u zabbix-agent2 -n 100 --no-pager

Verify:

ServerActive=zabbix-server.example.internal
Hostname=uat
HostMetadata=linux,docker

Also confirm the Linux host can reach the Zabbix server:

nc -vz zabbix-server.example.internal 10051

12. Security considerations

Network access

Allow only required traffic:

SourceDestinationPortPurpose
Linux hostsZabbix serverTCP/10051Active checks / auto-registration
Zabbix serverLinux hostsTCP/10050Passive checks, if used
Zabbix serverPostgreSQL serverTCP/5432Database access
Zabbix servern8nHTTPS/443Webhook alerts

Secrets

Do not commit real secrets to Git:

  • PostgreSQL password
  • n8n webhook URL, if considered sensitive
  • Telegram bot token

Use CI/CD secrets or host-local .env files.

Docker monitoring

The zabbix user being added to the docker group should be treated as privileged access.

Only enable Docker monitoring on hosts where it is needed.


13. Future improvements

Potential improvements:

  • Manage zabbix-agent2 installation with Ansible
  • Template zabbix_agent2.conf using host variables
  • Use Zabbix API, Terraform, or Ansible for host groups, templates, and auto-registration actions
  • Add Proxmox monitoring using the Proxmox VE by HTTP template
  • Add TLS/reverse proxy in front of Zabbix Web
  • Add database backups for the Zabbix PostgreSQL database
  • Consider TimescaleDB later if history volume grows significantly