In this series

  1. Why I modernized my homelab instead of upgrading it
  2. Making a homelab repository agent-friendly
  3. Building the new K3s foundation
  4. Proving the entire platform with one blog
  5. Handing PostgreSQL over to Amp (this post)
A developer watches a stream of historical records flow between an old and a new database machine, with a backup drive and a verification notebook on the desk

More than five years of history moved from the old cluster to the new one and was checked at both ends.

A stateless blog was a safe first test. PostgreSQL was different. If I broke it, rebuilding the container would not bring the data back.

The database holds my public IP history, written by the simple script that updates my public IP at Cloudflare DNS every 15 minutes. Every run since January 2021 has added a row: more than five years of history. No single row matters much, but I like having the whole timeline.

In the old homelab this would have been a weekend of checklists and nervous copy-pasting. This time I typed one sentence to Amp:

look into that control plane, migrate the old dns history table to the new Postgres instance, and also stand up the cloud flare dns updater job in the new cluster

Three jobs in one breath, with no plan and no mention of how to check anything. I did not care how the rows got from one cluster to the other, as long as all of them arrived and new ones kept coming.

What Amp did with one sentence

Once I had given it SSH access to the old Raspberry Pi control plane, it went looking. It found the table, counted 215,089 rows and noticed two old updater jobs still writing every 15 minutes. Then it came up with the plan I would have wanted if I had bothered to think about it: back up first, rehearse the copy while the old jobs kept running, then freeze the writers, copy the last few rows and compare both sides properly. I never described any of that.

The comparison is where it earned my trust. Its first check failed, and for a moment it looked as if five years of history had been damaged between two terminal windows. Amp exported both sides the same way, found them byte-for-byte identical and traced the mismatch to its own checking query. The data was fine. The check was wrong.

Before suspending the old jobs and letting the new updater touch a real DNS record, it listed what it was about to do and asked. I typed “yes”. It copied the final four rows and confirmed that both databases held 215,093 rows with identical checksums . New rows started arriving on schedule. Along the way it also fixed two bugs I had not asked about, one of which could have lost history while Kubernetes reported the job green. I learned about both from the summary.

Then I asked for the upgrade

The next day the new cluster still had one old part in it: PostgreSQL 15. I typed:

upgrade the deployed Postgres instance to the latest stable version

I did not say 18, I did not say how, and I half expected it to change 15 to 18 in the manifest and tell me it was done. Instead it checked the running server, worked out that the current release was PostgreSQL 18.6, and came back with the reason a tag swap would not work: a major release can change the on-disk format, and the official image had moved its data path too. Kubernetes would have rolled out a perfect image and left me with a database that refused to start. So it planned a dump and restore, and asked once whether it could push the change and run it on the live cluster. Thirty-four seconds later I replied “yes do that”. That was my last contribution.

A developer checks a restore between an old and a new database machine while an unplugged cable keeps the way back open

PostgreSQL 18 got its own directory on the same volume. The PostgreSQL 15 files stayed where they were, in case I needed them.

Two things happened in the next few minutes that I would have found hard on my own. Flux fought back: Amp had paused reconciliation so that GitOps could not recreate a writer mid-move, but a parent reconciliation restored it and the writers came back to life. Amp noticed, stopped them again and paused both layers. GitOps was doing exactly what I had configured it to do, at the worst possible moment!

After the restore the new server held 215,174 rows, the previous day’s total plus a day of new ones. Another Amp thread was already waiting to put Baby Tracker into the same database; the two agreed between themselves who owned it and who would wait. I was not part of that conversation.

From my one-line prompt to the final report took under fourteen minutes.

What actually changed

The database moved, it was upgraded and no rows are missing. What stayed with me is that I did not do it, and I barely steered it. Apart from lending it an SSH key, I wrote one sentence for each job, said yes twice and read two reports. Amp asked before touching DNS and before pushing to master, and that is the boundary I want to keep. Everything in between was decided by Amp, and decided better than I would have in a hurry.

To be fair, this was a small database. One table, a couple of hundred thousand rows; the move did not even need pg_dump. The job was small. The range Amp covered was not. It read the old cluster before touching anything, knew that a PostgreSQL major version and a Docker image layout are different kinds of change, noticed Flux undoing its work and understood why, fixed bugs in code I had not pointed it at, and negotiated with another agent over who owned the database. That is a wider range than I would cover on a good day, and nobody told it which of those things mattered.

That, I think, is the actual shift. My job was to describe the outcome and to know which two questions I wanted to be asked. The rest was somebody else’s problem, and for once that somebody was not future me.