I run every one of my side projects the same way: a Rails 8 app, a €4/month Hetzner box, Kamal 2, Cloudflare in front. It's cheap and it works. But the first deploy of every new project costs me an evening, and it's almost always one of the same 14 mistakes. Here they are, with the fix for each, in the order you'll probably hit them.
1. hcloud: command not found
You're creating the server from a script and the Hetzner CLI isn't installed. brew install hcloud on macOS, or grab the binary from the GitHub releases page. Create a Read & Write API token in the Hetzner console (project → Security → API tokens).
2. Permission denied (publickey) right after the server boots
The key you uploaded to Hetzner isn't the one your ssh-agent offers. Run ssh-add ~/.ssh/id_ed25519, or point your bootstrap at the key you actually use. If the box was created with the wrong key, delete it and recreate — it's 30 seconds.
3. docker: permission denied during kamal setup
Your deploy user isn't in the docker group yet (cloud-init still running) or you connected before usermod ran. Wait a minute, or sudo usermod -aG docker deploy and reconnect. Kamal 2 defaults to root; if you use a non-root user, set ssh.user in deploy.yml.
4. Missing secret 'KAMAL_REGISTRY_PASSWORD'
Kamal reads .kamal/secrets, which reads your environment. If your secrets live in a .env file, source it first: set -a; . ./.env; set +a. A Makefile that sources the file before every kamal command removes this class of bug entirely.
5. denied: permission_denied: write_package (ghcr.io)
The GitHub token needs write:packages and read:packages. Fine-grained tokens don't support packages — use a classic token.
6. exec format error on the server
You built the image on an Apple Silicon Mac and shipped an arm64 image to an amd64 box. Set builder.arch: amd64 in deploy.yml, and make sure Docker Desktop has a multi-arch builder (docker buildx create --use once).
7. "Waiting for app to boot" then the healthcheck fails
Get the real error with kamal app logs. Four usual suspects:
-
RAILS_MASTER_KEYis wrong →ActiveSupport::MessageEncryptor::InvalidMessage. - The DB isn't reachable →
DB_HOSTmust equal the accessory container name (<service>-db), andPOSTGRES_PASSWORDmust match what the accessory was booted with. Changed the password after first boot?kamal accessory remove dbthenkamal accessory boot db. - Your app listens on a port other than 3000 → set
proxy.app_port. -
/upisn't routable (Rails < 7.1) → addget "up" => "rails/health#show"or changeproxy.healthcheck.path.
8. ERR_TOO_MANY_REDIRECTS in the browser
Cloudflare's SSL mode is Flexible. Set it to Full (strict) — kamal-proxy already has a valid Let's Encrypt cert, so Cloudflare can talk HTTPS to your origin.
9. Let's Encrypt fails: acme: error 403 / connection refused
Cloudflare's proxy (orange cloud) was on during the first certificate issuance, or DNS hadn't propagated. Keep the record DNS-only (grey cloud) for the very first kamal setup, confirm dig +short app.example.com returns the server IP, run kamal proxy reboot, retry. Turn the proxy on afterwards.
10. Assets 404 for a few seconds after every deploy
The old container stops serving old fingerprints during the swap. Add asset_path: /rails/public/assets to deploy.yml so both containers can serve both sets of assets during the overlap.
11. Uploads disappear after a deploy
They were written inside the container. Active Storage's local disk must live on a volume: mount <app>_storage:/rails/storage in deploy.yml and keep config/storage.yml pointing at Rails.root.join("storage").
12. The build dies with an out-of-memory error during assets:precompile
Small boxes plus Tailwind/esbuild. Add 2 GB of swap in your cloud-init (fallocate -l 2G /swapfile …), build locally instead of builder.remote, or move up one server size.
13. Every deploy takes 5+ minutes
Docker layer cache isn't reused. Your Dockerfile must copy Gemfile* and run bundle install before COPY . . (the Rails 8 Dockerfile does this). Also check you're not pushing a 2 GB image: docker images | head.
14. You have no idea what Kamal is doing
kamal deploy --verbose. To see the configuration exactly as Kamal parsed it: kamal config.
The part I got tired of redoing
The fixes above are easy once you know them. What's annoying is doing the setup by hand every time: creating the server, hardening it, opening the firewall, the Cloudflare records, the SSL mode, the secrets, checking everything before kamal setup.
So I scripted it. make server creates and hardens the box through the Hetzner API (Docker, deploy user, ufw 22/80/443, fail2ban, unattended upgrades, swap, sshd lockdown). make dns creates the A record and sets SSL Full (strict) through the Cloudflare API. make check runs 15 preflight checks. make first runs kamal setup. It also includes nightly Postgres backups to any S3-compatible bucket, a GitHub Actions deploy workflow, and this troubleshooting doc.
If it's useful to you, it's $10 here: https://payhip.com/b/yrO3i — with a refund if it doesn't save you an evening. Either way, happy to answer Kamal/Hetzner/Cloudflare questions in the comments.
Don't want to do it at all?
If you'd rather not spend the evening, I'll do the whole setup on your server for $150: Hetzner box created and hardened, Cloudflare DNS and SSL, Kamal 2 configured, first deploy done, nightly Postgres backups to S3, and a walkthrough of how to deploy from then on. You keep everything — the server, the domain, the accounts are all yours; I just set it up and hand it over. Nothing to pay until it's deployed and working.
Email me at gilbergarciata@gmail.com and tell me what the app is.
Top comments (0)