Migrating from DigitalOcean to Hetzner: $1,432 to $233/mo
My notes
Summary
A production migration of a full-stack application (248 GB MySQL, 34 Nginx sites, GitLab EE, Neo4j) from DigitalOcean to a Hetzner dedicated server achieved zero downtime and cut monthly infrastructure costs from $1,432 to $233 - an 84% reduction with a hardware upgrade in every dimension. The 24-hour migration used MySQL master-slave replication, an intermediate Nginx reverse proxy, and scripted DNS TTL reduction to eliminate any service window.
Key Insight
The cost gap between managed cloud and dedicated servers is enormous for steady-state workloads:
- Hetzner AX162-R at $233/month vs DigitalOcean 192 GB droplet at $1,432/month - same workload, more powerful hardware
- Savings: $1,199/month / $14,388/year
- Hardware improvement in every metric: 32 vCPU -> 96 logical CPUs, 192 GB -> 256 GB DDR5, 600 GB SSD -> 1.92 TB NVMe Gen4 RAID1
Zero-downtime migration architecture (6 phases):
- Full stack installation on new server before touching DNS
- Web files cloned via rsync with
--checksumflag; incremental re-sync before cutover - MySQL master-slave replication using
mydumper(parallel, 32 threads) instead ofmysqldump- reduced what would be days to hours - DNS TTL reduced from 3600 -> 300 seconds via API script (A/AAAA only, never MX/TXT)
- Old server Nginx converted to reverse proxy via Python script parsing all 34
server {}blocks - traffic forwarded during propagation window - DNS cutover script flipped all A records in ~10 seconds
Non-obvious gotchas documented in detail:
- MySQL 5.7 -> 8.0 jump:
mysql.usertable schema mismatch (45 vs 51 columns) requiredmysqld --upgrade=FORCE;sysschema imported as tables instead of views - drop and rerun upgrade - Replication duplicate key errors (1062) on first start: fix with
SET GLOBAL slave_exec_mode = 'IDEMPOTENT' - App users with
SUPERprivilege bypassread_only = 1on the slave - must be revoked before cutover (24 users affected) - GitLab project webhooks still pointed to old IP after cutover - required a separate API bulk-update script
- SSL certs: rsync
/etc/letsencrypt/to new server, force-renew after cutover; useproxy_ssl_verify offin Nginx proxy config (cert is valid for domain, not IP)
Testing trick before DNS change: edit local /etc/hosts to point domains to new server IP - validates all services without affecting live traffic.
All migration scripts open-sourced on GitHub (DNS TTL update, nginx proxy converter, MySQL row count comparator, DNS cutover, GitLab webhook updater).