Introduction
The ELK stack (Elasticsearch, Logstash, Kibana) — now called the Elastic Stack — is the most popular open-source solution for centralized logging and observability. Ansible automates the entire deployment: Elasticsearch cluster, Logstash pipelines, Kibana dashboards, and Filebeat agents on every server. This guide covers single-node dev setups through multi-node production clusters.
Architecture
┌─────────┐ ┌─────────┐ ┌──────────────┐ ┌─────────┐
│ Servers │────►│ Filebeat│────►│ Logstash │────►│ Elastic │
│ (logs) │ │ (agent) │ │ (pipeline) │ │ search │
└─────────┘ └─────────┘ └──────────────┘ └────┬────┘
│
┌─────▼────┐
│ Kibana │
│ (UI) │
└──────────┘
Install Elasticsearch
---
- name: Deploy Elasticsearch cluster
hosts: elasticsearch
become: true
vars:
es_version: "8.15"
es_cluster_name: production-logs
es_heap_size: "4g"
tasks:
- name: Import Elastic GPG key
ansible.builtin.rpm_key:
key: https://artifacts.elastic.co/GPG-KEY-elasticsearch
state: present
when: ansible_os_family == 'RedHat'
- name: Add Elastic repository (Debian)
ansible.builtin.apt_repository:
repo: "deb https://artifacts.elastic.co/packages/{{ es_version }}/apt stable main"
state: present
filename: elastic
when: ansible_os_family == 'Debian'
- name: Install Elasticsearch
ansible.builtin.package:
name: elasticsearch
state: present
- name: Configure Elasticsearch
ansible.builtin.template:
src: elasticsearch.yml.j2
dest: /etc/elasticsearch/elasticsearch.yml
mode: '0660'
owner: root
group: elasticsearch
notify: restart elasticsearch
- name: Configure JVM heap
ansible.builtin.template:
src: jvm.options.j2
dest: /etc/elasticsearch/jvm.options.d/heap.options
mode: '0644'
notify: restart elasticsearch
- name: Start Elasticsearch
ansible.builtin.service:
name: elasticsearch
state: started
enabled: true
- name: Wait for Elasticsearch
ansible.builtin.uri:
url: "http://localhost:9200/_cluster/health"
method: GET
status_code: 200
register: es_health
until: es_health.status == 200
retries: 30
delay: 10
handlers:
- name: restart elasticsearch
ansible.builtin.service:
name: elasticsearch
state: restarted
Elasticsearch Config Template
# templates/elasticsearch.yml.j2
cluster.name: {{ es_cluster_name }}
node.name: {{ inventory_hostname }}
path.data: /var/lib/elasticsearch
path.logs: /var/log/elasticsearch
network.host: {{ ansible_default_ipv4.address }}
http.port: 9200
transport.port: 9300
discovery.seed_hosts:
{% for host in groups['elasticsearch'] %}
- {{ hostvars[host].ansible_default_ipv4.address }}
{% endfor %}
{% if inventory_hostname == groups['elasticsearch'][0] %}
cluster.initial_master_nodes:
{% for host in groups['elasticsearch'] %}
- {{ host }}
{% endfor %}
{% endif %}
# Security (Elasticsearch 8+)
xpack.security.enabled: true
xpack.security.transport.ssl.enabled: true
xpack.security.transport.ssl.verification_mode: certificate
xpack.security.transport.ssl.keystore.path: elastic-certificates.p12
xpack.security.transport.ssl.truststore.path: elastic-certificates.p12
# templates/jvm.options.j2
-Xms{{ es_heap_size }}
-Xmx{{ es_heap_size }}
Install Logstash
---
- name: Deploy Logstash
hosts: logstash
become: true
tasks:
- name: Install Logstash
ansible.builtin.package:
name: logstash
state: present
- name: Deploy Logstash pipeline
ansible.builtin.template:
src: logstash-pipeline.conf.j2
dest: /etc/logstash/conf.d/main.conf
mode: '0644'
notify: restart logstash
- name: Start Logstash
ansible.builtin.service:
name: logstash
state: started
enabled: true
handlers:
- name: restart logstash
ansible.builtin.service:
name: logstash
state: restarted
Logstash Pipeline
# templates/logstash-pipeline.conf.j2
input {
beats {
port => 5044
}
}
filter {
if [fields][type] == "syslog" {
grok {
match => { "message" => "%{SYSLOGTIMESTAMP:syslog_timestamp} %{SYSLOGHOST:hostname} %{DATA:program}(?:\[%{POSINT:pid}\])?: %{GREEDYDATA:message}" }
overwrite => [ "message" ]
}
date {
match => [ "syslog_timestamp", "MMM d HH:mm:ss", "MMM dd HH:mm:ss" ]
}
}
if [fields][type] == "nginx" {
grok {
match => { "message" => '%{IPORHOST:remote_addr} - %{DATA:remote_user} \[%{HTTPDATE:time_local}\] "%{WORD:method} %{URIPATHPARAM:request} HTTP/%{NUMBER:http_version}" %{NUMBER:status} %{NUMBER:body_bytes_sent} "%{DATA:http_referer}" "%{DATA:http_user_agent}"' }
}
mutate {
convert => {
"status" => "integer"
"body_bytes_sent" => "integer"
}
}
geoip {
source => "remote_addr"
}
}
# Add hostname
mutate {
add_field => { "[@metadata][target_index]" => "%{[fields][type]}-%{+YYYY.MM.dd}" }
}
}
output {
elasticsearch {
hosts => [
{% for host in groups['elasticsearch'] %}
"http://{{ hostvars[host].ansible_default_ipv4.address }}:9200"{{ ',' if not loop.last else '' }}
{% endfor %}
]
index => "%{[@metadata][target_index]}"
user => "elastic"
password => "{{ vault_elastic_password }}"
}
}
Install Kibana
---
- name: Deploy Kibana
hosts: kibana
become: true
tasks:
- name: Install Kibana
ansible.builtin.package:
name: kibana
state: present
- name: Configure Kibana
ansible.builtin.template:
src: kibana.yml.j2
dest: /etc/kibana/kibana.yml
mode: '0660'
notify: restart kibana
- name: Start Kibana
ansible.builtin.service:
name: kibana
state: started
enabled: true
handlers:
- name: restart kibana
ansible.builtin.service:
name: kibana
state: restarted
# templates/kibana.yml.j2
server.host: "0.0.0.0"
server.port: 5601
server.name: "{{ inventory_hostname }}"
elasticsearch.hosts:
{% for host in groups['elasticsearch'] %}
- "http://{{ hostvars[host].ansible_default_ipv4.address }}:9200"
{% endfor %}
elasticsearch.username: "kibana_system"
elasticsearch.password: "{{ vault_kibana_password }}"
Deploy Filebeat Agents
---
- name: Deploy Filebeat on all servers
hosts: all
become: true
tasks:
- name: Install Filebeat
ansible.builtin.package:
name: filebeat
state: present
- name: Configure Filebeat
ansible.builtin.template:
src: filebeat.yml.j2
dest: /etc/filebeat/filebeat.yml
mode: '0600'
notify: restart filebeat
- name: Enable system module
ansible.builtin.command: filebeat modules enable system
args:
creates: /etc/filebeat/modules.d/system.yml
- name: Enable nginx module (web servers only)
ansible.builtin.command: filebeat modules enable nginx
args:
creates: /etc/filebeat/modules.d/nginx.yml
when: "'webservers' in group_names"
- name: Start Filebeat
ansible.builtin.service:
name: filebeat
state: started
enabled: true
handlers:
- name: restart filebeat
ansible.builtin.service:
name: filebeat
state: restarted
# templates/filebeat.yml.j2
filebeat.inputs:
- type: filestream
id: syslog
paths:
- /var/log/syslog
- /var/log/messages
fields:
type: syslog
{% if 'webservers' in group_names %}
- type: filestream
id: nginx-access
paths:
- /var/log/nginx/access.log
fields:
type: nginx
- type: filestream
id: nginx-error
paths:
- /var/log/nginx/error.log
fields:
type: nginx-error
{% endif %}
output.logstash:
hosts:
{% for host in groups['logstash'] %}
- "{{ hostvars[host].ansible_default_ipv4.address }}:5044"
{% endfor %}
logging.level: warning
Index Lifecycle Management
- name: Configure ILM policy
ansible.builtin.uri:
url: "http://{{ groups['elasticsearch'][0] }}:9200/_ilm/policy/logs-policy"
method: PUT
body_format: json
body:
policy:
phases:
hot:
min_age: "0ms"
actions:
rollover:
max_size: "50gb"
max_age: "1d"
warm:
min_age: "7d"
actions:
shrink:
number_of_shards: 1
forcemerge:
max_num_segments: 1
cold:
min_age: "30d"
actions:
freeze: {}
delete:
min_age: "90d"
actions:
delete: {}
user: elastic
password: "{{ vault_elastic_password }}"
status_code: [200]
delegate_to: localhost
Docker-Based Deployment
- name: Deploy ELK with Docker Compose
hosts: elk_server
become: true
tasks:
- name: Deploy ELK stack
ansible.builtin.copy:
dest: /opt/elk/docker-compose.yml
content: |
services:
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:8.15.0
environment:
- discovery.type=single-node
- xpack.security.enabled=false
- "ES_JAVA_OPTS=-Xms2g -Xmx2g"
ports:
- "9200:9200"
volumes:
- esdata:/usr/share/elasticsearch/data
logstash:
image: docker.elastic.co/logstash/logstash:8.15.0
ports:
- "5044:5044"
volumes:
- ./pipeline:/usr/share/logstash/pipeline
depends_on:
- elasticsearch
kibana:
image: docker.elastic.co/kibana/kibana:8.15.0
ports:
- "5601:5601"
depends_on:
- elasticsearch
volumes:
esdata:
- name: Start ELK stack
community.docker.docker_compose_v2:
project_src: /opt/elk
state: present
Troubleshooting
Elasticsearch Won't Start
- name: Check Elasticsearch logs
ansible.builtin.command: journalctl -u elasticsearch --no-pager -n 50
register: es_logs
changed_when: false
# Common fix: increase vm.max_map_count
- ansible.posix.sysctl:
name: vm.max_map_count
value: '262144'
sysctl_set: true
Cluster Health Yellow/Red
- name: Check cluster health
ansible.builtin.uri:
url: "http://localhost:9200/_cluster/health?pretty"
register: health
- ansible.builtin.debug:
var: health.json
Related Articles
Conclusion
Ansible deploys the entire ELK stack from inventory — Elasticsearch cluster with proper discovery, Logstash pipelines with grok filters, Kibana connected to the cluster, and Filebeat agents on every server. Template all configurations from group variables so adding a node is just adding a host to inventory. For smaller setups, use Docker Compose; for production, use dedicated hosts with ILM policies for automatic index lifecycle management.