Zabbix Maps as Code with Ansible
Managing Zabbix network maps by clicking through the frontend works fine for a handful of maps, but it does not scale — and it is not reproducible. This guide shows how to define maps as code in a YAML file and create them automatically using Ansible.
Two approaches are covered. The deciding question is whether the official module’s constraints actually get in your way:
- Part 1 — community.zabbix.zabbix_map: start here. It’s an
ansible-galaxy collection installaway, ships as part ofcommunity.zabbix, and there’s no custom code to own, test, or carry across Zabbix upgrades. It’s the right fit as long as Zabbix’s automatic layout produces a readable map, one click URL per element is enough, and the collection’s current auth options (see finding #1) cover your service account. - Part 2 — Custom Ansible module: reach for this only once one of those constraints actually bites — you need explicit
x/ypositioning because auto-layout produces a tangle, multiple click URLs per element (a “Latest data” link and a runbook link), or API-token authentication. In exchange for that control you take on one Python file that you maintain, review, and keep working as the Zabbix API evolves.
Part 1 — community.zabbix.zabbix_map
How it works
The community.zabbix.zabbix_map module takes a graph description written in Graphviz DOT language, calls the Zabbix API, and creates the map. Graphviz handles the layout automatically, so you describe what is connected rather than where every element sits on the canvas.
The workflow is:
- Define all maps in
maps.yamlusing DOT syntax - Run the playbook — it reads the file and creates or updates each map
- Commit the YAML to version control
Prerequisites
Ansible control node:
pip install 'ansible-core>=2.16' # community.zabbix 4.x requires >= 2.16
ansible-galaxy collection install community.zabbix
apt install graphviz # Debian/Ubuntu
# dnf install graphviz # RHEL/Rocky
pip install pydotplus webcolors Pillow # required Python packages
Zabbix: a service account with permissions to create maps.
Directory structure
zabbix-maps/
├── create_zabbix_maps.yml # Playbook
├── maps.yaml # Map definitions
├── inventory.yml # Zabbix server connection
└── group_vars/
└── ZABBIX_API/
└── vars.yml # Admin password (vault-encrypt this)
Connection
community.zabbix 4.x uses Ansible’s httpapi connection plugin for all API calls. There is no server_url or login_user parameter — connection details live in the inventory.
# inventory.yml
all:
children:
ZABBIX_API:
hosts:
zabbix-server:
ansible_host: "zabbix.example.com"
vars:
ansible_connection: httpapi
ansible_network_os: community.zabbix.zabbix
ansible_user: Admin
ansible_httpapi_port: 443
ansible_httpapi_use_ssl: true
ansible_httpapi_validate_certs: true
Put the password in a separate file so it can be vault-encrypted independently:
# group_vars/ZABBIX_API/vars.yml
ansible_password: "your_admin_password"
ansible-vault encrypt group_vars/ZABBIX_API/vars.yml
Map definition format
Maps are defined in maps.yaml. Each entry has a name, optional width/height, and a data block containing the DOT graph.
Element types in DOT
Nodes become map elements. The zbx_* attribute determines the element type:
| Attribute | Element type |
|---|---|
zbx_host="Hostname" | Host element — binds to a Zabbix host |
zbx_sysmap="Map name" | Sub-map element — drill-down to another map. The referenced map must already exist. |
zbx_group="Group name" | Host group element — aggregates the status of every host in the group |
No zbx_* attribute | Static image element |
The quoted node name is not the label — add
zbx_labelexplicitly. It’s tempting to assume"PostgreSQL" [zbx_host="db-zabbix-01" ...]makes “PostgreSQL” the on-map label, since that’s the string you wrote and the one used to draw links to/from this node. It isn’t. The module stores the referenced Zabbix object’s own name (db-zabbix-01, or the host group’s name forzbx_group) as the element’s label, silently discarding the node identifier you chose. Addzbx_label="PostgreSQL"to the node’s attributes to get the label you actually want — confirmed live: see Testing Zabbix Maps as Code for the side-by-side proof. Every example below now includes it.
Icon attributes
| Attribute | Shown when |
|---|---|
zbx_image_default | No problems |
zbx_image_problem | Active problems |
zbx_image_maintenance | Host in maintenance |
zbx_image_disabled | Host disabled |
Icon names must match exactly what is in Administration → Images in Zabbix. The built-in icons follow the pattern Server_(48), Router_(64), Switch_(48) — the number is the pixel size. The available set changed in Zabbix 7.x; verify exact names under Administration → Images before using them.
Label type
Controls the text shown under each element. Set as a parameter on the module task — not inside the DOT graph.
The most important thing: if you use element_name, host elements show the Zabbix hostname, but map and image elements have no object name and fall back to showing "Image". Use label instead — and remember that the stored label still needs zbx_label set explicitly on the node (see the callout above), or you’ll get the Zabbix object’s technical name regardless of which label_type you pick.
| Value | What is shown |
|---|---|
label | The node label from the DOT graph (recommended) |
ip | Host IP address |
element_name | Zabbix object name — shows “Image” for map/image elements |
status | Problem status only |
nothing | Nothing |
Element click URLs
Both host and map elements support click URLs. The syntax is the same — only the available macros differ. URLs can point to anything — Zabbix internal pages, a wiki, a runbook, or any external system.
Host elements
By default clicking a host element opens a Zabbix context menu. The DOT format specification documents zbx_url_name and zbx_url for navigating directly, but in Zabbix 7.4 including either attribute makes map creation fail outright, with no clear error message pointing at the cause. They have been removed from every example in this post — see Testing Zabbix Maps as Code for the live-tested finding. Use the URL A/inventory route below, or the custom module’s urls: list from Part 2.
Available macros:
| Macro | Resolves to |
|---|---|
{HOST.ID} | Internal numeric host ID |
{HOST.HOST} | Zabbix host name |
{HOST.NAME} | Visible name of the host |
{INVENTORY.URL.A} | URL A field from the host’s inventory |
{INVENTORY.URL.B} | URL B field from the host’s inventory |
{INVENTORY.URL.C} | URL C field from the host’s inventory |
The DOT format supports only one URL per node, so choose the most useful one. A wiki or runbook link is often more valuable than the Zabbix host page for operators looking at a map.
"Core Router" [zbx_host="Router-Core-01"
zbx_label="Core Router"
zbx_image_default="Router_(64)"
zbx_image_problem="Router_(64)"]
Using {HOST.HOST} in an external URL makes it dynamic — the same entry covers all hosts as long as your wiki follows a consistent URL pattern like wiki.example.com/hosts/<hostname>.
Alternatively, store the wiki URL in the host’s inventory URL A field in Zabbix and reference it with {INVENTORY.URL.A}. This way the URL is managed per-host in Zabbix rather than hardcoded in the map definition.
For multiple URLs per element use the custom module.
Map elements
Clicking a map element already navigates to the referenced map by default — no URL configuration needed. zbx_url_name and zbx_url would, in principle, add an extra link in the click menu alongside that default navigation — but as noted above, both attributes make map creation fail outright in Zabbix 7.4, so they are not usable here either.
Available macros:
| Macro | Resolves to |
|---|---|
{URL.MAP} | The map’s URL in Zabbix |
{MAP.ID} | Internal numeric map ID |
{MAP.NAME} | Map name |
"Server Farm" [zbx_sysmap="Server Farm"
zbx_label="Server Farm"
zbx_image_default="Server_(48)"
zbx_image_problem="Server_(48)"]
Link styling
"Router" -- "Switch" [label="Gi0/1"
zbx_draw_style=bold
zbx_color="#00AA00"]
| Attribute | Values |
|---|---|
label | Text shown on the link |
zbx_draw_style | line | bold | dotted | dashed |
zbx_color | Hex colour with # prefix |
zbx_color does not work in Zabbix 7.4 — zbx_draw_style does. Live testing confirmed zbx_draw_style renders correctly (bold, dotted, etc. all show up as expected), but every zbx_color value gets silently discarded and replaced with a fixed #008000, regardless of the hex you specify. The map still creates cleanly with no warning, so this is easy to miss without comparing the result against the source data — see Testing Zabbix Maps as Code for the side-by-side proof. If link colour carries meaning in your topology, use the custom module’s color field from Part 2 instead — it passes the exact requested hex straight through.
Trigger-based link styling is not supported by this module. Link colours are static — and per the above, not even reliably static at the value you asked for.
Linking to a device in another map
Use zbx_sysmap to place a sub-map element in the current map and draw a link to it. Clicking the element drills down into the referenced map. The element aggregates the worst problem severity from everything inside it.
"Core Router" [zbx_host="Router-Core-01"
zbx_label="Core Router"
zbx_image_default="Router_(64)"
zbx_image_problem="Router_(64)"]
"Server Farm" [zbx_sysmap="Server Farm"
zbx_label="Server Farm"
zbx_image_default="Server_(48)"
zbx_image_problem="Server_(48)"]
// Workaround for community.zabbix issue #675 — bare node required
// before link definitions or the module throws KeyError: 'pos'
"__workaround__"
"Core Router" -- "Server Farm" [label="Te0/2"
zbx_draw_style=bold
zbx_color="#00AA00"]
Deployment order matters: the referenced map must exist before the map that references it is created. Put the referenced map first in
maps.yaml.
Known bug — KeyError: ‘pos’
The module throws KeyError: 'pos' when re-running if nodes are declared in one block and links follow in a separate block. The workaround is to add a bare node with no attributes as the last line of the node declarations, before any link definitions:
"__workaround__"
This node will appear as an unlinked element on the map. You can minimise its visual footprint by not giving it a label and leaving it without a zbx_* attribute.
Example: Zabbix infrastructure map
# maps.yaml
maps:
- name: "Zabbix Infrastructure"
width: 1400
height: 800
state: present
data: |
graph {
"PostgreSQL" [zbx_host="db-zabbix-01"
zbx_label="PostgreSQL"
zbx_image_default="Disk_array_3D_(64)"
zbx_image_problem="Disk_array_3D_(64)"]
"Database VIP" [zbx_host="vip-lb-prod"
zbx_label="Database VIP"
zbx_image_default="Network_(48)"
zbx_image_problem="Network_(48)"]
"Zabbix Server" [zbx_host="zabbix-server-01"
zbx_label="Zabbix Server"
zbx_image_default="Server_(64)"
zbx_image_problem="Server_(64)"]
"Proxy Group" [zbx_group="Zabbix proxy groups"
zbx_label="Proxy Group"
zbx_image_default="Server_(48)"
zbx_image_problem="Server_(48)"]
"Proxy 01" [zbx_host="proxy01"
zbx_label="Proxy 01"
zbx_image_default="Server_(48)"
zbx_image_problem="Server_(48)"]
"Proxy 02" [zbx_host="proxy02"
zbx_label="Proxy 02"
zbx_image_default="Server_(48)"
zbx_image_problem="Server_(48)"]
"__workaround__"
"PostgreSQL" -- "Database VIP" [label="5432"
zbx_draw_style=bold
zbx_color="#0066CC"]
"Database VIP" -- "Zabbix Server" [zbx_color="#0066CC"]
"Zabbix Server" -- "Proxy Group" [label="10051"
zbx_color="#00AA00"]
"Proxy Group" -- "Proxy 01" [zbx_color="#00AA00"]
"Proxy Group" -- "Proxy 02" [zbx_color="#00AA00"]
}
The playbook
The community module does not handle re-runs well — it errors if the map already exists. The fix is to delete before creating.
The playbook runs against the ZABBIX_API inventory group. Authentication is handled by the httpapi connection — no module_defaults block needed.
# create_zabbix_maps.yml
- name: Create Zabbix network maps
hosts: ZABBIX_API
gather_facts: false
vars:
maps_file: "maps.yaml"
map_name: ""
tasks:
- name: Load map definitions
ansible.builtin.include_vars:
file: "{{ maps_file }}"
name: maps_config
- name: Filter maps
ansible.builtin.set_fact:
maps_to_process: >-
{{
maps_config.maps
if not map_name
else maps_config.maps | selectattr('name', 'equalto', map_name) | list
}}
- name: Delete existing maps (clean slate on re-runs)
community.zabbix.zabbix_map:
name: "{{ item.name }}"
state: absent
loop: "{{ maps_to_process }}"
loop_control:
label: "{{ item.name }}"
- name: Create maps
community.zabbix.zabbix_map:
name: "{{ item.name }}"
width: "{{ item.width | default(1200) }}"
height: "{{ item.height | default(800) }}"
state: present
data: "{{ item.data }}"
default_image: "Server_(48)"
expand_problem: true
# Always use "label" — "element_name" shows "Image" for map and image elements
label_type: label
loop: "{{ maps_to_process }}"
loop_control:
label: "{{ item.name }}"
Defining topology with a Jinja2 template
Writing DOT syntax by hand works but is error-prone for larger maps. A cleaner approach is to define the map topology as structured YAML and let a Jinja2 template render the DOT. The module still receives valid DOT — only the source changes.
Updated directory structure
zabbix-maps/
├── create_zabbix_maps.yml
├── map_hosts.yaml # structured topology — nodes and links
├── inventory.yml
├── templates/
│ └── map_dot.j2 # renders DOT from topology data
└── group_vars/
└── ZABBIX_API/
└── vars.yml
map_hosts.yaml
Each map entry has a nodes list and a links list. The type field on each node maps to the correct DOT attribute — no DOT syntax to write by hand.
type | DOT attribute | zbx_ref contains |
|---|---|---|
host | zbx_host | Zabbix host name |
map | zbx_sysmap | Map name |
group | zbx_group | Host group name |
# map_hosts.yaml
maps:
- name: "Zabbix Infrastructure"
width: 1400
height: 800
nodes:
- name: "PostgreSQL"
type: host
zbx_ref: "db-zabbix-01"
icon_default: "Disk_array_3D_(64)"
icon_problem: "Disk_array_3D_(64)"
- name: "Database VIP"
type: host
zbx_ref: "vip-lb-prod"
icon_default: "Network_(48)"
icon_problem: "Network_(48)"
- name: "Zabbix Server"
type: host
zbx_ref: "zabbix-server-01"
icon_default: "Server_(64)"
icon_problem: "Server_(64)"
- name: "Proxy Group"
type: group
zbx_ref: "Zabbix proxy groups"
icon_default: "Server_(48)"
icon_problem: "Server_(48)"
- name: "Proxy 01"
type: host
zbx_ref: "proxy01"
icon_default: "Server_(48)"
icon_problem: "Server_(48)"
- name: "Proxy 02"
type: host
zbx_ref: "proxy02"
icon_default: "Server_(48)"
icon_problem: "Server_(48)"
links:
- from: "PostgreSQL"
to: "Database VIP"
label: "5432"
draw_style: bold
color: "#0066CC"
- from: "Database VIP"
to: "Zabbix Server"
color: "#0066CC"
- from: "Zabbix Server"
to: "Proxy Group"
label: "10051"
color: "#00AA00"
- from: "Proxy Group"
to: "Proxy 01"
color: "#00AA00"
- from: "Proxy Group"
to: "Proxy 02"
color: "#00AA00"
Sub-map references use type: map and zbx_ref set to the map name:
- name: "Datacenter"
type: map
zbx_ref: "Datacenter"
icon_default: "Server_(64)"
icon_problem: "Server_(64)"
templates/map_dot.j2
The template iterates over nodes and links and emits valid DOT. The __workaround__ node is added automatically after all node definitions.
graph {
{% for node in current_map.nodes %}
"{{ node.name }}" [{% if node.type | default('host') == 'host' %}zbx_host="{{ node.zbx_ref }}"{% elif node.type == 'map' %}zbx_sysmap="{{ node.zbx_ref }}"{% elif node.type == 'group' %}zbx_group="{{ node.zbx_ref }}"{% endif %}
zbx_image_default="{{ node.icon_default }}"
zbx_image_problem="{{ node.icon_problem | default(node.icon_default) }}"]
{% endfor %}
"__workaround__"
{% for link in current_map.links %}
"{{ link.from }}" -- "{{ link.to }}" [{% if link.label is defined %}label="{{ link.label }}" {% endif %}{% if link.draw_style is defined %}zbx_draw_style={{ link.draw_style }} {% endif %}zbx_color="{{ link.color | default('#000000') }}"]
{% endfor %}
}
Updated playbook task
Replace data: "{{ item.data }}" with a lookup('template', ...) call. The playbook runs against ZABBIX_API — no module_defaults block is needed.
# create_zabbix_maps.yml
- name: Create Zabbix network maps from topology data
hosts: ZABBIX_API
gather_facts: false
vars:
maps_file: "map_hosts.yaml"
map_name: ""
tasks:
- name: Load map topology
ansible.builtin.include_vars:
file: "{{ maps_file }}"
name: maps_config
- name: Filter maps
ansible.builtin.set_fact:
maps_to_process: >-
{{
maps_config.maps
if not map_name
else maps_config.maps | selectattr('name', 'equalto', map_name) | list
}}
- name: Delete existing maps (clean slate on re-runs)
community.zabbix.zabbix_map:
name: "{{ item.name }}"
state: absent
loop: "{{ maps_to_process }}"
loop_control:
label: "{{ item.name }}"
- name: Create maps from topology
community.zabbix.zabbix_map:
name: "{{ item.name }}"
width: "{{ item.width | default(1200) }}"
height: "{{ item.height | default(800) }}"
state: present
data: "{{ lookup('template', 'templates/map_dot.j2') }}"
default_image: "Server_(48)"
expand_problem: true
label_type: label
vars:
current_map: "{{ item }}"
loop: "{{ maps_to_process }}"
loop_control:
label: "{{ item.name }}"
The vars: block makes current_map available to the template for each loop iteration. Authentication is handled by the inventory’s httpapi vars — nothing changes in the task parameters.
Multi-site example: Office, Datacenter, and Offsite
This example defines four maps — an overview and one per site — matching the topology shown in the diagram below.
Deployment order: Offsite and Office both reference the Datacenter map, and Datacenter references both back. Since Zabbix requires a referenced map to exist before the referencing map is created, there is a circular dependency. The workaround is to order the maps Offsite → Office → Datacenter → Overview in the file, then run the playbook twice. The first run creates all four maps (Offsite and Office warn that Datacenter does not exist yet, but still create). The second run with update: true resolves all cross-references.
ansible-playbook create_zabbix_maps.yml --ask-vault-pass
ansible-playbook create_zabbix_maps.yml -e update=true --ask-vault-pass
# maps.yaml
maps:
# 1 — Create Offsite first (references Datacenter which does not exist yet on first run)
- name: "Offsite"
width: 1000
height: 600
state: present
data: |
graph {
"PoC host" [zbx_host="offsite-poc-01"
zbx_image_default="Server_(48)"
zbx_image_problem="Server_(48)"]
"Datacenter" [zbx_sysmap="Datacenter"
zbx_image_default="Server_(64)"
zbx_image_problem="Server_(64)"]
"__workaround__"
"PoC host" -- "Datacenter" [label="WAN"
zbx_draw_style=dashed
zbx_color="#808080"]
}
# 2 — Create Office second (also references Datacenter which does not exist yet on first run)
- name: "Office"
width: 1000
height: 600
state: present
data: |
graph {
"Workstation" [zbx_host="office-ws-01"
zbx_image_default="Workstation_(48)"
zbx_image_problem="Workstation_(48)"]
"Datacenter" [zbx_sysmap="Datacenter"
zbx_image_default="Server_(64)"
zbx_image_problem="Server_(64)"]
"__workaround__"
"Workstation" -- "Datacenter" [label="LAN/WAN"
zbx_color="#00AA00"]
}
# 3 — Datacenter third — Office and Offsite now exist, so their map refs resolve
- name: "Datacenter"
width: 1400
height: 900
state: present
data: |
graph {
"Office" [zbx_sysmap="Office"
zbx_image_default="Server_(48)"
zbx_image_problem="Server_(48)"]
"External DB" [zbx_host="db-zabbix-01"
zbx_image_default="Disk_array_3D_(64)"
zbx_image_problem="Disk_array_3D_(64)"]
"Zabbix Server" [zbx_host="zabbix-server-01"
zbx_image_default="Server_(64)"
zbx_image_problem="Server_(64)"]
"Proxy Group" [zbx_host="proxy-group-dc"
zbx_image_default="Server_(48)"
zbx_image_problem="Server_(48)"]
"Proxy DC-01" [zbx_host="zabbix-proxy-dc-01"
zbx_image_default="Server_(48)"
zbx_image_problem="Server_(48)"]
"Proxy DC-02" [zbx_host="zabbix-proxy-dc-02"
zbx_image_default="Server_(48)"
zbx_image_problem="Server_(48)"]
"Offsite" [zbx_sysmap="Offsite"
zbx_image_default="Server_(48)"
zbx_image_problem="Server_(48)"]
"__workaround__"
"External DB" -- "Zabbix Server" [label="5432" zbx_draw_style=bold zbx_color="#0066CC"]
"Zabbix Server" -- "Proxy Group" [label="10051" zbx_color="#00AA00"]
"Proxy Group" -- "Proxy DC-01" [zbx_color="#00AA00"]
"Proxy Group" -- "Proxy DC-02" [zbx_color="#00AA00"]
"Office" -- "Proxy DC-01" [label="WAN" zbx_color="#808080"]
"Offsite" -- "Proxy DC-02" [label="WAN" zbx_draw_style=dashed zbx_color="#808080"]
}
# 4 — Overview last — all three site maps now exist
- name: "Overview"
width: 1400
height: 600
state: present
data: |
graph {
"Office" [zbx_sysmap="Office"
zbx_image_default="Server_(48)"
zbx_image_problem="Server_(48)"]
"Datacenter" [zbx_sysmap="Datacenter"
zbx_image_default="Server_(64)"
zbx_image_problem="Server_(64)"]
"Offsite" [zbx_sysmap="Offsite"
zbx_image_default="Server_(48)"
zbx_image_problem="Server_(48)"]
"__workaround__"
"Office" -- "Datacenter" [label="WAN" zbx_color="#00AA00"]
"Datacenter" -- "Offsite" [label="WAN" zbx_draw_style=dashed zbx_color="#00AA00"]
}
Deploying
Creating the dummy hosts
zabbix_host is a community.zabbix module too, so the same Connection rules apply — it authenticates through the ZABBIX_API httpapi connection, not through server_url / login_user / login_password task parameters.
- name: Ensure proxy group dummy hosts exist
community.zabbix.zabbix_host:
host_name: "{{ item.host }}"
visible_name: "{{ item.label }}"
host_groups:
- "Zabbix proxy groups"
link_templates:
- "Zabbix proxy group health"
monitored_by: proxy_group
proxy_group: "{{ item.proxy_group }}"
status: enabled
state: present
# No interfaces — this host has no agent
loop:
- { host: "proxy01", label: "Proxy 01", proxy_group: "CPH" }
- { host: "proxy02", label: "Proxy 02", proxy_group: "AAR" }
Two prerequisites this task quietly assumes:
- The
"Zabbix proxy group health"template — it isn’t part of Zabbix’s default template set, solink_templateswill fail to resolve it on a clean 7.4 install. Create it yourself first (even an empty placeholder works —community.zabbix.zabbix_template, or Data collection → Templates → Create template).- The
CPH/AARproxy groups — create them withcommunity.zabbix.zabbix_proxy_group, or under Administration → Proxy groups, before this task runs.
proxy_groupalone won’t switch monitoring over, either: Zabbix only treats a host as proxy-group-monitored oncemonitored_by: proxy_groupis also set (included above). Skip it and the host quietly stays “Monitored by: Server” — the proxy group binding is accepted but ignored.One architectural note: this example sets
monitored_by: proxy_groupon the dummy hosts representing the proxy servers themselves. That works as long asCPHandAARare different proxy groups from the ones those same proxy daemons are members of. If they share the same group — as is common in single-group setups — routing a proxy’s own monitoring checks back through the group it belongs to is circular: the group going offline is exactly when you most need to see that the proxy is down, and that’s precisely when the monitoring path collapses. See finding #10 for the full reasoning. For a single shared group, usemonitored_by: zabbix_serveron the proxy Host objects and setmonitored_by: proxy_groupon regular hosts in the same segment instead.
Referencing in the map (DOT format)
"Proxy 01" [zbx_host="proxy01"
zbx_label="Proxy 01"
zbx_image_default="Server_(48)"
zbx_image_problem="Server_(48)"]
Template name: verify the exact name under Data collection → Templates — search for proxy group. Trigger descriptions may also vary between Zabbix versions.
Adding a virtual IP to a map
A VIP used by keepalived, VRRP, or a load balancer is not a host you can install an agent on. Create a dummy host with an ICMP interface at the VIP address. The built-in ICMP Ping template provides a trigger that fires when the VIP stops responding.
Unlike the proxy group dummy host, this host does need an interface so Zabbix can ping it.
Creating the dummy hosts
- name: Ensure VIP dummy hosts exist
community.zabbix.zabbix_host:
host_name: "{{ item.host }}"
visible_name: "{{ item.label }}"
host_groups:
- "Virtual IPs"
link_templates:
- "ICMP Ping"
interfaces:
- type: agent
main: true
useip: true
ip: "{{ item.ip }}"
dns: ""
port: "10050" # required by API, ignored for ICMP
status: enabled
state: present
loop:
- { host: "vip-lb-prod", label: "VIP LB Prod", ip: "10.0.0.10" }
- { host: "vip-api-prod", label: "VIP API Prod", ip: "10.0.0.11" }
Referencing in the map (DOT format)
"Database VIP" [zbx_host="vip-lb-prod"
zbx_label="Database VIP"
zbx_image_default="Network_(48)"
zbx_image_problem="Network_(48)"]
The VIP element turns red automatically when the ICMP ping trigger fires. Link colours are static — trigger-based link styling is not available with this module.
Monitoring via proxy: if the VIP is not reachable from the Zabbix server, set the
proxyparameter on the host so a proxy in the right network segment performs the ping.
/var/lib/zabbix permissions
ICMP checks run as the zabbix OS user. If /var/lib/zabbix is not owned by that user the ping will fail silently — the trigger never fires and the element stays green regardless of actual reachability.
Check the current ownership:
ls -ld /var/lib/zabbix
If it is not owned by zabbix:zabbix, fix it:
chown zabbix:zabbix /var/lib/zabbix
With Ansible:
- name: Ensure /var/lib/zabbix is owned by zabbix
ansible.builtin.file:
path: /var/lib/zabbix
owner: zabbix
group: zabbix
state: directory
become: true
Run this on every host that performs the ping — the Zabbix server, and any proxy that monitors VIPs in its network segment.
Limitations
| community.zabbix.zabbix_map | |
|---|---|
| Element positioning | Auto (Graphviz) — no manual x/y |
| API token auth | No — username/password only |
| Trigger-based link styling | No |
| Multiple URLs per element | No — one URL per node in DOT format |
| Extra dependencies | graphviz, pydotplus, webcolors, Pillow |
| Known bugs | KeyError: ‘pos’ — requires dummy node workaround |
| Custom code to maintain | None |
Part 2 — Custom Ansible module
Installing it
There’s no install step in the ansible-galaxy/pip sense — a local module is just a file Ansible finds on disk:
- Create a
library/directory next to the playbook that will use the module (or, inside a role, atroles/<role-name>/library/). - Save the module’s source — shown in full further down — as
library/zabbix_map_from_yaml.py. - Reference it in tasks by its bare name,
zabbix_map_from_yaml— not a fully-qualified collection name likecommunity.zabbix.zabbix_map. Ansible’s module-discovery automatically searcheslibrary/directories that sit next to the playbook or role using it.
That’s the whole thing. Nothing to restart, nothing else to install — the very next playbook run picks it up. The trade-off is that it travels with the playbook: copy the playbook elsewhere without its library/ directory and the task fails with a plain “module not found”.
How it works
The module:
- Reads a map definition passed in as a dict
- Calls the Zabbix API to resolve host names, icon names, and trigger descriptions to their internal IDs
- Builds the
map.createormap.updatepayload - Creates or updates the map and returns a proper
changed/failedstate
Map definitions use explicit x/y coordinates, so you control the layout. Multiple click URLs per element are supported.
Prerequisites
On the Ansible control node: nothing extra. The module is pure ansible.module_utils — it talks to the API through the built-in fetch_url helper (ansible.module_utils.urls), so there’s nothing to pip install. No system packages, no collection install.
Zabbix side: create an API token under User menu → API tokens → Create API token. Token-based authentication is preferred over username/password for service accounts in Zabbix 7.x.
Directory structure
zabbix-maps/
├── create_zabbix_maps.yml # Playbook
├── maps.yaml # Map definitions
├── library/
│ └── zabbix_map_from_yaml.py # Custom module
└── group_vars/all/
└── zabbix.yml # Credentials (vault-encrypted)
Credentials
# group_vars/all/zabbix.yml
zabbix_url: "https://zabbix.example.com"
zabbix_api_token: "your_token_here"
ansible-vault encrypt group_vars/all/zabbix.yml
Username/password is also accepted if you cannot use a token:
zabbix_url: "https://zabbix.example.com"
zabbix_user: "ansible-svc"
zabbix_password: "your_password_here"
The custom module
Save this file as library/zabbix_map_from_yaml.py.
#!/usr/bin/python
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, print_function
__metaclass__ = type
import json
import traceback
from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils.urls import fetch_url
# ── Zabbix API client ──────────────────────────────────────────────────────────
class ZabbixAPI:
# Initialises the client and authenticates immediately.
# Appends /api_jsonrpc.php to the URL and either stores the Bearer token
# or calls user.login to obtain a session token for password-based auth.
def __init__(self, module, url, token=None, user=None, password=None):
self.module = module
self.url = url.rstrip("/") + "/api_jsonrpc.php"
self._id = 1 # incremented per request so each call has a unique id
self._auth = None # session token, only set when using user/password auth
self._token = token # API token, only set when using token auth
if not token:
if user and password:
self._auth = self._login(user, password)
else:
module.fail_json(
msg="Provide either 'token' or 'login_user' + 'login_password'"
)
# Returns the HTTP headers for every request.
# Adds the Authorization header only when using an API token.
def _headers(self):
h = {"Content-Type": "application/json"}
if self._token:
h["Authorization"] = f"Bearer {self._token}"
return h
# Sends a single JSON-RPC request to the Zabbix API and returns the result.
# Fails the Ansible task on any non-200 HTTP status or API-level error.
def call(self, method, params):
payload = {
"jsonrpc": "2.0",
"method": method,
"params": params,
"id": self._id,
}
if self._auth:
payload["auth"] = self._auth
self._id += 1
resp, info = fetch_url(
self.module,
self.url,
data=json.dumps(payload).encode("utf-8"),
headers=self._headers(),
method="POST",
)
if info["status"] != 200:
self.module.fail_json(
msg=f"HTTP {info['status']} calling Zabbix API method '{method}'"
)
body = json.loads(resp.read())
if "error" in body:
err = body["error"]
self.module.fail_json(
msg=f"Zabbix API error [{method}]: ({err['code']}) {err['data']}"
)
return body["result"]
# Authenticates with username/password and returns the session token.
# Called once during __init__ when token auth is not used.
def _login(self, user, password):
return self.call("user.login", {"username": user, "password": password})
# Looks up host names in Zabbix and returns a {hostname: hostid} dict.
# Emits a warning for any name that does not exist so the caller can see what failed.
def resolve_hosts(self, names):
if not names:
return {}
result = self.call("host.get", {
"filter": {"host": names}, "output": ["hostid", "host"]
})
mapping = {h["host"]: h["hostid"] for h in result}
missing = set(names) - set(mapping)
if missing:
self.module.warn(f"Hosts not found in Zabbix: {sorted(missing)}")
return mapping
# Looks up map names and returns a {mapname: sysmapid} dict.
# Used to resolve sub-map (drill-down) elements in the map definition.
def resolve_maps(self, names):
if not names:
return {}
result = self.call("map.get", {
"filter": {"name": names}, "output": ["sysmapid", "name"]
})
mapping = {m["name"]: m["sysmapid"] for m in result}
missing = set(names) - set(mapping)
if missing:
self.module.warn(f"Maps not found in Zabbix: {sorted(missing)}")
return mapping
# Looks up host group names in Zabbix and returns a {groupname: groupid} dict.
# Backs the "hostgroup" element type — group elements show the aggregate
# status of every host in the group rather than a single host's status.
def resolve_hostgroups(self, names):
if not names:
return {}
result = self.call("hostgroup.get", {
"filter": {"name": names}, "output": ["groupid", "name"]
})
mapping = {g["name"]: g["groupid"] for g in result}
missing = set(names) - set(mapping)
if missing:
self.module.warn(f"Host groups not found in Zabbix: {sorted(missing)}")
return mapping
# Looks up image names in Zabbix and returns a {imagename: imageid} dict.
# Covers all icon states (default, problem, maintenance, disabled) and backgrounds.
def resolve_icons(self, names):
if not names:
return {}
result = self.call("image.get", {
"filter": {"name": names}, "output": ["imageid", "name"]
})
mapping = {i["name"]: i["imageid"] for i in result}
missing = set(names) - set(mapping)
if missing:
self.module.warn(f"Icons not found in Zabbix: {sorted(missing)}")
return mapping
# Looks up trigger descriptions per host and returns a {(host, description): triggerid} dict.
# Queried one at a time because the API does not support cross-host description filtering.
def resolve_triggers(self, specs):
mapping = {}
for spec in specs:
result = self.call("trigger.get", {
"host": spec["host"],
"filter": {"description": spec["trigger"]},
"output": ["triggerid", "description"],
})
if result:
mapping[(spec["host"], spec["trigger"])] = result[0]["triggerid"]
else:
self.module.warn(
f"Trigger not found: host='{spec['host']}' "
f"description='{spec['trigger']}'"
)
return mapping
# Returns the existing map dict for a given name, or None if it does not exist.
# Used to decide whether to create or update.
def get_map_by_name(self, name):
result = self.call("map.get", {
"filter": {"name": name}, "output": ["sysmapid", "name"]
})
return result[0] if result else None
# Creates a new map from the payload and returns its sysmapid.
def create_map(self, payload):
result = self.call("map.create", payload)
return result["sysmapids"][0]
# Overwrites an existing map by merging the sysmapid into the payload.
def update_map(self, sysmapid, payload):
self.call("map.update", {**payload, "sysmapid": sysmapid})
# Deletes a map by its sysmapid.
def delete_map(self, sysmapid):
self.call("map.delete", [sysmapid])
# ── Constants ──────────────────────────────────────────────────────────────────
# Maps human-readable YAML strings to the integer codes the Zabbix API expects.
ELEMENT_TYPE = {"host": 0, "map": 1, "trigger": 2, "hostgroup": 3, "image": 4}
DRAWTYPE = {"line": 0, "bold": 2, "dotted": 3, "dashed": 4}
LABEL_TYPE = {"label": 0, "ip": 1, "name": 2, "status_only": 3, "nothing": 4}
# ── Payload builder ────────────────────────────────────────────────────────────
# Translates a string value through a lookup dict, or passes integers through unchanged.
# Used everywhere a YAML string like "bold" must become an API integer like 2.
def _resolve(val, mapping, default=0):
return mapping.get(val, default) if isinstance(val, str) else val
# Walks the map definition and collects every name that needs an API lookup.
# Returns five deduplicated lists: host names, map names, icon names, trigger specs, and group names.
def _collect_resources(map_def):
host_names, map_names, icon_names, trigger_specs, group_names = [], [], [], [], []
for elem in map_def.get("elements", []):
etype = elem.get("type", "host")
if etype == "host" and "host" in elem: host_names.append(elem["host"])
if etype == "map" and "map" in elem: map_names.append(elem["map"])
if etype == "hostgroup" and "group" in elem: group_names.append(elem["group"])
for key in ("default", "problem", "maintenance", "disabled"):
name = elem.get("icon", {}).get(key)
if name:
icon_names.append(name)
for link in map_def.get("links", []):
for t in link.get("triggers", []):
trigger_specs.append({"host": t["host"], "trigger": t["trigger"]})
if t["host"] not in host_names:
host_names.append(t["host"])
bg = map_def.get("background")
if bg:
icon_names.append(bg)
return (
list(dict.fromkeys(host_names)), # dict.fromkeys preserves order while deduplicating
list(dict.fromkeys(map_names)),
list(dict.fromkeys(icon_names)),
trigger_specs,
list(dict.fromkeys(group_names)),
)
# Converts the YAML map definition into the dict structure expected by map.create/map.update.
# Resolves all names to IDs first, then builds the selements and links arrays.
def build_map_payload(map_def, api):
host_names, map_names, icon_names, trigger_specs, group_names = _collect_resources(map_def)
# Resolve all names to Zabbix internal IDs in bulk before building the payload.
host_map = api.resolve_hosts(host_names)
map_id_map = api.resolve_maps(map_names)
icon_map = api.resolve_icons(icon_names)
trigger_map = api.resolve_triggers(trigger_specs)
group_map = api.resolve_hostgroups(group_names)
# Assign sequential numeric selementids (1, 2, 3...) and map YAML local ids to them.
# Links reference these numbers, so the mapping must be built before processing links.
local_to_selid = {}
selements = []
for idx, elem in enumerate(map_def.get("elements", []), start=1):
local_id = elem["id"]
selid = str(idx)
local_to_selid[local_id] = selid
etype_int = _resolve(elem.get("type", "host"), ELEMENT_TYPE, 0)
selement = {
"selementid": selid,
"elementtype": etype_int,
"label": elem.get("label", local_id),
"x": elem.get("x", 0),
"y": elem.get("y", 0),
}
# The "elements" sub-array links this map element to its Zabbix object
# (host, map, or host group). Image elements are the only type that don't
# need one, so they fall through to the empty-list default.
if etype_int == 0:
hname = elem.get("host")
selement["elements"] = (
[{"hostid": host_map[hname]}] if hname and hname in host_map else []
)
elif etype_int == 1:
mname = elem.get("map")
selement["elements"] = (
[{"sysmapid": map_id_map[mname]}] if mname and mname in map_id_map else []
)
elif etype_int == 3:
gname = elem.get("group")
selement["elements"] = (
[{"groupid": group_map[gname]}] if gname and gname in group_map else []
)
else:
selement["elements"] = []
# Map the four YAML icon states to their Zabbix API field names and attach image IDs.
for yaml_key, api_key in {
"default": "iconid_off",
"problem": "iconid_on",
"maintenance": "iconid_maintenance",
"disabled": "iconid_disabled",
}.items():
icon_name = elem.get("icon", {}).get(yaml_key)
if icon_name and icon_name in icon_map:
selement[api_key] = icon_map[icon_name]
if "label_type" in elem:
selement["label_type"] = _resolve(elem["label_type"], LABEL_TYPE, 0)
# Pass through any click URLs defined on the element.
# Each entry needs a name and url; macros like {HOST.HOST} and {HOST.ID} are supported.
if "urls" in elem:
selement["urls"] = elem["urls"]
selements.append(selement)
links = []
for link in map_def.get("links", []):
# Translate YAML local element ids (e.g. "router1") to numeric selementids (e.g. "3").
from_selid = local_to_selid.get(link["from"])
to_selid = local_to_selid.get(link["to"])
if not from_selid or not to_selid:
api.module.warn(
f"Link skipped — unknown element: "
f"'{link.get('from')}' → '{link.get('to')}'"
)
continue
link_obj = {
"selementid1": from_selid,
"selementid2": to_selid,
"label": link.get("label", ""),
"color": link.get("color", "000000"),
"drawtype": _resolve(link.get("drawtype", "line"), DRAWTYPE, 0),
}
# Build the linktriggers array — each entry changes the link appearance when its trigger fires.
linktriggers = []
for t in link.get("triggers", []):
key = (t["host"], t["trigger"])
if key not in trigger_map:
api.module.warn(
f"Linktrigger skipped — trigger not found: "
f"host='{t['host']}' description='{t['trigger']}'"
)
continue
linktriggers.append({
"triggerid": trigger_map[key],
"color": t.get("color", "FF0000"),
"drawtype": _resolve(t.get("drawtype", "bold"), DRAWTYPE, 2),
})
if linktriggers:
link_obj["linktriggers"] = linktriggers
links.append(link_obj)
lt_raw = map_def.get("label_type", 0)
payload = {
"name": map_def["name"],
"width": map_def.get("width", 1200),
"height": map_def.get("height", 800),
"label_type": _resolve(lt_raw, LABEL_TYPE, 0),
"selements": selements,
"links": links,
}
# Optional background image — only attached if the name resolved to a valid image ID.
bg = map_def.get("background")
if bg and bg in icon_map:
payload["backgroundid"] = icon_map[bg]
return payload
# ── Module entry point ─────────────────────────────────────────────────────────
# Declares all module parameters, validates mutual exclusions, and drives the create/update/delete logic.
# Wraps everything in a try/except so any unexpected error surfaces as a clean Ansible failure.
def main():
module = AnsibleModule(
argument_spec=dict(
url=dict(type="str", required=True),
token=dict(type="str", no_log=True),
login_user=dict(type="str"),
login_password=dict(type="str", no_log=True),
map_definition=dict(type="dict", required=True),
state=dict(type="str", default="present",
choices=["present", "absent"]),
update=dict(type="bool", default=False),
validate_certs=dict(type="bool", default=True),
),
mutually_exclusive=[
["token", "login_user"],
["token", "login_password"],
],
required_together=[["login_user", "login_password"]],
required_one_of=[["token", "login_user"]],
supports_check_mode=True,
)
map_def = module.params["map_definition"]
state = module.params["state"]
do_update = module.params["update"]
map_name = map_def.get("name")
if not map_name:
module.fail_json(msg="map_definition must contain a 'name' key")
try:
api = ZabbixAPI(
module,
url=module.params["url"],
token=module.params["token"],
user=module.params["login_user"],
password=module.params["login_password"],
)
# Check whether the map already exists — used by all branches below.
existing = api.get_map_by_name(map_name)
if state == "absent":
if not existing:
module.exit_json(
changed=False,
msg=f"Map '{map_name}' does not exist"
)
if module.check_mode:
module.exit_json(changed=True, msg=f"Would delete map '{map_name}'")
api.delete_map(existing["sysmapid"])
module.exit_json(
changed=True,
msg=f"Deleted map '{map_name}' (sysmapid={existing['sysmapid']})",
)
# Build the full API payload from the YAML definition.
payload = build_map_payload(map_def, api)
if existing:
# Map exists — skip unless update=true, in which case overwrite it entirely.
if not do_update:
module.exit_json(
changed=False,
sysmapid=existing["sysmapid"],
msg=f"Map '{map_name}' already exists "
f"(set update=true to overwrite)",
)
if module.check_mode:
module.exit_json(
changed=True,
sysmapid=existing["sysmapid"],
msg=f"Would update map '{map_name}'",
)
api.update_map(existing["sysmapid"], payload)
module.exit_json(
changed=True,
sysmapid=existing["sysmapid"],
msg=f"Updated map '{map_name}' "
f"(sysmapid={existing['sysmapid']})",
)
else:
# Map does not exist — create it and return the new sysmapid.
if module.check_mode:
module.exit_json(changed=True, msg=f"Would create map '{map_name}'")
sysmapid = api.create_map(payload)
module.exit_json(
changed=True,
sysmapid=sysmapid,
msg=f"Created map '{map_name}' (sysmapid={sysmapid})",
)
except Exception as exc:
module.fail_json(msg=str(exc), exception=traceback.format_exc())
if __name__ == "__main__":
main()
The playbook
# create_zabbix_maps.yml
- name: Create Zabbix network maps from YAML templates
hosts: localhost
connection: local
gather_facts: false
vars:
maps_file: "maps.yaml"
map_name: "" # filter to a single map; empty = all
update: false # set true to overwrite existing maps
tasks:
- name: Load map definitions
ansible.builtin.include_vars:
file: "{{ maps_file }}"
name: maps_config
- name: Assert at least one map is defined
ansible.builtin.assert:
that: maps_config.maps | length > 0
fail_msg: "No maps found in {{ maps_file }}"
- name: Filter maps
ansible.builtin.set_fact:
maps_to_process: >-
{{
maps_config.maps
if not map_name
else maps_config.maps
| selectattr('name', 'equalto', map_name)
| list
}}
- name: Fail if filter matched nothing
ansible.builtin.fail:
msg: "No map named '{{ map_name }}' found in {{ maps_file }}"
when: map_name | length > 0 and maps_to_process | length == 0
- name: Create or update Zabbix maps
zabbix_map_from_yaml:
url: "{{ zabbix_url }}"
token: "{{ zabbix_api_token | default(omit, true) }}"
login_user: "{{ zabbix_user | default(omit, true) }}"
login_password: "{{ zabbix_password | default(omit, true) }}"
map_definition: "{{ item }}"
state: present
update: "{{ update | bool }}"
validate_certs: "{{ zabbix_validate_certs | default(true) }}"
loop: "{{ maps_to_process }}"
loop_control:
label: "{{ item.name }}"
register: map_results
- name: Summary
ansible.builtin.debug:
msg: "{{ item.msg }}"
loop: "{{ map_results.results }}"
loop_control:
label: "{{ item.item.name }}"
Map definition format
This YAML format applies to the custom module only. The
community.zabbix.zabbix_mapmodule uses DOT language — see Part 1.
Element identifiers explained
Three different identifiers appear on every element. They look similar but serve completely different purposes:
elements:
- id: core_router # (1) local YAML reference key
type: host
host: "Router-Core-01" # (2) Zabbix object name
label: "Core Router" # (3) display label on the map canvas
(1) id — local reference key: used only inside maps.yaml, specifically in the links section (from: and to:). Never sent to Zabbix. Think of it as a variable name — call it whatever makes sense.
(2) host / map / group — Zabbix object name: the actual name of the object in Zabbix. The module calls the API to look this up and get the internal numeric ID. Must match exactly what is configured in Zabbix.
(3) label — display text: what appears underneath the element on the map canvas. Free-form. Falls back to id if omitted.
Element types
type | What it creates | Required key |
|---|---|---|
host | A host element linked to a Zabbix host | host: "Hostname" |
map | A sub-map element (drill-down) | map: "Map name" |
hostgroup | A host group element | group: "Group name" |
image | A static decorative image | — |
hostgroupresolves a name to an ID just likehostandmapdo —group: "Group name"must match a real host group exactly (case-sensitive; check Data collection → Host groups), and the module looks it up viahostgroup.get, warning if it can’t find a match. Unlike ahostelement, which reflects one host’s status, ahostgroupelement shows the aggregate status of every host in that group — useful for “all of site X” or “all proxy hosts” summary nodes without listing each one individually.
Icon keys
| Key | Shown when |
|---|---|
default | No problems |
problem | Active problems |
maintenance | Host in a maintenance window |
disabled | Host is disabled |
Icon names must match exactly what is in Administration → Images in Zabbix. The built-in icons follow the pattern Server_(48), Router_(64), Switch_(48) — the number is the pixel size. The available set changed in Zabbix 7.x; verify exact names under Administration → Images before using them.
Label type
Controls the text shown under each element. Set at the map level.
| Value | What is shown |
|---|---|
0 | The label field on the element (recommended) |
1 | The IP address of the host |
2 | The Zabbix object name — shows “Image” for map/image elements |
3 | Problem status only |
4 | Nothing |
Always set label_type: 0 and define an explicit label on every element. Using 2 causes map and image elements to display “Image” as their label because they have no Zabbix object name.
Element click URLs
Both host and map elements support click URLs. The syntax is identical — only the available macros differ. URLs can point to anything — Zabbix internal pages, a wiki, a runbook, or any external system.
Host elements
By default clicking a host element opens a Zabbix context menu. Add a urls entry to provide direct navigation. Multiple entries are supported — Zabbix shows a menu listing all of them on click.
Available macros:
| Macro | Resolves to |
|---|---|
{HOST.ID} | Internal numeric host ID |
{HOST.HOST} | Zabbix host name |
{HOST.NAME} | Visible name of the host |
{INVENTORY.URL.A} | URL A field from the host’s inventory |
{INVENTORY.URL.B} | URL B field from the host’s inventory |
{INVENTORY.URL.C} | URL C field from the host’s inventory |
- id: core_router
type: host
host: "Router-Core-01"
label: "Core Router"
x: 660
y: 280
icon:
default: "Router_(64)"
problem: "Router_(64)"
urls:
- name: "Wiki"
url: "https://wiki.example.com/hosts/{HOST.HOST}"
- name: "Latest data"
url: "zabbix.php?action=latest.view&hostids[]={HOST.ID}"
- name: "Problems"
url: "zabbix.php?action=problem.view&hostids[]={HOST.ID}"
Using {HOST.HOST} in an external URL makes it dynamic — the same entry covers all hosts as long as your wiki follows a consistent URL pattern like wiki.example.com/hosts/<hostname>.
Alternatively, store the wiki URL in the host’s inventory URL A field in Zabbix and reference it with {INVENTORY.URL.A}. This way the URL is managed per-host in Zabbix rather than hardcoded in the map definition — useful when hosts have different wiki pages that don’t follow a predictable naming pattern.
Map elements
Clicking a map element already navigates to the referenced map by default — no URL configuration needed for that. Add urls only to provide extra options alongside the default navigation.
Available macros:
| Macro | Resolves to |
|---|---|
{URL.MAP} | The map’s URL in Zabbix |
{MAP.ID} | Internal numeric map ID |
{MAP.NAME} | Map name |
- id: server_farm_map
type: map
map: "Server Farm"
label: "Server Farm"
x: 660
y: 520
icon:
default: "Server_(48)"
problem: "Server_(48)"
urls:
- name: "Open map"
url: "{URL.MAP}"
Link draw styles
line (default), bold, dotted, dashed
Trigger-based link styling
Not supported in Zabbix 7.4. The
map.createandmap.updateAPI endpoints reject non-emptylinktriggersarrays in Zabbix 7.4 withInvalid parameter: should be empty. The YAML format and module code support the feature, but it cannot be used against a Zabbix 7.4 server.
When a trigger fires the link changes colour and line style. Specify the host and the exact trigger description as it appears in Zabbix. Multiple triggers can be attached to a single link.
links:
- from: router
to: switch
color: "00AA00"
drawtype: bold
triggers:
- host: "Router-Core-01"
trigger: "Interface TenGigabitEthernet1/0/1 link down"
color: "FF0000"
drawtype: bold
Linking to a device in another map
Use type: map to place a sub-map element and draw a link to it. Clicking the element drills down into the referenced map. The element aggregates the worst problem severity from everything inside it.
elements:
- id: core_router
type: host
host: "Router-Core-01"
label: "Core Router"
x: 660
y: 280
icon:
default: "Router_(64)"
problem: "Router_(64)"
- id: server_farm_map
type: map
map: "Server Farm"
label: "Server Farm"
x: 660
y: 520
icon:
default: "Server_(48)"
problem: "Server_(48)"
urls:
- name: "Open map"
url: "{URL.MAP}"
links:
- from: core_router
to: server_farm_map
label: "Te0/2"
color: "00AA00"
drawtype: bold
triggers:
- host: "Router-Core-01"
trigger: "Interface TenGigabitEthernet0/2 link down"
color: "FF0000"
drawtype: bold
The trigger on the link reflects the interface state — independent of what is happening inside Server Farm. Both can be red at the same time for different reasons.
Deployment order matters: the referenced map must exist before the map that references it. Put the referenced map first in
maps.yaml.
Example: Zabbix infrastructure map
# maps.yaml
maps:
- name: "Zabbix Infrastructure"
width: 1400
height: 850
label_type: 0
elements:
- id: postgresql
type: host
host: "db-zabbix-01"
label: "PostgreSQL"
x: 660
y: 40
icon:
default: "Disk_array_3D_(64)"
problem: "Disk_array_3D_(64)"
urls:
- name: "Latest data"
url: "zabbix.php?action=latest.view&hostids[]={HOST.ID}"
- id: vip_lb
type: host
host: "vip-lb-prod"
label: "Database VIP"
x: 660
y: 200
icon:
default: "Network_(48)"
problem: "Network_(48)"
- id: zabbix_server
type: host
host: "zabbix-server-01"
label: "Zabbix Server"
x: 660
y: 360
icon:
default: "Server_(64)"
problem: "Server_(64)"
urls:
- name: "Latest data"
url: "zabbix.php?action=latest.view&hostids[]={HOST.ID}"
- id: proxy_grup
type: hostgroup
group: "Zabbix proxy groups"
label: "Proxy Group"
x: 660
y: 540
icon:
default: "Server_(48)"
problem: "Server_(48)"
- id: proxy01
type: host
host: "proxy01"
label: "Proxy 01"
x: 440
y: 700
icon:
default: "Server_(48)"
problem: "Server_(48)"
urls:
- name: "Latest data"
url: "zabbix.php?action=latest.view&hostids[]={HOST.ID}"
- id: proxy02
type: host
host: "proxy02"
label: "Proxy 02"
x: 880
y: 700
icon:
default: "Server_(48)"
problem: "Server_(48)"
urls:
- name: "Latest data"
url: "zabbix.php?action=latest.view&hostids[]={HOST.ID}"
links:
- from: postgresql
to: vip_lb
label: "5432"
color: "0066CC"
drawtype: bold
triggers:
- host: "db-zabbix-01"
trigger: "PostgreSQL: Service is down"
color: "FF0000"
drawtype: bold
- from: vip_lb
to: zabbix_server
color: "0066CC"
drawtype: line
- from: zabbix_server
to: proxy_grup
label: "10051"
color: "00AA00"
drawtype: line
- from: proxy_grup
to: proxy01
color: "00AA00"
drawtype: line
triggers:
- host: "proxy01"
trigger: "Zabbix proxy group: Active proxies below minimum threshold"
color: "FF0000"
drawtype: dashed
- from: proxy_grup
to: proxy02
color: "00AA00"
drawtype: line
triggers:
- host: "proxy02"
trigger: "Zabbix proxy group: Active proxies below minimum threshold"
color: "FF0000"
drawtype: dashed
Multi-site example: Office, Datacenter, and Offsite
This example defines the same four maps as the Part 1 example (see the topology diagram there). The custom module supports explicit x/y positioning so you control exactly where each element sits on the canvas.
Deployment order: The same circular dependency applies here as in Part 1. Run the playbook twice — once to create, once with update: true to resolve cross-references.
ansible-playbook create_zabbix_maps.yml --ask-vault-pass
ansible-playbook create_zabbix_maps.yml -e update=true --ask-vault-pass
# maps.yaml
maps:
# 1 — Offsite first (Datacenter map ref will warn on first run, resolves on second)
- name: "Offsite"
width: 1000
height: 600
label_type: 0
elements:
- id: poc_host
type: host
host: "offsite-poc-01"
label: "PoC host"
x: 460
y: 200
icon:
default: "Server_(48)"
problem: "Server_(48)"
urls:
- name: "Latest data"
url: "zabbix.php?action=latest.view&hostids[]={HOST.ID}"
- id: dc_map_ref
type: map
map: "Datacenter"
label: "Datacenter"
x: 460
y: 400
icon:
default: "Server_(64)"
problem: "Server_(64)"
urls:
- name: "Open map"
url: "{URL.MAP}"
links:
- from: poc_host
to: dc_map_ref
label: "WAN"
color: "808080"
drawtype: dashed
# 2 — Office second (same situation with Datacenter ref)
- name: "Office"
width: 1000
height: 600
label_type: 0
elements:
- id: workstation
type: host
host: "office-ws-01"
label: "Workstation"
x: 460
y: 200
icon:
default: "Workstation_(48)"
problem: "Workstation_(48)"
urls:
- name: "Latest data"
url: "zabbix.php?action=latest.view&hostids[]={HOST.ID}"
- id: dc_map_ref
type: map
map: "Datacenter"
label: "Datacenter"
x: 460
y: 400
icon:
default: "Server_(64)"
problem: "Server_(64)"
urls:
- name: "Open map"
url: "{URL.MAP}"
links:
- from: workstation
to: dc_map_ref
label: "LAN/WAN"
color: "00AA00"
drawtype: line
# 3 — Datacenter third — Office and Offsite now exist
- name: "Datacenter"
width: 1400
height: 900
label_type: 0
elements:
- id: office_map_ref
type: map
map: "Office"
label: "Office"
x: 200
y: 380
icon:
default: "Server_(48)"
problem: "Server_(48)"
urls:
- name: "Open map"
url: "{URL.MAP}"
- id: ext_db
type: host
host: "db-zabbix-01"
label: "External DB"
x: 700
y: 100
icon:
default: "Disk_array_3D_(64)"
problem: "Disk_array_3D_(64)"
urls:
- name: "Latest data"
url: "zabbix.php?action=latest.view&hostids[]={HOST.ID}"
- id: zabbix_server
type: host
host: "zabbix-server-01"
label: "Zabbix Server"
x: 700
y: 300
icon:
default: "Server_(64)"
problem: "Server_(64)"
urls:
- name: "Latest data"
url: "zabbix.php?action=latest.view&hostids[]={HOST.ID}"
- id: proxy_group
type: host
host: "proxy-group-dc"
label: "Proxy Group"
x: 700
y: 500
icon:
default: "Server_(48)"
problem: "Server_(48)"
urls:
- name: "Latest data"
url: "zabbix.php?action=latest.view&hostids[]={HOST.ID}"
- id: proxy_1
type: host
host: "zabbix-proxy-dc-01"
label: "Proxy DC-01"
x: 500
y: 700
icon:
default: "Server_(48)"
problem: "Server_(48)"
urls:
- name: "Latest data"
url: "zabbix.php?action=latest.view&hostids[]={HOST.ID}"
- id: proxy_2
type: host
host: "zabbix-proxy-dc-02"
label: "Proxy DC-02"
x: 900
y: 700
icon:
default: "Server_(48)"
problem: "Server_(48)"
urls:
- name: "Latest data"
url: "zabbix.php?action=latest.view&hostids[]={HOST.ID}"
- id: offsite_map_ref
type: map
map: "Offsite"
label: "Offsite"
x: 1200
y: 380
icon:
default: "Server_(48)"
problem: "Server_(48)"
urls:
- name: "Open map"
url: "{URL.MAP}"
links:
- from: office_map_ref
to: proxy_1
label: "WAN"
color: "808080"
drawtype: line
- from: ext_db
to: zabbix_server
label: "5432"
color: "0066CC"
drawtype: bold
triggers:
- host: "db-zabbix-01"
trigger: "PostgreSQL: Service is down"
color: "FF0000"
drawtype: bold
- from: zabbix_server
to: proxy_group
label: "10051"
color: "00AA00"
drawtype: line
triggers:
- host: "proxy-group-dc"
trigger: "Zabbix proxy group: Active proxies below minimum threshold"
color: "FF0000"
drawtype: dashed
- from: proxy_group
to: proxy_1
color: "00AA00"
drawtype: line
- from: proxy_group
to: proxy_2
color: "00AA00"
drawtype: line
- from: offsite_map_ref
to: proxy_2
label: "WAN"
color: "808080"
drawtype: dashed
# 4 — Overview last — all three site maps now exist
- name: "Overview"
width: 1400
height: 600
label_type: 0
elements:
- id: office_map
type: map
map: "Office"
label: "Office"
x: 200
y: 280
icon:
default: "Server_(48)"
problem: "Server_(48)"
urls:
- name: "Open map"
url: "{URL.MAP}"
- id: datacenter_map
type: map
map: "Datacenter"
label: "Datacenter"
x: 700
y: 280
icon:
default: "Server_(64)"
problem: "Server_(64)"
urls:
- name: "Open map"
url: "{URL.MAP}"
- id: offsite_map
type: map
map: "Offsite"
label: "Offsite"
x: 1200
y: 280
icon:
default: "Server_(48)"
problem: "Server_(48)"
urls:
- name: "Open map"
url: "{URL.MAP}"
links:
- from: office_map
to: datacenter_map
label: "WAN"
color: "00AA00"
drawtype: line
- from: datacenter_map
to: offsite_map
label: "WAN"
color: "00AA00"
drawtype: dashed
Deploying
# Create all maps
ansible-playbook create_zabbix_maps.yml --ask-vault-pass
# Dry-run — resolves names and validates without writing
ansible-playbook create_zabbix_maps.yml --check --ask-vault-pass
# Single map
ansible-playbook create_zabbix_maps.yml \
-e "map_name='Zabbix Infrastructure'" --ask-vault-pass
# Different maps file
ansible-playbook create_zabbix_maps.yml \
-e maps_file=prod-maps.yaml --ask-vault-pass
Updating maps
The module defaults to update=false — existing maps are left untouched. To apply changes:
ansible-playbook create_zabbix_maps.yml -e update=true --ask-vault-pass
Update replaces the entire map. Any manual edits made in the frontend since the last run will be lost. Treat maps.yaml as the source of truth.
Removing a map
Set state: absent in the map entry and re-run:
- name: "Old Network Map"
state: absent
Idempotency summary
| Situation | Result |
|---|---|
| Map does not exist | Created, changed=true |
Map exists, update=false | Skipped, changed=false |
Map exists, update=true | Overwritten, changed=true |
Map exists, state=absent | Deleted, changed=true |
Map does not exist, state=absent | No-op, changed=false |
Any run with --check | Reported only, no writes |
Adding proxy groups to a map
The zabbix_host task is identical to Part 1. What differs is how you reference the host in the map definition.
Creating the dummy hosts
- name: Ensure proxy group dummy hosts exist
community.zabbix.zabbix_host:
host_name: "{{ item.host }}"
visible_name: "{{ item.label }}"
host_groups:
- "Zabbix proxy groups"
link_templates:
- "Zabbix proxy group health"
monitored_by: proxy_group
proxy_group: "{{ item.proxy_group }}"
status: enabled
state: present
loop:
- { host: "proxy01", label: "Proxy 01", proxy_group: "CPH" }
- { host: "proxy02", label: "Proxy 02", proxy_group: "AAR" }
The same two gaps from Part 1 apply here:
"Zabbix proxy group health"isn’t a built-in template — create it yourself first, even as an empty placeholder — and theCPH/AARproxy groups don’t exist on a clean install either, so both need to be created before this task can resolve them.monitored_by: proxy_group(included above) is also required;proxy_groupalone won’t switch the host over to proxy-group monitoring, it just gets accepted and silently ignored.
Referencing in the map (custom module YAML)
The map itself doesn’t reference the individual proxy hosts directly — it points at the "Zabbix proxy groups" host group instead, via a hostgroup element that aggregates the status of every proxy in it:
- id: proxy_grup
type: hostgroup
group: "Zabbix proxy groups"
label: "Proxy Group"
x: 660
y: 540
icon:
default: "Server_(48)"
problem: "Server_(48)"
Link colours are static — trigger-based link styling is not supported in Zabbix 7.4 (see the Trigger-based link styling note above).
Adding a virtual IP to a map
The zabbix_host task is identical to Part 1.
Creating the dummy hosts
- name: Ensure VIP dummy hosts exist
community.zabbix.zabbix_host:
host_name: "{{ item.host }}"
visible_name: "{{ item.label }}"
host_groups:
- "Virtual IPs"
link_templates:
- "ICMP Ping"
interfaces:
- type: agent
main: true
useip: true
ip: "{{ item.ip }}"
dns: ""
port: "10050"
status: enabled
state: present
loop:
- { host: "vip-lb-prod", label: "VIP LB Prod", ip: "10.0.0.10" }
- { host: "vip-api-prod", label: "VIP API Prod", ip: "10.0.0.11" }
Referencing in the map (custom module YAML)
- id: vip_lb
type: host
host: "vip-lb-prod"
label: "Database VIP"
x: 660
y: 200
icon:
default: "Network_(48)"
problem: "Network_(48)"
Link colour is static — trigger-based link styling is not supported in Zabbix 7.4.
/var/lib/zabbix permissions
ICMP checks run as the zabbix OS user. If /var/lib/zabbix is not owned by that user the ping will fail silently — the trigger never fires and the element stays green regardless of actual reachability.
Check the current ownership:
ls -ld /var/lib/zabbix
If it is not owned by zabbix:zabbix, fix it:
chown zabbix:zabbix /var/lib/zabbix
With Ansible:
- name: Ensure /var/lib/zabbix is owned by zabbix
ansible.builtin.file:
path: /var/lib/zabbix
owner: zabbix
group: zabbix
state: directory
become: true
Run this on every host that performs the ping — the Zabbix server, and any proxy that monitors VIPs in its network segment.
Troubleshooting
Host not found warning, element has no host linked
The host value must match the exact host name in the Host name field in Zabbix — not the visible name or DNS name. Verify under Data collection → Hosts.
Icon not found warning, element uses default icon
Icon names are case-sensitive. Open Administration → Images and copy the name exactly, including the size suffix like _(48).
Trigger not found warning, link has no trigger styling
The trigger value must match the trigger description exactly, including any macro text such as Interface {#IFNAME} link down. Copy from Monitoring → Problems or directly from the trigger configuration.
TLS certificate errors
Set zabbix_validate_certs: false in group_vars/all/zabbix.yml for self-signed certificates.
API authentication fails
Check the token expiry under User menu → API tokens. If using username/password, verify the account has at minimum read access to hosts and write access to maps.