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:
- Installing system requirements (Ruby, Node, Lib*)
- Check out Git
bundle install
- Building assets
- Running migrations
- Restarting app server (atomically, ZDT) & background-job workers
- Health Checks/Blue-Green Deployment
- 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.”
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 8Dockerfile
+.dockerignore
get "/up"
Healthcheck Endpointconfig.assume_ssl = true
config/environments/production.rb
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:
- Build image locally
docker build .
and push to the registry - Install docker on remote machine if necessary
- Push secrets from
.kamal/secrets
- Authenticates production box via Registry Token & pull image from Registry
- Rolling restart:
- boot one container, try to access /up until it get’s a 200
- Zero-Downtime restart container one after the other
- 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
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
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.