Elasticsearch is a distributed search and analytics engine: data is split into shards that are spread and replicated across the nodes of a cluster, so the cluster keeps working when a node fails. Since version 8, the Debian package enables security by default and generates TLS certificates on first start, and new nodes join through short-lived enrollment tokens.

In this tutorial you will install Elasticsearch 9 from the official Elastic repository on three Ubuntu 24.04 servers, form a secured cluster, verify that shards and replicas are distributed, test what happens when a node goes down, and add an index lifecycle management (ILM) policy for time-based data.

Prerequisites

To follow this tutorial, you will need:

  • Three servers running Ubuntu 24.04 LTS, each with at least 4 GB of RAM and 2 vCPUs (8 GB or more for real workloads), for example three CubePath VPS connected by a private network.
  • A non-root user with sudo privileges on each server.
  • Private IPs for the three nodes. This guide uses:
NodeHostnamePrivate IP
1es-node-110.0.0.21
2es-node-210.0.0.22
3es-node-310.0.0.23

Replace these values with your own. Three nodes is the minimum for a production cluster: master election needs a majority, and with three master-eligible nodes the cluster tolerates the loss of one.

Step 1 - Installing Elasticsearch on all nodes

Run this step on all three servers.

Import the Elastic signing key into a dedicated keyring:

sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://artifacts.elastic.co/GPG-KEY-elasticsearch | sudo gpg --dearmor -o /etc/apt/keyrings/elasticsearch.gpg

Add the Elasticsearch 9.x repository, restricted to that key:

echo "deb [signed-by=/etc/apt/keyrings/elasticsearch.gpg] https://artifacts.elastic.co/packages/9.x/apt stable main" | sudo tee /etc/apt/sources.list.d/elastic-9.x.list

Install the package:

sudo apt update
sudo apt install -y elasticsearch

On every node the installer prints a "Security autoconfiguration information" block. On node 1 only, copy the password it shows for the elastic superuser:

--------------------------- Security autoconfiguration information ------------------------------

Authentication and authorization are enabled.
TLS for the transport and HTTP layers is enabled and configured.

The generated password for the elastic built-in superuser is : 3kP_x0Zr9s...

The package also sets the kernel limit vm.max_map_count that Elasticsearch needs. Check it:

sysctl vm.max_map_count

The value must be at least 262144.

Step 2 - Opening the firewall between nodes

Elasticsearch uses two ports: 9200 for the HTTP REST API and 9300 for node-to-node transport. On each node, allow both only from the private network:

sudo ufw allow from 10.0.0.0/24 to any port 9200 proto tcp
sudo ufw allow from 10.0.0.0/24 to any port 9300 proto tcp

Adjust 10.0.0.0/24 to your private subnet. Do not expose port 9300 to the internet, and only expose 9200 publicly behind a reverse proxy or firewall rules for known clients.

Step 3 - Configuring and starting the first node

All commands in this step run on node 1.

The installer already wrote a security section to the end of /etc/elasticsearch/elasticsearch.yml. You need to set a cluster name and node name, and allow the transport layer to listen on the network so other nodes can join. Open the file:

sudo nano /etc/elasticsearch/elasticsearch.yml

Near the top, uncomment and set the cluster and node names:

cluster.name: production-cluster
node.name: es-node-1

Scroll to the auto-generated block at the end. It contains cluster.initial_master_nodes and http.host. Uncomment the transport.host line so it reads:

transport.host: 0.0.0.0

Transport traffic is still encrypted and mutually authenticated with the certificates the installer generated, so listening on all interfaces does not bypass security; the firewall rules from Step 2 restrict who can reach it.

Enable and start the service:

sudo systemctl daemon-reload
sudo systemctl enable --now elasticsearch

Startup takes 20 to 60 seconds. Copy the HTTP CA certificate to your home directory so you can call the API without sudo, and store the elastic password in a shell variable without writing it to your history:

sudo cp /etc/elasticsearch/certs/http_ca.crt ~/http_ca.crt
sudo chown "$USER": ~/http_ca.crt
read -rs ELASTIC_PASSWORD && export ELASTIC_PASSWORD

Paste the password and press ENTER. Now query the node:

curl --cacert ~/http_ca.crt -u "elastic:$ELASTIC_PASSWORD" https://localhost:9200
{
  "name" : "es-node-1",
  "cluster_name" : "production-cluster",
  ...
  "tagline" : "You Know, for Search"
}

If you lost the generated password, reset it with sudo /usr/share/elasticsearch/bin/elasticsearch-reset-password -u elastic.

Step 4 - Joining nodes 2 and 3 with enrollment tokens

An enrollment token contains the address of node 1 and the fingerprint of its CA, so a new node can fetch its certificates securely. Tokens expire after 30 minutes; generate one right before you use it.

On node 1, create a token for a new node:

sudo /usr/share/elasticsearch/bin/elasticsearch-create-enrollment-token -s node
eyJ2ZXIiOiI5LjEuMCIsImFkciI6WyIxMC4wLjAuMjE6OTIwMCJdLCJmZ3IiOiI...

Check that the adr inside the token points to node 1's private IP. If the node has several interfaces, you can force it with --url "https://10.0.0.21:9200".

On node 2, apply the token. This replaces the auto-generated security configuration with one that points at the existing cluster:

sudo /usr/share/elasticsearch/bin/elasticsearch-reconfigure-node --enrollment-token your_enrollment_token

Answer y when asked to reconfigure. Then set the cluster and node names on node 2:

sudo nano /etc/elasticsearch/elasticsearch.yml
cluster.name: production-cluster
node.name: es-node-2

The cluster name must be identical on every node. Start the service:

sudo systemctl daemon-reload
sudo systemctl enable --now elasticsearch

Repeat this step for node 3: generate a new token on node 1, run elasticsearch-reconfigure-node on node 3, set node.name: es-node-3, and start the service.

Step 5 - Verifying the cluster

From node 1, list the nodes:

curl --cacert ~/http_ca.crt -u "elastic:$ELASTIC_PASSWORD" "https://localhost:9200/_cat/nodes?v&h=name,ip,node.role,master,heap.percent"
name      ip        node.role   master heap.percent
es-node-1 10.0.0.21 cdfhilmrstw *      38
es-node-2 10.0.0.22 cdfhilmrstw -      24
es-node-3 10.0.0.23 cdfhilmrstw -      27

The * marks the elected master. The role letters show that each node holds all default roles (master-eligible, data, ingest and so on), which is the right choice for a small cluster.

Check the cluster health:

curl --cacert ~/http_ca.crt -u "elastic:$ELASTIC_PASSWORD" "https://localhost:9200/_cluster/health?pretty"
{
  "cluster_name" : "production-cluster",
  "status" : "green",
  "number_of_nodes" : 3,
  "number_of_data_nodes" : 3,
  ...
}

green means every primary and replica shard is assigned.

Removing the bootstrap setting

cluster.initial_master_nodes is only used the first time a cluster forms. Leaving it in place can cause a new, empty cluster to be bootstrapped if node 1's data is ever lost. On node 1, open /etc/elasticsearch/elasticsearch.yml, comment out or delete the cluster.initial_master_nodes line, and save. No restart is needed; the change takes effect at the next restart.

The enrollment process wrote discovery.seed_hosts on nodes 2 and 3 pointing only at node 1, and node 1 has no seed list at all. So that any node can find the cluster after a restart even if node 1 is down, set the same line on all three nodes in /etc/elasticsearch/elasticsearch.yml (edit the existing line on nodes 2 and 3, add it on node 1):

discovery.seed_hosts: ["10.0.0.21:9300", "10.0.0.22:9300", "10.0.0.23:9300"]

If you change it, restart that node with sudo systemctl restart elasticsearch and wait for green before moving to the next one.

Step 6 - Creating an index with shards and replicas

Create an index with three primary shards and one replica of each. Elasticsearch never places a replica on the same node as its primary, so any single node can fail without data loss:

curl --cacert ~/http_ca.crt -u "elastic:$ELASTIC_PASSWORD" -X PUT "https://localhost:9200/products" \
  -H "Content-Type: application/json" \
  -d '{"settings": {"number_of_shards": 3, "number_of_replicas": 1}}'
{"acknowledged":true,"shards_acknowledged":true,"index":"products"}

See where the shards landed (p is primary, r is replica):

curl --cacert ~/http_ca.crt -u "elastic:$ELASTIC_PASSWORD" "https://localhost:9200/_cat/shards/products?v&h=index,shard,prirep,state,node"
index    shard prirep state   node
products 0     p      STARTED es-node-2
products 0     r      STARTED es-node-1
products 1     p      STARTED es-node-3
products 1     r      STARTED es-node-2
products 2     p      STARTED es-node-1
products 2     r      STARTED es-node-3

Testing a node failure

Stop Elasticsearch on node 3:

sudo systemctl stop elasticsearch

On node 1, check the health again:

curl --cacert ~/http_ca.crt -u "elastic:$ELASTIC_PASSWORD" "https://localhost:9200/_cluster/health?pretty&filter_path=status,number_of_nodes,unassigned_shards"
{
  "status" : "yellow",
  "number_of_nodes" : 2,
  "unassigned_shards" : 2
}

yellow means all data is still available but some replicas are missing. Replicas on the lost node were promoted to primaries. After one minute (the default index.unassigned.node_left.delayed_timeout), Elasticsearch recreates the missing replicas on the remaining nodes and the status returns to green. Start node 3 again with sudo systemctl start elasticsearch and the cluster rebalances shards onto it.

Step 7 - Managing time-based data with ILM

For logs and metrics, index lifecycle management rolls over to a new backing index when the current one gets large or old, and deletes data after a retention period. Create a policy that rolls over at 50 GB per primary shard or after 7 days, and deletes data 30 days after rollover:

curl --cacert ~/http_ca.crt -u "elastic:$ELASTIC_PASSWORD" -X PUT "https://localhost:9200/_ilm/policy/app-logs-policy" \
  -H "Content-Type: application/json" -d '
{
  "policy": {
    "phases": {
      "hot": {
        "actions": {
          "rollover": { "max_primary_shard_size": "50gb", "max_age": "7d" }
        }
      },
      "delete": {
        "min_age": "30d",
        "actions": { "delete": {} }
      }
    }
  }
}'

Create an index template that applies the policy to a data stream called app-logs. The priority of 200 keeps it above Elasticsearch's built-in templates:

curl --cacert ~/http_ca.crt -u "elastic:$ELASTIC_PASSWORD" -X PUT "https://localhost:9200/_index_template/app-logs" \
  -H "Content-Type: application/json" -d '
{
  "index_patterns": ["app-logs*"],
  "data_stream": {},
  "priority": 200,
  "template": {
    "settings": {
      "number_of_shards": 1,
      "number_of_replicas": 1,
      "index.lifecycle.name": "app-logs-policy"
    }
  }
}'

Index a document. Data streams require an @timestamp field, and the stream is created on the first write:

curl --cacert ~/http_ca.crt -u "elastic:$ELASTIC_PASSWORD" -X POST "https://localhost:9200/app-logs/_doc" \
  -H "Content-Type: application/json" \
  -d '{"@timestamp": "2026-09-25T10:00:00Z", "level": "info", "message": "service started"}'

Confirm the backing index is managed by the policy:

curl --cacert ~/http_ca.crt -u "elastic:$ELASTIC_PASSWORD" "https://localhost:9200/app-logs/_ilm/explain?pretty&filter_path=indices.*.policy,indices.*.phase"
{
  "indices" : {
    ".ds-app-logs-2026.09.25-000001" : {
      "policy" : "app-logs-policy",
      "phase" : "hot"
    }
  }
}

Step 8 - Tuning the JVM heap (optional)

Elasticsearch sizes its heap automatically from the available memory and node roles, which is correct for most deployments. If you need to set it explicitly, create a file in jvm.options.d on each node instead of editing jvm.options:

sudo nano /etc/elasticsearch/jvm.options.d/heap.options
-Xms4g
-Xmx4g

Use the same value for both, no more than half of the server's RAM, and stay below about 31 GB. Restart nodes one at a time with sudo systemctl restart elasticsearch, waiting for green before moving to the next node.

Troubleshooting

Node 2 or 3 forms its own cluster: it was started before elasticsearch-reconfigure-node ran. Stop it, purge the package (sudo apt purge elasticsearch), delete /var/lib/elasticsearch, reinstall and enroll it again.

elasticsearch-reconfigure-node cannot connect: node 1's port 9200 is not reachable from the new node, or the token expired. Test with curl -k https://10.0.0.21:9200 from the new node and generate a fresh token.

Service fails to start: read the logs with sudo journalctl -u elasticsearch -n 50 and sudo tail -n 50 /var/log/elasticsearch/production-cluster.log. Common causes are a YAML indentation error or a heap larger than available memory.

Cluster stays yellow with a single index: an index has more replicas than there are other nodes to hold them. Run GET _cluster/allocation/explain to see why a shard is unassigned.

Conclusion

You now have a secured three-node Elasticsearch cluster with TLS on both layers, indices whose replicas survive the loss of any node, and an ILM policy that controls retention for time-based data.

From here you can install Kibana and enroll it with elasticsearch-create-enrollment-token -s kibana, create dedicated users and roles instead of using elastic for applications, and configure snapshots to an S3-compatible repository for backups.