Infrastructure · 20 Aug 2026

The migration isn't done when the app responds

Teams declare a migration finished the moment the service returns 200. That's the point where the interesting work starts. Here's what proving it actually took, and the three things it found.

Amit Ranjan · 20 August 2026

There's a specific moment in every migration where everyone relaxes. The DNS has propagated, you load the URL, and the application responds. Somebody posts a green tick in the channel.

I've come to treat that moment as roughly the halfway mark.

Not because something is likely to be broken right then — usually it isn't. But because at that point you have proven exactly one thing: the application starts and serves traffic in the new place. You have not proven you can get the data back if the box dies, that the thing you're running is the thing you think you're running, or that anyone other than you can operate it.

Those three take longer than the move.

"We have backups" is not a claim about backups

Every team I've asked has backups. Far fewer have ever restored one.

That gap is where the risk lives, because a backup job that exits zero tells you almost nothing. It tells you a process ran. It doesn't tell you the dump contains rows, that it was taken against the right database, that it isn't silently truncating on a connection timeout, or that the volume you assumed was included was ever mounted in the first place.

When I moved my own applications off a managed platform onto a single VPS, I decided the migration wasn't finished until I had performed a restore rather than described one. Concretely, that meant:

  • Restore every database dump into a scratch instance, then compare row counts table by table against the source. Not "the restore completed" — the same numbers on both sides.
  • Run an integrity check on the SQLite database rather than assuming a file copy is a valid database. It is usually fine. Usually is doing a lot of work in that sentence.
  • Compare the large data volume byte for byte. It came to 954,306,698 bytes on both sides. That number is only interesting because it's identical; if I'd checked size-to-the-nearest-megabyte I'd have learned nothing.

None of that is clever. It's just the difference between a backup and a proven backup, and it's the part that gets skipped because it happens after the green tick, when everyone has moved on.

Three things the process found

I expected the restores to be a formality. They weren't — not because a restore failed, but because setting them up forced an inventory, and the inventory disagreed with what I believed.

Four containers were named postgres. Two of them were running different major versions. I'd assumed one Postgres. There were three application databases across two major versions, plus the orchestration platform quietly running its own. Any of my mental models about upgrades, dumps or compatibility would have been wrong on at least one of them. I wrote that one up separately because the way it surfaced is more interesting than the fact itself.

One application had no database at all. I'd been planning backup coverage for a service whose entire corpus is Markdown files baked into the image and loaded into memory at startup. It needed no dumps and no volume. I'd have happily written "nightly backup" next to it in a document and never noticed.

My own migration inventory had gone stale. Four rows in the document I'd written to track this were wrong by the time I checked them against the running system. The document was younger than the drift.

That last one is the general lesson. A migration inventory is a snapshot, and a running system is not a snapshot. The only reliable inventory is one you regenerate by asking the machine, and the restore is what forces you to ask.

Two things I chose not to fix

It would be tidier to end there, but the honest version includes what I left alone.

The weekly pull to my own machine runs as a scheduled task that only fires when I'm logged in. Making it run regardless requires storing a Windows password, and I'd rather have the gap than the stored credential. The machine is in daily use and a missed week costs nothing at current data volumes. That's a judgement, and it's revisable — but it's deliberate rather than overlooked.

The nightly job is pinned to a cron expression in the server's timezone, which means the hour it runs shifts when European daylight saving ends. The backup still happens; only the hour moves. Not worth a fix, worth knowing.

I mention both because "we have backups" was the claim I started out suspicious of, and it would be a poor showing to replace it with an equally unexamined "we have proven backups." The proof has edges. Say where they are.

What done actually looks like

The checklist I'd now apply to any migration before calling it finished:

  1. The application serves traffic in the new environment.
  2. The environment can be rebuilt from a file in the repository, not from memory.
  3. Every datastore has a dump, and at least one dump has been restored and compared against the source — row counts, not exit codes.
  4. Any volume that matters has been verified byte for byte, not by size.
  5. The rollback has been executed at least once, not just written down.
  6. Someone other than the person who did the migration can deploy and restore it, from a document.
  7. The inventory was regenerated from the running system on the day you finished, not carried over from planning.

Item three is the one that separates a migration from a move. Items five and six are the ones that get skipped when a deadline is close, and they're the two that matter at 3am.


Most of this is unglamorous, and none of it demos well. It's also the part that determines whether the migration was a success or just hasn't failed yet.

If you're carrying a move that keeps getting deferred because nobody has time to prove it's safe — that proof is the work, and it's the kind of thing I do.