35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English 35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English
Deploy Rails 8 with Kamal 2: A Production-Ready Setup From Scratch

Deploy Rails 8 with Kamal 2: A Production-Ready Setup From Scratch

TTB Software
rails, devops
Step-by-step guide to deploying Rails 8 with Kamal 2. Covers server setup, Docker configuration, SSL, database migrations, and the gotchas that bit us in production.

Kamal 2 shipped as the default deployment tool in Rails 8, replacing the old Capistrano-centric workflow that most of us grew up with. After migrating three production apps from Capistrano to Kamal over the past few months, here’s the complete walkthrough — including the parts the docs gloss over.

What Kamal 2 Actually Does

Kamal uses Docker containers deployed via SSH. No Kubernetes. No managed container service. You point it at a server, it builds your Docker image, pushes it to a registry, pulls it on the server, and swaps traffic using Kamal Proxy (which replaced Traefik from Kamal 1).

The mental model: Capistrano deployed code to a server and restarted processes. Kamal deploys containers to a server and swaps traffic. The server doesn’t need Ruby, Node, or any of your app’s dependencies installed — just Docker.

Prerequisites

You need:

  • A server with Ubuntu 22.04+ and SSH access (a $10/month VPS works fine)
  • A Docker Hub account (or any container registry)
  • Rails 8.0+ with Docker support (new apps get this automatically; existing apps need a Dockerfile)
  • Ruby 3.3+ locally

If you’re on an existing Rails 7 app, run rails app:update after bumping to Rails 8. It generates the Dockerfile, .dockerignore, and config/deploy.yml you’ll need.

Step 1: Install Kamal

gem install kamal

Rails 8 apps include it in the Gemfile already. Verify with:

kamal version
# => 2.4.0

Step 2: Configure deploy.yml

The generated config/deploy.yml needs real values. Here’s a stripped-down production config:

service: myapp

image: yourdockerhub/myapp

servers:
  web:
    hosts:
      - 203.0.113.42
    options:
      memory: 512m

proxy:
  ssl: true
  host: myapp.com

registry:
  username: yourdockerhub
  password:
    - KAMAL_REGISTRY_PASSWORD

env:
  clear:
    RAILS_LOG_TO_STDOUT: "1"
    RAILS_SERVE_STATIC_FILES: "1"
  secret:
    - RAILS_MASTER_KEY
    - DATABASE_URL

builder:
  arch: amd64

accessories:
  db:
    image: postgres:16
    host: 203.0.113.42
    port: "127.0.0.1:5432:5432"
    env:
      secret:
        - POSTGRES_PASSWORD
    directories:
      - data:/var/lib/postgresql/data

A few things the docs don’t emphasize enough:

Memory limits matter. Without memory: 512m, a single request that triggers heavy allocation can eat all your VPS RAM and the OOM killer takes down your container (and sometimes the database with it). Set this based on your app’s actual memory profile — check what your GC-tuned Rails process actually uses.

Bind Postgres to localhost. The 127.0.0.1:5432:5432 mapping keeps your database off the public internet. I’ve seen Kamal tutorials that expose the database port without this, which is asking for trouble on a $10 VPS without a firewall.

Builder arch must match your server. If you develop on Apple Silicon but deploy to an AMD64 server, set arch: amd64. Without this, Kamal builds an ARM image that crashes immediately on the server with a cryptic exec format error.

Step 3: Set Up Secrets

Create a .kamal/secrets file (not committed to git):

KAMAL_REGISTRY_PASSWORD=your_docker_hub_token
RAILS_MASTER_KEY=contents_of_config_master.key
DATABASE_URL=postgresql://myapp:secretpassword@myapp-db:5432/myapp_production
POSTGRES_PASSWORD=secretpassword

Kamal reads this file automatically during deployment. For team environments, you can source secrets from 1Password, AWS SSM, or any command that outputs KEY=VALUE pairs:

# config/deploy.yml
env:
  secret:
    - RAILS_MASTER_KEY
    - DATABASE_URL
# .kamal/secrets
RAILS_MASTER_KEY=$(op read "op://Production/Rails/master_key")

Step 4: Prepare the Server

kamal server bootstrap

This SSHs into your server(s) and installs Docker. On a fresh Ubuntu VPS, it takes about 60 seconds. If Docker is already installed, it skips the installation.

Before your first deploy, set up the database accessory:

kamal accessory boot db

This pulls the Postgres image and starts the container with your configured volumes and environment variables.

Step 5: Deploy

kamal deploy

First deploy takes 3-5 minutes (building the Docker image, pushing it, pulling it on the server). Subsequent deploys are faster thanks to Docker layer caching — typically 60-90 seconds if only your application code changed.

What happens during kamal deploy:

  1. Builds the Docker image locally (or on a remote builder)
  2. Pushes the image to your registry
  3. Pulls the image on each server
  4. Runs kamal deploy hooks (if configured)
  5. Starts the new container
  6. Kamal Proxy health-checks the new container
  7. Swaps traffic from old container to new
  8. Stops the old container

The traffic swap is the key improvement over Capistrano. There’s no moment where your app is down during deployment. Kamal Proxy sends requests to the old container until the new one passes health checks, then switches instantly.

Step 6: Database Migrations

Here’s where opinions diverge. Kamal doesn’t run migrations automatically. You have three options:

Option A: Run migrations as a deploy hook

# .kamal/hooks/pre-deploy
#!/bin/bash
kamal app exec --primary "bin/rails db:migrate"

This runs migrations on the primary server before the new containers start receiving traffic. Simple, but if a migration fails, you’ve got a partially migrated database and need to intervene manually.

Option B: Run migrations inside the container boot

Add to your Dockerfile:

CMD ["sh", "-c", "bin/rails db:migrate && bin/rails server -b 0.0.0.0 -p 3000"]

I don’t recommend this. Every container restart runs migrations, and if you scale to multiple servers, you’ll get concurrent migration attempts.

Option C: Run migrations manually before deploying (recommended)

kamal app exec --primary "bin/rails db:migrate"
kamal deploy

This gives you a chance to verify the migration succeeded before swapping traffic. For zero-downtime migrations, this is the safest approach — you can run your backward-compatible migration, verify it, then deploy the code that depends on it.

Step 7: SSL with Kamal Proxy

Kamal 2 replaced Traefik with Kamal Proxy, a purpose-built reverse proxy. SSL setup is automatic:

proxy:
  ssl: true
  host: myapp.com

Kamal Proxy uses Let’s Encrypt to provision and renew certificates. Point your domain’s DNS A record at your server’s IP, deploy, and SSL works. No certbot configuration, no cron jobs for renewal.

One catch: the first request after deploying might be slow (2-3 seconds) because Kamal Proxy provisions the certificate on-demand. Subsequent requests use the cached certificate.

The Gotchas

After running Kamal 2 in production for a few months, these are the issues that actually came up:

Docker image size creep. The default Rails 8 Dockerfile uses multi-stage builds, but your image can still balloon if you’re not careful. Run docker images and check — if your app image is over 500MB, look at what’s being copied in. Common culprits: node_modules in the final stage, test files, development gems. A well-optimized Rails 8 image should be 200-350MB.

Log rotation. Kamal doesn’t set up log rotation. Docker’s default logging driver keeps everything in JSON files that grow unbounded. Add this to your deploy config:

servers:
  web:
    hosts:
      - 203.0.113.42
    options:
      log-opt: max-size=50m
      log-opt: max-file=3

Health check failures on slow-booting apps. Kamal Proxy checks /up by default. If your Rails app takes more than 7 seconds to boot (heavy initializers, preloading a large codebase), the health check fails and the deploy rolls back. Increase the timeout:

proxy:
  healthcheck:
    interval: 3
    timeout: 30

Accessory data persistence. If you destroy and recreate your database accessory, the data directory mapping preserves your data. But if you change the directory path in deploy.yml, Kamal creates a new empty volume. Your old data is still on the server in the original directory — you just need to move it or update the path.

Monitoring and Maintenance

Once deployed, useful commands:

# View logs
kamal app logs -f

# Open a Rails console
kamal app exec -i "bin/rails console"

# Check container status
kamal details

# Rollback to previous version
kamal rollback

# Restart without redeploying
kamal app boot

For production monitoring, you’ll want something beyond logs. I’ve been using a combination of Rails’ built-in error reporting (Rails 8 has Rails.error.report) and a lightweight monitoring stack. That’s a topic for another post.

Kamal vs. Capistrano: When to Switch

If you’re running a single-server Rails app with Capistrano and it works, there’s no urgent reason to migrate. Capistrano is proven and reliable.

Switch to Kamal when:

  • You want zero-downtime deploys without configuring Puma phased restarts
  • You’re spinning up new servers and don’t want to manage Ruby/Node installations on them
  • You want a single tool that handles the proxy, SSL, and deployment
  • You’re starting a new Rails 8 app (Kamal is the default — just use it)

Stay with Capistrano when:

  • Your team knows it inside out and your deploy pipeline is solid
  • You have complex multi-stage deploy workflows that rely on Capistrano’s task system
  • You’re not ready to containerize your app

FAQ

How much does a Kamal deployment cost?

The tool itself is free and open source. Your costs are the VPS ($5-20/month for a small app) and the Docker registry (Docker Hub’s free tier gives you one private repo, which is enough for a single app). No managed Kubernetes cluster, no container orchestration service. That’s the whole point — Kamal gives you container-based deployment without the infrastructure cost of container orchestration platforms.

Can I deploy to multiple servers with Kamal 2?

Yes. Add more hosts under the web key in deploy.yml. Kamal deploys to all servers in parallel. You’ll need a load balancer in front of them (a simple Nginx or HAProxy instance, or your cloud provider’s load balancer). Kamal handles the per-server container management; load balancing is separate.

How do I handle background jobs with Kamal?

Define a separate server role for your background job processor:

servers:
  web:
    hosts:
      - 203.0.113.42
  worker:
    hosts:
      - 203.0.113.42
    cmd: bundle exec sidekiq

This runs the worker process in a separate container on the same (or different) server. Solid Queue users can run it in the same process as Puma using config.solid_queue.connects_to — no separate worker container needed.

What happens if a deploy fails?

Kamal Proxy keeps routing traffic to the old container. The new container either fails its health check and gets stopped, or never starts. Your app stays up on the previous version. Run kamal rollback to clean up, fix the issue, and deploy again.

Can I migrate from Capistrano to Kamal without downtime?

Yes, but plan for 5-10 minutes of setup. The basic approach: set up Kamal targeting the same server, deploy your app alongside the Capistrano-managed version on a different port, verify it works, then swap your DNS or reverse proxy to point at the Kamal-managed container. Once traffic is flowing through Kamal, decommission the Capistrano setup.

T

About the Author

Roger Heykoop is a senior Ruby on Rails developer with 19+ years of Rails experience and 35+ years in software development. He specializes in Rails modernization, performance optimization, and AI-assisted development.

Get in Touch

Share this article

Need Expert Rails Development?

Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise

Schedule a Free Consultation