Expire backups after 30 days with object-storage lifecycle rules
Prefix-scoped retention on a private backup bucket: setting lifecycle rules on Infrai, verifying them in CI, and the sweeper you still need for count-based rules.
Retention belongs to the bucket, not to your cron script. One call to POST /v1/storage/bucket/set_lifecycle/{bucket} tells Infrai’s storage layer to delete anything under a given prefix after N days, and it keeps doing that whether or not your backup job ran, whether or not the machine that wrote the files still exists. Rules are free to set and free to read back.
The reason to prefer that over a deletion loop is failure behaviour. A sweeper that crashes leaves you paying for data you promised a customer you’d erased; a lifecycle rule that’s already installed doesn’t need anything of yours to be healthy.
Rules replace, they don’t merge
This is the one that bites. The rules array you submit becomes the bucket’s entire policy — send a single rule to add a prefix and you have silently deleted the retention policy for every other prefix.
So build the whole set in one place and treat it as configuration:
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/storage/bucket/set_lifecycle/kb-retention-0726" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"rules": [
{"prefix": "backups/db/", "expire_days": 30},
{"prefix": "backups/weekly/", "expire_days": 90},
{"prefix": "tmp/", "expire_days": 1}
]
}'
The response is the bucket, with the policy it now holds:
{
"ok": true,
"data": {
"bucket_id": "bkt_27c277e7ae63450ea920d1",
"name": "kb-retention-0726",
"vendor": "cos",
"region": "eu-central-1",
"acl": "private",
"cors_rules": [],
"lifecycle_rules": [
{"prefix": "backups/db/", "expire_days": 30},
{"prefix": "backups/weekly/", "expire_days": 90},
{"prefix": "tmp/", "expire_days": 1}
]
}
}
expire_days counts from the object’s creation and must be at least 1. There’s also an optional transition_class for moving objects to colder storage rather than deleting them.
Tiered retention, expressed as prefixes
Grandfather-father-son doesn’t need clever rules — it needs a key layout where each tier lives under its own prefix, so a rule can address it:
| Prefix | What the backup job writes there | expire_days | Roughly how many survive |
|---|---|---|---|
backups/db/ | every nightly dump | 30 | 30 |
backups/weekly/ | a copy of Sunday’s dump | 90 | 13 |
backups/monthly/ | a copy of the 1st | 1095 | 36 |
tmp/ | in-progress and scratch artefacts | 1 | whatever today produced |
The weekly and monthly tiers are copies, not moves — POST /v1/storage/object/copy puts a second reference under the longer-lived prefix, and the nightly original still expires on schedule. Copies are cheap; a botched retention policy is not.
Buckets are private by default and the only other supported value is signed-only, so a backup bucket needs no extra hardening step. There is no public-read mode to forget to turn off.
Verify the policy, in CI, every deploy
Reading the rules back is free, so there’s no excuse for trusting that they’re still what you think:
curl -sS "https://api.infrai.cc/v1/storage/bucket/get/kb-retention-0726" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Wire that into your pipeline as an assertion rather than a human glance:
import assert from "node:assert/strict";
const API = "https://api.infrai.cc";
const BUCKET = "kb-retention-0726";
const EXPECTED = [
{ prefix: "backups/db/", expire_days: 30 },
{ prefix: "backups/weekly/", expire_days: 90 },
{ prefix: "tmp/", expire_days: 1 },
];
const res = await fetch(`${API}/v1/storage/bucket/get/${BUCKET}`, {
headers: { Authorization: `Bearer ${process.env.INFRAI_API_KEY}` },
});
if (!res.ok) {
console.error("bucket read failed", res.status, await res.text());
process.exit(1);
}
const { data } = await res.json();
const actual = (data.lifecycle_rules ?? []).map(({ prefix, expire_days }) => ({ prefix, expire_days }));
try {
assert.deepEqual(
[...actual].sort((a, b) => a.prefix.localeCompare(b.prefix)),
[...EXPECTED].sort((a, b) => a.prefix.localeCompare(b.prefix)),
);
assert.equal(data.acl, "private");
console.log(`retention policy OK on ${BUCKET} (${actual.length} rules)`);
} catch (err) {
console.error("retention drift:", err.message);
process.exit(1);
}
Thirty seconds of CI time, and the class of incident where someone widened a rule during an outage and nobody noticed for a quarter simply stops happening.
What lifecycle rules can’t do
They’re age-based only. “Keep the most recent 14 dumps” isn’t expressible, and if your job skips a week the age rule will happily delete you down to nothing. When the policy is count-based you need your own sweep, and POST /v1/storage/object/delete_batch/{bucket} takes up to 1,000 keys per call:
KEYS=$(curl -sS "https://api.infrai.cc/v1/storage/object/list/kb-retention-0726?prefix=backups/db/&limit=1000" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; items=json.load(sys.stdin)['data']['items']; items.sort(key=lambda o: o['key']); print(json.dumps([o['key'] for o in items[:-14]]))")
curl -sS -X POST "https://api.infrai.cc/v1/storage/object/delete_batch/kb-retention-0726" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d "{\"keys\": ${KEYS}}"
Missing keys come back in errors with STORAGE_OBJECT_NOT_FOUND rather than failing the call, which makes the sweep safe to re-run. Sort by key, not by listing order — that’s why dated key names are worth the discipline.
Two further limitations worth stating plainly. There’s no object versioning and no immutability lock on this surface, so a leaked key can delete a backup before its retention window ends; if you need retention that survives a compromised credential, S3 Object Lock in compliance mode is the control, and no lifecycle rule substitutes for it. And rules act on age, so an object re-uploaded under the same key restarts its clock.
Proving that deletion actually happened
Auditors ask for evidence, and GET /v1/storage/bucket/usage/{bucket} is the cheapest evidence there is — free, and it reports byte_count, object_count and an as_of timestamp:
curl -sS "https://api.infrai.cc/v1/storage/bucket/usage/kb-retention-0726" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Record that daily. A retention policy that works shows a sawtooth: the count climbs by one a night and drops when the window rolls. A flat climb means a rule isn’t matching the prefix you think it is — usually a missing trailing slash.
What retention costs
Structure first: set_lifecycle, bucket/get, object/list, delete_batch and bucket/usage are all free and rate-limited. What retention actually saves you is stored bytes, which is the metered part. The copies that populate the weekly and monthly tiers are billable per call — verified 26 July 2026 at $0.0001 per copy, so promoting one dump a week for ten years comes to about five cents in call charges. Not the line item to optimise.
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; [print(c['id'], c['billing']['is_billable'], c['billing'].get('price_usd')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.')]"
Rates drift downward and campaigns run, so read them rather than quoting this page. New accounts get $2 of trial credit, which covers a lot of retention plumbing.
When to reach for something else
If your retention story needs cold-tier transitions with real archive economics, Amazon S3 lifecycle rules into Glacier Deep Archive or Google Cloud Storage’s Archive class will beat a flat delete-after-N-days policy on a hot tier — Backblaze B2 has lifecycle rules too and is cheaper per stored TB than most. Self-hosting MinIO gives you full ILM control if you’re already running the hardware.
Where this surface earns its place is that retention, the nightly job that fills the bucket, the cron entry that fires it and the alert when it doesn’t are one account and one bill. Related reading: nightly full-app backups covers producing the artefacts this policy expires.