Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 051 / 100

Deploying Rails with Kamal: patterns, pitfalls and fixes

Deploy to any server with Docker.

Expona Admin6 min read

Deploying Rails with Kamal: patterns, pitfalls and fixes

Rails 8 ships with Kamal as the default deployment tool, replacing Capistrano for most teams. If you've been managing production deploys with shell scripts or cobbling together Docker commands, Kamal offers a cleaner abstraction that works across any server—whether that's a VPS, bare metal, or a cloud provider without Kubernetes overhead. In this guide, we'll walk through setting up Kamal, configuring it for your infrastructure, and handling the real-world gotchas we've seen in production.

Kamal — Default deployment tool in Rails 8

Kamal is a containerized deployment tool built by 37signals (the company behind Rails and Hey). It handles Docker image building, registry pushes, and container orchestration without requiring you to run Kubernetes. Think of it as the bridge between your laptop and production servers.

The appeal is straightforward: Kamal uses SSH to connect to your servers and manages containers directly. You don't need a cluster, load balancer API, or container orchestration platform. If you can SSH into a box and run Docker, you can deploy with Kamal.

Here's what makes Kamal practical for mid-sized teams:

  • Zero-downtime deploys through built-in proxy management
  • Multi-server deployments with health checks
  • Environment-specific configs without duplicating logic
  • Simple rollback with kamal rollback
  • Works with any Docker registry (Docker Hub, ECR, private registries)

We've deployed Rails apps to bare metal servers, Linode instances, and dedicated hardware using Kamal. The process is consistent regardless of where your servers sit.

Config — config/deploy.yml defines servers and registry

Your Kamal configuration lives in config/deploy.yml. This single file defines your servers, registry credentials, and deployment behavior. It's YAML-based, supports ERB for dynamic values, and keeps your deploy logic declarative rather than procedural.

A typical production config looks like this:

ruby
service: myapp
image: mycompany/myapp

registry:
  server: ghcr.io
  username: <%= ENV["REGISTRY_USERNAME"] %>
  password: <%= ENV["REGISTRY_PASSWORD"] %>

servers:
  web:
    hosts:
      - 192.168.1.10
      - 192.168.1.11
      - 192.168.1.12
  job:
    hosts:
      - 192.168.1.20
    cmd: bundle exec sidekiq

env:
  clear:
    RAILS_ENV: production
    RAILS_LOG_TO_STDOUT: "true"
  secret:
    - DATABASE_URL
    - REDIS_URL
    - RAILS_MASTER_KEY

volumes:
  - /data/uploads:/app/storage

accessories:
  db:
    image: postgres:16
    port: "5432:5432"
    env:
      clear:
        POSTGRES_DB: myapp_prod
      secret:
        - POSTGRES_PASSWORD
    volumes:
      - /var/lib/postgresql/data:/var/lib/postgresql/data
    options:
      restart: always

healthcheck:
  path: /up
  interval: 30
  timeout: 5
  max_attempts: 3

A few patterns to follow:

Use environment variables for secrets. Never commit database URLs or API keys. Kamal reads from your shell environment and injects them at deploy time. The secret key tells Kamal which variables to pull from your machine.

Separate web and job servers. If you run background jobs on a separate server pool, add a job role with a custom cmd. Kamal will run a different container command on those hosts.

Define volumes for persistent data. Uploads, logs, or other mutable state should live in volumes mapped to your host. Containers are ephemeral; your data shouldn't be.

Use accessories for supporting services. Postgres, Redis, or other stateful services can run as "accessories" alongside your Rails containers. This keeps everything in one config file and works well for small to medium deployments.

For staging, create config/deploy.staging.yml and override only what differs:

ruby
servers:
  web:
    hosts:
      - staging.example.com

healthcheck:
  path: /up

Then deploy to staging with kamal -c config/deploy.staging.yml deploy.

Deploy — kamal setup then kamal deploy

Deploying with Kamal is two commands: setup and deploy. The setup runs once per environment to prepare your servers. The deploy runs every time you want to push new code.

kamal setup does the heavy lifting:

  • Ensures Docker is installed on all servers
  • Configures Traefik (Kamal's built-in proxy)
  • Creates networks for container communication
  • Pulls accessor images (Postgres, Redis)
  • Starts health check monitoring

Run this once when onboarding a new server:

ruby
kamal setup

If it fails partway through, you can rerun it safely. Kamal is idempotent.

kamal deploy is what you run on each release:

  • Builds your Docker image locally
  • Pushes it to your registry
  • Pulls the image on all servers
  • Stops the old containers
  • Starts new containers with the fresh image
  • Waits for health checks to pass

In practice:

ruby
git push origin main
kamal deploy

That's it. Kamal reads your Git commit SHA, tags the image, and ensures only that version runs in production. Rollback is equally simple:

ruby
kamal rollback

This pulls the previous image and restarts containers. You're back to the last deploy in seconds.

One key point: Kamal runs rails db:migrate as a one-off container before the deploy. If your migrations fail, the deploy halts. This prevents incompatible code and schema from running simultaneously.

Zero downtime — Kamal Proxy switches traffic between containers

Kamal uses Traefik as its reverse proxy. When you deploy, Traefik switches incoming traffic from old containers to new ones without dropping requests. This happens because Kamal runs both versions briefly, waits for health checks on the new version, then removes the old containers.

The flow looks like this:

  1. New container starts with the fresh image
  2. Health check endpoint (/up by default) returns 200
  3. Traefik adds the new container to its backend pool
  4. Old containers drain existing connections
  5. Old containers stop after a grace period

For this to work, your health check endpoint must be reliable. Rails 7.1+ includes /up out of the box, but if you're on an older version, add this to your routes:

ruby
get "/up" => "rails/health#show", as: :rails_health_check

Your health check should verify that:

  • The Rails app boots and responds
  • Database connectivity works
  • Critical dependencies (cache, queue) are reachable

A custom health check might look like:

ruby
class HealthController < ApplicationController
  def check
    begin
      ActiveRecord::Base.connection.execute("SELECT 1")
      Redis.current.ping
      render json: { status: "ok" }, status: :ok
    rescue => e
      render json: { status: "error", error: e.message }, status: :service_unavailable
    end
  end
end

Update your Kamal config to point to it:

ruby
healthcheck:
  path: /health/check
  interval: 10
  timeout: 3
  max_attempts: 2

The max_attempts parameter is important: if a health check fails twice in a row, Kamal won't consider the deploy successful. This catches startup issues early and prevents broken deployments from going live.

Common mistakes

Forgetting to export secrets before deploy. If your deploy.yml references DATABASE_URL as a secret, it must exist in your shell environment. Export it first: export DATABASE_URL=postgres://... Then run kamal deploy. Many teams script this in a .env.local file sourced before deployment.

Hardcoding registry credentials. Never paste AWS access keys or Docker Hub tokens into deploy.yml. Always use environment variables. If credentials leak, rotate them immediately.

Deploying without running migrations locally first. While Kamal runs migrations automatically, test them on a staging environment first. A broken migration blocks your deploy and can leave the database in a bad state.

Not configuring log rotation on servers. Containers write logs to stdout, which Kamal captures. Without log rotation, your server's disk fills up. Add a docker-compose logging driver or use an external log aggregator (Datadog, Papertrail, etc.).

Assuming health checks pass means your app works. /up only verifies that the Rails process started. It doesn't catch logic errors, missing environment variables, or broken feature flags. Monitor your app in production with proper logging and error tracking.

Running multiple deploys concurrently. If two engineers run kamal deploy simultaneously, they'll interfere with each other. Use a deploy lock or coordinator (e.g., a deploy bot in Slack that serializes requests).

Key takeaways

  • Kamal is Rails 8's default deployment tool; it manages Docker containers via SSH without requiring Kubernetes or orchestration platforms.
  • Define everything in config/deploy.yml: servers, registry details, environment variables, volumes, and health checks.
  • Run kamal setup once per environment to prepare servers, then kamal deploy for each release.
  • Zero-downtime deploys work because Traefik switches traffic between old and new containers once health checks pass.
  • Store secrets in environment variables, not in config files; test migrations on staging before prod.
  • Health checks must be reliable and actually verify your app works, not just that the container started.
  • Log rotation and external log aggregation prevent disk issues on long-running servers.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles