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:
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:
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:
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:
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:
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:
- New container starts with the fresh image
- Health check endpoint (
/upby default) returns 200 - Traefik adds the new container to its backend pool
- Old containers drain existing connections
- 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:
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:
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:
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 setuponce per environment to prepare servers, thenkamal deployfor 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.






Comments
Loading comments…