Tiering logs/ to cold storage and expiring at 90 days in one call

One lifecycle call covers the prefix, the cold class and the delete day on Infrai — but not the 30-day transition boundary. What's expressible, and what isn't.

Mostly yes. On Infrai the retention policy for a prefix is a single POST /v1/storage/bucket/set_lifecycle/{bucket} carrying a rule list, and one rule can name the prefix, the colder storage class to move objects into, and the day count after which they’re deleted. What that rule can’t express is the when of the transition — there is no transition-day field, only expire_days. So “delete at 90” is exact and “cold at 30” is not.

That gap is the interesting part of this question, and it’s worth being precise about before you write the config, because a 200 response from this endpoint is weaker evidence than it looks.

The call itself

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/storage/bucket/set_lifecycle/kb-logs-0726" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"rules":[{"prefix":"logs/","transition_class":"STANDARD_IA","expire_days":90}]}'
{
  "ok": true,
  "data": {
    "bucket_id": "bkt_2ea1b6a83cb64ad89b92f1",
    "name": "kb-logs-0726",
    "vendor": "cos",
    "region": "ap-singapore",
    "acl": "private",
    "cors_rules": [],
    "lifecycle_rules": [
      { "prefix": "logs/", "transition_class": "STANDARD_IA", "expire_days": 90 }
    ]
  }
}

A rule takes three things: prefix (a plain key-prefix match, logs/ including everything under it), expire_days (an integer of at least 1 — send 0 and you get STORAGE_INVALID_LIFECYCLE_RULES with a 400 and the offending rule index), and transition_class.

The submitted list replaces the current one wholesale. There’s no add-a-rule endpoint, so a deploy script that sets only the logs/ rule silently drops the tmp/ rule somebody added last quarter. Read before you write:

curl -sS "https://api.infrai.cc/v1/storage/bucket/get/kb-logs-0726" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "bucket_id": "bkt_2ea1b6a83cb64ad89b92f1",
    "name": "kb-logs-0726",
    "region": "ap-singapore",
    "lifecycle_rules": [
      { "prefix": "logs/", "transition_class": "STANDARD_IA", "expire_days": 90 },
      { "prefix": "tmp/", "expire_days": 1 }
    ]
  }
}

Two things that will bite you

First, transition_class is passed through to the backing vendor rather than translated, so the accepted vocabulary is the vendor’s, not a friendly one. On a COS-backed bucket in our testing on 26 July 2026, STANDARD_IA and DEEP_ARCHIVE were accepted; ARCHIVE, INTELLIGENT_TIERING and glacier all came back as a 503:

curl -sS -X POST "https://api.infrai.cc/v1/storage/bucket/set_lifecycle/kb-logs-0726" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"rules":[{"prefix":"logs/","transition_class":"glacier","expire_days":90}]}'
{
  "ok": false,
  "error": {
    "code": "VENDOR_DOWN",
    "http_status": 503,
    "message": "storage dispatch failed: An error occurred (InvalidArgument) when calling the PutBucketLifecycleConfiguration operation: Invalid Argument",
    "retryable": true
  }
}

Confusingly, glacier is a valid value for the storage_class field on PUT /v1/storage/object/put/{bucket}/{key}, where the vocabulary is the logical set standard, ia, archive, glacier. Same concept, two different name spaces, one call away from each other — so test your class string against the endpoint you’re actually calling before you ship it.

Second, and more dangerous: rule keys the API doesn’t implement are stored and echoed back to you verbatim. Send {"prefix":"logs/","transition_days":30,"storage_class":"STANDARD_IA","expire_days":90} and you get a cheerful 200 with all four fields reflected in lifecycle_rules, and a later GET /v1/storage/bucket/get/{bucket} will keep showing them. Nothing tiers at 30 days. The response is an echo of your submission, not a description of enforced vendor behaviour, and the only fields with enforcement behind them are prefix, expire_days and transition_class.

That’s the caveat that matters for a compliance-shaped retention policy.

So where does the 30-day boundary go?

RequirementInfrai lifecycleAmazon S3 lifecycle
Match a prefixprefixFilter.Prefix
Delete after N daysexpire_daysExpiration.Days
Move to a colder classtransition_classTransition.StorageClass
Move after N daysnot expressibleTransition.Days
Rules per bucketreplace-the-listreplace-the-list
Cost of the config callfreefree

If the 30-day transition boundary is genuinely load-bearing — a contractual retention schedule, a finance model that assumes 60 days of the 90 sit in the cheap tier — you’d be better off putting the log bucket on S3 or Backblaze B2 and using their native transition-day rules. That’s a one-bucket exception, not a platform decision, and an honest one to make.

If it isn’t load-bearing, the practical Infrai shape is: write logs cold from the start with storage_class on the object, and let lifecycle handle only the delete.

LINE='{"ts":"2026-07-26T00:00:00Z","level":"info","msg":"rotated"}'
BODY=$(printf '{"data_base64":"%s","content_type":"application/x-ndjson","storage_class":"ia"}' \
  "$(printf '%s' "$LINE" | base64 | tr -d '\n')")

curl -sS -X PUT "https://api.infrai.cc/v1/storage/object/put/kb-logs-0726/logs/app/2026-07-26.jsonl" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d "$BODY"

Note the shape of that body — this route takes base64 in JSON, not raw bytes, and it’s meant for small objects. A rotated log line is fine; a 200 MB archive is not, and above roughly 1 MB you want a presigned or multipart upload instead.

Logs are written once and read almost never, so the day-0 decision is usually the right one anyway. The 30-day boundary mostly exists in S3 examples because S3 charges a minimum-duration fee for early deletion from its colder classes.

Auditing that the policy is real

A rule you can’t observe isn’t a policy. This runs as a nightly job and answers one question — is anything under logs/ still here that the 90-day rule should have removed?

import process from "node:process";

const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

const BUCKET = "kb-logs-0726";
const PREFIX = "logs/";
const MAX_AGE_DAYS = 90;

async function get(path) {
  const res = await fetch(`${API}${path}`, {
    method: "GET",
    headers: { Authorization: `Bearer ${KEY}` },
  });
  const json = await res.json();
  if (!res.ok || json.ok === false) {
    const e = json.error ?? {};
    throw new Error(`GET ${path} -> HTTP ${res.status} ${e.code ?? ""} ${e.message ?? ""}`);
  }
  return json.data;
}

const cutoff = Date.now() - MAX_AGE_DAYS * 86_400_000;
const stale = [];
let cursor = null;
let scanned = 0;

do {
  const qs = new URLSearchParams({ prefix: PREFIX, limit: "1000" });
  if (cursor) qs.set("cursor", cursor);
  const page = await get(`/v1/storage/object/list/${BUCKET}?${qs}`);
  for (const obj of page.items) {
    scanned += 1;
    const modified = Date.parse(obj.last_modified ?? obj.created_at);
    if (Number.isFinite(modified) && modified < cutoff) {
      stale.push({ key: obj.key, size_bytes: obj.size_bytes });
    }
  }
  cursor = page.next_cursor;
} while (cursor);

console.log(`scanned ${scanned} objects under ${PREFIX}; ${stale.length} older than ${MAX_AGE_DAYS} days`);
if (stale.length) {
  const bytes = stale.reduce((n, o) => n + o.size_bytes, 0);
  console.error(`lifecycle not enforced: ${(bytes / 1e6).toFixed(1)} MB unexpired`);
  process.exitCode = 1;
}

Confirm the listing yourself first — it’s a free call, so run it as often as you like:

curl -sS "https://api.infrai.cc/v1/storage/object/list/kb-logs-0726?prefix=logs/&limit=10" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

One thing that surprises people: content_type and metadata come back null in list results even for objects that definitely have both. Use GET /v1/storage/object/head/{bucket}/{key} per object if you need those fields, and don’t build the sweep on them.

Cost and one residency warning

POST /v1/storage/bucket/set_lifecycle/{bucket}, GET /v1/storage/bucket/get/{bucket} and GET /v1/storage/object/list/{bucket} are all free and rate-limited — verified 26 July 2026, and none of them touch the $2 of credit a new account starts with. Read today’s numbers straight from the API:

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | python3 -c "import json,sys; [print(c['id'], c['billing'].get('price_usd','free')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.bucket')]"

Storage rates in this market drift downward and campaigns run, so what you read will likely be at or below these figures. The bill you’re actually managing here is GB-months, which is exactly what the cold class and the 90-day expiry are for.

The residency limitation, since log retention often comes with a jurisdiction attached: the region you pass at bucket creation is not a placement guarantee. A bucket we created with eu-central-1 reported that region back and was served from an ap-singapore host. If your retention policy is really a data-residency policy, don’t let this field carry it — pin the vendor explicitly, or use a provider whose region is contractual.

References

Browse more storage developer guides