Zero-Downtime Deploys with Kamal 2 + Thruster

1. How it works

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:

The traffic path looks like this:

Request flow per host
Internet
kamal-proxy
:80 / :443
Thruster
:3000
Puma
:3001

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.

2. Prerequisites

RequirementNotes
Kamal ≥ 2.0gem install kamal or add to Gemfile
Docker on servers24.x+ recommended; Kamal installs it if absent
SSH accessKey-based auth only; no password prompts in CI
Container registryGHCR, 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):

shell
gem install kamal   # ~2.3.0 at time of writing
kamal version

In an existing Rails app, run the initializer:

shell
kamal init

This generates config/deploy.yml, .kamal/secrets, and a Dockerfile if none exists.

3. Thruster in the container

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.

Dockerfile entrypoint

dockerfile
# 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 varDefaultPurpose
PORT3000Port Thruster listens on (what kamal-proxy connects to)
THRUST_WORKER_PORT3001Port Puma listens on (internal only)
THRUST_HTTP20Enable HTTP/2 (requires TLS; useful without kamal-proxy)
THRUST_SEND_FILE_HEADERX-SendfileHeader to use for sendfile acceleration
THRUST_MAX_CACHE_ITEM_SIZE1048576Bytes; 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.

Asset caching behavior

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.

4. Kamal 2 configuration

The entirety of your deployment is described in config/deploy.yml. Below is a production-ready example with inline annotations.

config/deploy.yml
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)

Builder options

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.

5. Health checks & boot timeouts

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.

Customizing /up

config/routes.rb
# 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.

Timeout math

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.

6. The deploy sequence

Running kamal deploy executes the following steps in order:

  1. Build — Docker image built (locally or remote) and pushed to registry.
  2. Lock — A deploy lock is acquired on the primary server. Concurrent deploys abort.
  3. Pull — Each host pulls the new image.
  4. Run boot commands — e.g. kamal app exec --reuse 'bin/rails db:migrate' if configured.
  5. Start new container — New container starts alongside the old one.
  6. Health check — kamal-proxy polls /up until 200 or timeout.
  7. Traffic switch — kamal-proxy atomically routes new connections to the new container. In-flight requests to the old container drain.
  8. Stop old container — Old container receives SIGTERM then SIGKILL after a stop timeout.
  9. Release lock.

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:

shell
# Manual rolling deploy across two hosts
kamal deploy --hosts 192.0.2.10
kamal deploy --hosts 192.0.2.11

Migrations

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:

.kamal/hooks/pre-connect (executable)
#!/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.

7. Rollback

Kamal tracks deployed image versions. Rolling back is a single command:

shell
kamal 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.

Canary deploys

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

8. Secrets management

Kamal 2 introduces a dedicated secrets backend. Configure it in .kamal/secrets:

.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/secrets
RAILS_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.

9. Common pitfalls

Port conflicts

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.

Health check on HTTPS redirect

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.

Sticky sessions

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.

Long-running requests during switch

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:

config/deploy.yml
proxy:
  stop_wait_time: 120    # seconds; default 30

Image tag collisions

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 and Puma config mismatch

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:

config/puma.rb
port ENV.fetch("THRUST_WORKER_PORT", 3001)

Kamal 2 docs: kamal-deploy.org  ·  Thruster source: github.com/basecamp/thruster