Introduction

Elasticsearch is the backbone of the ELK stack and one of the most widely deployed search and analytics engines. A production cluster requires careful node configuration, memory tuning, security setup, and lifecycle management — all of which Ansible automates reliably across dozens of nodes.

Inventory Structure

# inventory/elasticsearch/hosts
[elasticsearch:children]
es_master
es_data
es_coordinating

[es_master]
es-master-1 ansible_host=10.0.1.10
es-master-2 ansible_host=10.0.1.11
es-master-3 ansible_host=10.0.1.12

[es_data]
es-data-1 ansible_host=10.0.2.10
es-data-2 ansible_host=10.0.2.11
es-data-3 ansible_host=10.0.2.12
es-data-4 ansible_host=10.0.2.13

[es_coordinating]
es-coord-1 ansible_host=10.0.3.10
es-coord-2 ansible_host=10.0.3.11

Install Elasticsearch

---
- name: Deploy Elasticsearch Cluster
  hosts: elasticsearch
  become: true
  vars:
    es_version: "8.17.0"
    es_cluster_name: "production"
    es_heap_size: "{{ (ansible_memtotal_mb * 0.5) | int }}m"
    es_data_path: /var/lib/elasticsearch
    es_log_path: /var/log/elasticsearch
  tasks:
    - name: Import Elasticsearch GPG key
      ansible.builtin.rpm_key:
        key: https://artifacts.elastic.co/GPG-KEY-elasticsearch
        state: present
      when: ansible_os_family == "RedHat"

    - name: Add Elasticsearch repository
      ansible.builtin.yum_repository:
        name: elasticsearch
        description: Elasticsearch 8.x
        baseurl: https://artifacts.elastic.co/packages/8.x/yum
        gpgcheck: true
        gpgkey: https://artifacts.elastic.co/GPG-KEY-elasticsearch
      when: ansible_os_family == "RedHat"

    - name: Install Elasticsearch
      ansible.builtin.package:
        name: "elasticsearch-{{ es_version }}"
        state: present

    - name: Configure JVM heap size
      ansible.builtin.copy:
        dest: /etc/elasticsearch/jvm.options.d/heap.options
        content: |
          -Xms{{ es_heap_size }}
          -Xmx{{ es_heap_size }}
        mode: "0644"
      notify: restart elasticsearch

    - name: Set system limits
      ansible.builtin.copy:
        dest: /etc/security/limits.d/elasticsearch.conf
        content: |
          elasticsearch soft nofile 65536
          elasticsearch hard nofile 65536
          elasticsearch soft memlock unlimited
          elasticsearch hard memlock unlimited
        mode: "0644"

    - name: Configure sysctl for Elasticsearch
      ansible.posix.sysctl:
        name: vm.max_map_count
        value: "262144"
        sysctl_set: true
        state: present
        reload: true

    - name: Deploy Elasticsearch configuration
      ansible.builtin.template:
        src: elasticsearch.yml.j2
        dest: /etc/elasticsearch/elasticsearch.yml
        owner: elasticsearch
        group: elasticsearch
        mode: "0660"
      notify: restart elasticsearch

    - name: Enable and start Elasticsearch
      ansible.builtin.systemd:
        name: elasticsearch
        enabled: true
        state: started

  handlers:
    - name: restart elasticsearch
      ansible.builtin.systemd:
        name: elasticsearch
        state: restarted

Configuration Template

# templates/elasticsearch.yml.j2
cluster.name: {{ es_cluster_name }}
node.name: {{ inventory_hostname }}
path.data: {{ es_data_path }}
path.logs: {{ es_log_path }}
network.host: {{ ansible_default_ipv4.address }}
http.port: 9200
transport.port: 9300

# Node roles
{% if inventory_hostname in groups['es_master'] %}
node.roles: [master]
{% elif inventory_hostname in groups['es_data'] %}
node.roles: [data, data_content, data_hot, ingest]
{% elif inventory_hostname in groups['es_coordinating'] %}
node.roles: []
{% endif %}

# Discovery
discovery.seed_hosts:
{% for host in groups['es_master'] %}
  - {{ hostvars[host]['ansible_host'] }}:9300
{% endfor %}

cluster.initial_master_nodes:
{% for host in groups['es_master'] %}
  - {{ host }}
{% endfor %}

# Security
xpack.security.enabled: true
xpack.security.transport.ssl.enabled: true
xpack.security.transport.ssl.verification_mode: certificate
xpack.security.transport.ssl.keystore.path: /etc/elasticsearch/certs/transport.p12
xpack.security.transport.ssl.truststore.path: /etc/elasticsearch/certs/transport.p12

xpack.security.http.ssl.enabled: true
xpack.security.http.ssl.keystore.path: /etc/elasticsearch/certs/http.p12

Index Lifecycle Management

- name: Configure ILM policy
  ansible.builtin.uri:
    url: "https://{{ es_coordinating_host }}:9200/_ilm/policy/logs-policy"
    method: PUT
    user: elastic
    password: "{{ vault_es_password }}"
    force_basic_auth: true
    validate_certs: false
    body_format: json
    body:
      policy:
        phases:
          hot:
            min_age: 0ms
            actions:
              rollover:
                max_primary_shard_size: 50gb
                max_age: 1d
              set_priority:
                priority: 100
          warm:
            min_age: 3d
            actions:
              shrink:
                number_of_shards: 1
              forcemerge:
                max_num_segments: 1
              set_priority:
                priority: 50
          cold:
            min_age: 30d
            actions:
              searchable_snapshot:
                snapshot_repository: s3_repository
          delete:
            min_age: 90d
            actions:
              delete: {}

Snapshot and Backup

- name: Configure S3 snapshot repository
  ansible.builtin.uri:
    url: "https://{{ es_coordinating_host }}:9200/_snapshot/s3_backups"
    method: PUT
    user: elastic
    password: "{{ vault_es_password }}"
    force_basic_auth: true
    validate_certs: false
    body_format: json
    body:
      type: s3
      settings:
        bucket: "{{ es_backup_bucket }}"
        region: "{{ aws_region }}"
        base_path: "elasticsearch/{{ es_cluster_name }}"

- name: Create daily snapshot
  ansible.builtin.uri:
    url: "https://{{ es_coordinating_host }}:9200/_slm/policy/daily-snapshot"
    method: PUT
    user: elastic
    password: "{{ vault_es_password }}"
    force_basic_auth: true
    validate_certs: false
    body_format: json
    body:
      schedule: "0 30 2 * * ?"
      name: "<daily-snap-{now/d}>"
      repository: s3_backups
      config:
        indices: ["*"]
        ignore_unavailable: true
      retention:
        expire_after: 30d
        min_count: 7
        max_count: 30

Health Check Playbook

- name: Check Elasticsearch cluster health
  hosts: localhost
  connection: local
  tasks:
    - name: Get cluster health
      ansible.builtin.uri:
        url: "https://{{ es_coordinating_host }}:9200/_cluster/health"
        user: elastic
        password: "{{ vault_es_password }}"
        force_basic_auth: true
        validate_certs: false
      register: health

    - name: Assert cluster is healthy
      ansible.builtin.assert:
        that:
          - health.json.status in ['green', 'yellow']
          - health.json.number_of_nodes >= 7
          - health.json.unassigned_shards == 0
        fail_msg: "Cluster unhealthy: {{ health.json.status }}, {{ health.json.unassigned_shards }} unassigned shards"

    - name: Display cluster info
      ansible.builtin.debug:
        msg: |
          Cluster: {{ health.json.cluster_name }}
          Status: {{ health.json.status }}
          Nodes: {{ health.json.number_of_nodes }}
          Shards: {{ health.json.active_shards }} active, {{ health.json.relocating_shards }} relocating

Troubleshooting

# Cluster health
curl -k -u elastic:password https://localhost:9200/_cluster/health?pretty

# Node info
curl -k -u elastic:password https://localhost:9200/_cat/nodes?v

# Shard allocation
curl -k -u elastic:password https://localhost:9200/_cat/shards?v&s=state

# Index stats
curl -k -u elastic:password https://localhost:9200/_cat/indices?v&s=docs.count:desc

Conclusion

A production Elasticsearch cluster requires coordinated configuration of master, data, and coordinating nodes with proper JVM tuning, security, and lifecycle management. Ansible makes this repeatable — from initial deployment through rolling upgrades, snapshot management, and health monitoring.