We put the Postgres write-ahead log on object storage. We did not put the database there.
Short version: PostgreSQL databases on Layerbase ship their write-ahead log to object storage continuously, and a restore replays that log onto a base snapshot to rebuild the database at any timestamp in the window. What we did not do is the thing Databricks did with Lakebase: make object storage the database. Our Postgres still reads and writes local disk, there is no page server, and no query has ever fetched a page from a bucket. The log leaves the box. The database does not.
Databricks published a good piece on putting the WAL on object storage for Lakebase, their managed Postgres built on the Neon architecture: make the log the source of truth, materialize pages asynchronously in a separate storage tier, and branching, scale-to-zero, and instant copies stop being expensive because none of them copy data any more. We made a much smaller version of the same bet, and the interesting part is the line between the two.
Two places durability can live
The page-centric answer is that the data files are the database, and the log is a crash-recovery detail replayed after an unclean shutdown and then forgotten. Back it up by copying the files, on a schedule.
The log-centric answer inverts that. Every change enters the write-ahead log before it touches a page, so the log already is a complete, ordered, replayable description of the database, and the files are a materialization of it. Keep the log and you do not have backups at points in time, you have the database at every point in time.
Lakebase takes that all the way down: a pageserver materializes pages from the log on demand, stateless compute nodes read GetPage@LSN out of the storage layer, and object storage holds the immutable history underneath. Nothing on a compute node's local disk is precious, which is what makes a branch a pointer instead of a copy.
We take it exactly one layer deep. The log is authoritative for recovery. It is not authoritative for reads. Postgres on our fleet is stock Postgres reading its own pages off a local ZFS dataset, and the copy of the log in object storage exists so a database can be rebuilt somewhere else, at any moment we still hold segments for.
That is the smaller claim, and it paid the bill we owed. When we moved layerbase.com's own database off Neon, the internal audit put our worst-case recovery point at about 65 minutes: hourly dumps, no replay in between, so a bad DELETE at :59 costs the hour. The build story and the launch post cover what the replacement does for customers. What follows is what it looks like from inside.
What actually leaves the box
Four moving parts, and the boring one is deliberate.
Inside the customer's container: a managed block in postgresql.conf sets archive_mode = on, archive_timeout = 60, and an archive_command that is nothing but a file copy.
test ! -f <spool>/%f && cp %p <spool>/%f.tmp && mv <spool>/%f.tmp <spool>/%fNo credentials, no network, no binaries beyond cp and mv. That is a hard constraint, not a simplification. A tenant is the superuser of their own instance, so anything readable in that container is theirs, which rules out running pgBackRest or WAL-G in there holding our storage keys. It is also a latency constraint: Postgres will not recycle a segment until archive_command exits zero, so anything network-shaped there turns a slow bucket into back-pressure on customer writes.
On the host: a ship cycle runs every 15 seconds, compressing each spooled file with zstd, uploading it, and only then deleting the local copy, so a failed upload costs a retry and never a segment. It ships at most 50 files per database per tick, so one backlogged database cannot starve everyone else. A file whose name says it is a segment but whose size is not exactly 16MB is never shipped at all, for reasons the next section explains.
Most of what we ship is empty. Over a 54 hour staging soak the archiver produced 3,277 segments with no gaps, occupying 7.70 MiB in total: roughly 2.5KB stored per 16MB source file. The cost of this feature is not storage, it is the number of PUT requests.
In the bucket: two prefixes per database, one for base snapshots and one for segments.
<env>/wal/<userId>/<databaseId>/base/<timestamp>.tar.zst
<env>/wal/<userId>/<databaseId>/segments/<walFileName>.zstSegment objects keep Postgres's own archive name verbatim, because WAL names sort lexically in the order Postgres replays them, so retention and restore reason about ordering without parsing anything. The prefix is also structurally separate from every prefix holding a logical dump, because the WAL sweep is the only code we have that deletes objects it found by listing rather than by following a database row.
Underneath: pg_basebackup -Ft -X none takes a fresh base snapshot daily and retention keeps eight, so reachable history runs about a week. That bound only exists because something rotates the bases: an earlier version had none, so a healthy database kept its enable-time base forever and nothing was ever pruned.
The uncomfortable decision is what happens when shipping stalls. Postgres will not recycle an unarchived segment, so a broken shipper ends as a full disk that takes down the database and every neighbor on that volume. The spool is therefore bounded: paged at 256MB of backlog, and at 2GB the pipeline points archive_command at /bin/true, discards segments, and stamps the chain broken, loudly. We would rather lose a restore window we can rebuild than a database we cannot.
The rename that turned out to be the whole feature
The first version of that archive command was a single cp straight to the final name. One line shorter, and wrong.
A cp publishes the file's name before its bytes. The destination exists at size zero for the whole duration of the copy, and the host sweep runs every 15 seconds with no idea the file is still being written. So the sweep found a zero-byte file, compressed those zero bytes into a 13-byte zstd frame, uploaded it under the segment's real key, and deleted the source out from under the running cp, which still exited zero. Postgres marked the segment archived and recycled the only good copy.
The archive looked healthy the entire time. Every object present, every object correctly named. Four of them, out of 3,299, were 13 bytes: 0.12 percent, about what an 18 millisecond copy racing a 15 second tick should produce, and small enough to survive casual inspection. A restore that needed the first one ran for 23.7 minutes and then died with FATAL: archive file has wrong size: 0 instead of 16777216.
The fix is the temp name plus the mv. Same directory, so it is an atomic rename: the final name does not exist until all 16MB are there, and the shipper skips the temp suffix. The test ! -f guard is the form the Postgres docs specify, because an archive command that overwrites is how a re-archived segment after a crash destroys a copy that was already good.
It is the same class of bug as the restore that reported success and returned an empty database, and nothing but an executed restore finds either of them.
Reading it back
A restore never touches the source database. It builds a new one.
Given a target timestamp, the planner picks the newest completed base snapshot taken at or before it, because every base further back costs every segment in between. If none qualifies the API refuses rather than restoring from a base taken after the moment the operator asked about.
Then it stages segments: everything lexically at or above the base's start WAL file, which comes out of backup_label, plus every timeline history file. If backup_label is unparseable it stages everything rather than guessing a floor, because a floor guessed too high omits the segments the replay starts from and produces a database that stops short of the target with no error anyone would recognize.
The recovery block written into the restored copy is four lines and a recovery.signal file.
restore_command = 'cp <staging>/%f %p'
recovery_target_time = '...'
recovery_target_action = 'promote'
recovery_target_inclusive = falserestore_command is a plain copy for the same reason archive_command is. promote opens the restored cluster read-write on a new timeline, so nobody has to run a second step. recovery_target_inclusive = false excludes a transaction committed exactly at the target, which makes the boundary statable in one sentence: committed before your target is present, committed at or after it is absent.
The prefetcher reads further ahead than the restore does
There is a fifth line in that block, and it is the one we would not have predicted: recovery_prefetch = off.
PostgreSQL 15 and later prefetch WAL through a reader that runs ahead of the redo loop, and the read-ahead window is bounded by decoded record bytes rather than by log position. wal_decode_buffer_size defaults to 512kB of decoded records.
Our archives are the pathological shape for that. archive_timeout = 60 closes a 16MB segment every minute no matter how little was written into it, so a quiet database produces long runs of segments carrying a couple of hundred bytes each, and 512kB of decoded records can span hundreds of them. The prefetcher therefore pulls segments through restore_command far beyond the record redo is applying, and RestoreArchivedFile raises FATAL, not a warning, on a segment that is missing or the wrong size.
The result is a restore that dies on damage it was never going to replay. Measured on staging on 2026-08-31 against that same 3,299-segment archive, with its zero-length segments still in it: restores targeting moments 34, 35 and 36 segments below the damage promoted cleanly, while restores targeting 26 and 30 segments below it died on a segment they had no business reading. Replay never went past its target in any run. The prefetcher did.
The failure therefore looked target-dependent, which is the worst shape a recovery bug can take, because it reads as flakiness rather than as a rule. The honest description before the fix was "restores sometimes fail". After it, the identical run stops with recovery stopping before commit of transaction 2610 and promotes.
What this design does not do
The Databricks piece describes capabilities this architecture does not have and will not grow into by itself.
There is no page server, no GetPage@LSN, and no disaggregation. Reads never touch object storage; the archive is a recovery input, not a read path. A database here is a container with a data directory, and moving it to another host is a real operation with real bytes, not a matter of pointing a stateless node at a different log position.
Branching is not built on this. Branches on Layerbase are filesystem clones: copy-on-write on ZFS where the engine supports it, a plain file copy where it does not. Separate mechanism, many more engines than Postgres, and none of the pointer economics a log-structured storage tier gives you, so our branches are not free at 2TB the way a pointer is. The branching post covers it on its own terms.
Archiving pauses when a database sleeps. Free-tier databases hibernate when idle, and a stopped Postgres writes no WAL. A gapped chain cannot honestly promise restore to any timestamp, so this requires a database pinned always-on. That is a property of the mechanism; which plan it comes with is a packaging decision, and that answer is Pro and above.
FerretDB gets it free, which is the one place we get Lakebase-shaped leverage. FerretDB stores its documents as rows in a real Postgres cluster, so documents written over the MongoDB wire become WAL records and the same archive and replay reach them. A restore-to-any-timestamp document database falls out of a decision made for a different reason.
Where full disaggregation would actually pay
The case for going all the way is real, and it is not about durability. It is about what becomes cheap. If the log is the source of truth and pages are materialized on demand, a branch is a pointer, a copy of a 2TB database costs nothing, and a compute node can suspend when idle and return in a few hundred milliseconds. That is worth having if your workload is many short-lived, mostly-idle databases: one per agent task, one per pull request, one per preview environment. Databricks bought Neon for a billion dollars in significant part because agents, not humans, were creating most of the databases on it.
We did not build it, for reasons about our size rather than about the design being wrong. A pageserver is not a component you add to a managed Postgres service. It is a second database engine, with its own compaction, garbage collection, layer-lookup problem across millions of files, and failure modes no PostgreSQL runbook covers. It only pays off at a scale of idle databases we do not have, and it would cost something currently valuable: what our customers run is stock PostgreSQL, page for page, so every piece of Postgres advice on the internet applies directly. Own the storage layer and you own every performance question about it. We found real throughput sitting on the floor in our own filesystem tuning without inventing a storage engine to look after.
A team our size gets to be excellent at a bounded number of things. Continuous archiving with a proven restore is bounded. A log-structured storage engine is not.
The limits, plainly
Archive lag is bounded by segment closure, not by the shipper. Under write load a 16MB segment fills and ships quickly; on an idle database archive_timeout closes it after 60 seconds and a ship tick follows, so the practical floor for a quiet database is a minute rather than seconds. Getting under that means streaming WAL records off the box continuously instead of shipping closed segments, a lever we have written down and deliberately not pulled. A disaggregated design gets it for free, because acknowledging the commit and durably placing the record are the same event.
The restore window is bounded by base retention and it is per-database. A restore produces a second live database rather than rewinding the first, which is the safe default and also means a restore during an incident leaves you something to clean up afterwards. That copy inherits neither archiving nor the always-on pin, on purpose: it is a forensic artifact, not a second production database quietly paying for a second archive.
The archive survives the loss of the machine; the restore flow does not, because rebuilding from base plus segments is an operation the platform performs. A whole-box disaster is still the logical dumps' job, which is one reason the dumps kept running when we shipped this. Two recovery paths with different failure modes is the design, and one being more precise is not a reason to delete the other.
Point-in-time restore covers PostgreSQL and FerretDB. Every other engine keeps scheduled and manual backups, which is a real limit and not one we are going to blur on a comparison table.
For the operational version rather than the architectural one, the point-in-time restore doc and the backups doc cover day-to-day behavior, and the build post has the drill that proved the boundary is exact. If you want a Postgres database that archives its log continuously without owning any of the above, start one.
Keep reading
- The Restore Said Success. The Data Was Gone.Our nightly restore drill caught a restore that reported success and handed back an empty database. Here is the race that caused it, how we found it, and why we drill restores instead of trusting green backup jobs.
- Point-in-time restore is generally available, including FerretDBContinuous WAL archiving and restore-to-any-timestamp are now switchable from the Backups tab of any always-on PostgreSQL or FerretDB database on the Pro plan. A restore builds a new database and never touches the source. FerretDB gets it because its documents live in Postgres, and we proved that over the MongoDB wire before writing this.
- Point-in-time restore for Postgres: we lose seconds now, not an hourHourly dumps mean a bad DELETE at :59 costs you most of an hour, unrecoverably. We built continuous WAL archiving and restore-to-any-timestamp for always-on Postgres on Layerbase, then proved it with an executed drill: the row written two seconds before the target came back, the row written four seconds after it did not.
- Postgres over HTTP for Vercel, Workers, and Lambda: the Neon serverless driver now works on LayerbaseEvery Layerbase Postgres now speaks the HTTP protocol used by @neondatabase/serverless. Pass your normal connection string to neon() and query from Vercel Functions, Cloudflare Workers, or AWS Lambda with no TCP socket, no VPC, and no RDS Proxy. Here is what works, what does not, and how it is built.