DEV Community

EXO
EXO

Posted on

Heroku to Hetzner with Kamal 2: the real bill, the real work, and when not to bother

I moved a Rails app off Heroku onto a single Hetzner box with Kamal 2 last month. The bill went from $121/month to about EUR 14. That headline is the easy part; what follows is the part nobody writes down: what actually has to be rebuilt, what it costs you in time, and when you should not do it at all.

The bill, honestly

The app: Rails 8, Postgres, Sidekiq, Redis, a few gigabytes of uploads, moderate traffic. On Heroku:

  • 2 x Standard-1X dynos, $50
  • Standard-0 Postgres, $50
  • Mini Redis, $3
  • Papertrail and a couple of small add-ons, $18

About $121/month.

After: one Hetzner CPX21 (3 vCPU, 4 GB) at roughly EUR 8, plus a EUR 6 volume for uploads and backups. Cloudflare free. Roughly EUR 14, call it $15.

That is a real saving of about $1,270 a year. It is also not free money: you just bought a second job. Read the rest before you decide it is worth it.

What Heroku was actually doing for you

This is the list people underestimate. Heroku was running, at minimum:

  1. Postgres backups, automatically, with point-in-time recovery.
  2. Postgres upgrades and patching.
  3. OS patching on the dyno hosts.
  4. TLS certificates, issued and renewed.
  5. Zero-downtime releases with an automatic rollback if boot fails.
  6. Log retention, searchable, without you thinking about it.
  7. A restart when your process dies, and a restart of the whole dyno every 24 hours, which quietly papered over your memory leaks.

Kamal gives you 4, 5 and 7 for free. Everything else is now yours. That is the actual trade: about $100/month against owning backups, upgrades, patching and log retention.

The migration, in the order I would do it again

1. Get the app running in Docker locally, first

Do not touch the server until docker build and docker run work on your machine. Rails 8 generates a production Dockerfile that is good enough. The two things that bite:

  • Anything you read from Heroku's environment. DATABASE_URL is provided by Heroku in a specific format; you are now providing it yourself.
  • Anything that assumed an ephemeral filesystem. If your app writes to tmp/ and reads it back later, it worked on Heroku only by accident of a single dyno; now it works until your second container, and then breaks confusingly.

2. Build for the right architecture

If you are on an Apple Silicon Mac, your local build is arm64 and your server is amd64. Set it explicitly:

builder:
  arch: amd64
Enter fullscreen mode Exit fullscreen mode

Otherwise your first deploy dies with exec format error, which tells you nothing.

3. Move the database with the boring method

# on your machine, with the Heroku CLI
heroku pg:backups:capture --app your-app
heroku pg:backups:download --app your-app   # gives you latest.dump

# copy it to the server, then restore into the accessory container
scp latest.dump deploy@your.server.ip:/tmp/
ssh deploy@your.server.ip
docker exec -i myapp-db pg_restore --clean --no-owner --no-acl \\
  -U myapp -d myapp_production < /tmp/latest.dump
Enter fullscreen mode Exit fullscreen mode

Two things to check before you cut over: that the Postgres major version on the server is not older than Heroku's, and that any extensions you use (pgcrypto, pg_trgm, postgis) exist in your image. The postgres:16 image does not ship PostGIS; you need postgis/postgis.

4. Move the uploads before you move the traffic

If you are on S3, there is nothing to do. If you were using Active Storage's local disk on Heroku, you have already been losing files on every deploy and probably do not know it. Either way, on the new box the storage has to live on a volume:

volumes:
  - "myapp_storage:/rails/storage"
Enter fullscreen mode Exit fullscreen mode

5. Cut over with a low TTL

Drop your DNS TTL to 60 seconds a day before. Then: put Heroku in maintenance mode, take a final dump, restore, flip DNS, watch. Keep the Heroku app alive and paid for a week. The rollback is one DNS change, and that is worth $30.

The four things that will actually bite you

Backups. A Docker volume is not a backup. The smallest thing that works, nightly:

docker exec myapp-db pg_dump -U myapp myapp_production \\
  | gzip > /var/backups/myapp-$(date +%F).sql.gz
Enter fullscreen mode Exit fullscreen mode

Then push it off the machine, to any S3-compatible bucket. And restore one into a scratch database this week: a backup you have never restored is a guess, not a backup.

Memory. Heroku restarted your dynos every 24 hours. Your server will not. If your app leaks, you now find out at 4am instead of never. Put a memory limit on the container and let it restart, or fix the leak. Two gigabytes of swap in cloud-init will also save you during assets:precompile.

Logs. kamal app logs reads the Docker logs of the running container. Deploy, and the old container's logs are gone. Configure Docker's json-file driver with max-size and max-file, or ship logs somewhere. Otherwise the first time you need to investigate something from yesterday, it is not there.

Postgres upgrades. Nobody's job until it is yours. Pin the major version in deploy.yml, and put a calendar reminder for the day that version leaves support.

When you should stay on Heroku

I would rather say this plainly than sell you a migration.

Stay if the app earns money, you are the only developer, and you have no interest in servers. A hundred dollars a month is cheap insurance against your own 3am.

Stay if you need real high availability. One box is one box. Kamal handles multiple servers fine, but then you are running a load balancer and replicated Postgres, and you have rebuilt the thing you were paying to avoid.

Stay if the app is pre-revenue and your time is worth more shipping features than saving $1,200 a year.

Move if you have several apps. The second, third and fourth cost almost nothing on the same box, and that is where the saving stops being marginal.

Move if you already run Docker somewhere and the operational part does not scare you.

What it actually takes

For a straightforward app: an evening for the Dockerfile and deploy.yml, an hour for the server and DNS, an hour for the data, and a week of watching. Say two evenings if nothing surprises you, and something always surprises you.


I keep the whole setup as a kit: the deploy.yml, plus scripts that create and harden the Hetzner box through the API, set up the Cloudflare record and SSL mode, run 15 preflight checks before the first kamal setup, and install the nightly Postgres backup to S3. It is $10: https://payhip.com/b/yrO3i

And if you would rather not spend the two evenings, I will do the migration for you for $150: server created and hardened, DNS and SSL, Kamal configured, database and uploads moved, backups running, and a walkthrough at the end so you can deploy yourself from then on. You keep every account. Nothing to pay until it is running. Email me at gilbergarciata@gmail.com and tell me what the app is.

Either way, ask in the comments. I answer Kamal, Hetzner and Heroku-migration questions whether or not you buy anything.

Top comments (1)

Collapse
 
topstar_ai profile image
Luis Cruz

It's insightful how you highlight the often-overlooked responsibilities that come with self-hosting, particularly the need for consistent backups and patching. I’ve encountered similar challenges when migrating applications and found that implementing automated backup scripts can save significant headaches down the road. If you're considering enhancements to your backup strategy or need support in optimizing your deployment process, I’d be happy to discuss a paid collaboration. How have you found managing these new responsibilities so far?