Nightly app backups to a bucket: schedule, upload, expire, restore
A Node 22 backup job that archives app data, streams it to a private S3-compatible bucket, keeps 30 days of history, and proves the restore works.
Archive the data, stream it to a private bucket under a date-shaped key, let a lifecycle rule delete the old copies, and download one back every month to check the archive still opens. Infrai gives you the bucket, the signed upload slot, the retention rule and the scheduler on a single API key, which removes the part of this job most teams get wrong — not the upload, the expiry and the proof.
The dull truth about backups is that writing them is easy and nobody notices when they silently stop. So this walkthrough spends as much space on “did last night’s run actually land” as on the upload itself.
Retention belongs on the bucket, not in your script
Deleting old archives from application code means a bug in your code becomes a bug in your retention. Push the rule down to the bucket instead:
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST https://api.infrai.cc/v1/storage/bucket/set_lifecycle/app-backups \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"rules":[{"prefix":"daily/","expire_days":30},{"prefix":"weekly/","expire_days":180}]}'
{
"ok": true,
"data": {
"bucket_id": "bkt_6f9e2aca4f424d5fa8ee54",
"name": "app-backups",
"vendor": "cos",
"region": "ap-singapore",
"acl": "private",
"lifecycle_rules": [
{ "prefix": "daily/", "expire_days": 30 },
{ "prefix": "weekly/", "expire_days": 180 }
]
}
}
Two things to know before you copy that. The submitted list replaces the existing one — there’s no partial update, so always send every rule you want to keep. And the rules key off a prefix, which is why the key layout matters: daily/2026-07-26/… and weekly/2026-W30/… give you two independent retention classes for free, while a flat backup-2026-07-26.tar.gz gives you one.
The job itself
Archive first, upload second, never both at once. A tar that’s still being written is a corrupt object nobody notices for six months. Finish, then send.
import { execFile } from "node:child_process";
import { createReadStream } from "node:fs";
import { stat } from "node:fs/promises";
import { Readable } from "node:stream";
import { promisify } from "node:util";
const run = promisify(execFile);
const API = "https://api.infrai.cc";
const BUCKET = "app-backups";
const token = process.env.INFRAI_API_KEY;
if (!token) throw new Error("INFRAI_API_KEY is not set");
export async function nightlyBackup(dataDir = "/srv/app/data") {
const day = new Date().toISOString().slice(0, 10);
const archive = `/tmp/app-${day}.tar.gz`;
await run("tar", ["-czf", archive, "-C", dataDir, "."]);
const { size } = await stat(archive);
if (size < 1024) throw new Error(`archive suspiciously small: ${size} bytes`);
const objectKey = `daily/${day}/app-${day}.tar.gz`;
const slotRes = await fetch(`${API}/v1/storage/object/presign/${BUCKET}/${objectKey}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ op: "put", expires_seconds: 3600, content_type: "application/gzip" }),
});
const slot = await slotRes.json();
if (!slotRes.ok || slot.ok === false) throw new Error(`presign: ${slot?.error?.code ?? slotRes.status}`);
const upload = await fetch(slot.data.url, {
method: slot.data.method,
headers: slot.data.headers ?? {},
body: Readable.toWeb(createReadStream(archive)),
duplex: "half",
});
if (!upload.ok) throw new Error(`upload failed: HTTP ${upload.status}`);
return { objectKey, size, etag: upload.headers.get("etag") };
}
Streaming matters once the archive passes a few hundred megabytes — Readable.toWeb plus duplex: "half" keeps Node 22 from buffering the whole file into the heap. An hour of slot lifetime is generous on purpose; a 4 GB archive over a slow uplink will use it.
Where the schedule lives
Three options, and the honest ranking depends less on features than on who gets paged when the box reboots.
| Where the schedule runs | Setup cost | Survives an app-server rebuild | Fails silently? |
|---|---|---|---|
crontab on the app server | Minutes | No — you rebuild the crontab too | Yes, unless you wire up mail or a health ping |
Hosted scheduler (POST /v1/cron/create) | One API call, free | Yes, it’s outside your box | No — run history and retries are recorded |
| Managed database snapshots | Zero, if your provider offers them | Yes | No, but you only get the database |
The hosted job takes name, schedule, url and an optional payload, then POSTs your endpoint on the schedule; your endpoint runs nightlyBackup(). Cron calls are free on Infrai, so the scheduler adds nothing to the bill. Confirm what’s registered:
curl -sS https://api.infrai.cc/v1/cron/list \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Did last night’s run actually land?
This is the check almost nobody writes. Two free reads answer it — one lists the newest object under a prefix, the other reports what the bucket is holding:
curl -sS "https://api.infrai.cc/v1/storage/object/list/app-backups?prefix=daily/&limit=5" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
curl -sS https://api.infrai.cc/v1/storage/bucket/usage/app-backups \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"items": [
{
"bucket_id": "bkt_6f9e2aca4f424d5fa8ee54",
"key": "daily/2026-07-26/app-2026-07-26.tar.gz",
"size_bytes": 200083,
"etag": "c44e701797e2c3eb84d8d0152882cab0",
"last_modified": "2026-07-26T00:25:14Z"
}
],
"next_cursor": null
}
}
Wire that into whatever already pages you:
const API = "https://api.infrai.cc";
const token = process.env.INFRAI_API_KEY;
if (!token) throw new Error("INFRAI_API_KEY is not set");
export async function assertFreshBackup(maxAgeHours = 26) {
const res = await fetch(`${API}/v1/storage/object/list/app-backups?prefix=daily/&limit=1000`, {
method: "GET",
headers: { Authorization: `Bearer ${token}` },
});
const json = await res.json();
if (!res.ok || json.ok === false) throw new Error(`list failed: ${json?.error?.code ?? res.status}`);
const items = json.data.items ?? [];
if (!items.length) throw new Error("no backups at all under daily/");
const newest = items.reduce((a, b) => (a.last_modified > b.last_modified ? a : b));
const ageHours = (Date.now() - Date.parse(newest.last_modified)) / 3_600_000;
if (ageHours > maxAgeHours) throw new Error(`newest backup ${newest.key} is ${ageHours.toFixed(1)}h old`);
return { key: newest.key, sizeBytes: newest.size_bytes, ageHours: Number(ageHours.toFixed(1)) };
}
Note the size_bytes in the return value. A backup that shrinks by 90% overnight is usually a dump that failed halfway, exited zero, got uploaded anyway, and will look perfectly healthy in every dashboard you own until the afternoon you actually need it — size drift catches far more real incidents than a plain existence check ever will. Watch the trend, not the file.
Restoring, which is the only part that counts
Mint a download URL and pull the archive back. The signed GET is a plain HTTPS link, so curl or any restore runner can use it without a key:
curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/presign/app-backups/daily/2026-07-26/app-2026-07-26.tar.gz" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"get","expires_seconds":600}'
Feed the returned url to curl -o restore.tar.gz, then run tar -tzf restore.tar.gz | head before you trust it. Do that on a schedule, not when you need it — a monthly calendar entry that restores into a scratch container is worth more than another replica.
What the loop costs
The billing shape is what survives a price change: presign, list, head, bucket usage and lifecycle rules are all free and rate-limited, so the whole monitoring half of this article costs nothing. You pay for object writes and server-side reads. Verified 26 July 2026, storage.object.put runs $0.0001 per call and storage.object.get $0.0002, with stored bytes and egress metered on top. One nightly archive is one write — call it $0.003 a year in call fees, with the stored gigabytes as the real line item.
curl -sS https://api.infrai.cc/v1/account/usage \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
That returns a 30-day breakdown per capability, so storage.object.put sits in the same list as everything else you run — per-tenant or per-job cost attribution is a query rather than a spreadsheet. Rates drift downward over time and discounts run, so what you read today may well be lower than the figures above.
When something else is the better tool
If your data lives entirely in one managed database, provider snapshots beat a hand-rolled job: they’re consistent, incremental, and restore with one button. Amazon S3 with Glacier transitions is stronger for multi-year compliance archives, and Backblaze B2 or Wasabi will usually be cheaper per stored terabyte at scale. MinIO on your own hardware is the answer when the archive legally can’t leave the building.
The limitation to plan around here: there’s no cross-region replication route on this API, so a second copy in a second region means running the upload twice with two bucket names. For a backup you’d hate to lose, that’s a fair price — but you have to write it yourself.