Deploying Rails with Kamal and Thruster
Most Rails products don't need Kubernetes. They need to be online, fast, and cheap to run — and they need a deploy that any engineer on the team can trigger from their laptop without a runbook. Kamal plus Thruster covers exactly that, on a single VPS.
This is a walkthrough of how the pieces fit and the details that aren't obvious from the documentation — a guide to the tooling, not a case study.
What each tool actually does
Kamal is a deploy tool. It builds your app into a Docker image, pushes it to a registry, and rolls it out across one or more hosts over SSH — health-checking the new container before it swaps traffic over. There's no agent running on the server. Kamal SSHes in, runs Docker commands, and leaves.
Thruster is a tiny HTTP proxy that runs inside the container, in front of Puma. It terminates TLS (via automatic Let's Encrypt certificates), serves and caches static assets with far-future headers, does X-Sendfile acceleration, and compresses responses. It's what lets a single container answer the public internet directly, without an nginx sidecar to configure.
Together they replace a surprising amount of infrastructure: no load balancer, no separate reverse proxy, no cert-renewal cron, no platform.
The deploy config
Everything Kamal needs lives in config/deploy.yml. The shape that matters:
service: myapp
image: your-registry/myapp
servers:
web:
- 203.0.113.10
proxy:
ssl: true
host: api.yourdomain.com
app_port: 80
registry:
server: ghcr.io
username: your-github-user
password:
- KAMAL_REGISTRY_PASSWORD
env:
secret:
- RAILS_MASTER_KEY
- DATABASE_URL
clear:
RAILS_ENV: production
WEB_CONCURRENCY: 2The proxy block is Kamal's own front proxy (kamal-proxy) that lives on the
host and routes to your container — this is the layer that gives you
zero-downtime deploys. Thruster runs one level deeper, inside the container.
People conflate the two constantly. The rule of thumb: kamal-proxy handles
host-level routing and the blue-green swap; Thruster handles TLS termination and
asset delivery for the app itself.
Wiring Thruster in
Thruster wraps your existing server command. In the Dockerfile (the default
Rails 8 one already does this), the entrypoint becomes:
# Start the server via Thruster, which boots Puma behind it
CMD ["./bin/thrust", "./bin/rails", "server"]bin/thrust boots the proxy, which in turn boots Rails. A few environment
variables tune it — the ones worth setting:
env:
clear:
# Cache static assets aggressively; they're fingerprinted anyway
THRUSTER_HTTP_PORT: 80
THRUSTER_TARGET_PORT: 3000
THRUSTER_X_SENDFILE_ENABLED: 1
THRUSTER_MAX_CACHE_SIZE: 67108864That last one is the in-memory asset cache. For an API that serves mostly JSON it barely matters; for anything serving packs or images it's the difference between hammering Puma and never touching it.
Secrets, the part that bites first
Kamal reads secrets from .kamal/secrets, which is itself a shell-sourced file
— so you never commit real values. A version that pulls from the environment or
a password manager:
# .kamal/secrets
KAMAL_REGISTRY_PASSWORD=$(gh auth token)
RAILS_MASTER_KEY=$(cat config/master.key)
DATABASE_URL=$(op read "op://Deploy/myapp/DATABASE_URL")The first failed deploy is almost always a missing or misnamed secret. Kamal
will happily build and push an image whose container then crash-loops because
RAILS_MASTER_KEY never made it in. Check kamal app logs before you suspect
anything more exotic.
The first deploy
kamal setup # installs Docker on the host, boots kamal-proxy, first deploysetup is idempotent-ish but really meant to run once. Every deploy after that:
kamal deployWhat happens under the hood: build the image, push to the registry, pull it on the host, boot a new container, wait for its health check to pass, then tell kamal-proxy to swing traffic to it and retire the old one. If the health check never passes, the old container keeps serving and the deploy fails loudly — which is exactly what you want.
Make sure you have a health endpoint. Rails ships one at /up; point Kamal at
it:
proxy:
healthcheck:
path: /up
interval: 3Where this stops being enough
The setup grows the obvious way: a single box first, then a couple of web
hosts behind the proxy. The limits worth knowing before you commit to it:
- Stateful services (Postgres, Redis) belong on managed instances, not in Kamal-managed containers. Losing your database to a bad deploy is not a trade worth making to save on hosting.
- Background jobs go in a second Kamal role (
workers) off the same image, so app and jobs never drift out of sync. - Genuinely outgrowing one region, or needing autoscaling, is a real platform conversation — but it's a migration you make with revenue, not a tax you pay on day one.
That's the appeal. Kamal and Thruster get you a TLS-terminated, zero-downtime Rails deploy in an afternoon, on infrastructure you fully understand and can debug at 2am. For a product that needs to work before it needs to scale, that's a reasonable place to start.
Building something and want a team that takes it from empty repo to shipped? We'd love to hear about it.