Last updated on
The backups for a database I can't run pg_dump against
Neon already backs up my production database. Continuously, physically, to any second inside the retention window, with no cron job and no code of mine involved. So the honest first question wasn't how to back the database up. It was why to build anything on top of a provider that already does the hard part better than I would.
The answer is that a backup you can only reach through one account, at one vendor, isn't really a backup of the data so much as a bet on the vendor. Neon's restore is the fastest, most exact way to get production back, and it's useless the day the thing I've lost is the Neon account itself. So the design ended up as three layers, each covering a failure the others can't.
Three layers, three different failures
Neon point-in-time restore is the primary path. It's continuous, it's physical, and it includes everything, down to the user password hashes the file export below deliberately throws away. Recovering from a bad migration or a fat-fingered delete is a console operation, not a script. The only thing it needs from me is a retention window long enough to notice the problem, which is a single project setting (currently 24h, up to 7d on the plan).
The daily encrypted file export is the portable copy. Once a day it writes a full logical export of the CMS to Vercel Blob, encrypted, off to the side of Neon entirely. This is the layer that survives losing the Neon account, the Vercel-Neon integration, or my ability to log into either. It is not as exact as layer one and isn't trying to be.
The on-demand physical dump, pnpm db:backup-prod, is the "before I do something risky" button. It's a read-only pg_dump I run by hand before a migration or any destructive operation, so there's always a rollback point from a minute ago. That script and its footguns are their own story (it shares the connection-string handling from pulling production data into local dev safely); the rest of this post is about the daily automated layer, where all the interesting constraints showed up.
Why the cron can't just run pg_dump
The obvious way to back up Postgres is pg_dump. The daily job can't use it. It runs inside Vercel's serverless Node runtime, and that runtime has no Postgres client binaries: there's no pg_dump on the box to shell out to. My local db:backup-prod gets away with it only because it runs pg_dump inside the docker-compose Postgres container on my machine, and nothing like that exists in a Vercel function.
So the export is logical instead of physical. It walks every Payload collection and global through the local API and serializes them to JSON. Payload becomes the dump tool because Postgres's own isn't reachable.
// depth: 0 keeps relationships as plain IDs (compact and restorable, no
// nested duplication). Hidden fields stay excluded (the default), so user
// password hashes / reset tokens are never written to the export.
for (const collection of payload.config.collections) {
if (SKIP_COLLECTIONS.has(collection.slug)) continue
const result = await payload.find({
collection: collection.slug,
depth: 0,
limit: 0, // 0 = no limit: return every document
pagination: false,
})
// ...
}depth: 0 matters more than it looks. It keeps every relationship as a bare ID instead of inlining the related document, so a post's category is 4, not a nested copy of the whole category that then disagrees with the copy in the categories collection. The export stays compact and, more importantly, restorable: the IDs line back up.
Encryption, because "public" Blob really means public
Vercel Blob has exactly one access level: public. Every object gets a live, internet-reachable URL. The URL has a random suffix so it's practically unguessable, but "unguessable" is not "private," and this file is a full export of every post, page, setting, and user email on the site. Leaning on URL obscurity for that is the kind of decision you get to regret exactly once.
So the export is encrypted before it's uploaded: AES-256-GCM, with a key derived from a BACKUP_SECRET I keep off the platform. A leaked Blob URL yields ciphertext and nothing else.
function encrypt(plaintext: Buffer, secret: string): Buffer {
const key = createHash('sha256').update(secret).digest()
const iv = randomBytes(12)
const cipher = createCipheriv('aes-256-gcm', key, iv)
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()])
return Buffer.concat([iv, cipher.getAuthTag(), ciphertext])
}Encryption is the outer wall. The more interesting decision is what never makes it into the plaintext in the first place. Password hashes and reset tokens are marked hidden in Payload, so payload.find doesn't return them and they're absent from the export by construction. Restoring from this backup means an admin sets a new password, which I'll take over storing credential material in a file at a public URL.
That covers the fields Payload hides. It doesn't cover the auth fields Payload doesn't hide but that are just as dangerous in a backup: a live session ID is enough to impersonate a logged-in admin. Those get stripped explicitly.
collections[collection.slug] = collection.auth
? result.docs.map(
({ sessions, lockUntil, loginAttempts, ...rest }) => rest,
)
: result.docsAn export that hands someone a working session would fail the whole "no credential material" promise, hidden fields or not.
Fail closed, in both directions
The endpoint is a plain GET /api/backup that Vercel Cron calls on a schedule. A URL like that is trivially reachable by anyone, so it fails closed. Without a valid secret the endpoint returns 401 and does nothing.
function isAuthorized(req: Request): boolean {
const secret = process.env.CRON_SECRET
if (!secret) return false
const provided = req.headers.get('authorization')
if (!provided) return false
const expected = createHash('sha256').update(`Bearer ${secret}`).digest()
const actual = createHash('sha256').update(provided).digest()
return timingSafeEqual(actual, expected)
}Two details in that tiny function. If CRON_SECRET is unset, the answer is "unauthorized" for everyone, so a missing secret can never accidentally leave a full data export open to the world. And the comparison is constant-time. timingSafeEqual needs equal-length inputs, so both sides get hashed to a fixed 32 bytes first, which sidesteps the length requirement and stops response timing from leaking the secret byte by byte.
It fails closed on the other end too. If BACKUP_SECRET is missing, the job throws rather than quietly uploading an unencrypted export. The one outcome worse than no backup is a plaintext one sitting at a public URL.
Retention by count, not size (the part I over-built first)
My first instinct for pruning was a storage cap: keep backups until they hit some size ceiling, then drop the oldest. I reached for 1GB, felt responsible, and then did the arithmetic.
The database is tiny, tens of kilobytes per export. At that rate a 1GB cap is something like fifty years of daily backups. It would never once trip. Which sounds harmless until you notice what it means: the pruning code would never run, so the single most destructive path in the whole feature, the one that deletes files, would sit untested in production forever, waiting for the day it finally fired against a real mistake.
So the window is a count, not a size.
// Keep the most recent N daily backups. Sized by count, not bytes, because the
// database is tiny (tens of KB per export): a byte cap would essentially never
// trip, so its pruning path would never actually run and could rot unnoticed.
const MAX_BACKUPS = 30
// Backstop only. If exports ever balloon, stop total backup storage from
// growing without bound even if that means keeping fewer than MAX_BACKUPS.
const MAX_TOTAL_BYTES = 50 * 1024 * 1024"Keep the newest 30" runs every single day, so the delete path is exercised constantly and stays honest. The 50MB size cap didn't go away: it just changed jobs, from primary policy to a backstop that only earns its keep if exports ever balloon unexpectedly.
The shared-Blob-store trap
There's a footgun specific to how this project is wired: local dev and production share one Vercel Blob store. A media upload or delete from my laptop writes to the same bucket production reads from. That's a known hazard I work around elsewhere, and it shapes the backup job too: the cron is production-only. If it also ran locally against the shared store, dev and prod would prune each other's backups, racing over the same backups/ prefix. Keeping the writer in one place keeps the retention math predictable. Boundaries between environments are a running theme on this site; the same instinct produced the preview-branch isolation that keeps a pull request from ever touching prod.
Getting the data back out
A backup you've never restored is only a guess that it would work. The counterpart to the cron is pnpm backup:decrypt, which takes a backup (a local file or a Blob URL) and round-trips it back to readable JSON.
It's deliberately a decrypt-and-inspect tool, not an importer. It proves the ciphertext decrypts, the gzip inflates, and the JSON parses, and then it writes that JSON to disk and stops. It never connects to a database, which is the whole point: it's safe to run against production credentials because there's no code path in it that could write to production. Verifying a backup should never be able to damage the thing it's a backup of.
For an actual disaster, though, the file export is the fallback, not the first move. Neon's point-in-time restore is exact, includes the password hashes this JSON omits, and is a console click rather than a hand re-import. The layers have an order, and the portable copy is the bottom of it.
What this doesn't cover
Two honest gaps. Media isn't in here: the JSON export is the database only, and production's uploaded images live in Blob storage this job doesn't touch. And the export omits password hashes by design, so a full restore from the file layer is a restore of content followed by an admin password reset, not a bit-for-bit resurrection. Both are deliberate, and neither is invisible to me the day I need them, which is the whole reason to write them down.
None of the three layers is clever on its own. Neon does the real work; the encrypted export exists for the day I've lost Neon; the manual dump exists for the minute before I break something. What I wanted wasn't really a backup. It was to make sure that whatever went wrong, the answer to "what did I lose" would never be "all of it."
Comments
No comments yet. Be the first to comment.