Monitoring

Deploy a Complete Prometheus Monitoring Stack with Ansible

Teach me Ansible | 2026-08-19 | 22 min read

Learn how to deploy a complete, production-ready monitoring stack — Prometheus, node_exporter, Alertmanager, and Grafana — entirely with Ansible. Your scrape configs, alert rules, and dashboards become version-controlled code that rebuilds itself from your inventory.

Why Deploy Monitoring as Code?

Monitoring is usually the last thing teams automate — and the first thing that drifts. Someone hand-edits prometheus.yml to add a host, someone else clicks together a Grafana dashboard that only exists on one server, and six months later nobody knows why alerts fire (or worse, why they don't).

Treating your monitoring stack as Ansible code fixes this at the root:

  • Self-updating targets - Scrape configs are generated from your Ansible inventory. Add a host to a group, re-run the playbook, and Prometheus starts scraping it. No manual target lists.
  • Reviewable alerts - Alert rules live in Git and go through pull requests, not midnight SSH sessions.
  • Disaster recovery - Lose the monitoring server? One playbook run rebuilds everything: exporters, server, rules, and dashboards.
  • Consistency - Every environment (dev, staging, prod) gets the same stack with only variables changing.

Architecture Overview

The stack has four components, each with a clear job:

  • node_exporter (port 9100) - Runs on every host and exposes CPU, memory, disk, and network metrics over HTTP.
  • Prometheus (port 9090) - The central server. Scrapes every exporter on an interval, stores time-series data, and evaluates alert rules.
  • Alertmanager (port 9093) - Receives alerts from Prometheus, deduplicates and groups them, and routes notifications to Slack, email, or PagerDuty.
  • Grafana (port 3000) - Dashboards. Queries Prometheus and visualizes everything.

In inventory terms, that maps to two groups: a monitoring group (one or two servers running Prometheus, Alertmanager, and Grafana) and everything else, which just gets node_exporter:

# inventory/hosts.ini
[monitoring]
monitor01.example.com

[webservers]
web01.example.com
web02.example.com

[databases]
db01.example.com

[all:vars]
ansible_user=ansible

Step 1: Deploy node_exporter Everywhere

Start with the exporters, since Prometheus is useless without something to scrape. We'll write a small role: download the binary, create a dedicated system user, install a systemd unit, and open the firewall.

Role Tasks

# roles/node_exporter/tasks/main.yml
---
- name: Create node_exporter system user
  ansible.builtin.user:
    name: node_exporter
    system: true
    shell: /usr/sbin/nologin
    create_home: false

- name: Download and unpack node_exporter
  ansible.builtin.unarchive:
    src: "https://github.com/prometheus/node_exporter/releases/download/v{{ node_exporter_version }}/node_exporter-{{ node_exporter_version }}.linux-amd64.tar.gz"
    dest: /tmp
    remote_src: true
    creates: "/tmp/node_exporter-{{ node_exporter_version }}.linux-amd64"

- name: Install node_exporter binary
  ansible.builtin.copy:
    src: "/tmp/node_exporter-{{ node_exporter_version }}.linux-amd64/node_exporter"
    dest: /usr/local/bin/node_exporter
    remote_src: true
    owner: root
    group: root
    mode: "0755"
  notify: Restart node_exporter

- name: Deploy systemd unit
  ansible.builtin.template:
    src: node_exporter.service.j2
    dest: /etc/systemd/system/node_exporter.service
    mode: "0644"
  notify: Restart node_exporter

- name: Allow Prometheus server through the firewall
  ansible.posix.firewalld:
    port: 9100/tcp
    permanent: true
    immediate: true
    state: enabled
  when: ansible_facts['os_family'] == "RedHat"

- name: Enable and start node_exporter
  ansible.builtin.systemd:
    name: node_exporter
    state: started
    enabled: true
    daemon_reload: true

The systemd Unit Template

# roles/node_exporter/templates/node_exporter.service.j2
[Unit]
Description=Prometheus Node Exporter
After=network-online.target

[Service]
User=node_exporter
Group=node_exporter
ExecStart=/usr/local/bin/node_exporter \
  --web.listen-address={{ ansible_default_ipv4.address }}:9100
Restart=on-failure
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true

[Install]
WantedBy=multi-user.target

And the matching handler:

# roles/node_exporter/handlers/main.yml
---
- name: Restart node_exporter
  ansible.builtin.systemd:
    name: node_exporter
    state: restarted
    daemon_reload: true

Step 2: Deploy the Prometheus Server

This is where monitoring-as-code really shines. Instead of hardcoding a target list, we generate the scrape configuration from the Ansible inventory itself using Jinja2. Every host Ansible knows about automatically becomes a Prometheus target.

Templating prometheus.yml from Inventory Groups

# roles/prometheus/templates/prometheus.yml.j2
---
global:
  scrape_interval: {{ prometheus_scrape_interval | default('15s') }}
  evaluation_interval: 15s
  external_labels:
    environment: {{ deploy_env | default('production') }}

alerting:
  alertmanagers:
    - static_configs:
        - targets:
            - "localhost:9093"

rule_files:
  - /etc/prometheus/rules/*.yml

scrape_configs:
  - job_name: prometheus
    static_configs:
      - targets: ["localhost:9090"]

{% for group in ['webservers', 'databases', 'monitoring'] %}
  - job_name: {{ group }}
    static_configs:
      - targets:
{% for host in groups[group] %}
          - "{{ hostvars[host]['ansible_default_ipv4']['address'] }}:9100"
{% endfor %}
        labels:
          group: {{ group }}
{% endfor %}

Read that middle section carefully — it loops over inventory groups, and inside each group it loops over every host, pulling the host's IP from gathered facts via hostvars. Add web03 to the [webservers] group tomorrow, re-run the playbook, and it's scraped and labeled with zero config edits. That single template is the payoff of the whole approach.

Prometheus Role Tasks (abridged)

# roles/prometheus/tasks/main.yml (key tasks)
---
- name: Create Prometheus directories
  ansible.builtin.file:
    path: "{{ item }}"
    state: directory
    owner: prometheus
    group: prometheus
    mode: "0755"
  loop:
    - /etc/prometheus
    - /etc/prometheus/rules
    - /var/lib/prometheus

- name: Deploy Prometheus configuration
  ansible.builtin.template:
    src: prometheus.yml.j2
    dest: /etc/prometheus/prometheus.yml
    owner: prometheus
    group: prometheus
    mode: "0644"
    validate: /usr/local/bin/promtool check config %s
  notify: Reload prometheus

Two details worth stealing: the validate parameter runs promtool against the rendered file before it replaces the live config, so a broken template can never take down monitoring. And the handler uses state: reloaded — Prometheus supports live config reload, so there's no need for a full restart:

# roles/prometheus/handlers/main.yml
---
- name: Reload prometheus
  ansible.builtin.systemd:
    name: prometheus
    state: reloaded

Step 3: Alertmanager and Alert Rules as Code

Alert rules are just YAML files, which makes them perfect Ansible payloads. Keep them in the role's files/ directory so they're diffable in Git:

# roles/prometheus/files/rules/node_alerts.yml
---
groups:
  - name: node_alerts
    rules:
      - alert: InstanceDown
        expr: up == 0
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "Instance {{ $labels.instance }} is down"
          description: "{{ $labels.instance }} has been unreachable for 2 minutes."

      - alert: HighDiskUsage
        expr: |
          (node_filesystem_avail_bytes{mountpoint="/"}
           / node_filesystem_size_bytes{mountpoint="/"}) < 0.10
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Low disk space on {{ $labels.instance }}"

      - alert: HighMemoryUsage
        expr: |
          (1 - node_memory_MemAvailable_bytes
             / node_memory_MemTotal_bytes) > 0.90
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "Memory above 90% on {{ $labels.instance }}"

Gotcha: Prometheus vs. Jinja2 Templating

Prometheus alert annotations use {{ $labels.instance }} — the same delimiters as Jinja2! If you deploy rule files with Ansible's template module, Ansible will try to render them and fail. Either use the copy module for rule files (recommended), or wrap the Prometheus expressions in {% raw %} blocks.

Ship the rules and Alertmanager routing config:

# roles/alertmanager/tasks/main.yml (key tasks)
---
- name: Deploy alert rules (copy, NOT template!)
  ansible.builtin.copy:
    src: rules/
    dest: /etc/prometheus/rules/
    owner: prometheus
    mode: "0644"
  notify: Reload prometheus

- name: Deploy Alertmanager configuration
  ansible.builtin.template:
    src: alertmanager.yml.j2
    dest: /etc/alertmanager/alertmanager.yml
    mode: "0640"
  notify: Restart alertmanager
# roles/alertmanager/templates/alertmanager.yml.j2
---
route:
  receiver: slack-default
  group_by: [alertname, group]
  group_wait: 30s
  group_interval: 5m
  repeat_interval: 4h
  routes:
    - match:
        severity: critical
      receiver: pagerduty-oncall

receivers:
  - name: slack-default
    slack_configs:
      - api_url: "{{ vault_slack_webhook_url }}"
        channel: "#alerts"
        send_resolved: true

  - name: pagerduty-oncall
    pagerduty_configs:
      - service_key: "{{ vault_pagerduty_key }}"

Notice the webhook URL and PagerDuty key come from Ansible Vault variables — secrets never land in Git as plaintext.

Step 4: Grafana Provisioning — Dashboards from Files

Grafana has a killer feature for automation: provisioning. Drop YAML files into /etc/grafana/provisioning/ and Grafana configures datasources and loads dashboards on startup — no clicking, no API calls.

Provision the Prometheus Datasource

# roles/grafana/templates/datasource.yml.j2
---
apiVersion: 1
datasources:
  - name: Prometheus
    type: prometheus
    access: proxy
    url: http://localhost:9090
    isDefault: true
    editable: false

Provision Dashboards

# roles/grafana/templates/dashboards.yml.j2
---
apiVersion: 1
providers:
  - name: ansible-managed
    folder: Infrastructure
    type: file
    disableDeletion: true
    options:
      path: /var/lib/grafana/dashboards

Then copy dashboard JSON files (export any dashboard from a Grafana UI once, commit the JSON) into place:

# roles/grafana/tasks/main.yml (key tasks)
---
- name: Install Grafana
  ansible.builtin.package:
    name: grafana
    state: present

- name: Provision datasources and dashboard providers
  ansible.builtin.template:
    src: "{{ item.src }}"
    dest: "{{ item.dest }}"
    mode: "0640"
  loop:
    - { src: datasource.yml.j2, dest: /etc/grafana/provisioning/datasources/prometheus.yml }
    - { src: dashboards.yml.j2, dest: /etc/grafana/provisioning/dashboards/ansible.yml }
  notify: Restart grafana

- name: Deploy dashboard JSON files
  ansible.builtin.copy:
    src: dashboards/
    dest: /var/lib/grafana/dashboards/
    owner: grafana
    mode: "0640"
  notify: Restart grafana

A great starting point is the community "Node Exporter Full" dashboard (Grafana.com ID 1860) — download its JSON once and commit it to roles/grafana/files/dashboards/.

Shortcut: The prometheus.prometheus Galaxy Collection

Don't want to maintain your own roles? The official prometheus.prometheus collection (successor to the popular cloudalchemy roles) packages battle-tested roles for the entire stack:

# Install the collection
ansible-galaxy collection install prometheus.prometheus
---
- name: Exporters everywhere
  hosts: all
  become: true
  roles:
    - prometheus.prometheus.node_exporter

- name: Monitoring stack
  hosts: monitoring
  become: true
  roles:
    - role: prometheus.prometheus.prometheus
      vars:
        prometheus_scrape_configs:
          - job_name: node
            static_configs:
              - targets: "{{ groups['all'] | map('regex_replace', '$', ':9100') | list }}"
    - prometheus.prometheus.alertmanager

The same inventory-driven trick works here: the map filter turns your entire inventory into a target list in one line. The collection handles versions, checksums, users, and systemd units for you — writing your own roles is still worth doing once to understand what's happening underneath.

Tying It Together: One site.yml

# site.yml
---
- name: Deploy node_exporter to all hosts
  hosts: all
  become: true
  roles:
    - node_exporter

- name: Deploy the monitoring stack
  hosts: monitoring
  become: true
  roles:
    - prometheus
    - alertmanager
    - grafana
# One command, whole stack
ansible-playbook -i inventory/hosts.ini site.yml --ask-vault-pass

Verifying the Deployment

Don't trust green Ansible output alone — verify the stack actually sees your hosts. You can automate that check too:

# verify.yml
---
- name: Verify all Prometheus targets are up
  hosts: monitoring
  tasks:
    - name: Query the Prometheus targets API
      ansible.builtin.uri:
        url: http://localhost:9090/api/v1/targets
        return_content: true
      register: targets

    - name: Assert every target is healthy
      ansible.builtin.assert:
        that:
          - targets.json.data.activeTargets
            | selectattr('health', 'ne', 'up') | list | length == 0
        fail_msg: >-
          Unhealthy targets:
          {{ targets.json.data.activeTargets
             | selectattr('health', 'ne', 'up')
             | map(attribute='labels.instance') | list }}
        success_msg: "All {{ targets.json.data.activeTargets | length }} targets are up."

Manual spot checks if you prefer:

# Is a node exporting metrics?
curl -s http://web01.example.com:9100/metrics | head

# Prometheus UI: http://monitor01:9090/targets  (all should be UP)
# Run the query:  up
# Grafana:        http://monitor01:3000  (dashboards pre-loaded)

Bonus: Close the Loop with Event-Driven Ansible

Here's where it gets fun. Alertmanager can POST alerts to a webhook — and Event-Driven Ansible can listen on that webhook and run remediation playbooks automatically. Disk filling up? A rulebook triggers a cleanup playbook. Service down? EDA restarts it and only pages a human if that fails.

# rulebooks/auto-remediate.yml
---
- name: Remediate Prometheus alerts
  hosts: all
  sources:
    - ansible.eda.alertmanager:
        host: 0.0.0.0
        port: 5050
  rules:
    - name: Restart a service that went down
      condition: event.alert.labels.alertname == "InstanceDown"
      action:
        run_playbook:
          name: playbooks/restart_service.yml

Your monitoring stack stops being a passive dashboard and becomes a self-healing feedback loop — deployed, configured, and now acted upon entirely as code. We cover this pattern in depth in our Event-Driven Ansible guide.

Conclusion

You now have a complete monitoring stack where every moving part is code: exporters rolled out by a role, scrape configs generated from inventory groups, alert rules reviewed in pull requests, dashboards provisioned from JSON, and a single site.yml that rebuilds it all from scratch. The pattern generalizes — the inventory-driven templating trick works just as well for HAProxy backends, WireGuard peers, or anything else that needs to know about your fleet.

Pro Tip

Add the verify.yml targets check as the final play in site.yml. Every deployment then ends with proof that monitoring can actually see your fleet — a failed run tells you about a firewall or DNS problem before your users do.