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

> Production migration of a full-stack app from DigitalOcean to Hetzner dedicated, cutting monthly costs from $1,432 to $233 with zero downtime.

Published: 2026-03-17
URL: https://daniliants.com/insights/migrating-from-digitalocean-to-hetzner-1432-to-233-per-month/
Tags: hetzner, digitalocean, server-migration, mysql, nginx, dedicated-servers, devops, cost-optimization

---

## 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).