Kamal Coder
Servers need Docker, SSH access, and ports 22/80/443 open. Provision with Ansible or cloud-init.
Configuration: config/deploy.yml
Minimal Setup
service: myapp image: username/myapp
servers:
web:
hosts:
- 192.168.1.1
labels:
traefik.http.routers.myapp.rule: Host(myapp.com)
registry: username: username password: - KAMAL_REGISTRY_PASSWORD
env: clear: RAILS_ENV: production RAILS_LOG_TO_STDOUT: "true" secret: - RAILS_MASTER_KEY - DATABASE_URL
Multi-Role Setup
service: myapp image: username/myapp
servers:
web:
hosts:
- 192.168.1.1
- 192.168.1.2
labels:
traefik.http.routers.myapp.rule: Host(myapp.com)
worker:
hosts:
- 192.168.1.3
cmd: bundle exec sidekiq
traefik: false # No HTTP traffic
registry: username: username password: - KAMAL_REGISTRY_PASSWORD
env: clear: RAILS_ENV: production secret: - RAILS_MASTER_KEY - DATABASE_URL - REDIS_URL
With Accessories (Databases, Redis)
service: myapp image: username/myapp
servers: web: hosts: - 192.168.1.1
accessories: db: image: postgres:16 host: 192.168.1.1 port: 5432 env: clear: POSTGRES_DB: myapp_production secret: - POSTGRES_PASSWORD directories: - data:/var/lib/postgresql/data options: shm-size: 256m
redis: image: redis:7-alpine host: 192.168.1.1 port: 6379 directories: - data:/data cmd: redis-server --appendonly yes
Secrets: .kamal/secrets
Kamal reads secrets from .kamal/secrets (git-ignored).
With 1Password CLI
.kamal/secrets
KAMAL_REGISTRY_PASSWORD=$(op read "op://Infrastructure/DockerHub/password") RAILS_MASTER_KEY=$(op read "op://MyApp/production/master_key") DATABASE_URL=$(op read "op://MyApp/production/database_url") POSTGRES_PASSWORD=$(op read "op://MyApp/production-db/password")
With Environment Variables
.kamal/secrets
KAMAL_REGISTRY_PASSWORD=$DOCKERHUB_TOKEN RAILS_MASTER_KEY=$RAILS_MASTER_KEY DATABASE_URL=$DATABASE_URL
Multi-Environment
config/deploy.yml
<% if ENV["KAMAL_DESTINATION"] == "staging" %> service: myapp-staging <% else %> service: myapp <% end %>
.kamal/secrets.staging
RAILS_MASTER_KEY=$(op read "op://MyApp/staging/master_key")
Traefik Configuration
SSL with Let's Encrypt
traefik: options: publish: - "443:443" volume: - /letsencrypt:/letsencrypt args: entryPoints.web.address: ":80" entryPoints.websecure.address: ":443" entryPoints.web.http.redirections.entryPoint.to: websecure entryPoints.web.http.redirections.entryPoint.scheme: https certificatesResolvers.letsencrypt.acme.email: admin@myapp.com certificatesResolvers.letsencrypt.acme.storage: /letsencrypt/acme.json certificatesResolvers.letsencrypt.acme.httpchallenge.entrypoint: web
servers:
web:
hosts:
- 192.168.1.1
labels:
traefik.http.routers.myapp.rule: Host(myapp.com)
traefik.http.routers.myapp.entrypoints: websecure
traefik.http.routers.myapp.tls.certresolver: letsencrypt
Health Checks
healthcheck: path: /up port: 3000 interval: 10s max_attempts: 30
Common Commands
First Deployment
Bootstrap server (installs Docker, creates directories)
kamal server bootstrap
Full setup (push config, start traefik, deploy app)
kamal setup
Regular Deployment
Deploy latest
kamal deploy
Deploy specific version
kamal deploy --version=abc123
Deploy to staging
kamal deploy -d staging
Rollback
List available versions
kamal app containers
Rollback to previous
kamal rollback
Debugging
SSH into container
kamal app exec --interactive bash
View logs
kamal app logs -f
Rails console
kamal app exec --interactive "bin/rails console"
Accessories
Start all accessories
kamal accessory boot all
Restart specific accessory
kamal accessory reboot db
Exec into accessory
kamal accessory exec db --interactive psql -U postgres
Provisioning Workflow
Terraform + Ansible + Kamal Pipeline
infra/bin/provision
#!/usr/bin/env bash set -euo pipefail
1. Terraform: Create infrastructure
cd infra && tofu apply
2. Ansible: Configure server
SERVER_IP=$(tofu output -raw server_ip) cd ansible echo "[web]\n$SERVER_IP ansible_user=root" > hosts.ini ansible-playbook -i hosts.ini playbook.yml
3. Kamal: Bootstrap containers
cd ../.. bundle exec kamal server bootstrap
What Ansible Should Configure
Based on kamal-ansible-manager:
Task Purpose
Install Docker Container runtime
Configure fail2ban SSH intrusion prevention
Setup UFW Firewall (22, 80, 443)
Enable NTP Time synchronization
Create swap Memory overflow protection
Harden SSH Disable password auth, root login
Kernel tuning swappiness, somaxconn
Builder Configuration
Native ARM64 Builds (Hetzner CAX)
builder: arch: arm64
OR for multi-arch:
multiarch: true
Remote Builder
builder: remote: arch: amd64 host: ssh://builder@build-server
Hooks
Pre-Deploy
.kamal/hooks/pre-deploy
#!/bin/sh echo "Running pre-deploy tasks..." bundle exec rails assets:precompile
Post-Deploy
.kamal/hooks/post-deploy
#!/bin/sh echo "Running migrations..." kamal app exec "bin/rails db:migrate"
Directory Structure
myapp/ ├── config/ │ └── deploy.yml # Main Kamal config ├── .kamal/ │ ├── secrets # Secret values (git-ignored) │ ├── secrets.staging # Staging secrets (git-ignored) │ └── hooks/ │ ├── pre-deploy │ └── post-deploy └── Dockerfile # Application container
Troubleshooting
Issue Cause Fix
Connection refused Docker not running kamal server bootstrap
Permission denied SSH key not authorized Check server's authorized_keys
Health check failing App not starting Check kamal app logs
Registry auth failed Wrong credentials Verify .kamal/secrets
Traefik 502 Container not healthy Increase max_attempts