Migrating from DigitalOcean to Hetzner: $1,432 to $233/mo

2 min read
hetznerdigitaloceanserver-migrationmysqlnginxdedicated-serversdevopscost-optimization
View as Markdown
Originally from isayeter.com
View source

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):

  1. Full stack installation on new server before touching DNS
  2. Web files cloned via rsync with --checksum flag; incremental re-sync before cutover
  3. MySQL master-slave replication using mydumper (parallel, 32 threads) instead of mysqldump - reduced what would be days to hours
  4. DNS TTL reduced from 3600 -> 300 seconds via API script (A/AAAA only, never MX/TXT)
  5. Old server Nginx converted to reverse proxy via Python script parsing all 34 server {} blocks - traffic forwarded during propagation window
  6. DNS cutover script flipped all A records in ~10 seconds

Non-obvious gotchas documented in detail:

  • MySQL 5.7 -> 8.0 jump: mysql.user table schema mismatch (45 vs 51 columns) required mysqld --upgrade=FORCE; sys schema 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 SUPER privilege bypass read_only = 1 on 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; use proxy_ssl_verify off in 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).