{"title":"Moving A 1 TB Postgres Database With Five Minutes Of Downtime","html_url":"https://deallocatedobjects.com/posts/moving-a-1-tb-postgres-database-with-five-minutes-of-downtime","source_url":"","markdown_content":"Last night I finished moving ShopSavvy's main Postgres database off Google's AlloyDB and onto a PostgreSQL server we run ourselves on DigitalOcean, on the same machine as the app. The database was a little under 1 TB. Services went down at 00:55 UTC and came back up at 01:00, talking to the new database.\n\nI did it by myself over most of a week, and it went smoother than I expected. Here's how it went.\n\n### The Shape of the Plan\n\nThere's no way to copy a terabyte in five minutes, so almost all of the work happens while the old database is still serving traffic. The plan had four big pieces:\n\n1. Make the database smaller before moving it.\n2. Copy a full snapshot to the new server with `pg_dump` and `pg_restore`.\n3. Keep the new server current with logical replication.\n4. Stop everything, confirm the copy has caught up, point the app at the new server and start everything again.\n\nOnly that last step is downtime. Before any of it, I made sure the database host and port came from environment variables everywhere, so switching servers meant editing config files, with no code changes and no deploys on cutover night.\n\n### Archive First\n\nThe cheapest data to move is data you don't move. A few tables had years of rows we never read anymore: old scrape runs, click logs and history. I copied every row older than our retention window into its own table with `CREATE TABLE AS`, dumped each one, uploaded the dumps to object storage, checked them there and then deleted the rows.\n\nOne thing worth knowing if you ever do this: check for `NULL` in the column you're filtering on. A `WHERE time_created < ...` clause quietly skips rows where `time_created` is null, and one table had 65.5 million of them. Adding `OR time_created IS NULL` and running a second pass picked them up.\n\nAll together that archived about 154 million rows into 21 GB of compressed dumps, and the biggest table went from 92.5 million rows to under a million.\n\n### Setting Up the New Server\n\nThe new box got 64 GB of RAM, 8 CPUs and 1.2 TB of NVMe. AlloyDB is Postgres 14 underneath, so I installed PostgreSQL 14 to match and added the extensions the app uses: `pgvector` for product embeddings, `pg_trgm`, `pg_stat_statements` and `hypopg`. I tuned the basics for the machine (`shared_buffers` at 16 GB, `effective_cache_size` at 40 GB), set `vm.swappiness` to 1, turned off transparent huge pages, and only allowed port 5432 through the firewall from my own IP.\n\n### Dump and Restore\n\nThe snapshot copy was the long part. `pg_dump` wrote a directory format dump, 76 GB compressed across 123 files, in a little over four hours. `pg_restore` turned that back into 574 GB on the new server in about four hours and forty minutes, building 1,057 indexes with none invalid. The only errors were three expected ones about AlloyDB's own extensions, which don't exist outside AlloyDB.\n\n### Logical Replication From AlloyDB\n\nA snapshot is out of date as soon as it's taken, so the new server subscribes to the old one with Postgres' built-in logical replication and receives every change as it happens. Getting AlloyDB to publish took a few steps:\n\n- Turn on the `alloydb.logical_decoding` database flag in the console, which restarts the instance, and confirm `wal_level` is `logical` afterward.\n- Grant the `REPLICATION` role to the user that owns the publication. AlloyDB doesn't hand it out by default.\n- List the tables explicitly. `FOR ALL TABLES` also picks up AlloyDB's internal `pgsnap` schema, and `FOR TABLES IN SCHEMA public` is Postgres 15 syntax, which a Postgres 14 server doesn't understand.\n\nSo the publication is a long `CREATE PUBLICATION ... FOR TABLE` with every table in the public schema, and the new server has a subscription pointed at it. You can watch it catch up from the subscriber side:\n\n```sql\nSELECT subname, received_lsn, latest_end_lsn\nFROM pg_stat_subscription;\n```\n\nWhen those two numbers match, the new server has everything the old one has sent.\n\nBefore calling it ready I ran nine smoke tests against the new database: row counts, product search, the offers join, the deals feed, saved items, price history, a pgvector similarity query across 1.16 million embeddings, and a check for invalid indexes.\n\n### Cutover Night\n\nWith replication caught up and the tests passing, the downtime part is short and very scripted:\n\n1. Stop the website, the API and every worker so nothing writes to the old database.\n2. Check that `received_lsn` equals `latest_end_lsn` one last time.\n3. Drop the subscription, which also releases the replication slot on AlloyDB.\n4. Point all 14 env files at `127.0.0.1`.\n5. Reset the sequences.\n6. Run `ANALYZE`.\n7. Start everything back up.\n\nStep five is the one people forget. Logical replication copies rows, but it doesn't copy sequence values, so every `serial` column on the new server still thinks it's at whatever number the restore left it at. The first insert after cutover would collide with an existing id. I reset all 85 of them, 72 owned by a column and 13 standalone ones. For a sequence owned by a column, that means setting it to the column's current maximum:\n\n```sql\nSELECT setval('products_id_seq', (SELECT max(id) FROM products));\n```\n\nAt 01:00 everything came back up. The site returned 200 and 107 connections were open against the local database.\n\n### After the Move\n\nRunning Postgres yourself means doing the things a managed service did for you, so that same night I set up a daily backup to object storage (written to a temp name and renamed when it finishes, so a half-written backup never looks like a good one), ran `VACUUM ANALYZE` on the biggest tables, and added a small monitor that posts disk, memory, connection counts and cache hit ratio to Slack. AlloyDB is staying up for a few days as a fallback before I shut it down.\n\nWith the database on the same machine as the app, every query skips the network hop it used to make, and the connection pool went from 200 down to 30. It's been a fun one.","html_content":"<p>Last night I finished moving ShopSavvy’s main Postgres database off Google’s AlloyDB and onto a PostgreSQL server we run ourselves on DigitalOcean, on the same machine as the app. The database was a little under 1 TB. Services went down at 00:55 UTC and came back up at 01:00, talking to the new database.</p>\n<p>I did it by myself over most of a week, and it went smoother than I expected. Here’s how it went.</p>\n<h2 id=\"the-shape-of-the-plan\" data-level=\"3\">The Shape of the Plan</h2>\n<p>There’s no way to copy a terabyte in five minutes, so almost all of the work happens while the old database is still serving traffic. The plan had four big pieces:</p>\n<ol>\n<li>Make the database smaller before moving it.</li>\n<li>Copy a full snapshot to the new server with <code>pg_dump</code> and <code>pg_restore</code>.</li>\n<li>Keep the new server current with logical replication.</li>\n<li>Stop everything, confirm the copy has caught up, point the app at the new server and start everything again.</li>\n</ol>\n<p>Only that last step is downtime. Before any of it, I made sure the database host and port came from environment variables everywhere, so switching servers meant editing config files, with no code changes and no deploys on cutover night.</p>\n<h2 id=\"archive-first\" data-level=\"3\">Archive First</h2>\n<p>The cheapest data to move is data you don’t move. A few tables had years of rows we never read anymore: old scrape runs, click logs and history. I copied every row older than our retention window into its own table with <code>CREATE TABLE AS</code>, dumped each one, uploaded the dumps to object storage, checked them there and then deleted the rows.</p>\n<p>One thing worth knowing if you ever do this: check for <code>NULL</code> in the column you’re filtering on. A <code>WHERE time_created &lt; ...</code> clause quietly skips rows where <code>time_created</code> is null, and one table had 65.5 million of them. Adding <code>OR time_created IS NULL</code> and running a second pass picked them up.</p>\n<p>All together that archived about 154 million rows into 21 GB of compressed dumps, and the biggest table went from 92.5 million rows to under a million.</p>\n<h2 id=\"setting-up-the-new-server\" data-level=\"3\">Setting Up the New Server</h2>\n<p>The new box got 64 GB of RAM, 8 CPUs and 1.2 TB of NVMe. AlloyDB is Postgres 14 underneath, so I installed PostgreSQL 14 to match and added the extensions the app uses: <code>pgvector</code> for product embeddings, <code>pg_trgm</code>, <code>pg_stat_statements</code> and <code>hypopg</code>. I tuned the basics for the machine (<code>shared_buffers</code> at 16 GB, <code>effective_cache_size</code> at 40 GB), set <code>vm.swappiness</code> to 1, turned off transparent huge pages, and only allowed port 5432 through the firewall from my own IP.</p>\n<h2 id=\"dump-and-restore\" data-level=\"3\">Dump and Restore</h2>\n<p>The snapshot copy was the long part. <code>pg_dump</code> wrote a directory format dump, 76 GB compressed across 123 files, in a little over four hours. <code>pg_restore</code> turned that back into 574 GB on the new server in about four hours and forty minutes, building 1,057 indexes with none invalid. The only errors were three expected ones about AlloyDB’s own extensions, which don’t exist outside AlloyDB.</p>\n<h2 id=\"logical-replication-from-alloydb\" data-level=\"3\">Logical Replication From AlloyDB</h2>\n<p>A snapshot is out of date as soon as it’s taken, so the new server subscribes to the old one with Postgres’ built-in logical replication and receives every change as it happens. Getting AlloyDB to publish took a few steps:</p>\n<ul>\n<li>Turn on the <code>alloydb.logical_decoding</code> database flag in the console, which restarts the instance, and confirm <code>wal_level</code> is <code>logical</code> afterward.</li>\n<li>Grant the <code>REPLICATION</code> role to the user that owns the publication. AlloyDB doesn’t hand it out by default.</li>\n<li>List the tables explicitly. <code>FOR ALL TABLES</code> also picks up AlloyDB’s internal <code>pgsnap</code> schema, and <code>FOR TABLES IN SCHEMA public</code> is Postgres 15 syntax, which a Postgres 14 server doesn’t understand.</li>\n</ul>\n<p>So the publication is a long <code>CREATE PUBLICATION ... FOR TABLE</code> with every table in the public schema, and the new server has a subscription pointed at it. You can watch it catch up from the subscriber side:</p>\n<figure class=\"code\" data-lang=\"sql\"><figcaption><span class=\"lang\">SQL</span></figcaption><pre class=\"shiki shiki-themes github-light github-dark\" style=\"--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">SELECT</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> subname, received_lsn, latest_end_lsn</span></span>\n<span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">FROM</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> pg_stat_subscription;</span></span></code></pre></figure><p>When those two numbers match, the new server has everything the old one has sent.</p>\n<p>Before calling it ready I ran nine smoke tests against the new database: row counts, product search, the offers join, the deals feed, saved items, price history, a pgvector similarity query across 1.16 million embeddings, and a check for invalid indexes.</p>\n<h2 id=\"cutover-night\" data-level=\"3\">Cutover Night</h2>\n<p>With replication caught up and the tests passing, the downtime part is short and very scripted:</p>\n<ol>\n<li>Stop the website, the API and every worker so nothing writes to the old database.</li>\n<li>Check that <code>received_lsn</code> equals <code>latest_end_lsn</code> one last time.</li>\n<li>Drop the subscription, which also releases the replication slot on AlloyDB.</li>\n<li>Point all 14 env files at <code>127.0.0.1</code>.</li>\n<li>Reset the sequences.</li>\n<li>Run <code>ANALYZE</code>.</li>\n<li>Start everything back up.</li>\n</ol>\n<p>Step five is the one people forget. Logical replication copies rows, but it doesn’t copy sequence values, so every <code>serial</code> column on the new server still thinks it’s at whatever number the restore left it at. The first insert after cutover would collide with an existing id. I reset all 85 of them, 72 owned by a column and 13 standalone ones. For a sequence owned by a column, that means setting it to the column’s current maximum:</p>\n<figure class=\"code\" data-lang=\"sql\"><figcaption><span class=\"lang\">SQL</span></figcaption><pre class=\"shiki shiki-themes github-light github-dark\" style=\"--shiki-light:#24292e;--shiki-dark:#e1e4e8;--shiki-light-bg:#fff;--shiki-dark-bg:#24292e\" tabindex=\"0\"><code><span class=\"line\"><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">SELECT</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> setval(</span><span style=\"--shiki-light:#032F62;--shiki-dark:#9ECBFF\">'products_id_seq'</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">, (</span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">SELECT</span><span style=\"--shiki-light:#005CC5;--shiki-dark:#79B8FF\"> max</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\">(id) </span><span style=\"--shiki-light:#D73A49;--shiki-dark:#F97583\">FROM</span><span style=\"--shiki-light:#24292E;--shiki-dark:#E1E4E8\"> products));</span></span></code></pre></figure><p>At 01:00 everything came back up. The site returned 200 and 107 connections were open against the local database.</p>\n<h2 id=\"after-the-move\" data-level=\"3\">After the Move</h2>\n<p>Running Postgres yourself means doing the things a managed service did for you, so that same night I set up a daily backup to object storage (written to a temp name and renamed when it finishes, so a half-written backup never looks like a good one), ran <code>VACUUM ANALYZE</code> on the biggest tables, and added a small monitor that posts disk, memory, connection counts and cache hit ratio to Slack. AlloyDB is staying up for a few days as a fallback before I shut it down.</p>\n<p>With the database on the same machine as the app, every query skips the network hop it used to make, and the connection pool went from 200 down to 30. It’s been a fun one.</p>","created_at":"2026-04-03T13:00:00.000Z","updated_at":"2026-04-03T13:00:00.000Z"}