Ansible playbook to install Zabbix
An idempotent Ansible playbook that installs Zabbix server on Ubuntu 24.04, a vaulted database password that actually wins, and agents across your inventory.
What this Ansible playbook installs
An Ansible playbook to install Zabbix on Ubuntu 24.04 is two plays, two config templates, one encrypted variable file and an inventory. zabbix-server.yml adds the Zabbix repository, installs the server packages, creates the MySQL database and writes /etc/zabbix/zabbix_server.conf on one host. zabbix-agents.yml installs the agent on every other host in the inventory. Both are idempotent, so a second run reports changed=0 and you can use --check afterwards as a drift test.
You need Ubuntu 24.04 on the targets and a sudo account you can reach over SSH (secure shell). Everything below runs from a laptop or a jump box with ansible-core installed. If you have never written a play before, read your first Ansible playbook on a VPS first. If you want to see what these packages do before you automate them, the manual Zabbix install on Ubuntu 24.04 walks the same path by hand.
The playbook pins Zabbix 7.0 LTS, the long term support release it was written against in August 2026. Zabbix supports 7.0 until 2029, so the version belongs in a variable you change on purpose, not in a URL buried inside a task.
The project layout
zabbix/
ansible.cfg
inventory.ini
zabbix-server.yml
zabbix-agents.yml
group_vars/
all.yml
zabbix_server/
main.yml
vault.yml
templates/
zabbix_server.conf.j2
zabbix_agentd.conf.j2The config file keeps three settings, so nobody has to remember command line flags.
[defaults]
inventory = inventory.ini
forks = 20
vault_password_file = ~/.zabbix-vault-passProve which settings are live with ansible-config dump --only-changed. It prints the file each value came from:
DEFAULT_FORKS(/home/you/zabbix/ansible.cfg) = 20
DEFAULT_HOST_LIST(/home/you/zabbix/ansible.cfg) = ['/home/you/zabbix/inventory.ini']
DEFAULT_VAULT_PASSWORD_FILE(/home/you/zabbix/ansible.cfg) = /home/you/.zabbix-vault-passAnsible reads ansible.cfg from the current working directory, so run the same command from your home directory and none of those three lines appear. That is not a cosmetic difference: ansible-playbook started from the wrong directory has no vault password and stops with ERROR! Attempting to decrypt but no vault secrets found. Run these playbooks from the project directory, every time.
The vault password file holds one line, lives outside the repository, and is mode 600: chmod 600 ~/.zabbix-vault-pass. Anyone who can read that file can read every secret in the project.
[zabbix_server]
zbx01 ansible_host=203.0.113.10
[zabbix_agents]
web01 ansible_host=203.0.113.21
db01 ansible_host=203.0.113.22
[zabbix_agents:vars]
zabbix_server_ip=203.0.113.10Run ansible-inventory --list before the first playbook. It prints the groups and hosts Ansible actually parsed, which is how you catch a host that landed in no group at all.
Why your vaulted Zabbix password gets ignored
The database password is the only real secret here, and it is the thing most Zabbix playbooks get wrong. Put the defaults in group_vars/all.yml, including a placeholder password that is obviously not a password:
zabbix_version: "7.0"
zabbix_db_host: localhost
zabbix_db_name: zabbix
zabbix_db_user: zabbix
zabbix_db_password: CHANGE-ME-IN-VAULT
zabbix_start_pollers: 5
zabbix_cache_size: 32MThe hosts that run a database get the real value, from a file only they read. group_vars/zabbix_server/main.yml maps the vaulted name onto the name the tasks use:
zabbix_db_password: "{{ vault_zabbix_db_password }}"Generate a password with openssl rand -base64 24, then create the encrypted file:
ansible-vault create group_vars/zabbix_server/vault.yml
ansible-vault edit group_vars/zabbix_server/vault.ymlIt holds one line, vault_zabbix_db_password: the-string-you-generated, and on disk it starts with $ANSIBLE_VAULT;1.1;AES256. If you prefer one readable file with one encrypted value inside it, ansible-vault encrypt_string --name vault_zabbix_db_password 'the-string-you-generated' prints a block you paste into any vars file. The vault_ prefix is worth keeping: grep -r vault_ . then lists every secret the project expects.
Now the trap. Ansible ranks variable sources, and the ranking ignores which source looks more specific to you. From lowest to highest, the sources in play here are group_vars/all, then group_vars/<group>, then host_vars, then a vars: block written into the play itself, and last -e on the command line, which beats everything. So a play level default silently outranks the vault file:
- name: How a play level var silently wins
hosts: zabbix_server
vars:
zabbix_db_password: CHANGE-ME-IN-VAULT
tasks:
- name: Print the sha1 of the password that won
ansible.builtin.debug:
msg: "zabbix_db_password sha1 = {{ zabbix_db_password | hash('sha1') }}"
tags: [verify]Save that as precedence-demo.yml and run ansible-playbook precedence-demo.yml --tags verify. Nothing warns you. The vault file decrypts fine, vault_zabbix_db_password loads fine, and the play uses the placeholder anyway, so MySQL ends up with a user whose password is the literal string CHANGE-ME-IN-VAULT and Zabbix starts happily against it.
The fix is to delete the vars: block. The server playbook below has none. The placeholder sits in group_vars/all.yml and the real value in group_vars/zabbix_server/, and the group file wins because a named group outranks all.
Check which value won without printing it:
ansible-playbook zabbix-server.yml --tags verify
printf %s 'the-string-you-generated' | sha1sumThe two hashes match when the vault value won. A hash of CHANGE-ME-IN-VAULT means something above group_vars/zabbix_server is overriding it. Printing a hash is safe, and printing the password is not, which is why the verify task hashes it.
The command line is the deliberate exception. ansible-playbook zabbix-server.yml --tags verify -e zabbix_db_password=CHANGE-ME-IN-VAULT prints the placeholder hash, because -e outranks every file on disk. That is the escape hatch for a one off run, and it is also the quickest way to overwrite a working password by accident.
One side effect is worth knowing: group_vars/all.yml applies to the agents too, so ansible-inventory --host web01 shows CHANGE-ME-IN-VAULT on web01. Nothing in the agent playbook reads that variable, so it stays a placeholder, and the hosts holding a real secret are exactly the hosts in group_vars/zabbix_server/.
The Ansible playbook that installs the Zabbix server
- name: Install and configure the Zabbix server
hosts: zabbix_server
become: true
handlers:
- name: Restart zabbix-server
ansible.builtin.systemd_service:
name: zabbix-server
state: restarted
tasks:
- name: Show which zabbix_db_password won, without printing it
ansible.builtin.debug:
msg: "zabbix_db_password sha1 = {{ zabbix_db_password | hash('sha1') }}"
tags: [verify]
- name: Install the Zabbix repository package
ansible.builtin.apt:
deb: "https://repo.zabbix.com/zabbix/{{ zabbix_version }}/ubuntu/pool/main/z/zabbix-release/zabbix-release_latest_{{ zabbix_version }}+ubuntu{{ ansible_distribution_version }}_all.deb"
register: zabbix_repo
- name: Install the Zabbix server packages
ansible.builtin.apt:
name:
- zabbix-server-mysql
- zabbix-sql-scripts
state: present
update_cache: "{{ zabbix_repo is changed }}"
- name: Install MySQL and the Python client library
ansible.builtin.apt:
name:
- mysql-server
- python3-pymysql
state: present
- name: Enable and start MySQL
ansible.builtin.systemd_service:
name: mysql
enabled: true
state: started
- name: Create the Zabbix database
community.mysql.mysql_db:
name: "{{ zabbix_db_name }}"
encoding: utf8mb4
collation: utf8mb4_bin
state: present
login_unix_socket: /run/mysqld/mysqld.sock
- name: Create the Zabbix database user
community.mysql.mysql_user:
name: "{{ zabbix_db_user }}"
host: localhost
password: "{{ zabbix_db_password }}"
priv: "{{ zabbix_db_name }}.*:ALL"
update_password: on_create
state: present
login_unix_socket: /run/mysqld/mysqld.sock
no_log: trueThe repository comes from the zabbix-release package rather than a hand written sources file, because that one package carries both the signing key and the sources entry, and apt skips it when the same version is already installed. The URL is built from facts, so ansible_distribution_version fills in 24.04 and zabbix_version fills in 7.0. Zabbix 8.0 puts the same file under /zabbix/8.0/release/ubuntu/, so bumping the version variable means checking the path as well.
The package task refreshes the apt cache only when the repository task changed something. A separate apt task that only updates the cache reports a change on every run, which ruins the drift check described below. Skipping the refresh entirely is worse: apt has no idea the new repository exists and the install fails.
python3-pymysql is not optional. The community.mysql modules run on the target host and import PyMySQL there, so without that package every database task fails with A MySQL module is required: for Python 3.x, PyMySQL. Installing it on your laptop changes nothing.
The database tasks connect over the unix socket. On Ubuntu the MySQL root account uses the auth_socket plugin, which means there is no root password and identity comes from the connecting system user. That is why the play needs become: true and login_unix_socket together. Drop either one and MySQL answers Access denied for user 'root'@'localhost'.
update_password: on_create is the difference between a playbook you can run twice and one you cannot. MySQL stores a salted hash, so the module cannot compare your plaintext against what is stored, and with the default setting it rewrites the password and reports changed on every single run. With on_create the password is set once, when the user is created.
Loading the schema without loading it twice
- name: Look up whether the schema is already loaded
community.mysql.mysql_query:
login_db: "{{ zabbix_db_name }}"
login_unix_socket: /run/mysqld/mysqld.sock
query: "SELECT COUNT(*) AS n FROM information_schema.tables WHERE table_schema = %s AND table_name = 'users'"
positional_args:
- "{{ zabbix_db_name }}"
register: zabbix_schema
changed_when: false
check_mode: false
- name: Record whether the schema still has to be imported
ansible.builtin.set_fact:
zabbix_schema_missing: "{{ zabbix_schema.query_result[0][0].n == 0 }}"
- name: Allow function creation for the import
community.mysql.mysql_variables:
variable: log_bin_trust_function_creators
value: 1
login_unix_socket: /run/mysqld/mysqld.sock
when: zabbix_schema_missing | bool
check_mode: false
- name: Import the Zabbix schema
ansible.builtin.shell:
cmd: "set -o pipefail && zcat /usr/share/zabbix-sql-scripts/mysql/server.sql.gz | mysql --default-character-set=utf8mb4 --user={{ zabbix_db_user }} --database={{ zabbix_db_name }}"
executable: /bin/bash
environment:
MYSQL_PWD: "{{ zabbix_db_password }}"
when: zabbix_schema_missing | bool
no_log: true
- name: Stop allowing function creation
community.mysql.mysql_variables:
variable: log_bin_trust_function_creators
value: 0
login_unix_socket: /run/mysqld/mysqld.sock
when: zabbix_schema_missing | bool
check_mode: false
- name: Write /etc/zabbix/zabbix_server.conf
ansible.builtin.template:
src: zabbix_server.conf.j2
dest: /etc/zabbix/zabbix_server.conf
owner: root
group: zabbix
mode: "0640"
no_log: true
notify: Restart zabbix-server
- name: Enable and start the Zabbix server
ansible.builtin.systemd_service:
name: zabbix-server
enabled: true
state: startedThe guard asks the database, not the filesystem. A marker file under /etc would lie the moment somebody drops the database or restores an older backup, and information_schema always tells the truth about whether the users table exists. positional_args binds the database name instead of pasting it into the SQL string.
MySQL 8.0 on Ubuntu 24.04 has binary logging on by default, and the Zabbix schema creates stored functions. Without the temporary toggle the import stops at ERROR 1419 (HY000) with the message about not having the SUPER privilege while binary logging is enabled. The second toggle puts the setting back, so the loosened rule lives only as long as the import.
The import is the one place where shell beats a module. No module pipes a gzipped SQL file into a client, command cannot pipe at all, and set -o pipefail with executable: /bin/bash makes a failure in zcat fail the task instead of hiding behind the exit code of mysql. It runs for a minute or two on a small VPS with no output while it works.
The password reaches the client through MYSQL_PWD rather than -p. A password after -p makes the client print Warning: Using a password on the command line interface can be insecure, and it puts the secret into the command string Ansible builds. no_log: true on the same task keeps the whole thing out of the output.
templates/zabbix_server.conf.j2 is the file that makes the configuration reproducible:
# Managed by Ansible. Local edits are overwritten on the next run.
LogFile=/var/log/zabbix/zabbix_server.log
PidFile=/run/zabbix/zabbix_server.pid
SocketDir=/run/zabbix
DBHost={{ zabbix_db_host }}
DBName={{ zabbix_db_name }}
DBUser={{ zabbix_db_user }}
DBPassword={{ zabbix_db_password }}
StartPollers={{ zabbix_start_pollers }}
CacheSize={{ zabbix_cache_size }}
Timeout=4template beats lineinfile here because the whole file belongs to Ansible: one source, one owner, no drift from an edit somebody made at 2am. DBHost=localhost tells the MySQL client to use the unix socket. Write 127.0.0.1 instead and the client uses the network, which then has to be open and permitted.
Making the playbook safe to run with --check --diff
ansible-playbook zabbix-server.yml --check --diff is a drift report: it tells you what somebody changed on the host since the last run. That is only usable if a converged host reports changed=0, because a run that always shows four changes is a run nobody reads. Three details in the tasks above exist for that reason.
changed_when: false on the schema lookup keeps a read out of the change count, since a SELECT changes nothing. check_mode: false on the same task makes it run even under --check, which it must, because the next task reads zabbix_schema.query_result. Without it, check mode skips the query and the play dies on 'dict object' has no attribute 'query_result'.
The two log_bin_trust_function_creators toggles carry check_mode: false as a pair. They are the setup and the teardown around one import, so simulating them would report two changes the run never makes, and would show drift on a host that has none.
The template task carries no_log: true, and that has a cost worth naming: --diff prints the file it would write, and this file contains DBPassword. Without no_log, every drift check prints the database password to your terminal and into whatever collects the output. You give up the diff for that one file and keep the secret.
Check mode is a drift test, not a rehearsal of a first install. On a host that has never run the playbook it fails, because the database tasks cannot query a database that apt has not installed yet. Converge the host first, then check it.
Installing the Zabbix agent across the inventory
- name: Install the Zabbix agent
hosts: zabbix_agents
become: true
handlers:
- name: Restart zabbix-agent
ansible.builtin.systemd_service:
name: zabbix-agent
state: restarted
tasks:
- name: Install the Zabbix repository package
ansible.builtin.apt:
deb: "https://repo.zabbix.com/zabbix/{{ zabbix_version }}/ubuntu/pool/main/z/zabbix-release/zabbix-release_latest_{{ zabbix_version }}+ubuntu{{ ansible_distribution_version }}_all.deb"
register: zabbix_repo
- name: Install the agent package
ansible.builtin.apt:
name: zabbix-agent
state: present
update_cache: "{{ zabbix_repo is changed }}"
- name: Write /etc/zabbix/zabbix_agentd.conf
ansible.builtin.template:
src: zabbix_agentd.conf.j2
dest: /etc/zabbix/zabbix_agentd.conf
owner: root
group: root
mode: "0644"
notify: Restart zabbix-agent
- name: Enable and start the agent
ansible.builtin.systemd_service:
name: zabbix-agent
enabled: true
state: startedtemplates/zabbix_agentd.conf.j2 needs the server address and a host name that matches what you configure in the Zabbix frontend:
# Managed by Ansible. Local edits are overwritten on the next run.
PidFile=/run/zabbix/zabbix_agentd.pid
LogFile=/var/log/zabbix/zabbix_agentd.log
LogFileSize=0
Server={{ zabbix_server_ip }}
ServerActive={{ zabbix_server_ip }}
Hostname={{ inventory_hostname }}Server is an access list: the agent answers passive checks only from the addresses named there. ServerActive is the address the agent connects out to for active checks. inventory_hostname gives each agent the name Ansible already knows it by, so the inventory stays the single list of machines. Adding a host to monitoring is then one line in inventory.ini and one playbook run.
The two plays repeat the repository task, and that repetition is the signal that this pair has grown up into a role. The difference between a playbook and a role covers when to make that move and what it costs.
Agents listen on port 10050 and the server listens on port 10051, both over TCP (transmission control protocol). Open 10050 on each agent to the server address only, and 10051 on the server to the agent addresses. Basic ufw rules on a VPS has the syntax.
Run the playbooks, and what you should see
ansible-playbook zabbix-server.yml --syntax-check
ansible-playbook zabbix-server.yml
ansible-playbook zabbix-agents.ymlRun ansible-playbook zabbix-server.yml a second time. The play recap should end with changed=0. If a task still reports a change, that task is the one to fix, and the section above lists the usual causes.
On the server, check the daemon and its log:
systemctl is-active zabbix-server
sudo tail -n 20 /var/log/zabbix/zabbix_server.log
sudo ss -lntp | grep 10051A healthy log opens with Starting Zabbix Server. Zabbix 7.0.x, lists the enabled features, and then goes quiet. ss should show a process listening on 10051. From the server, ask an agent whether it is alive:
sudo apt install -y zabbix-get
zabbix_get -s 203.0.113.21 -k agent.pingThe answer is 1. The answer zabbix_get [1234]: Check access restrictions in Zabbix agent configuration means the agent is running and reachable, and its Server= line does not list the address your request came from.
Failure modes, and the exact errors
No package matching 'zabbix-server-mysql' is available. The apt cache has no idea the Zabbix repository exists. This is what a missing cache refresh looks like, and you can watch it happen on purpose with no-refresh-demo.yml:
- name: Show what a missing cache refresh looks like
hosts: zabbix_server
become: true
tasks:
- name: Install the server package without refreshing the cache
ansible.builtin.apt:
name: zabbix-server-mysql
state: present
update_cache: falseIt also happens in real life when somebody installed zabbix-release by hand: the repository task then reports ok, nothing refreshes the cache, and the next task cannot find the package. Run sudo apt update on the host and try again.
A missing python3-debian library. ansible.builtin.deb822_repository is the modern way to write a sources file, and it needs the python3-debian package on the target host, which a fresh Ubuntu 24.04 image does not have. Either install that package in an earlier task or use the zabbix-release deb as above, which needs nothing beyond apt.
A MySQL module is required: for Python 3.x, PyMySQL. The community.mysql collection lives on your controller, and the library it imports has to be on the target. Install python3-pymysql on the target host.
ERROR 1419 (HY000) during the import. Binary logging is on and the schema creates functions. The toggle task did not run, usually because the import was pulled out of the playbook and run by hand.
[Z3005] query failed: [1146] Table 'zabbix.config' doesn't exist in zabbix_server.log. The database exists and the schema was never imported. Check the guard by hand: SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='zabbix' should return a few hundred. If zcat reported No such file or directory instead, the zabbix-sql-scripts package is missing, and dpkg -L zabbix-sql-scripts | grep server.sql prints the path your version ships.
[Z3001] connection to database 'zabbix' failed: [1045] Access denied for user 'zabbix'@'localhost' (using password: YES). The password in the template is not the password MySQL holds. Because of update_password: on_create, changing the vaulted value does not change the existing MySQL user, by design. Change it in both places: sudo mysql -e "ALTER USER 'zabbix'@'localhost' IDENTIFIED BY 'the new password';" and then rerun the playbook so the template catches up.
ERROR! Decryption failed (no vault secrets would found that could decrypt) on /home/you/zabbix/group_vars/zabbix_server/vault.yml. The vault password file exists and holds the wrong password. ERROR! Attempting to decrypt but no vault secrets found is the other one: no vault password at all, which almost always means you ran from outside the project directory.
FAQ
Why does Ansible ignore my vaulted zabbix_db_password?
Because something with higher precedence defines the same variable name. A vars: block in the play outranks every group_vars file, and -e on the command line outranks that, so a placeholder left in either place wins over the vault file and nothing warns you. Keep the default in group_vars/all.yml, the real value in group_vars/<group>/vault.yml, and no vars: block in the play. Confirm it with a task that prints {{ zabbix_db_password | hash('sha1') }} and compare that against printf %s 'your-password' | sha1sum.
Why does the playbook report changes on a host that is already set up?
Three tasks cause almost all of it. A community.mysql.mysql_user task without update_password: on_create rewrites the password every run, because MySQL stores a salted hash the module cannot compare against your plaintext. A standalone apt task that only updates the cache reports a change every run, so fold the refresh into the install task and make it conditional on the repository task. A query task reports a change unless you add changed_when: false, since Ansible cannot tell a SELECT from an UPDATE.
Where is the Zabbix web frontend in this playbook?
It is left out on purpose. This playbook installs zabbix-server-mysql and zabbix-sql-scripts, which is the server daemon and its schema. The frontend is a separate set of packages plus a web server and a PHP pool, and it usually belongs on its own host or behind its own reverse proxy. Add zabbix-frontend-php with zabbix-nginx-conf in a third play once the server is listening on 10051.
How do I change the Zabbix database password later?
In two places, because update_password: on_create deliberately does not touch an existing user. Run ansible-vault edit group_vars/zabbix_server/vault.yml and set the new value, then run sudo mysql -e "ALTER USER 'zabbix'@'localhost' IDENTIFIED BY 'the new password';" on the database host. Rerun the playbook: the template writes the new DBPassword and the handler restarts the server. Watch /var/log/zabbix/zabbix_server.log for [Z3001] afterwards, which is the line that appears when the two no longer agree.