Every item below ends in something you can verify: a list, a query result, a runbook step with an owner. The examples assume Linux hosts and PostgreSQL, and a target of one cloud provider or several.
Phase 1: Discovery — what really runs
Connections: what talks to what
Established connections with their owning process, then listening ports:
sudo ss -tnp state established
sudo ss -tlnpA snapshot misses the job that connects at 03:00. Sample every five minutes for a week that includes a month-end:
# /etc/cron.d/conn-sample (delete after discovery)
PATH=/usr/sbin:/usr/bin:/sbin:/bin
*/5 * * * * root ss -tnp state established >> /var/tmp/conn-$(hostname).logThen count process and peer pairs:
grep -h 'users:' /var/tmp/conn-*.log |
awk -F'"' '{n = split($1, a, " "); sub(/:[0-9]+$/, "", a[n-1]); print $2, "<->", a[n-1]}' |
sort | uniq -c | sort -rn | head -50Every peer you cannot name is an undocumented dependency. Sampling misses short connections, so also enable log_connections in PostgreSQL for that week.
Scheduled work
# as root
for u in $(cut -d: -f1 /etc/passwd); do crontab -u "$u" -l 2>/dev/null | sed "s/^/$u: /"; done
cat /etc/crontab; ls /etc/cron.d /etc/cron.hourly /etc/cron.daily /etc/cron.weekly /etc/cron.monthly
systemctl list-timers --allEach job needs an owner and a destination; jobs that write to a database keep writing to the old copy after cutover unless stopped.
Hard-coded addresses, mounts, hosts entries
sudo grep -rIEno '([0-9]{1,3}[.]){3}[0-9]{1,3}' /etc /opt /srv 2>/dev/null |
grep -vE ':(127[.]0[.]0[.]1|0[.]0[.]0[.]0)$' | sort -u
findmnt -t nfs,nfs4,cifs
grep -v '^#' /etc/hostsAdd your application directories and repeat for your internal domain name. Every hit changes during the move.
DNS records and their TTLs
dig +short NS example.com
dig +noall +answer app.example.com @ns1.example.com
dig +short TXT example.comQuery the authoritative server: a recursive resolver shows its remaining cache time, not the configured TTL. Each changing record's TTL sets how early you must lower it. In the TXT answer, find SPF ip4: entries for data center mail relays.
Licenses tied to hardware
lscpu | grep -E '^ *(Socket|Core|Thread)'
sudo dmidecode -s system-uuid
ip -br linkSoftware licensed per socket or core, or bound to a MAC address or host ID, will not move to a new VM unchanged. Read those terms before choosing a strategy.
Data volumes and growth
df -hT -x tmpfs -x devtmpfsSELECT datname, pg_size_pretty(pg_database_size(datname)) AS size
FROM pg_database ORDER BY pg_database_size(datname) DESC;Compare with three months ago for growth, then do the transfer arithmetic. Say 2 TB over a 1 Gbit/s link you can use half of: 16,000 gigabits at 0.5 Gbit/s is 32,000 seconds, almost nine hours before any restore starts. That number picks the data-move option in phase 4.
Output: one line per workload: owner, hosts, peers, jobs, DNS names, data size, licenses.
Phase 2: Decide per workload
Give each workload exactly one outcome:
- Retire: no owner and no real users in a week of connection logs. Switch it off before migrating.
- Retain: latency to on-site equipment, a license that forbids cloud use, or an end of life soon after the move.
- Rehost: the default under a fixed exit date. Same OS and config, new VM.
- Replatform: swap the component that costs the most operations time for a managed one, such as managed PostgreSQL, and change nothing else.
- Refactor: only when the workload is changing anyway, never as a precondition for leaving.
- Repurchase: commodity functions such as mail, ticketing or a wiki. Check data export and data location first.
- Relocate: move the virtualization layer whole to a hosted version of the same hypervisor when too many VMs face a fixed exit date. It buys time, not savings.
On a multi-cloud target, add provider, region and reason to each line: one provider per workload, for a stated reason. Our multi-cloud strategy guide covers whether a second provider pays for itself. Workloads that talk heavily in the phase 1 log share a wave and a provider.
A free cloud migration assessment starts here: we run the connection and cron inventory on your hosts, assign each workload an outcome and a wave, and size the data move against your link. Our cloud migration practice carries the rest.
Phase 3: The landing zone, before the first workload
Nothing migrates until this exists in code. On a multi-cloud target, build it per provider, with one identity provider and one set of tag keys.
- Accounts or projects: one per environment, plus one for audit logs and one for shared networking. Nothing runs in the management account.
- Network: address ranges that do not overlap the data center, a site-to-site VPN back to it, a dedicated private link only if phase 1 traffic justifies one, and DNS forwarding both ways.
- Identity: federation from your identity provider over SAML or OIDC, MFA, no long-lived keys for people, roles for services, one break-glass account that alerts when used.
- Infrastructure as code: everything in Terraform from day one, remote state with locking, state split per environment and provider, changes by reviewed merge request. When this layer is missing, Terraform consulting is where we start.
- Guardrails: organization policies that restrict regions to the EU (GDPR data residency, enforced rather than promised), deny public buckets, require encryption at rest and protect audit logging.
- Logging: audit logs from every account into the log account with explicit retention; metrics and logs (Prometheus, OpenTelemetry) running before the first workload, as a baseline.
- Cost tags:
owner,cost-center,environment,app,migration-wave, enforced by policy. Lowercase with hyphens: at least one major provider accepts only lowercase label keys.
The overlap check, including VPN client pools and partner networks:
import ipaddress
on_prem = ["10.0.0.0/16", "172.16.8.0/22", "192.168.50.0/24"]
cloud = ["10.0.128.0/17", "10.40.0.0/16"]
for a in on_prem:
for b in cloud:
if ipaddress.ip_network(a).overlaps(ipaddress.ip_network(b)):
print("overlap:", a, b)Done means a merge request creates an account with network, identity, guardrails and logging, and nobody opens a console.
Phase 4: Move the data
Dump and restore
Downtime is dump, transfer, restore and verification. Rehearse it on a copy and time it; if it fits the window with margin, stop here.
pg_dump -Fd -j 4 -f /backup/appdb appdb
rsync -a /backup/appdb/ migrate@TARGET_HOST:/restore/appdb/
pg_restore -h NEW_DB_HOST -U app -d appdb -j 4 --no-owner --no-privileges /restore/appdbManaged databases usually withhold superuser, hence the ownership flags.
Replication and a short cutover
When the copy outlasts the window, replicate ahead of time and cut over in minutes. PostgreSQL logical replication works across major versions:
-- Source: needs wal_level = logical (changing it requires a restart)
CREATE PUBLICATION migration FOR ALL TABLES;
-- Target: load the schema first with pg_dump --schema-only, then
CREATE SUBSCRIPTION migration
CONNECTION 'host=10.0.2.7 dbname=appdb user=replicator password=REPLACE_ME'
PUBLICATION migration;Schema changes are not replicated, so the freeze must cover DDL. Sequence values are set at cutover (runbook step 5). And once a table without a primary key or replica identity is published, UPDATE and DELETE on it fail on the source, breaking production. Find those tables first:
SELECT n.nspname, c.relname FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'r' AND n.nspname NOT IN ('pg_catalog', 'information_schema')
AND NOT EXISTS (SELECT 1 FROM pg_constraint p WHERE p.conrelid = c.oid AND p.contype = 'p');Files take two passes: a bulk copy days ahead, then a final delta during the freeze.
rsync -aHAX --numeric-ids /srv/data/ migrate@TARGET_HOST:/srv/data/
rsync -aHAX --numeric-ids --delete /srv/data/ migrate@TARGET_HOST:/srv/data/Verify the copy
Count rows in every table on both sides. For tables that hold money or identity, compare a hash:
SET TimeZone = 'UTC';
SELECT count(*), md5(string_agg(o::text, '|' ORDER BY o.id)) FROM orders o;Without the same time zone in both sessions, timestamps print differently and the hashes never match. Run large tables per id range. For files, a checksum dry run lists every difference:
rsync -aHAXnci --delete /srv/data/ migrate@TARGET_HOST:/srv/data/How long the rollback path stays open
Until writes reopen on the new side, rollback is a DNS change with no data loss. After that, the old side falls behind. Either set up reverse replication at cutover (drop the forward subscription, publish from the new database, subscribe the old one with copy_data = false) or agree in writing that a late rollback means re-entering data by hand. Close the window on a business event, such as the first month-end invoicing run succeeding on the new side.
Phase 5: The cutover runbook
Before the day
- T-14 days: full rehearsal on a clone, rollback included, every step timed.
- One old TTL plus margin before: lower every changing record to 300 seconds and confirm with
digagainst the authoritative server. A record at 86400 needs at least a day; a week is safer. - T-3 days: change freeze on both sides: no deploys, schema or config changes.
- T-1 day: go/no-go meeting. Rehearsal passed, lag near zero, latest backup restore-tested, rollback written, one named person decides on rollback.
On the day, in order
- 1Enable the maintenance page and stop application writes.
- 2Stop cron and timers on the old hosts:
systemctl stop cron(orcrond, depending on the distribution), andsystemctl stopfor each.timerunit from your phase 1 inventory. - 3On the source, wait for zero lag:
SELECT application_name, pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) FROM pg_stat_replication; - 4Run the final rsync delta.
- 5Set each sequence, for example
SELECT setval(pg_get_serial_sequence('orders', 'id'), (SELECT max(id) FROM orders));, then run the verification queries. - 6Smoke-test the new side under its real hostname before touching DNS (command below).
- 7Switch DNS or the load balancer, then repeat the smoke tests from outside.
- 8Decide go or no-go at the time fixed in advance: go reopens writes, no-go runs the rollback.
curl -sS -o /dev/null -w '%{http_code} %{time_total}' --resolve app.example.com:443:203.0.113.10 https://app.example.com/healthzGo/no-go criteria
- Counts and hashes match, and every smoke test passes.
- Error rate and p95 latency within the range measured on the old side the week before.
- No replication or application errors since the switch.
Smoke tests
- Log in, read a known record, create and delete a test record.
- Run one background job; send one email and confirm SPF passes.
- One call per external integration: payment sandbox, partner SFTP, webhooks.
The rollback trigger
One sentence, agreed before the day. For example: "If any go/no-go criterion fails and is not fixed by 02:00, or the 5xx rate stays above the old baseline for 15 minutes in the first 24 hours, we roll back. The migration lead decides; nobody debates it on the call."
Keep the old side answering or redirecting for some days; some resolvers and clients cache beyond the TTL.
Phase 6: After the move
- Decommission deliberately. On each old host,
sudo ss -tn state established '( sport = :5432 )'lists clients still reaching the old database; each is a missed dependency. Keep the host powered off for two weeks before wiping it, revoke its credentials, tunnels, firewall rules and DNS records, and cancel its licenses. A forgotten host stops being patched but stays reachable, and its cron jobs keep running. - Rightsize on real data. After a few weeks that include a month-end, size to observed p95 plus headroom, not to the on-prem allocation. Commitment discounts come after rightsizing.
- Budgets and tags. Budget alerts per account and a weekly list of untagged resources.
- Test backup and disaster recovery in the new home. A green backup job is not a test. Restore a database to a fresh instance and time it, keep copies in another account and region, and compare measured recovery time and data loss with what the business agreed. Our backup and disaster recovery work starts from that restore.
Common pitfalls, and how to catch them early
- Overlapping address ranges: run the overlap check before the first network exists.
- The forgotten consumer: a week of
sssampling, database connection logs, and the post-cutoversscheck. - Allowlists pinned to old IPs: partner firewalls, payment providers, SFTP peers, your own SPF record. List your egress IPs in phase 1 and ask each integration owner then.
- A chatty pair split across the VPN: application in the cloud, database still on-prem. Multiply the round trip by the queries per page: say 200 queries over a 15 ms round trip, which adds 3 seconds.
- Tunnel MTU:
ping -M do -s 1472 CLOUD_HOSTfrom the data center sends an unfragmentable 1500-byte packet. If it fails, lower the size until it passes and set MTU or MSS clamping to match; otherwise large responses hang while small requests work. - An unrehearsed rollback: if the rehearsal skipped it, you do not have one.
Before wave one, the free cloud migration assessment covers what fails quietly: the dependency map from your own hosts, the address-range and TTL checks, the data-move arithmetic for your largest database, and a draft first-wave runbook with go/no-go criteria and a rollback trigger.