← POST

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 install away, ships as part of community.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/y positioning 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:

  1. Define all maps in maps.yaml using DOT syntax
  2. Run the playbook — it reads the file and creates or updates each map
  3. 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:

AttributeElement 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_* attributeStatic image element

The quoted node name is not the label — add zbx_label explicitly. 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 for zbx_group) as the element’s label, silently discarding the node identifier you chose. Add zbx_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

AttributeShown when
zbx_image_defaultNo problems
zbx_image_problemActive problems
zbx_image_maintenanceHost in maintenance
zbx_image_disabledHost 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.

ValueWhat is shown
labelThe node label from the DOT graph (recommended)
ipHost IP address
element_nameZabbix object name — shows “Image” for map/image elements
statusProblem status only
nothingNothing

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:

MacroResolves 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:

MacroResolves 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)"]
"Router" -- "Switch" [label="Gi0/1"
                      zbx_draw_style=bold
                      zbx_color="#00AA00"]
AttributeValues
labelText shown on the link
zbx_draw_styleline | bold | dotted | dashed
zbx_colorHex 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.

typeDOT attributezbx_ref contains
hostzbx_hostZabbix host name
mapzbx_sysmapMap name
groupzbx_groupHost 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.

Zabbix multi-site map topology Overview map Office map element WAN Datacenter map element WAN Offsite map element click to drill down Office Workstation host element LAN/WAN Datacenter sub-map Datacenter Office sub-map External DB host element Offsite sub-map 5432 Zabbix server host element 10051 Proxy group host element Proxy DC-01 host element Proxy DC-02 host element WAN WAN Offsite Proof of concept PoC host host element WAN Datacenter sub-map Legend host element (solid border) sub-map reference — dashed border, click to navigate into that map direct link WAN / dashed link drill-down
Overview map (top) contains three site map elements. Each expanded panel below shows its internal hosts and cross-references to other site maps. Office → Proxy DC-01, Offsite → Proxy DC-02. Dashed borders are sub-map references that navigate into another map on click.

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, so link_templates will 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 / AAR proxy groups — create them with community.zabbix.zabbix_proxy_group, or under Administration → Proxy groups, before this task runs.

proxy_group alone won’t switch monitoring over, either: Zabbix only treats a host as proxy-group-monitored once monitored_by: proxy_group is 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_group on the dummy hosts representing the proxy servers themselves. That works as long as CPH and AAR are 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, use monitored_by: zabbix_server on the proxy Host objects and set monitored_by: proxy_group on 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 proxy parameter 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 positioningAuto (Graphviz) — no manual x/y
API token authNo — username/password only
Trigger-based link stylingNo
Multiple URLs per elementNo — one URL per node in DOT format
Extra dependenciesgraphviz, pydotplus, webcolors, Pillow
Known bugsKeyError: ‘pos’ — requires dummy node workaround
Custom code to maintainNone

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:

  1. Create a library/ directory next to the playbook that will use the module (or, inside a role, at roles/<role-name>/library/).
  2. Save the module’s source — shown in full further down — as library/zabbix_map_from_yaml.py.
  3. Reference it in tasks by its bare name, zabbix_map_from_yaml — not a fully-qualified collection name like community.zabbix.zabbix_map. Ansible’s module-discovery automatically searches library/ 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:

  1. Reads a map definition passed in as a dict
  2. Calls the Zabbix API to resolve host names, icon names, and trigger descriptions to their internal IDs
  3. Builds the map.create or map.update payload
  4. Creates or updates the map and returns a proper changed / failed state

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_map module 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

typeWhat it createsRequired key
hostA host element linked to a Zabbix hosthost: "Hostname"
mapA sub-map element (drill-down)map: "Map name"
hostgroupA host group elementgroup: "Group name"
imageA static decorative image

hostgroup resolves a name to an ID just like host and map dogroup: "Group name" must match a real host group exactly (case-sensitive; check Data collection → Host groups), and the module looks it up via hostgroup.get, warning if it can’t find a match. Unlike a host element, which reflects one host’s status, a hostgroup element 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

KeyShown when
defaultNo problems
problemActive problems
maintenanceHost in a maintenance window
disabledHost 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.

ValueWhat is shown
0The label field on the element (recommended)
1The IP address of the host
2The Zabbix object name — shows “Image” for map/image elements
3Problem status only
4Nothing

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:

MacroResolves 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:

MacroResolves 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}"

line (default), bold, dotted, dashed

Not supported in Zabbix 7.4. The map.create and map.update API endpoints reject non-empty linktriggers arrays in Zabbix 7.4 with Invalid 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

SituationResult
Map does not existCreated, changed=true
Map exists, update=falseSkipped, changed=false
Map exists, update=trueOverwritten, changed=true
Map exists, state=absentDeleted, changed=true
Map does not exist, state=absentNo-op, changed=false
Any run with --checkReported 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 the CPH/AAR proxy 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_group alone 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.