← Back to list

Better Call Kamal: A Rails 8 Deployment Case

On paper, the plan was simple: Rails 8, Kamal, and a fresh Hetzner VPS. But as every developer knows, the main steps rarely break you — the…

Samet Polat · 2026-04-05 20:29 · 102 claps · 5.7 min read
#deployment #ruby-on-rails #kamal #qemu #tailwind-css
Open on Medium ↗
Wiki topics: 🌐 · Web Development

Better Call Kamal: A Rails 8 Deployment Case

On paper, the plan was simple: Rails 8, Kamal, and a fresh Hetzner VPS. But as every developer knows, the main steps rarely break you — the quiet ones do. In my case, it was a production stylesheet that loaded without a single error and broke everything anyway.

The Stack: Four Tools, One Goal

The plan was four tools doing exactly one job each:

  • Hetzner CX22 — the server
  • Cloudflare Registrar — the domain
  • Docker Hub — the container registry
  • Kamal — deployment (Rails 8’s native tool)

And they had to work in a specific order:

App works locally
      ↓
Package it into a Docker image
      ↓
Push that image to Docker Hub
      ↓
Rent a server (Hetzner)
      ↓
Point a domain at that server (Cloudflare DNS)
      ↓
Tell Kamal to deploy the app
      ↓
Live

Simple in theory. Let’s take a quick look at each step.

Step 1: Docker — Shipping Container

Docker packages your app and its entire environment into a single portable unit called an image. Like a shipping container — it doesn’t matter what ship carries it, the contents always arrive intact and ready to run.

Rails 8 generates a Dockerfile by default, so building the image was one command:

docker build -t kleomarcus .

Before going further, I tested it locally:

docker run --rm -p 3000:3000 -e RAILS_ENV=production -e SECRET_KEY_BASE=placeholder kleomarcus

It worked. Here we go.

Step 2: Docker Hub — Warehouse Port

The server couldn’t pull an image directly from my Mac — because my Mac isn’t publicly accessible. So the image needed a warehouse: Docker Hub, a public registry that both sides can reach.

docker login
docker tag kleomarcus sametpolat7/kleomarcus
docker push sametpolat7/kleomarcus

The cargo is at the warehouse. The server just needs to come and pick it up.

Step 3: The Server

I went with Hetzner’s CX22 — more than enough RAM and storage for this project, at a reasonable cost, also with data centers in Germany and Finland — meaning it’s closer to Turkey.

During setup, Hetzner will ask if you want to add an SSH key. Can say yes. Because Hetzner doesn’t use passwords — it uses key-based authentication. You generate a pair on your local machine:

ssh-keygen -t ed25519 -C "kleomarcus"

The private key stays on your machine. The public key goes to Hetzner. Two keys that recognize each other — like a secret handshake. And when they shake hands, you’ll hear this: “The Bluetooth device is connecting successfully”.

I also configured ~/.ssh/config to connect with just ssh kleomarcus. A small detail — but when you're debugging at 11 p.m., small details are the ones that keep you sane.

Step 4: DNS — Say My Name

The server had an IP address. But humans don’t remember numbers; they remember names. DNS handles that translation.

I used Cloudflare for this. In their DNS dashboard, I added two A records.

One important detail: I set both to “DNS only” (the gray cloud icon in Cloudflare) instead of “proxy.” Because Kamal manages its own SSL certificates through Let's Encrypt, and Cloudflare's proxy would block the traffic in a way that interrupts that process. "DNS-only" means Cloudflare handles the lookup and doesn't touch the traffic itself. (But don’t worry — Kamal's proxy takes care of that part.)

Verification is simple:

ping kleomarcus.com
# PING kleomarcus.com (xx.xx.xxx.xxx): 56 data bytes

Say the name, get the address back — DNS is working.

Step 5: Kamal — Mr Goodman

So, if you want a simpler deployment — and you want to get it done quickly… Better Call Kamal!

Kamal is a deployment tool built by the Rails team and included with Rails 8. The configuration lives in config/deploy.yml:

service: kleomarcus
image: sametpolat7/kleomarcus

servers:
  web:
    - xx.xx.xxx.xxx

proxy:
  ssl: true
  hosts:
    - kleomarcus.com
    - www.kleomarcus.com

registry:
  username: sametpolat7
  password:
    - KAMAL_REGISTRY_PASSWORD

With that in place, one command does everything:

kamal setup

This single command:

  1. SSHed into the server
  2. Installed Docker
  3. Pulled the image from Docker Hub
  4. Started the container
  5. Configured the proxy for HTTP/HTTPS traffic
  6. Obtained a real SSL certificate from Let’s Encrypt

382 seconds later: kleomarcus.com was live.

Step 6: It Works on My Machine

The site loaded. But something was wrong. The navbar items were squashed together, the hero image wasn’t as I had declared, the spacing was broken throughout. My local version looked cool. But the live version something straight out of the 2000s.

I opened Chrome DevTools. No console errors. No failed network requests. The CSS file was loading. And there was one thing that caught my eye. Some Tailwind classes were working. But some weren’t.

Suspicious.

The first thing I checked was whether the stylesheet had actually loaded properly. I SSHed into the server and looked at the compiled assets.

The files were there. tailwind-c795ca27.css existed. So why was the layout broken?

What Tailwind Actually Does in Production

While the first analysis of Opus 4.6 had come up empty and a second was still running, one line in the agent’s “Thinking” panel caught my attention:

Checked the local environment — no issues. Compiled stylesheet is on the server too. One thing: Tailwind’s dev and production build processes aren’t the same.

That last line got me thinking and I did some research about Tailwind.

In production, Tailwind doesn’t include every possible CSS class in its output. It scans your source files at build time, finds every class you actually use, and only generates CSS for those. Use p-4 — it's in. Never use p-7 — it doesn't exist in your production CSS.

Which raised an obvious question: Do localhost and the server even use the same stylesheet? — Obviously not.

I went back to the local version, opened DevTools, and downloaded the stylesheet from the Sources tab. Then I asked the agent to compare it against the file on the server. — BINGO.

The 118 KB compiled CSS file on the server was missing 92 utility classes.

The Culprit

I knew what the problem was. Now I needed to know why.

My Mac runs Apple Silicon — ARM64 architecture. The Hetzner server runs x86_64 (amd64). Two completely different architectures.

The default deploy.yml had this:

builder:
  arch: amd64

That line tells Docker to build an amd64 image. But I was building it on an ARM64 Mac. To bridge the gap, Docker used QEMU — a tool that emulates one CPU architecture on another. My ARM64 Mac was pretending to be an x86_64 machine.

Here’s the culprit. Tailwind uses a component called the Oxide scanner — written in Rust — to read through your template files and find every class you’ve used. Under QEMU emulation, Oxide silently failed. No crash. No error message. It just didn’t scan the templates properly.

This explained exactly what I was seeing. DaisyUI component classes like btn, card, and navbar still worked — they're defined in plugin files and don't depend on template scanning. But every utility class that required scanning to justify its inclusion — p-4, m-8, and 90 others like them — simply wasn't there.

118 KB of CSS. Looked complete. Wasn’t.

The Evidence

One addition to config/deploy.yml:

builder:
  arch: amd64
  remote: ssh://root@xx.xx.xxx.xxx

By adding remote, I told Kamal: “don't build the image on my Mac; connect to the Hetzner server via SSH and build it there”. The server is natively amd64. No emulation, no QEMU, no scanner failures.

After redeploying, the CSS file grew from 118 KB to 139 KB. This meant that all 92 missing utility classes were now present.

As a result, the site rendered perfectly.

Conclusion

kleomarcus.com is live.

One kamal deploy command is all it takes now. Every single time.

Total setup time, including debugging: a few hours.

The slow part wasn’t the setup — it was the bug. Not because it was hard to fix, but because it never announced itself. The stylesheet was there. The logs were clean. Every indicator pointed to a system that was working. Silent failures are the hardest kind — they don’t leave a trail.

But once you see it, it makes sense. An ARM64 Mac emulating amd64 via QEMU. A Rust-based scanner relying on low-level filesystem operations — exactly the kind that breaks quietly under emulation. A failure so deep in the stack that nothing on the surface even flinched. Just 92 classes, gone without a word.

If your logs ever tell you everything is fine while your eyes tell you otherwise — you know who to call now.


메타데이터
post_id
a2f486e03c63
slug
better-call-kamal-a-rails-8-deployment-case-a2f486e03c63
url
https://medium.com/@sametpolat7/better-call-kamal-a-rails-8-deployment-case-a2f486e03c63
canonical_url
https://medium.com/@sametpolat7/better-call-kamal-a-rails-8-deployment-case-a2f486e03c63
author_url
https://medium.com/@sametpolat7
status
ok
fetched_at
2026-06-24 04:09:36