Kamal - Presentation @Ruby Frankfurt

4th May 2025 – 2063 words

In the end of March 2025, I had the honor to present at the Ruby Frankfurt meetup. The topic was a deep dive into Kamal, the deployment tool for Rails 8. You can find the online presentation in Reveal.JS here.

In summary: during the last 6 months I migrated a rather large Rails app from our company (our Applicant-Tracking-System for Empfehlungsbund) from a Capistrano-like Ansible playbook into a full-blown containerized deployment using Kamal. Meanwhile, I also migrated all of my side-projects to deploy with Kamal + Gitlab-CI. After upgrading some apps to Kamal 2, I would recommend now, to have a look. Prior to that, there were some rough edges, but now, it’s worth to have a look, if you want to migrate off Heroku, or State-ful Capistrano-like deployments, and you want to avoid the complexity of Kubernetes and other Container Orchestration tools.

Deployment

Web Software Deployment: Bringing the latest version of my app (with all it’s dependencies) “live”, reproducibly!

Usually, that means:

  1. Installing system requirements (Ruby, Node, Lib*)
  2. Check out Git
  3. bundle install
  4. Building assets
  5. Running migrations
  6. Restarting app server (atomically, ZDT) & background-job workers
  7. Health Checks/Blue-Green Deployment
  8. if App Server N > 1, do it again (or in parallel)

Traditional deployment:
Capistrano, Ansistrano (Ansible), Mina etc.

  • Sophisticated Bash wrappers
  • Scaling issues/waste:
    if running N>1 app-servers, most tasks (bundle install, assets) are run over and over again
  • Work with Stateful servers
    After a couple of deployments state usually emerges, esp. when N>1
  • What about maintenance:
    Server restart?
    Linux Kernel & Distribution updates?
    Ruby upgrades? etc.

Enter Docker

  • Dependencies are part of the Docker Image
    Ruby, system libraries, Bundled Gems, Node etc.
  • Reproducible and efficient deployment
    when deploying in parallel on multiple app-servers
  • “dumb” App servers just run the Docker image

Docker - very quick overview

  • Containers vs. VMs: Docker uses containers, which share the host OS kernel, unlike virtual machines that require a full OS per instance. This makes containers much more lightweight.
  • Based on Linux namespaces and cgroups: Linux namespaces for isolation (e.g. network, process, file system) and control groups (cgroups) to limit resources like CPU and memory.
  • Images vs. Containers: A Docker image is a snapshot of a filesystem and app configuration (like a blueprint), while a container is a running instance of that image — isolated, but lightweight and ephemeral.
  • OverlayFS/UnionFS: images use layered file systems -> like Git. Allows efficient rebuilts

Docker - Orchestration?

Great! Now how do we:

  • Deploy more than 1 App-Server
  • Deploy new versions without downtime
  • Provide secrets/environment variables
  • Specify and run external service dependencies, such as Database, Redis
  • Attach persistent state/volumes

Simple Solution: Docker-compose

Allows to:

  • Provide secrets/environment variables
  • specify external service dependencies, such as Database, Redis
  • specify state/volumes
  • More than 1 App-Server
  • Deploying new versions without downtime

Enter Kamal (former “Mrsk”)

“Kamal offers zero-downtime deploys, rolling restarts, asset bridging, remote builds, accessory service management, and everything else you need to deploy and manage your web app in production with Docker. Originally built for Rails apps, Kamal will work with any type of web app that can be containerized.”

kamal-deploy.org

Requirements

  • Docker on your local machine(or build by CI/remotely)
  • SSH root access to (at least 1) Linux box where you want to deploy to
  • (A DNS Domain Name that resolves to the box)
  • A Docker registry!
    • dockerhub - 1 repo free
    • gitlab.com - ♾️ freemax. number of tags & build minutes
    • Generate Access Token and login
      docker login registry.gitlab.com

Installation

  • kamal init
    Rails 8 newly generated app.
  • If upgrading from an older app:
    • bundle add kamal
      Already in Gemfile in Rails 8
    • Dockerfile + .dockerignore
    • get "/up"
      Healthcheck Endpoint
    • config.assume_ssl = true config/environments/production.rb

🧠 Railsbyte template from me:

rails app:template LOCATION='https://railsbytes.com/script/Vp7s41'

Example

  • kelsterbach-spielt.de
    Boardgame Community Site
  • SolidQueue, SolidCache, Importmaps, Sqlite3
  • Single (shared) machine
# config/deploy.yml
service: kbs
image: zealot128/kelsterbach-spielt

servers:
  web:
    - 1.2.3.4

proxy:
  ssl: true # auto letsencrypt
  hosts:
    - kelsterbach-spielt.de
    - www.kelsterbach-spielt.de
  
registry:
  server: registry.gitlab.com
  username: <%= ENV['CI_REGISTRY_USER'] || 'zealot128' %>
  password:
    - KAMAL_REGISTRY_PASSWORD
    
env:
  clear:
    DATABASE_URL: "sqlite3:///rails/storage/db/development.sqlite3"
    RAILS_SERVE_STATIC_FILES: "true"
    RAILS_LOG_TO_STDOUT: "true"
    RAILS_ENV: production
  secret:
    - RAILS_MASTER_KEY

volumes:
  #  Sqlite3 in storage in Rails 8.
  - "storage:/rails/storage"

builder:
  arch: amd64

In Detail

Running kamal deploy will:

  1. Build image locally docker build . and push to the registry
  2. Install docker on remote machine if necessary
  3. Push secrets from .kamal/secrets
  4. Authenticates production box via Registry Token & pull image from Registry
  5. Rolling restart:
    • boot one container, try to access /up until it get’s a 200
    • Zero-Downtime restart container one after the other
  6. Clean up old containers & images

Dockerfile

Inside the default Dockerfile | Rails 8 Unpacked

Quick Overview over default Rails 8 Dockerfile →

More Features

kamal-proxy & thruster

Both are Go-based HTTP proxies written by 37signals.

Thruster:

  • Runs inside Docker container
  • Has access to your assets and/or storage and offloads file downloads via X-Sendfile-Header

Kamal-proxy:

  • Runs on the Docker-Host,
  • Performs ZDTD, Letsencrypt,
  • Routes between multiple Apps on one shared machine

Source: TestDouble Blog

Integration in CI-CD

Don’t want to crap your local laptop or integrate into CI release?

Built the image in a CI-Step:

docker build --pull -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA \
    --cache-from $CI_REGISTRY_IMAGE:latest \
    --label service=kbs .
docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

And deploy when ready:

kamal deploy --skip-push --version=$CI_COMMIT_SHA

Build process can start immediately in parallel to testing and so deployment afterwards is very quick!

rails console?

Kamal aliases:

aliases:
  console: app exec --interactive --reuse "bin/rails console"
  shell: app exec --interactive --reuse "bash"
  tail: app logs -f
  dbc: app exec --interactive --reuse "bin/rails dbconsole --include-password"
  deploy-lastest: "deploy -P --version=<%= `git log  | head -n 1 | awk '{ print  $2 }'`%>"

Migrations

By default, migrations are run by the Rails 8 ./bin/docker-entrypoint

#!/bin/bash -e
#bin/docker-entrypoint

# Enable jemalloc for reduced memory usage and latency.
if [ -z "${LD_PRELOAD+x}" ]; then
    LD_PRELOAD=$(find /usr/lib -name libjemalloc.so.2 -print -quit)
    export LD_PRELOAD
fi

# If running the rails server then create or migrate existing database
if [ "${@: -2:1}" == "./bin/rails" ] && [ "${@: -1:1}" == "server" ]; then
  ./bin/rails db:prepare
fi

exec "${@}"

ℹ️ IMPORTANT

Make sure, the migrations are quick, otherwise, the health check will kill the process.

Default is 7 tries each 1 second wait only!

proxy:
  healthcheck:
    interval: 2s
    max_attempts: 10

💡 👉 strong_migrations

Rollback

# get latest deployed release id 
kamal app containers

Rollback = Quick-deploy without build & download

kamal rollback fd7446ee

Secrets

  • Use config/credentials/production.yml.enc, and
  • only provide RAILS_MASTER_KEY environment variable via .kamal/secrets.
# 1.) set directly (don't check-in this file then, but provide on the deploying host)
KAMAL_REGISTRY_PASSWORD=glpat-123123123123123
RAILS_MASTER_KEY=abcabc123abc123abc123abc123

# 2.) or allowlist from Environment Variables
KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD

# 3.) or ask OS password manager / adapter (1Pass, Bitwarden, LastPass etc.)
SECRETS=$(kamal secrets fetch --adapter 1password --account my-account --from MyVault/MyItem KAMAL_REGISTRY_PASSWORD)
KAMAL_REGISTRY_PASSWORD=$(kamal secrets extract KAMAL_REGISTRY_PASSWORD $SECRETS)

Accessories

Services, independent of code deployments: Database, Redis.

service: example
# [..]
accessories:
  db:
    image: "postgres:16"
    hosts:
      - 1.2.3.4
    directories:
      - data:/var/lib/postgresql/data
    env:
      POSTGRES_HOST_AUTH_METHOD: trust
      
  redis:
    image: redis:7.0
    hosts:
      - 1.2.3.4
    directories:
      - redis_data:/data
      
  db_backup:
    image: eeshugerman/postgres-backup-s3:12
    hosts:
      - 1.2.3.4
    env:
      secret:
        - S3_REGION
        - S3_ACCESS_KEY_ID
        - S3_SECRET_ACCESS_KEY
        - S3_BUCKET
        - S3_ENDPOINT
      clear:
        SCHEDULE: '@daily'
        S3_PREFIX: example/db
        BACKUP_KEEP_DAYS: 7
        POSTGRES_HOST: example-db
        POSTGRES_DATABASE: example_production
        POSTGRES_USER: postgres
        POSTGRES_PASSWORD: "none"
        
env:
  clear:
    DATABASE_URL: "postgres://postgres@example-db:5432/example_production"
    REDIS_URL: "redis://example-redis:6379/0"
    RAILS_SERVE_STATIC_FILES: "true"
    RAILS_LOG_TO_STDOUT: "true"
    RAILS_ENV: production
  secret:
    - RAILS_MASTER_KEY

servers: 
  web:  ...
  jobs: ...

Shared host: multiple apps on one machine

  • Kamal-Proxy does the routing based on the host name. No further config required.
  • Just make sure the service: example name is unique for each app.
  • Prefix all Storage and Cache paths with the service name, otherwise, you might share the storage between apps.
  • You can also have multiple Accessories (like PostgreSQL) of the same kind. The hostname is unique example-db, so no port clashes.

BG-Jobs & Cronjobs

servers:
  web:
    - 10.0.0.15
    - 10.0.0.16
  job:
    hosts:
      - 10.0.0.17
      - 10.0.0.18
    # depends on your Job Queue:
    cmd: bundle exec sidekiq -C config/sidekiq.yml
    cmd: ./bin/jobs
    cmd: bundle exec good_job start
  cron:
    hosts:
      - 1.2.3.4
    cmd:
      bash -c "(env && cat config/crontab) | crontab - && cron -f"

Don’t use cron like this. Jobs are killed during deployment and not tried again. Better use a job-queue with Cron feature.

Asset Bridging

  • If you build assets (Vite, Sprockets etc), the asset fingerprints will change.
  • Browser loads html file from old deployment, then proceed to load assets with old fingerprints.
  • Meanwhile, new deployment is put live, old asset is not available anymore.
  • Solution: asset bridging
# asset_path: /rails/public/vite
asset_path: /rails/public/assets

Summary

  • Kamal 1 was incomplete, Kamal 2 works great.
  • Works especially well for small apps on a shared host for small money
  • …but also for larger apps.
  • Thin wrapper around Docker, no Magic.
    Docker-Compose++

Pieces, that Kamal has no opionion on:

  • Monitoring, Log-Aggregation, Error Tracking (AppSignal, Sentry etc.)
  • Backup
  • Data-Storage (S3 etc.)
  • Failover Database
  • CDN, Bot-Protection
  • Load-Balancer/Router, if have deploy more than 1 web-server.

Resources