One node or three, and growing from one to three
Verified against Elasticsearch 9.5.5 only, on 29 October 2026; OpenSearch 3.9.0 was not tested in the lab. Configs: github.com/jtsunne/small-elasticsearch-lab at v0.1.0.
One node is fine when you can rebuild or restore its data within the time you can afford to be down. Three master-eligible nodes is the smallest cluster that keeps accepting writes when a node dies. Two nodes buys you little: one of them can still stop the cluster. To get from one to three, build a new cluster and restore a snapshot into it; growing the existing node in place also worked in my lab, once, but it touches the only copy of your data.
| Topology | Keeps a master after losing | Writes during that loss | Basis |
|---|---|---|---|
| One node | nothing | none until the node is back; snapshots are the only redundancy | [official] |
| Two nodes, both master-eligible | only the node that is not the single voter | the voter’s loss stops the cluster | [official] |
| Two nodes, one master-eligible | the other node | losing the master-eligible node stops the cluster | [official] |
| Two nodes plus a voting-only tiebreaker | any one node | accepted | [official], not tested |
| Three master-eligible nodes | any one node | accepted (L-T1) | [measured] |
| Three nodes, two lost | nothing | reads from the last node’s local copy; writes held for 60 s, then 503 (L-T2) | [measured] |
What I ran: es-three-local (30 of 30 rows, plus fault-test.sh 15 of 15), growth procedure A es-grow-restore (22 of 22 end-state rows) and procedure B es-grow-inplace (22 of 22), with the traps L-G3 and L-G4, on 29 October 2026, on my laptop (Linux, cgroup v2, 12 logical CPUs, about 15.5 GiB of RAM, Docker 29.9.1, Compose 5.6.0), with Elasticsearch 9.5.5 and Caddy 2.11.7. Three nodes on one laptop share one CPU, one disk and one kernel, so this shows how quorum and allocation behave, not fault tolerance. Each procedure ran once, with 1,001 documents: the times below measure the mechanics, not a data volume. Nothing on this page was tested on OpenSearch. Row ids and TAP files are in results/tests/ of the companion repo.
Quorum in one paragraph
Elasticsearch elects a master from the master-eligible nodes in the voting configuration, and needs a majority of that configuration to do it. With an even number of master-eligible nodes, “Elasticsearch leaves one of them out of the voting configuration to ensure that it has an odd size”, which “does not decrease the failure-tolerance of the cluster”. New master-eligible nodes join the voting configuration by themselves, and cluster.auto_shrink_voting_configuration (default true) keeps it at three or more while three are available. The rule to remember: “you must not stop half or more of the nodes in the voting configuration at the same time.” [official] The lab saw the odd-size rule at work: the voting configuration had 1 member after the second node joined, and 3 after the third (procedure B, step 3).
One node
Replicas must be 0 for the node to be green, because a replica is never placed on the same node as its primary. Snapshots are then the only copy of the data that survives the node. That is acceptable when the data can be re-ingested from its source, or restored from a snapshot, within the downtime you can afford. The previous page builds this node.
Two nodes
With two master-eligible nodes the voting configuration holds only one of them. Lose that one and there is no master. Elastic’s advice for two nodes: “We recommend you set only one of your two nodes to be master-eligible”, and “we do not recommend deploying a two-node cluster in production”. [official] You get a replica, which protects the data, but not availability: one specific node still stops the cluster. Not tested in the lab.
Two plus a tiebreaker
A third, small node that only votes: a “dedicated voting-only master-eligible node, also known as a dedicated tiebreaker”, and “the two original nodes should not be voting-only”. [official] It holds no data, so it can be much smaller than the data nodes. I have not tested this topology and have no memory figure for the tiebreaker.
Three nodes
All three master-eligible and data, one replica. Any one node can fail. The cost at this scale is plain: three times the memory and disk of one node, and each node still has the same small heap. In the lab each node had cpus: 1, mem_limit: 2g and 1 GiB of heap (es-three-local-09), so the host needed about 7 GiB free: 3 x 2 GiB for the container limits plus about 1 GiB for Caddy and the host [estimate], and every index needed twice its size on disk.
The quorum simulator below applies the documented voting and allocation rules to a topology you choose. It is a model, not Elasticsearch.
What happened when nodes stopped
fault-test.sh stops the elected master, then a second node, and checks what still works through Caddy. Caddy has the three nodes as upstreams, round robin, with a retry when a connection to one fails.
| Step | Observed | Evidence |
|---|---|---|
| Stop the elected master (L-T1) | a new master at the first poll after docker compose stop returned (0 s); yellow with 2 nodes and 3 or 4 unassigned shards | fault TAP check 4 |
| Six searches through Caddy | all 200 | check 5 |
| Bulk create through Caddy | accepted | check 6 |
| Back to green on two nodes | 48 to 50 s in three runs, counted from after the searches and the write, not from the stop | check 7 |
| Stop a second node (L-T2) | the last node reports no master within a second; _cluster/health answers 503 master_not_discovered_exception | checks 8, 9 |
| Search through Caddy | 200 with hits, from the last node’s local shard copy (_shards.total 1) | check 9 diagnostics |
| Bulk create through Caddy | held for 60 s, then 503 cluster_block_exception | check 10 |
| Start both nodes again | green with 3 nodes 48 to 50 s after the start; the L-T1 write present, the L-T2 write absent, one cluster_uuid on all three | checks 11 to 15 |
The no-master write, as the client received it:
503 cluster_block_exception: blocked by: [SERVICE_UNAVAILABLE/2/no master];
What to take from this:
- Losing one of three is a non-event for clients. The searches did not fail. That Caddy retried on another node is the likely reason; I did not record Caddy’s logs, so that part is a guess.
- The time to green after a loss was not measured from the loss. The 48 to 50 s clock starts after the six searches and the write. It is consistent with the delay before Elasticsearch rebuilds replicas of a node that left,
index.unassigned.node_left.delayed_timeout, which “defaults to 1m”, [official] but it does not measure that delay. - Without a master, reads keep working and writes hang. Reads may return stale data:
cluster.no_master_blockdefaults towrite, and Elastic says reads then “succeed, based on the last known cluster configuration”. [official] The write took 60.0 s to fail. With a 30 s client timeout the client saw nothing at all: curl exit 28, status 000, 0 bytes. An application with a timeout under a minute only ever sees timeouts, never the reason.
Readiness after start
docker compose up --wait reported all three nodes healthy while the .security-7 primary was still unassigned. The first setup call as elastic then failed:
503 unavailable_shards_exception: at least one primary shard for the index [.security-7] is unavailable
authentication_processing_error: failed to retrieve password hash for reserved user [elastic]
In my first run the setup script did not notice, and 25 rows failed for lack of an app key. Every script in the companion repo now waits for health and retries 503, 401, 408 and no answer for up to 180 seconds (300 in the slow growth steps). Do the same in anything that runs right after a start or restart.
Growing: procedure A, a new cluster plus a restore
Recommended. The old node keeps running and is never reconfigured, a new three-node cluster is built next to it, a snapshot of the old node is restored into it, and Caddy is switched over. Rollback is pointing Caddy back.
- Register a shared filesystem repository on the old node. The directory must be mounted “to the same location on all master and data nodes” and listed in
path.repo, which on a running node means a restart. [official] In the lab the old node, es00, started withpath.reposet. - Take a rehearsal snapshot of
app-*plus thesecurityfeature state, while the old node keeps taking writes. The feature state carries users, roles and API keys, so the application’s existing key keeps working after cutover. - Start the new cluster: three nodes, transport TLS from the first start, bootstrapped once with
cluster.initial_master_nodes, a different cluster name. Register the same repository there read-only: “only one cluster should have write access to the repository”. [official] - Restore the rehearsal snapshot and compare document counts.
- Block writes on the old node’s
app-*, take the final snapshot, restore it with--replaceon the new cluster. From the write block on, application writes fail until cutover. - Hand the repository over (unregister on the old node first, then register writable on the new one) and point Caddy at the three new nodes.
The restore request, with replicas set and the write block lifted on the way in:
POST /_snapshot/grow/<snapshot>/_restore?wait_for_completion=true
{
"indices": "app-*",
"include_global_state": false,
"feature_states": ["security"],
"index_settings": {"index.number_of_replicas": 1, "index.blocks.write": false}
}
What the run showed (results/tests/2026-10-29-es-grow-restore-steps.log, results/tests/2026-10-29-es-grow-restore.tap):
| Step | Observed |
|---|---|
| Before cutover | two clusters with different cluster_uuid, as designed |
| Snapshots | both SUCCESS, with .security-7, app-a, app-b and the security feature state |
| Restore plus replica recovery | 5 s for the rehearsal, 5 s for the final [measured] |
| Document counts | equal after both restores (app-a 1, app-b 1000) |
| Restored indices | 1 replica, index.blocks.write false: index_settings overrides the block stored in the final snapshot (es-grow-restore-03, -06) |
| The API key created on the old node | authenticated on the new cluster (es-grow-restore-10) |
| Caddy recreate | back after 2 s [measured] |
Not measured: the write-blocked window from the final snapshot to cutover, which is the real downtime of A for writes, and client errors during the Caddy recreate. Not run: the rollback script. On a real host, reload Caddy instead of recreating it; caddy reload swaps the config without closing the listener.
Growing: procedure B, in place
Rehearsed once in my lab, not an Elastic-published procedure. No Elastic page describes turning a node started with discovery.type: single-node into the first node of a cluster; the steps follow the bootstrapping and add-node pages and the Elasticsearch source. Prefer A. B changes the only node that holds the data, and once a second node has joined there is no in-place way back.
- Preflight, nothing changes yet:
vm.max_map_countset to 1048576 [official], transport certificates for all nodes fromelasticsearch-certutil, the node’scluster_uuidrecorded, and a snapshot taken. That snapshot is the only way back after step 3. - Restart es01 with the same data volume, without
discovery.type: single-node, withdiscovery.seed_hosts, transport TLS and rolesmaster,data,ingest. Never setcluster.initial_master_nodes: the cluster was bootstrapped once already, by es01 in single-node mode. “Do not configure this setting on nodes joining an existing cluster. Do not configure this setting on nodes which are restarting.” [official] From here es01 runs in production mode and the bootstrap checks apply. - Start es02, then es03, each with an empty data path, and check that each reports es01’s
cluster_uuid. - Set replicas to 1 and wait for green. Between step 3 and green each shard has one copy; losing a node in that window loses data.
- Point Caddy at all three nodes, only after all three report the same
cluster_uuid.
The state after growth, the file the steps converge on:
# Procedure B, state after growth: es01 keeps its data and cluster UUID, loses
# discovery.type single-node and gains transport TLS; es02 and es03 join it with
# empty data paths. No node sets cluster.initial_master_nodes: the cluster was
# bootstrapped once, by es01 in single-node mode, and must never bootstrap
# again. Start from step-0-seed.sh, not from this file. Lab only: three nodes
# on one host.
name: es-grow-inplace
x-cluster-env: &cluster-env
cluster.name: es-grow-inplace
node.roles: master,data,ingest
discovery.seed_hosts: es01,es02,es03
path.repo: /usr/share/elasticsearch/snapshots
xpack.security.enabled: "true"
xpack.security.http.ssl.enabled: "false"
xpack.security.transport.ssl.enabled: "true"
xpack.security.transport.ssl.verification_mode: certificate
xpack.security.transport.ssl.key: certs/node-key.pem
xpack.security.transport.ssl.certificate: certs/node.pem
xpack.security.transport.ssl.certificate_authorities: certs/ca.pem
xpack.license.self_generated.type: basic
bootstrap.memory_lock: "true"
ELASTIC_PASSWORD: ${ELASTIC_PASSWORD:?set ELASTIC_PASSWORD in .env}
ES_JAVA_OPTS: ${ES_HEAP_OPTS--Xms1g -Xmx1g}
x-es-node: &es-node
image: ${ES_IMAGE:?ES_IMAGE comes from versions.env}
cpus: 1
mem_limit: 2g
ulimits:
memlock:
soft: -1
hard: -1
nofile:
soft: 65535
hard: 65535
networks:
- backend
healthcheck:
test: ["CMD-SHELL", "curl -s http://localhost:9200 | grep -q 'missing authentication credentials'"]
interval: 10s
timeout: 10s
retries: 30
start_period: 30s
x-snapshots: &snapshots
type: bind
source: ${SNAPSHOT_DIR:?set SNAPSHOT_DIR in .env}
target: /usr/share/elasticsearch/snapshots
bind:
create_host_path: false
x-ca: &ca
type: bind
source: ./certs/ca.pem
target: /usr/share/elasticsearch/config/certs/ca.pem
read_only: true
bind:
create_host_path: false
services:
es01:
<<: *es-node
environment:
<<: *cluster-env
node.name: es01
volumes:
- esdata01:/usr/share/elasticsearch/data
- *snapshots
- *ca
- {type: bind, source: ./certs/es01.pem, target: /usr/share/elasticsearch/config/certs/node.pem, read_only: true, bind: {create_host_path: false}}
- {type: bind, source: ./certs/es01-key.pem, target: /usr/share/elasticsearch/config/certs/node-key.pem, read_only: true, bind: {create_host_path: false}}
es02:
<<: *es-node
environment:
<<: *cluster-env
node.name: es02
volumes:
- esdata02:/usr/share/elasticsearch/data
- *snapshots
- *ca
- {type: bind, source: ./certs/es02.pem, target: /usr/share/elasticsearch/config/certs/node.pem, read_only: true, bind: {create_host_path: false}}
- {type: bind, source: ./certs/es02-key.pem, target: /usr/share/elasticsearch/config/certs/node-key.pem, read_only: true, bind: {create_host_path: false}}
es03:
<<: *es-node
environment:
<<: *cluster-env
node.name: es03
volumes:
- esdata03:/usr/share/elasticsearch/data
- *snapshots
- *ca
- {type: bind, source: ./certs/es03.pem, target: /usr/share/elasticsearch/config/certs/node.pem, read_only: true, bind: {create_host_path: false}}
- {type: bind, source: ./certs/es03-key.pem, target: /usr/share/elasticsearch/config/certs/node-key.pem, read_only: true, bind: {create_host_path: false}}
caddy:
image: ${CADDY_IMAGE:?CADDY_IMAGE comes from versions.env}
depends_on:
es01:
condition: service_healthy
es02:
condition: service_healthy
es03:
condition: service_healthy
environment:
- ES_SITE_ADDRESS=${ES_SITE_ADDRESS:?set ES_SITE_ADDRESS in .env}
- ALLOWED_CIDRS=${ALLOWED_CIDRS:?set ALLOWED_CIDRS in .env}
- ES_UPSTREAMS=es01:9200 es02:9200 es03:9200
ports:
- "${CADDY_BIND:-127.0.0.1}:${CADDY_HTTP_PORT:-80}:80"
- "${CADDY_BIND:-127.0.0.1}:${CADDY_HTTPS_PORT:-443}:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks:
- backend
networks:
backend: {}
volumes:
esdata01: {}
esdata02: {}
esdata03: {}
caddy_data: {}
caddy_config: {}
Tested at v0.1.0:
scenarios/es-grow-inplace/compose.yaml.
What the run showed (results/tests/2026-10-29-es-grow-inplace-steps.log, results/tests/2026-10-29-es-grow-inplace.tap):
| Step | Observed |
|---|---|
| es01 restarted out of single-node mode | cluster_uuid unchanged, voting configuration 1 |
| es01 downtime | answered again 55 s after the stop [measured]; through Caddy, requests fail for that long |
| es02 joined | voting configuration 1 |
| es03 joined | voting configuration 3 |
| Replicas set to 1 | green after 2 s [measured] |
| Caddy recreate | 2 s [measured] |
| End state | same cluster_uuid on all three nodes, counts equal to step 1, the API key from before the growth still valid, each app-* shard on two nodes (es-grow-inplace-08, -11) |
Not run: the rollback script, client traffic during the restart, losing a node between steps 3 and 4, and a host where the bootstrap checks of step 2 fail.
Traps, with the log lines
L-G3: cluster.initial_master_nodes on a joining node gives you a second cluster. I started es04 with cluster.initial_master_nodes=es04 and the seed hosts of the grown cluster. It logged:
This node has not joined a bootstrapped cluster yet; [cluster.initial_master_nodes] is set to [es04]
elected itself about half a second later, and formed a one-node cluster with its own cluster_uuid, under the same cluster name. es01 kept listing es01 to es03. Thirty seconds later es04 logged the “fully-formed single-node cluster” warning quoted below. The docs call this a risk; here it happened on the first try. Why es04 did not join the existing master first is my guess, not a checked fact: with itself as the only initial master node, it has a quorum of one before discovery reaches the others. With Caddy in front of both clusters, writes would split between them, and Elasticsearch never merges clusters. es04 got no traffic in the lab.
L-G4: a leftover discovery.type: single-node. I recreated es01 with discovery.type: single-node on top of the seed hosts and transport TLS. It started, answered after 48 s, listed only itself, and logged every 30 s:
This node is a fully-formed single-node cluster with cluster UUID [ySZebGm0SemWUgu2fpt5Vg], but it is configured as if to discover other nodes and form a multi-node cluster via the [discovery.seed_hosts=[es01, es02, es03]] setting. Fully-formed clusters do not attempt to discover other nodes, and nodes with different cluster UUIDs cannot belong to the same cluster. The cluster UUID persists across restarts and can only be changed by deleting the contents of the node's data path(s). Remove the discovery configuration to suppress this message. See [https://www.elastic.co/docs/deploy-manage/distributed-architecture/discovery-cluster-formation/modules-discovery-bootstrap-cluster?version=9.5#modules-discovery-bootstrap-cluster-joining] for more information.
es02, with an empty data path, tried to join about once a second for 60 s. All 19 logged attempts failed with es01’s rejection:
java.lang.IllegalStateException: cannot join node with [discovery.type] set to [single-node] discovery
This one does not split anything. The cluster simply never grows, and the log says why.
Two more, from the procedure scripts rather than the traps:
- A stale “way back” snapshot. The snapshot directory is a bind mount, and
docker compose down -vdoes not delete it. On a second run, step 1 printedsnapshot before-grow already exists, keeping it: the kept snapshot came from the previous run’s cluster. Check the date of the snapshot you are relying on before step 2. - Copying a data directory to a new node. The copy carries the old node’s identity and cluster UUID. A node with another cluster’s data path is rejected at join with “This node previously joined a cluster with UUID […] and is now trying to join a different cluster with UUID […]” (from the Elasticsearch source; not triggered in the lab). New nodes start empty; the cluster copies shards to them.
Generate the files
The config generator writes the files of a scenario: one node, one node with Kibana or Dashboards, or the three-node lab cluster (Elasticsearch only). For the inputs the lab ran, its output is the tested files, apart from a short generated header. Changed values such as memory or host names keep the tested structure and are listed in the file header; a structural change, such as a disabled route or an IPv6 allowlist, marks the output as not tested. Without JavaScript it shows the tested single-node files.
Not tested
- Five nodes, and losing nodes one at a time from five.
- Two nodes, and two nodes plus a voting-only tiebreaker.
- Network partitions, and anything that needs separate hosts.
- Growth on OpenSearch, by either procedure, and an OpenSearch three-node cluster.
- Kibana with three nodes (
elasticsearch.hostslisting all three). - The rollback scripts of both procedures, and the write-blocked window of procedure A.
- Time from a node loss to green, measured from the loss.
OpenSearch 3.9.0: what differs
Nothing on this page ran on OpenSearch 3.9.0; the OpenSearch three-node and growth scenarios are not written yet. From the documentation, not from the lab:
| Topic | OpenSearch | Basis |
|---|---|---|
| Bootstrap setting | cluster.initial_cluster_manager_nodes; the role is cluster_manager, “master” is deprecated | official, tuning your cluster |
| Transport TLS when growing | Already in place from day one (see the previous page), so growth has no TLS step. New node certificates will most likely have to come from the same CA and match plugins.security.nodes_dn; not checked | not tested |
| Voting-only tiebreaker | No voting-only role was found in the OpenSearch source on its main branch, and the 3.9 code was not checked; treat the two-plus-tiebreaker topology as Elasticsearch-only until it is | not tested |