This guide assumes a working Zabbix server with a few real templates (the architecture post and LLD post build the muscle), API access, and a git repo to commit to. Powershell examples reuse the API wrapper from the proxy load-balancing post.
The Zabbix UI is friendly, but templates edited only in the UI have no history, no review, no rollback. The first time someone "tweaks the prod CPU template" and the whole estate stops alerting, you'll wish you'd treated templates the same way you treat application code.
This post is the GitOps layer for Zabbix templates: export to a repo, diff cleanly, code-review changes, import via CI. No new product just the API and a discipline.
What "Template as Code" Means Here
A Zabbix template is a tree of items, triggers, graphs, discovery rules, macros, valuemaps, and tags. The API can export this tree as JSON, YAML, or XML. The plan:
- Nightly export of every template from the live server into the repo.
- Operators edit templates by editing files in the repo, not in the UI.
- PR review on every change.
- CI imports the merged file back into the server.
The first export is just template.export. Everything past that is plumbing.
Exporting
# Reuse Connect-Zabbix / Invoke-Zabbix from earlier posts
$session = Connect-Zabbix -Url $url -Credential (Get-Credential)
$templates = Invoke-Zabbix -Session $session -Method 'template.get' -Params @{
output = @('templateid', 'host', 'name')
}
foreach ($t in $templates)
{
$exp = Invoke-Zabbix -Session $session -Method 'configuration.export' -Params @{
format = 'yaml'
options = @{ templates = @($t.templateid) }
}
$safe = ($t.host -replace '[\\/:*?"<>|]', '_')
$exp | Set-Content "./templates/$safe.yaml" -Encoding UTF8
}
Each template lands in its own file. Commit, push, and your repo now contains the source of truth.
Use YAML, not XML. XML is the legacy export format. YAML is shorter, diffs cleanly, and Zabbix has supported it as a first-class import format since 5.4.
Normalizing for Clean Diffs
The first export gives you a baseline. Subsequent exports will have cosmetic noise timestamp fields, internal IDs that change between server restarts, key ordering shuffles. Normalize before you commit:
function ConvertTo-NormalizedTemplate
{
param([string]$Yaml)
# Strip volatile metadata
$obj = $Yaml | ConvertFrom-Yaml # PowerShell-Yaml module
$obj.zabbix_export.PSObject.Properties.Remove('date')
$obj.zabbix_export.PSObject.Properties.Remove('version')
# Sort lists deterministically (items by key, triggers by name, etc.)
foreach ($tpl in $obj.zabbix_export.templates)
{
if ($tpl.items) { $tpl.items = $tpl.items | Sort-Object key }
if ($tpl.triggers) { $tpl.triggers = $tpl.triggers | Sort-Object name }
if ($tpl.discovery_rules)
{
$tpl.discovery_rules = $tpl.discovery_rules | Sort-Object key
foreach ($d in $tpl.discovery_rules)
{
if ($d.item_prototypes) { $d.item_prototypes = $d.item_prototypes | Sort-Object key }
}
}
}
return $obj | ConvertTo-Yaml -Depth 50
}
After normalization, two consecutive exports of an unchanged template produce a byte-identical file. Genuine changes diff small and readable.
The normalization step is the difference between a clean repo and a useless one. Without it, every nightly export is a 200-line diff of nothing. With it, a real change stands out instantly.
A Nightly Export Job
# build/Export-Templates.ps1
[CmdletBinding()]
param([string]$Url, [pscredential]$Credential, [string]$OutDir = './templates')
$session = Connect-Zabbix -Url $Url -Credential $Credential
$templates = Invoke-Zabbix -Session $session -Method 'template.get' -Params @{
output = @('templateid','host')
}
foreach ($t in $templates)
{
$exp = Invoke-Zabbix -Session $session -Method 'configuration.export' -Params @{
format = 'yaml'
options = @{ templates = @($t.templateid) }
}
$clean = ConvertTo-NormalizedTemplate -Yaml $exp
$safe = ($t.host -replace '[\\/:*?"<>|]', '_')
$clean | Set-Content (Join-Path $OutDir "$safe.yaml") -Encoding UTF8
}
Run it from a scheduled job. If the diff is non-empty, open a PR automatically:
git add templates/
if ! git diff --quiet --staged; then
git commit -m "auto: nightly template export $(date -I)"
git push origin main
fi
Now the repo always reflects the live server. Drift is impossible to hide.
Importing The Reverse Direction
The interesting flow is the other way: edit files in the repo, merge a PR, CI imports them back into the server.
# build/Import-Templates.ps1
[CmdletBinding()]
param([string]$Url, [pscredential]$Credential, [string[]]$Paths)
$session = Connect-Zabbix -Url $Url -Credential $Credential
foreach ($p in $Paths)
{
$yaml = Get-Content $p -Raw
$null = Invoke-Zabbix -Session $session -Method 'configuration.import' -Params @{
format = 'yaml'
rules = @{
templates = @{ createMissing = $true; updateExisting = $true }
items = @{ createMissing = $true; updateExisting = $true; deleteMissing = $true }
triggers = @{ createMissing = $true; updateExisting = $true; deleteMissing = $true }
discoveryRules = @{ createMissing = $true; updateExisting = $true; deleteMissing = $true }
graphs = @{ createMissing = $true; updateExisting = $true; deleteMissing = $true }
host_groups = @{ createMissing = $true }
template_groups = @{ createMissing = $true }
valueMaps = @{ createMissing = $true; updateExisting = $true }
}
source = $yaml
}
}
The rules block is the mostly-undocumented power of configuration.import: per-element-type, you choose whether items missing in the import file get deleted from the live template. Set deleteMissing = $true on items/triggers/graphs to make the import file authoritative exactly what you want for GitOps.
Be careful with
deleteMissing. It will delete from the live server anything not in the imported file. That's the desired behavior for a code-driven workflow but catastrophic if you've been hand-editing the template in the UI on the side. Pick one source of truth.
CI Pipeline
A GitLab pipeline (drops into the publishing post shape):
stages: [validate, dryrun, apply]
validate:
stage: validate
image: mcr.microsoft.com/powershell:lts-7.4
script:
- pwsh -c "Install-Module powershell-yaml -Force; ./build/Validate-Templates.ps1 -Path ./templates"
dryrun:
stage: dryrun
rules:
- if: $CI_PIPELINE_SOURCE == 'merge_request_event'
script:
- pwsh -c "./build/Import-Templates.ps1 -Url '$ZBX_URL_TEST' -Credential (Get-Credential) -Paths (Get-ChildItem ./templates -Filter *.yaml).FullName"
apply:
stage: apply
rules:
- if: $CI_COMMIT_BRANCH == 'main'
script:
- pwsh -c "./build/Import-Templates.ps1 -Url '$ZBX_URL_PROD' -Credential (Get-Credential) -Paths (Get-ChildItem ./templates -Filter *.yaml).FullName"
Three stages: validate the YAML parses, dry-run import on a test server for every PR, apply to prod on merge to main.
Validation Catch Errors Before They Hit Prod
function Test-Template
{
param([string]$Path)
$obj = Get-Content $Path -Raw | ConvertFrom-Yaml
$tpl = $obj.zabbix_export.templates[0]
$errors = @()
if (-not $tpl.host) { $errors += "${Path}: missing 'host'" }
if (-not $tpl.name) { $errors += "${Path}: missing 'name'" }
if (-not $tpl.template_groups) { $errors += "${Path}: missing template_groups" }
foreach ($i in @($tpl.items))
{
if (-not $i.delay) { $errors += "${Path}: item $($i.key) missing delay" }
}
foreach ($t in @($tpl.triggers))
{
if (-not $t.priority) { $errors += "${Path}: trigger $($t.name) missing priority" }
}
return $errors
}
This is where you encode team conventions every item must have explicit delay, every trigger must have a priority, no template can be ungrouped, etc. Same idea as PSScriptAnalyzer custom rules but for templates instead of code.
A Few Operational Habits
- One template per file, one file per template. Multi-template exports work but break per-file ownership semantics in git (CODEOWNERS, blame).
- Macros at the host, not in the template. Templates should be reusable across environments; environment-specific values (
{$DB_HOST},{$THRESHOLD_CPU}) belong on the host or host group. - Tag the file with the version that authored it. The
zabbix_export.versionfield tells you which Zabbix major version exported the file useful when you upgrade and the schema changes. - Don't import on every commit. Batching a day's worth of changes into one nightly apply is gentler on the server than 30 incremental imports.
- Back up the database before any "bulk import after a long absence". The first big import after months of UI-side drift can do a lot.
pg_dumpfirst.
What This Doesn't Cover
Templates as code handles the config. It doesn't handle:
- Hosts. Host-level config, macros, and proxy assignments are usually managed separately (the proxy load-balancing post covers this via the same API).
- User accounts and permissions. RBAC belongs in its own pipeline usually mirrored from your IdP rather than committed by hand.
- Maintenance windows. Ephemeral; create them via the API at deploy time (covered in the quieting alerts post).
For each of these, the same export → diff → import pattern works just against a different API method.
What to Do Next
Templates as code isn't a Zabbix feature you turn on it's a discipline built on the API that's been in the product since 2.0. Export nightly with normalization, edit files in the repo, code-review every change, dry-run on a test server, apply to prod from CI. The reward is everything you'd want: history, ownership, reviewability, rollback. The cost is a few hundred lines of Powershell. Once it's running, the question "who changed this trigger and why" stops being unanswerable.
Three concrete moves to land templates-as-code this week:
- Set up the nightly export against an existing repo. Don't try to migrate every template at once. A nightly export with normalization (sort keys, strip volatile fields like
lastvalue, pretty-print) gives you the reviewable diff to find out what's actually changing in production today. - Pick one template, lock the UI on it, and make every change go through the repo. "Lock" means a reviewer in the team agreement, not a Zabbix permission. Once one template's diffs are flowing through PRs cleanly, the rest of the team will copy the pattern unprompted.
- Wire a dry-run import to a test Zabbix. A staging server that imports the merged YAML before prod does catches
lld_macro_pathstypos, missing valuemaps, and broken trigger expressions before they reach the production frontend. The test server is the cheapest piece of insurance you'll buy this year.
Pairs naturally with the PSK and TLS post (so the API client used for export and import authenticates over the same hardened channel as the rest of the platform) and the architecture post (which gives you the topology context for what "apply to prod" actually means at scale).


