Understanding Hot-Warm-Cold Architecture

A hot-warm-cold deployment exists to solve one economic problem: the newest 5% of your data absorbs 95% of the write load and almost all of the latency-sensitive queries, yet under a flat topology it sits on exactly the same hardware as the archival data that is never written to and rarely read. Paying for NVMe and high heap-per-shard across the entire retention window is the default failure of a single-tier design. The operational challenge this page addresses is how to route each index to storage that matches its access pattern — high-IOPS nodes while it is being written, dense read-optimized nodes while it is still queried, and cheap high-capacity nodes once it is only kept for compliance — and to have Index Lifecycle Management move indices between those tiers automatically, without a human touching allocation settings.

That routing is not a one-time placement decision. It is a continuous reconciliation driven by Index Lifecycle Management (ILM), which evaluates each managed index against a policy and rewrites its allocation requirements as it ages. Get the node attributes, index templates, and phase boundaries aligned and the tiers become invisible infrastructure; get any one of them wrong and indices pile up on the hot tier, shards go UNASSIGNED, and the disk watermark you were trying to avoid arrives faster than it would have on a flat cluster. This page walks the full path: prerequisites, how the tiers plug into the lifecycle state machine, the exact configuration, a step-by-step rollout, verification queries, sizing guidance, and the troubleshooting patterns for the transitions that stall.

Prerequisites

Confirm each of these before applying a tiered policy to a production cluster — a missing node role or an unmanaged template is the most common reason a hot-warm-cold rollout silently fails.

How the Tiers Fit the Lifecycle State Machine

The tiers are the physical projection of the lifecycle phases. Each phase in the ILM state machine corresponds to a class of hardware, and the transition between phases is, mechanically, the shard allocator relocating an index’s shards from one tier to the next. The policy never names a specific node; it declares the tier requirement for the current phase, and the allocator resolves that against whichever nodes carry the matching role. Where rollover conditions decide when an index leaves the Hot phase, the tier allocation decides where it lands next — the two are configured together and belong to the same rollover conditions design step.

Lifecycle phases projected onto hot, warm, and cold hardware tiersA left-to-right flow: Ingestion feeds the Hot tier (NVMe, write plus query). An arrow labelled min_age 7d leads to the Warm tier (shrink, forcemerge); an arrow labelled min_age 30d leads to the Cold tier (read-only, dense); an arrow labelled min_age 90d leads to Delete. A caption notes the flow is a one-way ratchet whose min_age is measured from rollover, not creation, with ILM rewriting the tier_preference at each boundary.Phases map onto hardware tiersIngestionwrite pathHot tierNVMe · write + queryWarm tiershrink · forcemergeCold tierread-only · denseDeletewritemin_age 7dmin_age 30dmin_age 90dOne-way ratchet —min_ageis measured from rollover, not index creation; ILM rewrites_tier_preferenceat each boundary.

Read the diagram as a one-way ratchet driven by min_age, which ILM measures from the moment the index rolls over, not from when it was created. An index that fills slowly can sit in the Hot phase well past its nominal warm min_age simply because it has not rolled yet. Once rollover fires, the clock starts, and each subsequent boundary rewrites the index’s index.routing.allocation.include._tier_preference to point at the next tier down. The allocator then moves the shards; only when relocation completes does ILM run that phase’s remaining actions (shrink, forcemerge, replica reduction). This is why a full or misconfigured tier does not throw an immediate error — it holds the index in a waiting step until capacity appears.

Node-tier topology: shards relocate down the tiers as ILM rewrites _tier_preferenceThree tier panels sit side by side. data_hot (NVMe, high IOPS) holds an index with three primary shards P0, P1, P2 and _tier_preference data_hot, running rollover at 50gb or 7d. An arrow labelled relocate crosses to data_warm (SSD, dense reads) with _tier_preference data_warm,data_hot, where the index is shrunk to one primary and forcemerged. A second relocate arrow crosses to data_cold (HDD, archival) with _tier_preference data_cold,data_warm,data_hot, held read-only with zero replicas. A dashed footer box explains that ILM's migrate action rewrites _tier_preference at each phase boundary, and the allocator only moves shards when the target tier carries the matching data role and sits below disk.watermark.high.Hot → Warm → Cold: shards move as ILM rewrites the tier preferencedata_hotNVMe · high IOPS · write + query_tier_preferencedata_hotdata node · role data_hotP0P1P2freshly rolledHot phase actionrollover @ 50gb / 7ddata_warmSSD · dense reads · no ingest_tier_preferencedata_warm,data_hotdata node · role data_warmP0shrunk to 1 primaryforcemerged → 1 segmentWarm phase actionsshrink · forcemergedata_coldHDD · archival · rarely read_tier_preferencedata_cold,data_warm,data_hotdata node · role data_coldP0read-only (immutable)replicas dropped to 0Cold phase actionsreadonly · 0 replicasrelocaterelocateILM'smigrateaction rewritesindex.routing.allocation.include._tier_preferenceat each phase boundary.The allocator relocates shards only when the target tier carries the matchingdata_*role and sits belowdisk.watermark.high.

Node Attribute and Role Configuration

Two mechanisms can pin an index to a tier, and modern deployments should prefer one over the other. The built-in data-tier roles (data_hot, data_warm, data_cold) are the recommended path: they integrate with _tier_preference fallback and searchable snapshots, and ILM’s migrate action targets them automatically. The older custom node attribute approach (node.attr.data: hot plus an explicit allocate.require filter) still works and is shown here because many existing clusters run it, but do not mix the two schemes for the same index — the allocator will try to satisfy both and can deadlock.

Node configuration (elasticsearch.yml):

# --- Hot tier node ---
node.roles: [ data_hot, ingest ]     # write path + latency-sensitive queries
node.attr.data: hot                   # custom attribute (only if using require filters)

# --- Warm tier node ---
node.roles: [ data_warm ]             # read-heavy, no ingest role needed
node.attr.data: warm

# --- Cold tier node ---
node.roles: [ data_cold ]             # dense, read-only archival
node.attr.data: cold

Keep master-eligible roles on separate dedicated nodes; co-locating master with data_hot means a heap spike from a large forcemerge can cost you the deployment’s coordination layer. With roles in place, the tier-preference chain gives you graceful fallback: setting index.routing.allocation.include._tier_preference: data_warm,data_hot tells the allocator to prefer warm nodes but fall back to hot ones if warm is full, so a temporarily saturated warm tier degrades to “stays hot a little longer” instead of an unassigned-shard incident.

The Tiered ILM Policy

The policy is a single declarative object; each phase boundary is annotated below to show exactly what it controls. This is the shape you would create through the ILM policy design workflow and version in source control rather than editing live.

{
  "policy": {
    "phases": {
      "hot": {
        "min_age": "0ms",
        "actions": {
          "rollover": {
            "max_primary_shard_size": "50gb",
            "max_age": "7d"
          },
          "set_priority": { "priority": 100 }
        }
      },
      "warm": {
        "min_age": "7d",
        "actions": {
          "allocate": {
            "require": { "data": "warm" },
            "number_of_replicas": 1
          },
          "shrink": { "number_of_shards": 1 },
          "forcemerge": { "max_num_segments": 1 },
          "set_priority": { "priority": 50 }
        }
      },
      "cold": {
        "min_age": "30d",
        "actions": {
          "allocate": {
            "require": { "data": "cold" },
            "number_of_replicas": 0
          },
          "readonly": {},
          "set_priority": { "priority": 0 }
        }
      },
      "delete": {
        "min_age": "90d",
        "actions": { "delete": {} }
      }
    }
  }
}

Two ordering facts matter here. First, actions inside a phase run in a fixed internal order — allocation and priority are applied before destructive actions like shrink and forcemerge, regardless of how you arrange the JSON — so an index always finishes relocating to the warm tier before it is shrunk on it. Second, forcemerge on the warm tier is I/O- and heap-intensive; running it right after shrink on nodes that also serve queries is deliberate, because warm nodes have spare CPU that hot nodes do not. Dropping number_of_replicas to 0 in the cold phase halves disk use but removes redundancy, which is only safe when the data is also covered by a snapshot repository.

Step-by-Step Implementation

Roll the topology out in an order that never leaves an index unmanaged or a tier without capacity.

1. Register the policy. Create the lifecycle definition before any template references it.

PUT _ilm/policy/logs-hot-warm-cold
{
  "policy": { "phases": { "hot": { "min_age": "0ms", "actions": {
    "rollover": { "max_primary_shard_size": "50gb", "max_age": "7d" } } } } }
}

2. Bind the policy through an index template. Every index created from the pattern inherits the policy and write alias, so nothing is ever managed by hand.

PUT _index_template/logs-tiered
{
  "index_patterns": ["logs-app-*"],
  "template": {
    "settings": {
      "index.lifecycle.name": "logs-hot-warm-cold",
      "index.lifecycle.rollover_alias": "logs-app-write",
      "index.routing.allocation.include._tier_preference": "data_hot",
      "number_of_shards": 3,
      "number_of_replicas": 1
    }
  }
}

3. Bootstrap the first backing index and write alias. ILM cannot roll over an alias that does not yet exist.

PUT logs-app-000001
{ "aliases": { "logs-app-write": { "is_write_index": true } } }

4. Automate the rollout with the Python v8+ client. The script below deploys the policy and template idempotently and, for legacy indices that predate ILM, reindexes them into the managed alias so they join the tiered lifecycle instead of bypassing retention.

import logging
from elasticsearch import Elasticsearch
from elasticsearch.exceptions import ApiError, BadRequestError, ConnectionError

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("hot_warm_cold")

es = Elasticsearch(
    "https://es-node-01:9200",
    api_key=("id", "api_key_string"),   # least-privilege service account, not basic auth
    verify_certs=True,
    ca_certs="/path/to/ca.crt",
    request_timeout=30,
    max_retries=3,
    retry_on_timeout=True,
)

POLICY_NAME = "logs-hot-warm-cold"
TEMPLATE_NAME = "logs-tiered"

POLICY = {
    "phases": {
        "hot": {"min_age": "0ms", "actions": {
            "rollover": {"max_primary_shard_size": "50gb", "max_age": "7d"},
            "set_priority": {"priority": 100}}},
        "warm": {"min_age": "7d", "actions": {
            "allocate": {"require": {"data": "warm"}, "number_of_replicas": 1},
            "shrink": {"number_of_shards": 1},
            "forcemerge": {"max_num_segments": 1},
            "set_priority": {"priority": 50}}},
        "cold": {"min_age": "30d", "actions": {
            "allocate": {"require": {"data": "cold"}, "number_of_replicas": 0},
            "readonly": {}, "set_priority": {"priority": 0}}},
        "delete": {"min_age": "90d", "actions": {"delete": {}}},
    }
}

def deploy() -> None:
    """Create or replace the policy and template idempotently (PUT semantics)."""
    try:
        es.ilm.put_lifecycle(name=POLICY_NAME, policy=POLICY)
        logger.info("Policy '%s' applied.", POLICY_NAME)
    except BadRequestError as exc:
        logger.error("Policy rejected as invalid: %s", exc.info)
        raise

    es.indices.put_index_template(
        name=TEMPLATE_NAME,
        index_patterns=["logs-app-*"],
        template={
            "settings": {
                "index.lifecycle.name": POLICY_NAME,
                "index.lifecycle.rollover_alias": "logs-app-write",
                "index.routing.allocation.include._tier_preference": "data_hot",
                "number_of_shards": 3,
                "number_of_replicas": 1,
            },
            "mappings": {"properties": {"@timestamp": {"type": "date"}}},
        },
    )
    logger.info("Template '%s' applied.", TEMPLATE_NAME)

def reindex_unmanaged(source_pattern: str, write_alias: str) -> None:
    """Fold pre-ILM indices into the managed alias so they inherit the tiered policy."""
    try:
        resp = es.reindex(
            source={"index": source_pattern},
            dest={"index": write_alias, "op_type": "create"},
            conflicts="proceed",
            wait_for_completion=False,   # returns a task id for long migrations
        )
        logger.info("Reindex task submitted: %s", resp["task"])
    except ApiError as exc:
        logger.error("Reindex failed: %s", exc.error)

if __name__ == "__main__":
    try:
        deploy()
        if not es.indices.exists_alias(name="logs-app-write"):
            es.indices.create(
                index="logs-app-000001",
                aliases={"logs-app-write": {"is_write_index": True}},
            )
        reindex_unmanaged("logs-app-legacy-*", "logs-app-write")
    except ConnectionError as exc:
        logger.error("Cluster unreachable: %s", exc)

Verifying Tier Placement

Never assume a phase completed — confirm the shards actually moved. These three read calls answer the only questions that matter after a transition.

Where is each index in its lifecycle?

GET logs-app-000001/_ilm/explain

Check that phase matches the tier you expect and that step is complete rather than ERROR. If step is ERROR, the step_info object names the underlying cause.

Did the shards land on the right tier?

GET _cat/shards/logs-app-000001?v&h=index,shard,prirep,state,node

Cross-reference the node column against your tier naming — warm-phase shards should sit on data_warm nodes. A shard still on a hot node after the warm min_age means the allocator could not place it.

Why won’t a shard move? When a transition hangs on allocate, this is the definitive answer:

GET _cluster/allocation/explain
{ "index": "logs-app-000001", "shard": 0, "primary": true }

The response’s deciders array explains each rejection — most often a disk_threshold decider on a warm tier that is over its high watermark, or a filter decider when no node carries the required attribute or role.

Threshold Tuning and Performance Guidance

The single most consequential number is primary shard size. Target 30–50 GB per primary: below ~10 GB you fragment the deployment into thousands of tiny shards that bloat cluster state and slow master-node updates; above ~50 GB, recovery, relocation, and forcemerge all stretch into hours and a single hot shard can dominate query latency. max_primary_shard_size is the rollover condition that holds this line under variable ingest far better than max_docs or max_age alone — see configuring rollover based on max primary shard size for the full sizing matrix against disk IOPS and heap.

Size the phase boundaries to your hardware, not to round numbers. The warm tier’s job is to absorb shrink and forcemerge, both of which briefly need heap and disk headroom roughly equal to the index being merged — so a warm node needs free disk of at least the largest single index it will hold, and enough heap that a one-segment forcemerge does not push it toward a circuit-breaker trip. Keep heap at or below ~50% of RAM and never above ~31 GB (the compressed-oops boundary), and let the filesystem cache use the rest, which is what makes dense warm and cold nodes fast for reads despite spinning or cheaper storage. On the cold tier, dropping to zero replicas is the main lever: it halves disk footprint at the cost of redundancy, acceptable only when the data is also in a snapshot repository you can restore from.

Finally, mind the poll interval. ILM evaluates the state machine every indices.lifecycle.poll_interval (default 10m), so a transition can lag its min_age by up to that interval — a feature in production, but shorten it in test clusters to iterate faster. Do not shorten it below what the deployment can service; every poll re-evaluates every managed index.

Troubleshooting Stuck Tier Transitions

Most “ILM is broken” reports are really allocation problems wearing an ILM costume. Diagnose from GET <index>/_ilm/explain first, read step_info, then match the symptom below.

SymptomRoot causeRemediation
Transition to warm/cold hangs on allocate, index stays on hotTarget tier has no node with the required role/attribute, or is over the high disk watermarkAdd a node to the tier or free disk; confirm with GET _cluster/allocation/explain; verify roles with GET _cat/nodes?v&h=name,node.role
step is ERROR at shrinkNot all primaries co-located on one node, or number_of_shards is not a factor of the source countEnsure a copy of every shard is on one node and set index.blocks.write: true; pick a target shard count that divides the source; then POST /<index>/_ilm/retry
forcemerge never finishes, warm-node heap spikesWarm node undersized for a single-segment merge, or forcemerge overlaps a snapshot windowRaise max_num_segments to 2, add warm heap, or stagger SLM and ILM schedules so heavy I/O does not collide
Shards go UNASSIGNED after a phase changeBoth a data-tier role and a conflicting custom require filter are set on the same indexStandardize on one scheme; clear the stale index.routing.allocation.* setting, then retry
Index never leaves Hot despite ageIt has not rolled over — write alias missing or is_write_index unsetRepair the alias so exactly one index has is_write_index: true; min_age only starts counting at rollover
Whole tier suddenly read-onlyFlood-stage disk watermark breached on that tierFree or add disk, then clear index.blocks.read_only_allow_delete once usage drops below the high watermark

When a step sits in ERROR after you have fixed the underlying condition, re-run it for that one index with POST /<index>/_ilm/retry; retry re-runs the failed step, so retrying without fixing the cause simply returns the index to ERROR. For continuous detection of these states across many indices rather than one-off triage, wire in the patterns from monitoring ILM execution and error states. And if a tier failure threatens a retention SLA, fallback routing for data retention covers redirecting reads to snapshot-mounted indices while you recover.

Frequently Asked Questions

Should I use data-tier roles or custom node.attr.data attributes?

Prefer the built-in data-tier roles (data_hot, data_warm, data_cold). They integrate with _tier_preference fallback, ILM's migrate action, and searchable snapshots without hand-written routing rules. Custom attributes with explicit allocate.require filters still work and appear in many older clusters, but do not apply both schemes to the same index — the allocator tries to satisfy both requirements at once and can deadlock the shard.

Do I need all three tiers to benefit from this architecture?

No. Hot-plus-warm is the most common production shape and already captures most of the cost saving, because the write-heavy hot tier is the expensive one. Add a cold tier only when you retain data long enough that dense, replica-free, read-only storage pays off, and a frozen tier with searchable snapshots when you must keep data queryable but rarely touch it. Omit any phase you do not need — the policy simply skips straight to the next defined phase.

Why is my index still on the hot tier long after its warm min_age?

Phase min_age is measured from the index's rollover time, not its creation time, so a slow-filling index that has not rolled over yet has an effective phase age of zero. Check GET <index>/_ilm/explain for age and whether rollover has fired. If the write alias is misconfigured, rollover never happens and no later phase — or tier move — ever begins.

Is it safe to set number_of_replicas to 0 in the cold phase?

Only when the data is also protected by a snapshot repository. Dropping to zero replicas halves the cold tier's disk footprint, which is usually the point, but it removes in-cluster redundancy — a single node loss then means data loss until you restore. Pair a zero-replica cold phase with snapshot lifecycle management so the durability moved off the live cluster still exists somewhere recoverable.

Can I change tier boundaries on a policy that is already managing indices?

Yes. put_lifecycle updates the policy in place and bumps its version; already-managed indices pick up the new definition at their next step evaluation, though an index mid-phase finishes its current action under the version it started with. Because live edits are hard to audit, route every boundary change through a version-controlled apply step rather than editing in Kibana Dev Tools.

← Back to ILM Architecture & Fundamentals