Kamal 2 ships a purpose-built reverse proxy called kamal-proxy (a standalone Go binary). It replaces the need for a separate Nginx or Caddy process in front of your app. During a deploy, kamal-proxy holds connections while the new container starts, then atomically switches traffic once the health check passes. No requests are dropped.
Thruster sits between kamal-proxy and your Puma process inside the same container. It handles:
X-Sendfile accelerationThe traffic path looks like this:
kamal-proxy runs in its own container (kamal-proxy) on the host, not inside your app image. It persists across deploys—only the app container is replaced.
| Requirement | Notes |
|---|---|
| Kamal ≥ 2.0 | gem install kamal or add to Gemfile |
| Docker on servers | 24.x+ recommended; Kamal installs it if absent |
| SSH access | Key-based auth only; no password prompts in CI |
| Container registry | GHCR, Docker Hub, ECR, or any OCI-compliant registry |
| Rails 7.2+ | Earlier versions work but bin/thrust shim ships by default in 7.2 |
Install the gem globally on your workstation (or in CI):
shellgem install kamal # ~2.3.0 at time of writing
kamal version
In an existing Rails app, run the initializer:
shellkamal init
This generates config/deploy.yml, .kamal/secrets, and a Dockerfile if none exists.
Thruster is the default web server wrapper in Rails 7.2's generated Dockerfile. Its job is to sit in front of Puma and handle concerns that are better served at the HTTP layer than in Ruby.
# Rails 7.2 default — abridged
FROM ruby:3.3-slim AS base
RUN gem install thruster
EXPOSE 3000
ENTRYPOINT ["/rails/bin/thrust", "/rails/bin/rails", "server"]
The bin/thrust shim forwards to the thrust binary installed by the gem. Thruster binds on :3000 and proxies internally to Puma on :3001. You can override both ports via environment variables:
| Env var | Default | Purpose |
|---|---|---|
| PORT | 3000 | Port Thruster listens on (what kamal-proxy connects to) |
| THRUST_WORKER_PORT | 3001 | Port Puma listens on (internal only) |
| THRUST_HTTP2 | 0 | Enable HTTP/2 (requires TLS; useful without kamal-proxy) |
| THRUST_SEND_FILE_HEADER | X-Sendfile | Header to use for sendfile acceleration |
| THRUST_MAX_CACHE_ITEM_SIZE | 1048576 | Bytes; assets larger than this aren't cached in memory |
Note
If you're running without kamal-proxy (direct TLS on the app container), set THRUST_HTTP2=1 and point SSL_CERT_PATH + SSL_KEY_PATH to your certificate files. Thruster handles ACME via Let's Encrypt as well if you set THRUST_ACME_DOMAIN.
Thruster caches fingerprinted static assets in memory on first request. Requests for /assets/application-abc123.js are served without touching Puma after the first hit. This is meaningful for high-concurrency assets-heavy pages—fewer Puma threads blocked on I/O.
The cache is per-container and evicted on restart. This is fine because fingerprinted assets change name on every deploy anyway.
The entirety of your deployment is described in config/deploy.yml. Below is a production-ready example with inline annotations.
service: myapp
image: ghcr.io/yourorg/myapp
# Target hosts — add more for horizontal scaling
servers:
web:
hosts:
- 192.0.2.10
- 192.0.2.11
options:
network: "host" # optional; skip if using Docker bridge
# kamal-proxy configuration (replaces env.proxy in v1)
proxy:
ssl: true # kamal-proxy handles TLS via Let's Encrypt
host: myapp.example.com
app_port: 3000 # must match PORT in container (Thruster's port)
healthcheck:
path: /up
interval: 2
timeout: 5
# Docker registry credentials (values interpolated from .kamal/secrets)
registry:
server: ghcr.io
username: yourorg
password:
- KAMAL_REGISTRY_PASSWORD
# Environment variables injected into the app container
env:
clear:
RAILS_ENV: production
RAILS_LOG_TO_STDOUT: "true"
PORT: "3000"
THRUST_WORKER_PORT: "3001"
secret:
- RAILS_MASTER_KEY
- DATABASE_URL
# Accessories: long-lived sidecars (db, cache, workers)
accessories:
sidekiq:
image: ghcr.io/yourorg/myapp # same image, different CMD
hosts: [192.0.2.10]
cmd: "bundle exec sidekiq"
env:
secret: [RAILS_MASTER_KEY, DATABASE_URL, REDIS_URL]
# Boot-time commands (run once per deploy, before traffic switch)
boot:
limit: 10 # max seconds to wait for container to pass health check
pause: 0 # seconds between rolling batches (0 = all at once per host)
Kamal 2 defaults to building locally and pushing to the registry. For CI, prefer a remote builder or multiplatform builds:
config/deploy.yml (builder section)builder:
arch: amd64 # or arm64, or [amd64, arm64] for multiplatform
remote:
arch: amd64
host: ssh://builder@192.0.2.20
cache:
type: registry # uses registry cache; faster layer reuse in CI
options: mode=max
Warning
Building multiplatform (amd64 + arm64) on an M-series Mac with an amd64 remote host cuts build time dramatically. Without a remote builder, QEMU emulation for the cross-arch layer is the dominant slowdown in CI.
kamal-proxy will only route traffic to a new container after it returns HTTP 200 on the configured health path. Rails ships a /up endpoint since 7.1—it checks Active Record, Action Cable's adapter, and Active Storage by default.
# Slim health check — skip DB if app is stateless
get "/up", to: lambda { |_|
[200, { "Content-Type" => "text/plain" }, ["OK"]]
}
# Or use the built-in Rails::HealthController:
get "/up" => "rails/health#show", as: :rails_health_check
For deep checks (DB + Redis), add a separate path like /healthz/full and monitor it externally. Don't block deploys on a Redis blip.
The effective boot window is healthcheck.interval × healthcheck.timeout. With the defaults above (interval: 2, timeout: 5), kamal-proxy will ping /up every 2 seconds for up to 5 seconds per attempt. The outer boot.limit in deploy.yml is the total wall-clock budget in seconds. Set it to cover your worst-case Puma startup + DB migration time.
Tip
Run kamal app boot in isolation to benchmark your container boot time before committing to a boot.limit value. Cold starts with a large Rails app and eager-loading can hit 20–30 seconds.
Running kamal deploy executes the following steps in order:
kamal app exec --reuse 'bin/rails db:migrate' if configured./up until 200 or timeout.SIGTERM then SIGKILL after a stop timeout.For multiple web hosts, steps 3–8 happen in parallel by default. To do a true rolling deploy (one host at a time), pin with --hosts:
# Manual rolling deploy across two hosts
kamal deploy --hosts 192.0.2.10
kamal deploy --hosts 192.0.2.11
The safest pattern is expand/contract: new code must be compatible with the old schema, and new schema must be compatible with old code. This lets you run migrations before the deploy:
shell# Run migration inside a temporary container before switching traffic
kamal app exec --reuse 'bin/rails db:migrate'
The --reuse flag runs the command inside the currently-running container. Alternatively, configure it as a deploy hook:
#!/bin/bash
kamal app exec --reuse --roles=web 'bin/rails db:migrate'
Warning
Never run db:migrate inside every container simultaneously. Use a single-host hook or a dedicated migration container. Concurrent migrations against the same schema_migrations table will cause lock contention or worse.
Kamal tracks deployed image versions. Rolling back is a single command:
shellkamal rollback # rolls back to the previous image version
kamal rollback abc1234 # rolls back to a specific git SHA/image tag
Rollback follows the same health-check-then-switch flow. It will fail if the old image is no longer in the registry. Retain at least 2–3 old image tags in your registry lifecycle policy.
Kamal doesn't natively support weighted traffic splitting, but you can approximate canary releases by deploying to a subset of hosts and monitoring before finishing:
shell# Deploy to one host, verify, then finish
kamal deploy --hosts 192.0.2.10
# Check logs, metrics, error rates...
kamal logs
# Proceed to remaining hosts
kamal deploy --hosts 192.0.2.11
Kamal 2 introduces a dedicated secrets backend. Configure it in .kamal/secrets:
# 1Password (recommended for teams)
RAILS_MASTER_KEY=$(op read op://Production/myapp/RAILS_MASTER_KEY)
DATABASE_URL=$(op read op://Production/myapp/DATABASE_URL)
KAMAL_REGISTRY_PASSWORD=$(op read op://Production/myapp/GHCR_TOKEN)
Alternatively, source from environment (CI-friendly):
.kamal/secretsRAILS_MASTER_KEY=$RAILS_MASTER_KEY
DATABASE_URL=$DATABASE_URL
KAMAL_REGISTRY_PASSWORD=$GHCR_TOKEN
Secrets are pulled on the deploying machine and injected into the container as environment variables. They are never written to disk on the server and do not appear in docker inspect output in plaintext (they're passed via --env-file over stdin, not as CLI arguments).
Note
In CI (GitHub Actions, etc.), store secrets as repository secrets and expose them as environment variables. The .kamal/secrets file uses shell interpolation, so $VAR_NAME reads from the runner's environment at deploy time.
If you're migrating from Kamal 1 with a manual Nginx setup, ensure ports 80 and 443 are free before first deploy. kamal proxy boot will fail if another process holds those ports. Stop Nginx with systemctl stop nginx before running.
If your app unconditionally redirects HTTP → HTTPS (via config.force_ssl = true), kamal-proxy's health check will follow the redirect. Make sure your health path returns 200, not 301. The Rails health#show controller is exempt from SSL force by default.
kamal-proxy does not support session affinity. If your app requires sticky sessions (e.g., Action Cable with non-Redis adapter, or server-side sessions), you need to address this at the application layer (use Redis/Memcached session store) before scaling beyond one host.
kamal-proxy drains in-flight requests to the old container before sending SIGTERM. The drain window defaults to 30 seconds. Requests longer than that (file uploads, reports) may be cut. Increase stop_wait_time under proxy: for those apps:
proxy:
stop_wait_time: 120 # seconds; default 30
Kamal tags images using the current git SHA by default. Deploying from a dirty working tree (uncommitted changes) tags with a hash of the working tree diff. This is fine locally but can produce confusing tags in CI. Always deploy from a clean commit in production pipelines:
shell# Fail fast if tree is dirty
git diff --exit-code && kamal deploy
Thruster forwards to Puma via TCP on THRUST_WORKER_PORT. If your config/puma.rb binds to a Unix socket (bind 'unix:///tmp/puma.sock'), Thruster won't find Puma. Ensure Puma listens on the TCP port matching THRUST_WORKER_PORT:
port ENV.fetch("THRUST_WORKER_PORT", 3001)
Kamal 2 docs: kamal-deploy.org · Thruster source: github.com/basecamp/thruster