5 min readAlvin

Deploying Rails with Kamal and Thruster

engineeringdevopsrails

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: 2

The 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: 67108864

That 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 deploy

setup is idempotent-ish but really meant to run once. Every deploy after that:

kamal deploy

What 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: 3

Where 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.