Nightly full-app backup: database dump plus user uploads, one bucket
A prefix layout, a Node 22 nightly job and a restore checklist for backing up a Postgres dump and every user upload into object storage on Infrai.
A full application backup is two artefacts that have to be restorable together: the database dump and the user files the rows point at. Put both under one dated prefix in one bucket, write a manifest that records what the run produced, and the restore stops being archaeology. On Infrai that’s POST /v1/storage/multipart/create/{bucket} for the dump, POST /v1/storage/object/copy for the files, and a free listing call to prove the run is complete.
The bucket is the easy part. The layout is what determines whether a 3 a.m. restore takes ten minutes or a whole morning, so start there.
One bucket, dated prefixes, one manifest
kb-backups-0726/
db/2026-07-26/postgres.sql.gz
db/2026-07-26/manifest.json
uploads/2026-07-26/tenant_42/contract.pdf
uploads/2026-07-26/tenant_57/logo.png
The run identifier is the date segment, and it appears in both trees. That single decision buys you three things: a restore is “list everything under 2026-07-26/”, a retention rule is a prefix rule, and a partial run is obvious because the manifest is missing.
Create the bucket once, private, in the region you actually want the bytes in:
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/storage/bucket/create" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"name":"kb-backups-0726","region":"eu-central-1","acl":"private"}'
Buckets default to private, and the only other supported value is signed-only — there’s no public-read mode, which for a backup bucket is exactly right.
Producing the dump
Nothing Infrai-specific here; pg_dump writes, gzip shrinks, and the custom format is what pg_restore wants:
#!/usr/bin/env bash
set -euo pipefail
RUN_DATE=$(date -u +%F)
OUT="/var/backups/postgres-${RUN_DATE}.dump.gz"
pg_dump --format=custom --no-owner --no-privileges "${DATABASE_URL}" \
| gzip -9 > "${OUT}"
echo "wrote ${OUT} ($(stat -f%z "${OUT}" 2>/dev/null || stat -c%s "${OUT}") bytes)"
A dump of any real size shouldn’t go up as one JSON request — base64 upload isn’t meant for anything past about 1 MB. Multipart is the path, and the minimum part size the API reports is 5 MiB with a ceiling of 10,000 parts.
The nightly job
import { createReadStream, statSync } from "node:fs";
import { once } from "node:events";
const API = "https://api.infrai.cc";
const BUCKET = "kb-backups-0726";
const KEY_PREFIX = `db/${new Date().toISOString().slice(0, 10)}`;
const PART_SIZE = 8 * 1024 * 1024;
async function api(path, init = {}) {
const res = await fetch(`${API}${path}`, {
...init,
headers: {
Authorization: `Bearer ${process.env.INFRAI_API_KEY}`,
"Content-Type": "application/json",
...(init.headers ?? {}),
},
});
if (!res.ok) throw new Error(`${path} -> ${res.status} ${await res.text()}`);
return (await res.json()).data;
}
async function uploadDump(file) {
const upload = await api(`/v1/storage/multipart/create/${BUCKET}`, {
method: "POST",
body: JSON.stringify({ key: `${KEY_PREFIX}/postgres.sql.gz`, content_type: "application/gzip" }),
});
const parts = [];
let partNumber = 1;
const stream = createReadStream(file, { highWaterMark: PART_SIZE });
try {
for await (const chunk of stream) {
const slot = await api(`/v1/storage/multipart/presign_part/${upload.upload_id}/${partNumber}`, {
method: "POST",
body: JSON.stringify({}),
});
const put = await fetch(slot.url, { method: slot.method, body: chunk });
if (!put.ok) throw new Error(`part ${partNumber} -> ${put.status}`);
parts.push({ part_number: partNumber, etag: put.headers.get("etag").replaceAll('"', "") });
partNumber += 1;
}
return await api(`/v1/storage/multipart/complete/${upload.upload_id}`, {
method: "POST",
body: JSON.stringify({ parts }),
});
} catch (err) {
await api(`/v1/storage/multipart/abort/${upload.upload_id}`, { method: "DELETE" }).catch(() => {});
throw err;
}
}
const file = process.argv[2];
if (!file) throw new Error("usage: node backup.mjs <dump-file>");
const object = await uploadDump(file);
console.log("dump stored", object.key, statSync(file).size, "bytes");
await once(process.stdout, "drain").catch(() => {});
The abort in the catch block is not optional housekeeping. An abandoned multipart upload keeps its uploaded parts around, and you’ll be paying rent on bytes that will never become an object — DELETE /v1/storage/multipart/abort/{upload_id} is free and takes the whole mess away.
Mirroring user uploads without moving the bytes
Files already living in object storage never need to travel through your backup host. Server-side copy does it in one call:
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-docs-0726",
"src_key": "tenants/tenant_42/docs/2026-07/agreement.pdf",
"dst_bucket": "kb-backups-0726",
"dst_key": "uploads/2026-07-26/tenant_42/agreement.pdf"
}'
Content type and custom metadata survive the copy, which matters more than it sounds — a restored PDF that comes back as application/octet-stream will download instead of preview, and nobody notices until a customer complains. Enumerate the source side with GET /v1/storage/object/list/{bucket} and its prefix, cursor and limit parameters; listing is free, so a nightly walk of a million-key bucket costs nothing but time.
The manifest is the restore plan
Write it last. Its existence is the signal that the run finished:
{
"run_id": "2026-07-26",
"database": {
"key": "db/2026-07-26/postgres.sql.gz",
"etag": "a4d5ec13552712f7b6c57ec089caf18d",
"size_bytes": 41231884,
"pg_dump_format": "custom"
},
"uploads": { "prefix": "uploads/2026-07-26/", "object_count": 1284, "byte_count": 9128374112 },
"completed_at": "2026-07-26T02:14:09Z"
}
The restore checklist
- Read the manifest for the run you intend to restore, and refuse to proceed if it’s absent.
- Compare
object_countin the manifest against a live listing of the uploads prefix. - Pull the dump, verify its ETag, then
pg_restoreinto a scratch database — never straight over production. - Point the scratch app at the restored uploads prefix and open three files a human recognises.
- Only then promote.
Steps 1 and 2 are two free calls:
curl -sS "https://api.infrai.cc/v1/storage/object/head/kb-backups-0726/db/2026-07-26/manifest.json" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
curl -sS "https://api.infrai.cc/v1/storage/object/list/kb-backups-0726?prefix=uploads/2026-07-26/&limit=1000" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
A backup you’ve never restored is a hypothesis. Run the drill quarterly against a scratch database and put the result in your incident runbook.
What a nightly run costs
Structure before figures: bucket creation, listing, head, presign_part and abort are free and rate-limited. Part uploads, multipart completion and object copies are billable per call, and new accounts get $2 of credit to start. So a 40 GB dump at 8 MB parts is roughly 5,000 part calls plus one completion — the copies for user files dominate only if you have hundreds of thousands of them.
Verified on 26 July 2026: $0.0001 per part upload and per object copy, $0.0002 per multipart completion. Read 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; [print(c['id'], c['billing'].get('price_usd')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.') and c['billing']['is_billable']]"
Rates move down over time and campaigns run, so treat those as a ceiling. GET /v1/storage/bucket/usage/{bucket} reports byte_count and object_count for free if you’d rather model the stored side directly.
Where a specialist beats this
A nightly logical dump gives you a 24-hour worst-case RPO. If losing a day of writes is unacceptable, you want continuous archiving — pgBackRest or WAL-G shipping WAL segments to S3 gives point-in-time recovery, and no object-storage API replaces that.
| Backup target | Worst-case RPO | Immutable against a stolen key | When it’s the right pick |
|---|---|---|---|
| Infrai bucket, nightly dump + copy | 24 hours | No | You want the job, its schedule and its alerting on one account |
| pgBackRest or WAL-G to S3 | Seconds, with PITR | With Object Lock | The database is the business and a lost day is unacceptable |
| S3 with Object Lock, compliance mode | Depends on the job | Yes, by design | Regulated retention, or a ransomware threat model |
| Backblaze B2 as a second copy | Depends on the job | Partly — a write-only application key | Cheap off-provider redundancy |
| Cloudflare R2 as the archive tier | Depends on the job | No | Large archives you may need to read back in bulk |
There’s a second gap worth flagging: this surface has no object versioning and no immutability lock, so a compromised key can delete backups as easily as it wrote them. If your threat model includes ransomware, S3 Object Lock in compliance mode — or a second copy on Backblaze B2 with an application key that can write but not delete — is the control you actually need. Cloudflare R2 is the cheaper landing zone when the archive is large and rarely read, since egress is free there.
What Infrai buys you is that the nightly job, the cron schedule that fires it, the queue that retries a failed leg and the email that tells you it finished all sit on one key and one bill. That’s the argument — not the storage rate.