Migrate Linux servers from AWS EC2 to Hetzner Cloud
Check your AWS dependencies, build the Hetzner network and servers, copy data, and switch traffic. Plan database rollback separately from DNS.
From AWS EC2 to Hetzner Cloud (dedicated vCPU) · Published 2026-10-08
Before you commit
- Hands-on effort
- 1–2 weeks for stateless apps; 1–3 months when databases and object storage move too
- Elapsed time
- Add a pilot, data-copy and replication windows, plus at least two weeks of rollback capacity.
- Examples cover
- AWS CLI v2 · Ubuntu 24.04 · hcloud CLI
- Content review
- 2026-10-09
- Integration testing
- Not recorded. Rehearse the commands and rollback on staging.
Prerequisites
- An AWS dependency and commitment inventory, Hetzner capacity, staging and infrastructure administration.
- Verified backups and a rehearsed data cutover and rollback procedure for every stateful service.
Don’t migrate yet if…
- Required regions, managed services, IAM controls or availability guarantees have no acceptable replacement.
- You cannot reconcile writes made after the database or storage cutover.
On AWS EC2 in us-east-1, an m7i.xlarge (4 vCPU, 16 GB) costs $150.82 a month on demand, before EBS disk ($0.08/GB-month for gp3) and egress ($0.09/GB after the first 100 GB). A Hetzner Cloud CCX23 with the same vCPU count and RAM costs $102.09 in Germany or Finland, including 160 GB of local NVMe and 20 TB of traffic. At 8 vCPU, it’s $297.99 on AWS against $163.59 on Hetzner.
Egress makes the biggest difference: 5 TB a month costs about $441 on AWS and nothing extra on Hetzner in the EU. Hetzner raised prices twice in 2026, so the gap is smaller than it used to be. Matching vCPU and RAM counts doesn’t guarantee the same performance. Compare your capacity and traffic in the calculator.
Before moving a VM, list the AWS services it calls, the data it needs and the network rules that protect it. Replacing those dependencies often takes longer than rebuilding the server.
0. Before you start
- Find out what you actually pay for. Group last month’s bill by service:
aws ce get-cost-and-usage --time-period Start=2026-09-01,End=2026-10-01 \
--granularity MONTHLY --metrics UnblendedCost \
--group-by Type=DIMENSION,Key=SERVICE
If EC2 is most of the bill, this guide fits. If RDS, S3, Lambda or managed Kafka dominate, moving the instances alone saves less than you’d think.
- List instances and volumes in every region you use:
aws ec2 describe-instances --region us-east-1 \
--filters Name=instance-state-name,Values=running \
--query 'Reservations[].Instances[].[InstanceId,InstanceType,Placement.AvailabilityZone,PrivateIpAddress,Tags[?Key==`Name`]|[0].Value]' \
--output table
aws ec2 describe-volumes --region us-east-1 \
--query 'Volumes[].[VolumeId,Size,VolumeType,Attachments[0].InstanceId]' --output table
- Check Savings Plans and Reserved Instances (
aws savingsplans describe-savings-plans,aws ec2 describe-reserved-instances). You can’t cancel them. Plan the cutover around their end dates, or move other workloads onto them. - Decide what moves first. Stateless app servers, workers and CI runners behind a load balancer are the easiest. Leave anything deeply tied to AWS (Lambda triggers, SQS/SNS fan-out, DynamoDB, IAM-role-based access to many services) where it is, at least for now. A hybrid setup is fine.
1. Pick a location
| Location | Code | Network zone | Included traffic per CCX server |
|---|---|---|---|
| Falkenstein, Nuremberg (DE) | fsn1, nbg1 |
eu-central |
20–60 TB |
| Helsinki (FI) | hel1 |
eu-central |
20–60 TB |
| Ashburn, VA (US) | ash |
us-east |
1–8 TB |
| Hillsboro, OR (US) | hil |
us-west |
1–8 TB |
| Singapore | sin |
ap-southeast |
1–8 TB |
The EU locations are the cheapest and include the most traffic. US and Singapore servers cost a little more and include far less traffic, so check the current figures if you serve a lot of data from there. Private Networks only span one network zone, so a database in nbg1 and app servers in ash can’t share a private network. Traffic between network zones is billed as outgoing traffic. If your users are in the US and your EC2 is in us-east-1, ash keeps latency about the same.
2. Map the AWS pieces
| AWS | Hetzner Cloud |
|---|---|
| m7i.large / xlarge / 2xlarge / 4xlarge / 8xlarge | CCX13 / CCX23 / CCX33 / CCX43 / CCX53 (same vCPU and RAM); CCX63 has 48 vCPU, 192 GB |
| EBS volume | Local NVMe on the server, or Volumes (network block storage, 10 GB–10 TB) |
| Security group | Cloud Firewall (stateful, inbound/outbound rules, applied to servers or labels) |
| VPC and subnets | Network with subnets, one network zone |
| ELB / ALB / NLB | Load Balancer (TCP, HTTP/HTTPS with managed certificates, health checks) |
| Elastic IP | Primary IP (bound to one server) or Floating IP (move it between servers) |
| AMI | Snapshot (hcloud server create-image --type snapshot) |
| Auto Scaling group | None built in. Use Terraform/OpenTofu, Kubernetes with the cluster autoscaler, or Nomad |
| RDS | Self-run PostgreSQL/MySQL on CCX servers, or a managed database from another provider |
| S3 | Object Storage (S3-compatible, fsn1, nbg1, hel1 only), or keep S3 |
| Route 53 | Keep it, or move zones to Hetzner DNS |
| IAM | API tokens per project (read or read/write), console roles per project member |
| CloudWatch | Your own monitoring: Prometheus + node_exporter, or a hosted service |
Hetzner Cloud has no equivalent of instance profiles. Anything on the new servers that still calls AWS needs an IAM user with a narrow policy and access keys stored as secrets. Rotate them, or use IAM Roles Anywhere if you already run a PKI.
3. Build the network and firewall
Install the hcloud CLI, create a project in the Hetzner Console, generate a read/write API token for it, and run hcloud context create prod.
hcloud ssh-key create --name ops --public-key-from-file ~/.ssh/id_ed25519.pub
hcloud network create --name prod --ip-range 10.10.0.0/16
hcloud network add-subnet prod --type cloud --network-zone eu-central --ip-range 10.10.1.0/24
hcloud firewall create --name web
hcloud firewall add-rule web --direction in --protocol tcp --port 443 \
--source-ips 0.0.0.0/0 --source-ips ::/0
hcloud firewall add-rule web --direction in --protocol tcp --port 22 \
--source-ips 203.0.113.10/32
Rebuild security groups rule by rule (aws ec2 describe-security-groups). Hetzner firewalls can’t reference other firewalls the way security groups reference each other, so use the private subnet’s CIDR for internal traffic. Traffic over the private Network isn’t filtered by Cloud Firewalls, so use a host firewall (nftables, ufw) if you need rules between your own servers.
4. Provision the servers
hcloud server create --name app-1 --type ccx23 --image ubuntu-24.04 \
--location nbg1 --ssh-key ops --network prod --firewall web \
--user-data-from-file cloud-init.yaml --label role=app
Reuse your EC2 user data if you have it. Hetzner servers run cloud-init too, but there is no instance metadata service with IAM credentials, and scripts that call 169.254.169.254 for AWS-specific data will fail. A minimal cloud-init.yaml:
#cloud-config
package_update: true
packages: [nginx, prometheus-node-exporter]
users:
- name: deploy
groups: sudo
shell: /bin/bash
ssh_authorized_keys: ["ssh-ed25519 AAAA... deploy"]
For anything beyond a few servers, use the hetznercloud/hcloud provider in Terraform or OpenTofu. It covers servers, networks, firewalls, load balancers, volumes and Primary IPs. A load balancer for the app tier:
hcloud load-balancer create --name web --type lb11 --location nbg1
hcloud load-balancer attach-to-network web --network prod
hcloud load-balancer add-target web --label-selector role=app --use-private-ip
hcloud load-balancer add-service web --protocol tcp --listen-port 443 --destination-port 443
5. Connect AWS and Hetzner during the move
During the transition, app servers on Hetzner will talk to databases and caches still on AWS. Don’t open those to the internet. Run WireGuard (or Tailscale, which uses it) between a small gateway in your VPC and one on Hetzner, and route the VPC CIDR and 10.10.0.0/16 through it. Choose non-overlapping ranges before you build anything. Expect a few milliseconds of latency between us-east-1 and ash, and around 80–100 ms to Germany. Chatty database access over that link will be slow, so move each app together with its data.
6. Move the data
Files and volumes: rsync over SSH, run once in bulk and again just before cutover:
rsync -aHAX --numeric-ids --delete --info=progress2 \
/srv/data/ deploy@hetzner-app-1:/srv/data/
PostgreSQL: for near-zero downtime, replicate instead of dump-and-restore. From self-managed PostgreSQL on EC2, set up streaming replication (pg_basebackup -R on the new server). From RDS, which doesn’t allow physical replication out, use logical replication. Set rds.logical_replication = 1 in the parameter group (this needs a reboot), grant the replication user rds_replication, then:
-- on RDS
CREATE PUBLICATION migrate FOR ALL TABLES;
-- on Hetzner, after loading the schema with pg_dump --schema-only
CREATE SUBSCRIPTION migrate CONNECTION 'host=... dbname=app user=repl password=...'
PUBLICATION migrate;
Logical replication doesn’t copy sequences or DDL. Before cutover, stop writes, set each sequence past its current maximum, then point the apps at the new primary.
S3: copy with rclone, configured with an s3 remote for AWS and one for Hetzner (provider = Other, endpoint nbg1.your-objectstorage.com). Check Hetzner’s list of supported S3 actions first if you use lifecycle rules, event notifications or object lock.
rclone sync aws:my-bucket hetzner:my-bucket --checksum --transfers 32 --progress
Egress during the migration. AWS bills data leaving AWS at $0.09/GB for the first 10 TB a month, so copying 20 TB costs roughly $1,750. AWS waives these charges for customers moving off AWS, if you ask first. Open a support case saying the request is for free data transfer to move off AWS. AWS reviews it per account and gives credits, and you then have 60 days to finish the move. It covers data transfer out only, not CloudFront or other services, and you don’t have to close the account.
7. Verify
- Run your test suite and a load test against the Hetzner stack through the load balancer, using a hosts-file entry or a test hostname.
- Compare row counts and checksums between the old and new databases, and object counts with
rclone check aws:my-bucket hetzner:my-bucket. - Confirm backups work: enable server backups (
hcloud server enable-backup app-1, seven daily slots), take a snapshot, and do a test restore of the database from your own dumps or WAL archive. Server backups aren’t a database backup strategy. - Check that monitoring and alerting fire for the new servers before they take traffic.
8. Cut over
- A day ahead, lower the TTL on the records you’ll change to 60 seconds.
- If you use weighted routing in Route 53, send 5–10% of traffic to the Hetzner load balancer first, then increase it.
- For stateful services: stop writes, wait for replication to catch up, promote the Hetzner database, then switch DNS.
- Watch error rates and latency on both sides until traffic to AWS stops.
9. What you give up
Agree on these trade-offs with your team before you start:
- Multiple zones in one region. AWS has several Availability Zones per region. Hetzner’s
fsn1,nbg1andhel1are separate locations in one network zone, so you can spread servers across them, but failover is up to you. - Managed services. No managed relational database, queues, autoscaling or serverless. You run them, or buy them elsewhere.
- Permissions. IAM can restrict access per resource and action. Hetzner tokens give read or read/write access to a whole project, so use separate projects to separate environments.
- SLA. AWS commits to 99.99% for EC2 deployed across two or more AZs and 99.5% per instance. Hetzner advertises a 99.9% uptime SLA for Cloud. Read both before you promise your own customers anything.
10. Rollback plan
Keep the AWS app stack ready to restart for at least two weeks. For stateless services, rolling back means restarting that stack and pointing DNS back at it. Clients follow as cached records expire, which is why the TTL is lowered in step 8.
Database rollback is a separate procedure. Switching DNS back doesn’t copy writes back. Before cutover, choose and rehearse one of two approaches: reverse replication (or CDC) from Hetzner to AWS, or a write freeze followed by a controlled copy-back. The subscription above only replicates one way. Reverse logical replication needs its own publication, subscription, credentials and network path, plus handling for sequences, DDL and replication loops, and the old database has to stay online as its target. Never leave both sides writable without a conflict strategy.
At rollback, pause writes, reconcile new data, verify replication or copy-back completion, then switch the apps and resume writes on only one primary. Apply the same plan to changed objects and files. If you cannot rehearse this within your recovery objective, migrate stateless services first and leave the data in place.
11. Clean up
- Take a final snapshot or AMI if you need one for compliance, then terminate the instances.
- Delete unattached EBS volumes and old snapshots and AMIs (
aws ec2 describe-snapshots --owner-ids self). They keep billing after the instances are gone. - Release Elastic IPs (
aws ec2 release-address) and delete idle load balancers and NAT gateways, which bill by the hour. - Let Savings Plans and Reserved Instances run out. You can sell unused Standard Reserved Instances on the RI Marketplace, but not Savings Plans.
- Raise the TTLs again, and revoke the IAM users and keys that were only used during the migration.
Found a command or version mismatch? Report a correction with the version and steps to reproduce it. Remove secrets and customer data first.