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

  1. Read the manifest for the run you intend to restore, and refuse to proceed if it’s absent.
  2. Compare object_count in the manifest against a live listing of the uploads prefix.
  3. Pull the dump, verify its ETag, then pg_restore into a scratch database — never straight over production.
  4. Point the scratch app at the restored uploads prefix and open three files a human recognises.
  5. 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 targetWorst-case RPOImmutable against a stolen keyWhen it’s the right pick
Infrai bucket, nightly dump + copy24 hoursNoYou want the job, its schedule and its alerting on one account
pgBackRest or WAL-G to S3Seconds, with PITRWith Object LockThe database is the business and a lost day is unacceptable
S3 with Object Lock, compliance modeDepends on the jobYes, by designRegulated retention, or a ransomware threat model
Backblaze B2 as a second copyDepends on the jobPartly — a write-only application keyCheap off-provider redundancy
Cloudflare R2 as the archive tierDepends on the jobNoLarge 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.

References

Browse more storage developer guides