Context: Why Not Kubernetes, and Why Kamal?
Kubernetes is powerful, but setting up a K8s cluster for a Rails or Node.js app running on a $5/month VPS is complete overkill. I’ve been through that phase — writing YAML for Deployment, Service, Ingress, cert-manager… just to deploy an app with a few hundred users. Not worth it.
What about the old-school approach? SSH into the server, pull the new image, restart the container manually. Simple, but no zero-downtime, no automatic rollback, and when you need to deploy to multiple servers, that Bash script quickly becomes a maintenance nightmare.
Kamal (formerly known as MRSK, developed by the Basecamp/37signals team) fills exactly that gap. Here’s what it brings to the table:
- Deploy via direct SSH — no need to install agents or daemons on the server
- Zero-downtime deployment through container swapping, using Traefik as a reverse proxy
- Automatic SSL management via Let’s Encrypt (through Traefik)
- Roll back to a previous version with a single command:
kamal rollback - Multi-server support — deploy to 5 servers simultaneously if needed
I migrated from docker-compose v1 to v2 for my entire stack when rebuilding the infrastructure, and the process was fairly smooth. But there was still a pain point: every deployment still required manual SSH, copy-pasting commands, and hoping for no downtime. Kamal solves exactly that.
Installing Kamal
Kamal is a Ruby gem, but you don’t need to know Ruby to use it. Ruby 3.1+ is required on your local machine — not the server.
# macOS — using rbenv
brew install rbenv
rbenv install 3.2.2
rbenv global 3.2.2
# Ubuntu/Debian (local machine)
sudo apt install ruby ruby-dev build-essential
# Install Kamal
gem install kamal
# Verify installation
kamal version
VPS (server) requirements:
- Ubuntu 20.04+ or Debian 11+
- Docker doesn’t need to be pre-installed — Kamal installs it during setup
- SSH key configured (no password authentication)
- User with sudo privileges
Verify SSH access before doing anything else:
ssh user@your-vps-ip "echo OK"
Detailed Configuration with deploy.yml
Navigate to your app directory and initialize the Kamal project:
kamal init
This command creates config/deploy.yml — the most important file. Below is a real-world configuration example for a typical web app:
# config/deploy.yml
service: myapp
image: ghcr.io/your-username/myapp
servers:
web:
hosts:
- 103.x.x.x
labels:
traefik.http.routers.myapp-secure.rule: Host(`myapp.com`)
traefik.http.routers.myapp-secure.tls: true
traefik.http.routers.myapp-secure.tls.certresolver: letsencrypt
registry:
server: ghcr.io
username: your-github-username
password:
- KAMAL_REGISTRY_PASSWORD
env:
clear:
APP_ENV: production
PORT: 3000
secret:
- DATABASE_URL
- SECRET_KEY_BASE
healthcheck:
path: /up
port: 3000
interval: 3s
timeout: 10s
retries: 5
traefik:
options:
publish:
- "443:443"
volume:
- "/letsencrypt/acme.json:/letsencrypt/acme.json"
args:
entryPoints.web.address: ":80"
entryPoints.websecure.address: ":443"
certificatesResolvers.letsencrypt.acme.email: "[email protected]"
certificatesResolvers.letsencrypt.acme.storage: "/letsencrypt/acme.json"
certificatesResolvers.letsencrypt.acme.tlschallenge: true
accessories:
db:
image: postgres:16
host: 103.x.x.x
port: 5432
env:
clear:
POSTGRES_DB: myapp_production
secret:
- POSTGRES_PASSWORD
volumes:
- /data/postgres:/var/lib/postgresql/data
redis:
image: redis:7
host: 103.x.x.x
port: 6379
volumes:
- /data/redis:/data
Create a .env file in the project root (do not commit to git):
# .env
KAMAL_REGISTRY_PASSWORD=ghp_your_github_token
DATABASE_URL=postgresql://user:pass@localhost/myapp_production
SECRET_KEY_BASE=your_long_random_secret
POSTGRES_PASSWORD=strong_db_password
# Add to .gitignore immediately
echo ".env" >> .gitignore
Useful Configuration Tips
Healthcheck is mandatory if you want true zero-downtime. Kamal keeps the old container running until the new one passes the healthcheck. No /up endpoint? Just create a route that returns HTTP 200 — that’s all you need.
Use volumes for persistent data — uploaded files, storage, cache. Example with Rails:
volumes:
- /data/myapp/storage:/rails/storage
Multi-server deploy — Kamal handles it in parallel with rolling updates by default:
servers:
web:
hosts:
- 103.x.x.1
- 103.x.x.2
workers:
hosts:
- 103.x.x.3
cmd: bundle exec sidekiq
First Deployment
# First-time server setup — installs Docker, Traefik, and accessories
kamal setup
# Every subsequent deployment
kamal deploy
The first time you run kamal setup, Kamal SSHes into the server and takes care of the rest: installing Docker, starting Traefik, launching Postgres/Redis, pulling the image, and running your app. It takes about 2-3 minutes. From the second deployment onward, kamal deploy typically finishes in 30-60 seconds.
Checking & Monitoring After Deployment
Your app is running in production — but the work doesn’t stop there. These commands will come in handy on a regular basis:
# Stream logs in real time
kamal app logs -f
# Container status on each server
kamal app details
# SSH into the running container for debugging
kamal app exec --interactive "bash"
# Run a one-off command inside the container (e.g., run DB migrations)
kamal app exec "rails db:migrate"
# View Traefik logs
kamal traefik logs
# Roll back to the previous version (very fast, uses the already-pulled image)
kamal rollback
Verifying SSL and Routing
To confirm SSL is working correctly, run a quick check:
curl -I https://myapp.com
Seeing HTTP/2 200 with a certificate from Let’s Encrypt means everything is good. The first time Let’s Encrypt issues a certificate it can take anywhere from a few seconds to 2-3 minutes — during that time Traefik uses a temporary self-signed cert, so there’s nothing to worry about.
Common Errors and How to Fix Them
Port 80/443 already in use: If Nginx or Apache is running on the server, Traefik won’t be able to bind to those ports. Stop the existing service first:
ssh user@vps "sudo systemctl stop nginx && sudo systemctl disable nginx"
acme.json permission error: The file that stores Let’s Encrypt certificates requires 600 permissions, otherwise Traefik refuses to read it:
ssh user@vps "sudo touch /letsencrypt/acme.json && sudo chmod 600 /letsencrypt/acme.json"
Slow image pulls: Use GitHub Container Registry (ghcr.io) instead of Docker Hub. Pulling a ~500MB image from ghcr.io to a server in Asia typically takes 20-40 seconds; the same image via Docker Hub can take 2-3 minutes.
Kamal is clearly not designed for 100-microservice architectures or horizontal autoscaling. But if you have 1-5 servers, an app handling a few thousand concurrent users, and no desire to spend a week setting up K8s — this is the right choice. I’ve been using it for a SaaS with around 800 users, it’s been running smoothly for 6 months now, and I haven’t had to touch the infrastructure once.

