Kamal: Deploy Docker Apps to VPS Without Kubernetes — A Practical Guide

Docker tutorial - IT technology blog
Docker tutorial - IT technology blog

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.

Share: