How to configure OpenSearch ISM hot-warm-cold architecture

This guide walks through the exact node settings, index template, policy JSON, and Python attachment workflow needed to move time-series indices deterministically from NVMe to SSD to HDD and finally to deletion, without unassigned shards or stalled transitions.

A tiered storage model only works when hardware capabilities, shard routing rules, and Index State Management (ISM) policy execution stay in lockstep. Misconfigured node attributes, malformed phase thresholds, or silent policy detachment translate directly into unassigned shards, uncontrolled rollovers, and degraded query latency. This procedure builds on the broader OpenSearch ISM Architecture & Fundamentals model and the capacity ratios established in the parent Hot-Warm-Cold Tier Design guide — read those first if you have not sized your tiers yet.

Prerequisites

Confirm each item before applying any payload to a production cluster. Every step below assumes these are already true.

Size the hot tier from measured ingestion so a full retention window fits before the first transition fires:

Hot capacity (GB)=Hourly ingest (GB)×24×Hot retention (days)×(1+replicas)\text{Hot capacity (GB)} = \text{Hourly ingest (GB)} \times 24 \times \text{Hot retention (days)} \times (1 + \text{replicas})

Step-by-step configuration

1. Assign node roles and tier attributes

OpenSearch routes indices to hardware tiers using node attributes, and the ISM allocation action enforces placement through index.routing.allocation.require.data. Set both the native role and an explicit node.attr.data value in opensearch.yml on each node group. Do not rely on implicit tier roles alone — the explicit attribute guarantees deterministic routing across cluster upgrades, exactly as described in Data Tier Routing Patterns.

YAML
# Hot tier — high IOPS NVMe, handles ingest and real-time query
node.name: "hot-01"
node.roles: ["data_hot", "ingest"]
node.attr.data: "hot"
path.data: /mnt/nvme/opensearch
YAML
# Warm tier — balanced SSD, recent history and retained search
node.name: "warm-01"
node.roles: ["data_warm"]
node.attr.data: "warm"
path.data: /mnt/ssd/opensearch
YAML
# Cold tier — high-capacity HDD, archive and infrequent access
node.name: "cold-01"
node.roles: ["data_cold"]
node.attr.data: "cold"
path.data: /mnt/hdd/opensearch

Restart nodes sequentially after applying changes, then confirm the attribute propagated:

Shell
curl -s "localhost:9200/_cat/nodeattrs?v&h=node,attr,value" | grep '\bdata\b'

Expected output — one row per node with the tier value you set:

Text
hot-01   data   hot
warm-01  data   warm
cold-01  data   cold

Gotcha: if data does not appear in this output, the attribute never loaded and every ISM allocation transition will silently fail to relocate shards. Fix opensearch.yml and restart before continuing.

2. Create a versioned index template with routing enforcement

The template binds new indices to the policy and pins their initial placement on the hot tier at creation time. Use the composable _index_template (v2) API so versioned templates never collide with legacy ones.

HTTP
PUT _index_template/logs_tiered_v2
{
  "index_patterns": ["logs-app-*"],
  "priority": 500,
  "template": {
    "settings": {
      "index.plugins.index_state_management.policy_id": "hot_warm_cold_policy",
      "index.routing.allocation.require.data": "hot",
      "number_of_shards": 3,
      "number_of_replicas": 1,
      "refresh_interval": "5s"
    },
    "mappings": {
      "properties": {
        "@timestamp": { "type": "date" },
        "message":    { "type": "text" },
        "trace_id":   { "type": "keyword" }
      }
    }
  }
}

Expected response:

JSON
{ "acknowledged": true }

Key fields:

  • index.plugins.index_state_management.policy_id binds the index to the ISM policy at creation. This is the OpenSearch setting; the Elasticsearch ILM equivalent index.lifecycle.name is ignored by OpenSearch.
  • index.routing.allocation.require.data forces the first shards onto hot nodes only.
  • priority must exceed any legacy template (> 100) so it wins the merge.

Gotcha: a version suffix (_v2) lets you roll the template forward or back without disrupting active indices. Bumping a template never rewrites indices that already exist — those keep the settings they were created with.

3. Define the hot-warm-cold ISM policy

The policy is the state machine: it defines rollover triggers, per-phase routing reallocation, and delete conditions. ISM evaluates conditions every 5 minutes by default (plugins.index_state_management.job_interval). Tune thresholds to your storage capacity and query SLA.

HTTP
PUT _plugins/_ism/policies/hot_warm_cold_policy
{
  "policy": {
    "description": "Tiered lifecycle for application logs",
    "default_state": "hot",
    "states": [
      {
        "name": "hot",
        "actions": [
          { "rollover": { "min_index_age": "1d", "min_primary_shard_size": "50gb" } }
        ],
        "transitions": [
          { "state_name": "warm", "conditions": { "min_index_age": "30d" } }
        ]
      },
      {
        "name": "warm",
        "actions": [
          { "allocation": { "require": { "data": "warm" } } },
          { "replica_count": { "number_of_replicas": 0 } },
          { "force_merge": { "max_num_segments": 1 } }
        ],
        "transitions": [
          { "state_name": "cold", "conditions": { "min_index_age": "60d" } }
        ]
      },
      {
        "name": "cold",
        "actions": [
          { "allocation": { "require": { "data": "cold" } } },
          { "read_only": {} }
        ],
        "transitions": [
          { "state_name": "delete", "conditions": { "min_index_age": "90d" } }
        ]
      },
      {
        "name": "delete",
        "actions": [ { "delete": {} } ]
      }
    ]
  }
}

Threshold guidance:

  • Hot → Warm at 30d offloads recent indices before NVMe saturates.
  • Warm → Cold at 60d, after force_merge collapses segments to one — this reduces query overhead and disk footprint before the shard lands on slow HDD.
  • Cold → Delete at 90d for compliance retention; the read_only action blocks stray writes during archival.
  • Match min_primary_shard_size to real shard density — oversized shards delay transitions, undersized shards inflate cluster overhead.

Gotcha: only indices created after the template is in place inherit policy_id automatically. Existing indices need the explicit attach in step 4.

4. Attach the policy across existing indices with Python

For indices that predate the template, attach the policy through the _plugins/_ism/add API — it is an ISM operation, not an index setting. This idempotent script pages through matching indices, treats an already-attached index as success, and logs failures without blocking ingestion.

Python
import logging
from opensearchpy import OpenSearch, exceptions

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger(__name__)


def attach_ism_policy(client: OpenSearch, index_pattern: str, policy_id: str) -> int:
    """Attach an ISM policy to every index matching a pattern. Returns count attached."""
    attached = 0
    try:
        indices = client.cat.indices(index=index_pattern, h="index", format="json")
    except exceptions.ConnectionError as exc:
        logger.critical("Cluster connection failed: %s", exc)
        return 0

    for entry in indices:
        idx = entry["index"]
        try:
            client.transport.perform_request(
                "POST",
                f"/_plugins/_ism/add/{idx}",
                body={"policy_id": policy_id},
            )
            attached += 1
        except exceptions.RequestError as exc:
            if "already" in str(exc).lower():
                logger.info("Policy already attached to %s", idx)
            else:
                logger.error("Failed to attach policy to %s: %s", idx, exc)
    logger.info("Attached policy to %d indices.", attached)
    return attached


if __name__ == "__main__":
    client = OpenSearch(
        hosts=["https://opensearch-cluster.internal:9200"],
        http_auth=("admin", "secure_password"),
        verify_certs=True,
        timeout=30,
    )
    attach_ism_policy(client, "logs-app-*", "hot_warm_cold_policy")

Schedule this via cron or a Kubernetes CronJob so policy compliance is re-enforced after every template change or cluster migration. See the OpenSearch Python Client documentation for authentication and connection-pooling options.

Gotcha: _ism/add attaches but does not retroactively rewind an index that is already mid-lifecycle under a different policy. Remove the old policy first with _plugins/_ism/remove if you are re-homing indices.

Verification

Run these three checks after the first ISM poll cycle (allow ~5 minutes) to confirm the pipeline is live.

Confirm the managed index is executing.

Shell
curl -s "localhost:9200/_plugins/_ism/explain/logs-app-2026.07.01"

Expected — the index reports its policy, current state, and a successful last step:

JSON
{
  "logs-app-2026.07.01": {
    "index.plugins.index_state_management.policy_id": "hot_warm_cold_policy",
    "state": { "name": "hot" },
    "action": { "name": "rollover" },
    "step": { "name": "attempt_rollover", "status": "condition_not_met" }
  }
}

Confirm shards landed on the correct tier.

Shell
curl -s "localhost:9200/_cat/shards/logs-app-2026.07.01?v&h=index,shard,prirep,state,node"

Every shard should show STARTED on a hot-* node. A warm-* or cold-* node here means the template routing setting was missing at creation.

Confirm the write alias rolls over. After a rollover, _cat/indices should show a new incremented index (logs-app-000002) in the hot state while the previous generation continues aging toward warm.

Common failure modes

Symptom Root cause Fix command
Index stuck; step.status stays failed for >2 cycles Transition blocked or index lacks the policy setting curl -s -X POST "localhost:9200/_plugins/_ism/retry/logs-app-2026.07.01"
Shards UNASSIGNED after a warm/cold transition Target tier missing node.attr.data or above watermark.high curl -s -X POST "localhost:9200/_cluster/allocation/explain" -H 'Content-Type: application/json' -d '{"index":"logs-app-2026.07.01","shard":0,"primary":true}'
Rollover never fires min_index_age / min_primary_shard_size never met, or write alias not set curl -s "localhost:9200/_plugins/_ism/explain/logs-app-2026.07.01" then lower the threshold
Policy silently detached after a template edit New template lacks policy_id; re-created index is unmanaged re-run the _ism/add attach script from step 4
Shard refuses to move to cold HDD tier above watermark.low, or cluster.routing.allocation.enable set to none curl -s -X PUT "localhost:9200/logs-app-2026.07.01/_settings" -H 'Content-Type: application/json' -d '{"index.routing.allocation.require.data":"cold"}'

When a require filter matches no available node, a graceful secondary target keeps the index from deadlocking — implement one using Fallback Routing Strategies. For the sequencing rules behind rollover, force-merge, and delete, see Index Lifecycle Basics.

The timeline below maps the four states configured above onto the index-age axis, showing the routing attribute ISM stamps, the actions each state runs, and the storage medium the shards live on at every stage — the complete contract this procedure implements.

Hot-warm-cold-delete lifecycle timeline An index-age axis runs left to right with boundaries at 0, 30, 60 and 90 days. Four tier cards sit on the axis: HOT on NVMe (require.data hot, rollover at 1d or 50gb), WARM on SSD (require.data warm, replica_count to 0, force_merge to 1 segment), COLD on HDD (require.data cold, read_only), and DELETE (index removed). Arrows between cards carry the min_index_age transition condition. 0d 30d 60d 90d index age (measured from creation) HOT NVMe · ingest & live query require.data: hot action: rollover 1d · 50gb primary age 0 – 30d WARM SSD · recent history require.data: warm replica_count → 0 force_merge → 1 seg age 30 – 60d COLD HDD · archive access require.data: cold action: read_only writes blocked age 60 – 90d DELETE action: delete index removed age ≥ 90d min_index_age 30d min_index_age 60d min_index_age 90d ISM stamps allocation.require on entry to each state; the decider relocates shards on the next evaluation cycle.

Frequently asked questions

Do I use policy_id in the template or the _ism/add API?

Both, for different indices. Put index.plugins.index_state_management.policy_id in the index template so every new index is managed at creation. Use the _plugins/_ism/add API for indices that already exist and were created before the template. The template alone never retroactively attaches a policy to older indices.

Why did my index stay on the hot tier past 30 days?

The min_index_age transition clock starts at index creation, not at rollover. If the index rolled over on day 1, the 30-day warm transition still measures from creation. Check the actual age with _plugins/_ism/explain — and remember ISM only evaluates every job_interval (5 minutes by default), so a transition can lag its threshold by one cycle.

Should force_merge run in the warm state or the hot state?

Run it in warm, after the replica count drops to 0. Force-merging on the hot tier competes with live ingestion for NVMe IOPS, and merging while replicas exist duplicates the work on every copy. Reducing replicas first, then merging to one segment, minimises both I/O and the footprint that ships to the cold HDD tier.

Is index.lifecycle.name a valid substitute for the OpenSearch policy setting?

No. index.lifecycle.name is the Elasticsearch ILM setting and OpenSearch ignores it entirely. OpenSearch ISM binds indices with index.plugins.index_state_management.policy_id (template) or the _plugins/_ism/add endpoint. Using the ILM key leaves your index completely unmanaged with no error.

Up: Hot-Warm-Cold Tier Design