Skip to content
Migration

From builder hosting to your own cloud without downtime

Zero-downtime migration from builder hosting to your own cloud: inventory, parallel build, three shapes of data move, and a cutover you rehearse first.

12 min readKato Chojiro

Migrations fail in boring ways. Not exotic data corruption - a forgotten webhook endpoint, a sequence that was never advanced, a DNS TTL of 86400 that someone noticed on cutover morning. The technical work is well understood. What separates a two-hour cutover from a two-day incident is inventory, rehearsal, and having written down what would make you turn back.

This is the playbook, in the order we run it.

Step 0: decide whether you should

Migration is a cost, not a virtue. We turn down this work more often than we take it. Good reasons to move:

  • The bill is scaling faster than revenue, and the pricing model is per-seat or per-request in a way that punishes success.
  • You need something the platform cannot do: background workers, private networking, a specific region for compliance, a long-running process, a VPC peer to a customer.
  • A customer's security review requires controls the platform does not offer.
  • Your team now writes real code, and the visual editor has become a bottleneck rather than an accelerator.
  • Concentration risk is genuine: the platform is early-stage and your business depends on it.

Bad reasons: the bill is forty dollars a month, you read that real companies use Kubernetes, or you have a free weekend. If nothing is currently blocked, the migration is technical debt you are voluntarily taking on.

Step 1: inventory, because you cannot migrate what you have not listed

This step finds the surprises, and it is the step people skip. Write an actual document. Every line becomes a row in the cutover runbook.

  • Runtime: what starts the app, on what command, with what environment variables.
  • Database: engine, version, size on disk, largest tables, extensions in use, and whether anything depends on platform-specific functions.
  • Auth: where users live, how sessions are signed, and whether password hashes are exportable. This is the item most likely to force a compromise.
  • File storage: buckets, total size, public versus private, and every URL format currently embedded in your data.
  • Outbound integrations: email, payments, analytics, LLM providers, SMS.
  • Inbound webhooks: Stripe, Slack, GitHub, calendar providers. Each one has a URL registered somewhere in a third party's dashboard.
  • Scheduled jobs and cron, including anything running inside the platform's own scheduler.
  • Domains, DNS records, TLS certificates, and who controls the registrar login.
  • Humans: who has access to what, and what needs revoking afterwards.

Step 2: design the target around your traffic

The boring default is right for almost every app leaving a builder: containerised app on a managed platform, managed Postgres, object storage, a CDN in front. That is Fly, Render, Railway, App Runner, Cloud Run, or ECS - the choice matters much less than people expect, and the operational burden of the fancier options is real.

Define it as code from the first commit. Reproducibility is the entire point of owning your infrastructure; an environment that exists only as a sequence of console clicks has swapped one lock-in for a worse one.

  • Terraform, Pulumi, or SST for the infrastructure itself.
  • A Dockerfile that builds identically on a laptop and in CI.
  • Environments as a parameter, not as a copy-pasted directory.
  • Secrets in the provider's secret manager, injected at runtime, never baked into an image.

Step 3: build in parallel and verify with real data

The new stack goes up alongside the old one and runs against a restored copy of production data. Anonymise personal data for this pass - you are about to give a new environment to a wider group of people than production has.

bash
# Restore a production dump into the new database
pg_dump --format=custom --no-owner --no-acl "$OLD_DATABASE_URL" > prod.dump
pg_restore --no-owner --no-acl --dbname "$NEW_DATABASE_URL" prod.dump

# Then scrub anything you would not want in a staging environment
psql "$NEW_DATABASE_URL" <<'SQL'
update auth.users
   set email = 'user+' || substr(md5(id::text), 1, 8) || '@example.invalid';
update public.profiles
   set phone = null, address_line1 = null;
SQL

Then run the golden paths by hand: signup, login, the core action your product exists for, a payment, a file upload, an email send. Write them down as a numbered list - this list becomes your cutover smoke test, and you will run it twice more before the day is over.

Step 4: pick the shape of the data move

There are three, and the right one depends almost entirely on how long you can tolerate writes being paused.

A. Dump and restore, with a maintenance window

Under roughly 20GB and with a business that can absorb thirty minutes of read-only mode, this is the correct choice. It is simple, it is easy to rehearse, and simplicity is worth a lot on cutover day. Put the app in read-only mode, take the dump, restore, verify, flip.

B. Logical replication, for near-zero downtime

Postgres to Postgres, with the source under your control, is the comfortable case. Create the schema on the target first, then stream:

sql
-- On the source
create publication migration_pub for all tables;

-- On the target (schema must already exist)
create subscription migration_sub
  connection 'host=old-db.example.com dbname=app user=replicator password=...'
  publication migration_pub;

-- Watch the lag until it is effectively zero
select subname, received_lsn, latest_end_lsn,
       now() - latest_end_time as behind
from pg_stat_subscription;
sql
-- Advance every sequence on the target after the final sync
do $$
declare r record;
begin
  for r in
    select c.relname as tbl, a.attname as col,
           pg_get_serial_sequence(c.relname, a.attname) as seq
    from pg_class c
    join pg_attribute a on a.attrelid = c.oid
    join pg_namespace n on n.oid = c.relnamespace
    where n.nspname = 'public'
      and pg_get_serial_sequence(c.relname, a.attname) is not null
  loop
    execute format(
      'select setval(%L, coalesce((select max(%I) from public.%I), 1))',
      r.seq, r.col, r.tbl
    );
  end loop;
end $$;

C. Dual write and backfill

When the source is not a database you can replicate from - a builder platform with only an export API, or a different engine entirely - you write to both systems for a period. Backfill historical records in batches, keep both writes in the request path, reconcile with a counting script until the diff is zero, then stop writing to the old one. It is the most work and the most flexible, and it is the only option that lets you run in a mixed state for weeks.

Step 5: DNS and TLS, a week early

  • Drop the TTL on the records you will change to 60 or 300 seconds, at least 48 hours before cutover, so the old value has expired from every cache.
  • Issue and validate the certificate on the new host before cutover day. Certificate issuance under time pressure is a bad experience.
  • If HSTS is enabled with a long max-age, confirm the new stack serves HTTPS correctly first. Browsers will refuse to fall back, which removes the option of an insecure emergency workaround.
  • Check the apex versus www setup, and whether your provider needs ALIAS or ANAME records rather than CNAME.
  • Do not change the registrar and the hosting in the same week. One variable at a time.

Step 6: the dry run is the deliverable

Rehearse the whole runbook against a clone, with a stopwatch. The output is a document where every step has an owner, an expected duration, a verification command, and a rollback line. If a step cannot be verified with a command, it is not a step, it is a hope.

#StepVerifyRollback
1Enable read-only modePOST returns 503Disable the flag
2Confirm replication lag is zeropg_stat_subscriptionWait, or abort
3Advance sequences on targetmax(id) vs currvalNone needed
4Point app config at the new database/healthz returns 200Revert config, redeploy
5Flip DNS or the proxy origindig + curl the new originFlip back, TTL 60
6Run the smoke test listAll ten paths passFlip back
7Disable read-only modeA write succeedsRe-enable

Step 7: cutover

Two people minimum: one executing, one watching dashboards and reading the runbook aloud. Nobody improvises. Pick a low-traffic window that is not a Friday afternoon and not the night before someone's holiday.

After the flip, watch error rate, p95 latency, and database connections for a full hour before anyone declares it done. Most migration failures surface in the first ten minutes; the rest surface when a scheduled job runs for the first time on the new stack.

Step 8: rollback is a decision you make in advance

Write the abort criteria before cutover, while you are calm, and give one named person the authority to call it. Something like: error rate above 2% for five minutes, p95 above one second, or any row-count check failing. During an incident nobody wants to be the person who says stop, which is exactly why the threshold has to be pre-agreed and impersonal.

Rollback only stays possible while the old stack can still accept writes. That is why 'delete the old environment' is never on the cutover checklist. It is a separate task, at least a week later, with its own checklist:

  1. Wait 7 to 14 days with the old stack running but receiving no traffic.
  2. Take a final archival dump of the old database and store it somewhere you control.
  3. Revoke old credentials, API keys, and third-party access grants.
  4. Remove old webhook endpoints from every provider dashboard.
  5. Cancel the plan, and only then delete the project.
  6. Update the runbook with what actually happened, including the steps that took longer than you estimated.

Done this way, a migration is a fairly dull day. That is the goal, and it is the shape of every Lovable to your own cloud migration we run. The interesting migrations are the ones where somebody skipped the inventory.

KC

Written by Kato Chojiro

Takes AI-built MVPs to production: security, data, and the infrastructure underneath.

Connect on LinkedIn
Book a Free Call

Want a second pair of eyes on your app?

The Production Check is free, and the written report is yours to keep.

Book a Free Call