Streaming pg_dump straight into a private bucket from Node, and back
Pipe pg_dump into a signed upload with no temp file, choose between -Fc and gzip honestly, and rehearse the pg_restore that makes the backup real.
The version worth building pipes pg_dump straight into a presigned PUT: no temp file, no disk to run out of, one billable call per backup. Infrai signs that upload slot for free and the bucket accepts a chunked stream, so a 40 GB database never touches the filesystem of the box running the job. What follows is that script, the restore rehearsal that makes it mean something, and an honest note on where a managed snapshot beats the whole idea.
Format first, though, because the wrong dump format is a slower problem than the wrong storage backend.
-Fc, not | gzip
pg_dump -Fc writes the custom format: compressed already, and readable by pg_restore, which is what buys you selective restores and parallel loading. The plain-SQL-through-gzip pattern most tutorials show gives you a file that only psql can replay, single-threaded, all-or-nothing.
| Format | Command | Restores with | Why you’d pick it |
|---|---|---|---|
| Custom | pg_dump -Fc | pg_restore -j 8 | Default choice: compressed, parallel restore, table-level selection |
| Directory | pg_dump -Fd -j 4 | pg_restore -j 8 | Fastest dump on a big machine; it’s a directory, so tar it before upload |
| Plain + gzip | pg_dump | gzip | psql | Diffable text, portable to non-Postgres tooling; slow to restore |
| Physical base backup | pg_basebackup | Replica promotion | Point-in-time recovery with WAL; a different discipline entirely |
A logical dump is a snapshot of a moment, not a point-in-time recovery system — it captures the database as it was when the transaction started, and everything committed after that is simply gone until the next run. If losing an hour of writes is unacceptable, pg_dump on a schedule isn’t the right tool and archived WAL is. Pick before you build.
The upload, with no temp file
import { spawn } from "node:child_process";
import { Readable } from "node:stream";
const API = "https://api.infrai.cc";
const BUCKET = "pg-backups";
const token = process.env.INFRAI_API_KEY;
const dbUrl = process.env.DATABASE_URL;
if (!token) throw new Error("INFRAI_API_KEY is not set");
if (!dbUrl) throw new Error("DATABASE_URL is not set");
export async function dumpToBucket(database = "appdb") {
const stamp = new Date().toISOString().replace(/:/g, "-").slice(0, 16) + "Z";
const key = `pg/${database}/${stamp}.dump`;
const slotRes = await fetch(`${API}/v1/storage/object/presign/${BUCKET}/${key}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ op: "put", expires_seconds: 7200, content_type: "application/octet-stream" }),
});
const slot = await slotRes.json();
if (!slotRes.ok || slot.ok === false) throw new Error(`presign: ${slot?.error?.code ?? slotRes.status}`);
const dump = spawn("pg_dump", ["-Fc", "--no-owner", "--no-acl", dbUrl], { stdio: ["ignore", "pipe", "pipe"] });
let stderr = "";
dump.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
const exited = new Promise((resolve, reject) => {
dump.on("close", (code) => (code === 0 ? resolve() : reject(new Error(`pg_dump exited ${code}: ${stderr.slice(0, 500)}`))));
});
const upload = await fetch(slot.data.url, {
method: slot.data.method,
headers: slot.data.headers ?? {},
body: Readable.toWeb(dump.stdout),
duplex: "half",
});
await exited;
if (!upload.ok) throw new Error(`upload failed: HTTP ${upload.status}`);
return { key, etag: (upload.headers.get("etag") ?? "").replaceAll('"', "") };
}
Two details that matter more than they look. await exited runs after the upload resolves, so a pg_dump that dies at 80% still fails the function instead of quietly storing a truncated object — the most common silent-corruption path in home-grown backup jobs. And because the stream has no known length, the PUT goes out chunked; in our testing on 26 July 2026 the bucket accepted that with a 200 and the expected byte count, so you don’t need to size the dump in advance.
Same thing from a shell, for a cron box that doesn’t run Node:
export INFRAI_API_KEY="your_infrai_api_key"
SLOT=$(curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/presign/pg-backups/pg/appdb/2026-07-26T02-15Z.dump" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"put","expires_seconds":7200,"content_type":"application/octet-stream"}')
URL=$(printf '%s' "$SLOT" | python3 -c "import json,sys; print(json.load(sys.stdin)['data']['url'])")
pg_dump -Fc --no-owner "$DATABASE_URL" | curl -sS -T - "$URL" -H "Content-Type: application/octet-stream"
Confirm it landed, and how big it is
curl -sS \
"https://api.infrai.cc/v1/storage/object/head/pg-backups/pg/appdb/2026-07-26T02-15Z.dump" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"found": true,
"status": "found",
"key": "pg/appdb/2026-07-26T02-15Z.dump",
"size_bytes": 200083,
"etag": "c44e701797e2c3eb84d8d0152882cab0",
"content_type": "application/octet-stream",
"last_modified": "2026-07-26T00:30:05Z"
}
}
Compare size_bytes against yesterday’s. A dump that halves overnight usually means a role lost SELECT on half your tables, and pg_dump will happily exit 0 having skipped them. Size is the smoke alarm.
Retention lives on the bucket, so a bug in the backup script can’t also break expiry:
curl -sS -X POST https://api.infrai.cc/v1/storage/bucket/set_lifecycle/pg-backups \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"rules":[{"prefix":"pg/","expire_days":14}]}'
The submitted rules replace the whole list, so send every rule you want to keep, every time.
The restore rehearsal
A backup you’ve never restored is a hypothesis. Rehearse it on a boring Tuesday, against a scratch database, with a stopwatch running — because the alternative is discovering the dump was unreadable at the exact hour when every minute is being counted by someone else. Sign a download, pull the dump, read its table of contents:
curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/presign/pg-backups/pg/appdb/2026-07-26T02-15Z.dump" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"get","expires_seconds":600}'
curl -sS -o /tmp/appdb.dump "$SIGNED_URL"
pg_restore --list /tmp/appdb.dump | head -20
createdb restore_check
pg_restore -j 4 --no-owner --dbname restore_check /tmp/appdb.dump
psql -d restore_check -c "select count(*) from users;"
pg_restore --list fails loudly on a truncated file, which makes it a 200 ms sanity check you can run on every backup rather than once a quarter. The full restore into a scratch database is the monthly version.
Cost, and the shape behind it
Presign, head, list and lifecycle calls are free and rate-limited; you pay for writes, server-side reads and stored bytes. Verified 26 July 2026, storage.object.put is $0.0001 per call and storage.object.get $0.0002 — one nightly dump is a single write, so the per-call cost of a year of backups is a rounding error and the stored gigabytes are the whole bill. Egress on restore is metered too, and pulling a very large dump repeatedly is what produces STORAGE_BANDWIDTH_EXCEEDED.
curl -sS https://api.infrai.cc/v1/account/balance \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
curl -sS https://api.infrai.cc/v1/account/usage \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Those two reads give you today’s numbers instead of yesterday’s blog post, and rates on this platform trend downward, so treat the figures above as an upper bound. New accounts carry $2 of credit, which covers a nightly dump for years before you fund anything.
Where you’d choose something else
Managed Postgres providers do this better if you’re already paying them: their snapshots are block-level, incremental, and come with point-in-time recovery you can’t rebuild with pg_dump. For long-horizon compliance archives, Amazon S3 with a Glacier transition is cheaper per stored terabyte than any per-call API, and Backblaze B2 sits below that again. MinIO is the honest answer when the dump can’t legally leave your rack.
The caveat on this route: there’s no server-side encryption key you control, so a dump containing regulated data should be encrypted before it leaves the box — gpg --encrypt in the pipe costs you nothing and keeps the key yours. There’s also no cross-region copy call, so a second geographic copy means a second upload.