A bucket decommission runbook where nothing is deleted on a human's word
Retiring old object storage without a data-loss incident: write tripwires, an attic copy, a lifecycle timer that does the deleting, and the force flag last.
A retirement process is fool-proof when every step before the last one is reversible and the last one is on a timer, not on someone’s judgement. That’s the shape to build: inventory, a tripwire that watches for live writes, a copy into an attic bucket, a lifecycle rule that empties the bucket after a cooling-off period, and only then a forced delete. On Infrai every step except the copy is free, so the safety costs you nothing but calendar time.
Object storage will not tell you whether anyone is reading a bucket.
That’s the first thing to internalise, because most decommission checklists quietly assume otherwise. last_modified is a write timestamp. A bucket whose newest object is 14 months old may still be served to production every second of the day, and the storage layer has no idea. So the “is it still in use?” question gets answered by your own telemetry — an access log, a request counter, a grep of the codebase for the bucket name — never by the bucket’s metadata. Any runbook that skips this and reasons from timestamps alone is one confident engineer away from an outage.
Step 1: inventory, with numbers attached
Start from the account, not from memory. GET /v1/storage/bucket/list enumerates everything the key can see, and GET /v1/storage/bucket/usage/{bucket} gives object count and byte count per bucket. Both are free and rate-limited.
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS "https://api.infrai.cc/v1/storage/bucket/list" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
const API = "https://api.infrai.cc";
const TOKEN = process.env.INFRAI_API_KEY;
if (!TOKEN) throw new Error("INFRAI_API_KEY is not set");
async function api(path) {
const res = await fetch(`${API}${path}`, {
method: "GET",
headers: { Authorization: `Bearer ${TOKEN}` },
});
const json = await res.json();
if (!res.ok || json.ok !== true) {
throw new Error(`${path}: HTTP ${res.status} ${JSON.stringify(json.error ?? json)}`);
}
return json.data;
}
const { items } = await api("/v1/storage/bucket/list");
const snapshot = [];
for (const b of items) {
const usage = await api(`/v1/storage/bucket/usage/${b.name}`);
snapshot.push({
name: b.name,
region: b.region,
objects: usage.object_count,
bytes: usage.byte_count,
lifecycle: b.lifecycle_rules.length,
as_of: usage.as_of,
});
}
console.log(JSON.stringify(snapshot, null, 2));
Write that JSON to a file and commit it. It is both your inventory and, two weeks later, your tripwire baseline.
Step 2: the write tripwire
Re-run the same snapshot after a fixed quiet window — 14 days is a reasonable default because it covers a fortnightly batch job, and monthly jobs are the ones that bite you, so 35 days is safer if you can wait.
import { readFile } from "node:fs/promises";
const TOKEN = process.env.INFRAI_API_KEY;
if (!TOKEN) throw new Error("INFRAI_API_KEY is not set");
const baseline = JSON.parse(await readFile("baseline.json", "utf8"));
async function usageOf(bucket) {
const res = await fetch(`https://api.infrai.cc/v1/storage/bucket/usage/${bucket}`, {
method: "GET",
headers: { Authorization: `Bearer ${TOKEN}` },
});
const json = await res.json();
if (!res.ok || json.ok !== true) throw new Error(`usage ${bucket}: HTTP ${res.status}`);
return json.data;
}
let blocked = 0;
for (const row of baseline) {
const now = await usageOf(row.name);
const moved = now.object_count !== row.objects || now.byte_count !== row.bytes;
if (moved) {
blocked++;
console.error(`STILL WRITTEN: ${row.name} ${row.objects}->${now.object_count} objects, ${row.bytes}->${now.byte_count} bytes`);
}
}
if (blocked > 0) {
console.error(`${blocked} bucket(s) failed the tripwire — decommission aborted`);
process.exit(1);
}
console.log("no writes observed; safe to proceed to archive");
A tripwire that fires is good news, not a delay. It means a writer you didn’t know about is alive, and you found it with a free API call instead of a 3 a.m. page.
Step 3: copy into an attic before anything disappears
POST /v1/storage/object/copy does a server-side copy, so the bytes never travel through your process. Point every object at a long-lived archive bucket under a prefix that records where it came from.
curl -sS -X POST "https://api.infrai.cc/v1/storage/object/copy" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"src_bucket":"kb-decom-0726","src_key":"reports/2024/q4.txt","dst_bucket":"kb-attic-0726","dst_key":"decom/kb-decom-0726/reports/2024/q4.txt"}'
{
"ok": true,
"data": {
"bucket_id": "bkt_acea4b16b0af44bf82f30a",
"key": "decom/kb-decom-0726/reports/2024/q4.txt",
"size_bytes": 19,
"etag": "01611af564e59a0020c300fc04075ed2",
"content_type": "text/plain",
"metadata": null,
"created_at": "2026-07-26T00:54:57.525821Z"
}
}
Compare ETags after the copy — source and destination should match byte for byte, as they do above. That check is what turns “we archived it” into “we can prove we archived it”, and GET /v1/storage/object/list/{bucket} gives you both sides cheaply.
Step 4: let a timer do the deleting
This is the step that makes the process fool-proof rather than careful. Instead of deleting objects, set a lifecycle rule that expires them after a cooling-off period, then walk away.
curl -sS -X POST "https://api.infrai.cc/v1/storage/bucket/set_lifecycle/kb-decom-0726" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"rules":[{"prefix":"","expire_days":30}]}'
curl -sS "https://api.infrai.cc/v1/storage/bucket/get/kb-decom-0726" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"bucket_id": "bkt_a1ca7493888b4276b19d6b",
"name": "kb-decom-0726",
"vendor": "cos",
"region": "eu-central-1",
"acl": "private",
"created_at": "2026-07-26T00:54:42.041151Z",
"cors_rules": [],
"lifecycle_rules": [{ "prefix": "", "expire_days": 30 }]
}
}
An empty prefix matches every key, so this schedules the whole bucket to drain 30 days from now. Nobody has to be brave. If a forgotten consumer screams on day 9, you post a new rule with a longer expiry and the data is still there — which is exactly the property a delete command can’t give you.
One caveat that has cost people data elsewhere: set_lifecycle replaces the entire rule set rather than merging. We confirmed it on a live bucket — posting one rule for c/ wiped existing rules for a/ and b/ without warning. Always send the full desired set, and read the rules back from bucket/get afterwards.
Step 5: the forced delete, and why it resists you
Once the bucket is empty and the cooling period has passed, remove it. A bucket that still holds objects refuses to go:
curl -sS -X DELETE "https://api.infrai.cc/v1/storage/bucket/delete/kb-decom-0726" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": false,
"error": {
"code": "STORAGE_DELETE_NOT_FORCED",
"http_status": 400,
"message": "bucket 'bkt_a1ca7493888b4276b19d6b' not empty; pass force=True to delete contents",
"retryable": false
}
}
Treat that error as a feature, not an obstacle. If your final step needs force=true you haven’t finished step 4, and the correct response is to wait rather than to add a flag.
| Gate | What it catches | Reversible? | Cost |
|---|---|---|---|
| Inventory snapshot | Buckets nobody remembers owning | n/a | free |
| Write tripwire (14–35 days) | A live writer nobody documented | n/a | free |
| Attic copy + ETag match | ”We thought it was backed up” | yes | $0.0001 per object |
| Lifecycle expiry timer | A reader who surfaces late | yes, until it fires | free |
| Forced bucket delete | — | no | free |
What this costs
Verified 2026-07-26: bucket/list, bucket/get, bucket/usage, object/list, set_lifecycle, object/delete and bucket/delete are all free (rate-limited) on Infrai. object/copy is $0.0001 per object, so archiving 100,000 objects into the attic runs about $10 — the only line item in the whole runbook. New accounts start with $2 of credit. Rates drift downward over time and campaigns run, so check today’s numbers rather than trusting a page:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; d=json.load(sys.stdin); print([(c['id'], c['billing'].get('price_usd', 'free')) for c in d['capabilities'] if c['id'].startswith('storage.')])"
Limitations worth knowing before you standardise on this
Infrai’s storage doesn’t support object versioning, object lock or MFA-delete. There’s no WORM mode, so an operator with a valid key can empty a bucket, and the protection in this runbook is procedural rather than cryptographic. If you’re retiring data under a retention regime — SEC 17a-4, or a legal hold — you’d be better off on S3 with Object Lock in compliance mode, where the platform itself refuses the delete for the retention window. Backblaze B2 offers a similar lock at a lower storage rate and is worth pricing if the attic is large and cold. For an air-gapped archive you control end to end, MinIO on your own hardware is still the honest answer.
object/delete is idempotent and silent — deleting a key that doesn’t exist succeeds — so don’t expect an error to save you from a wrong key. And the tripwire only sees writes, which is why step 2 is a complement to your access logs and not a replacement.
What the single credential buys you in this workflow is the boring glue: the cron entry that reruns the tripwire, the email that goes out on day 30, and the error capture when a copy fails all sit on the same account and the same bill as the storage itself. That’s fewer moving parts in a process whose whole value is that it runs unattended, correctly, twice a year.