Who is Kamal for?
Kamal suits teams who already have a Dockerfile and want push-button deploys to one or a few servers without running Kubernetes. It is a good match for Rails, Django, Node and Go web apps. It is a poor match if you need autoscaling, a scheduler across dozens of hosts, or you have no wish to touch containers at all.
Kamal runs on your machine or in CI, not on the server. It reads config/deploy.yml, connects to each host over SSH and drives Docker there. Nothing stays resident on the VPS except Docker, your containers and kamal-proxy.
What do you need before you start?
You need a VPS with a public IPv4 address, SSH key access, a domain whose A record points at that address, a container registry account and a working Dockerfile. Locally you need Ruby 3.x and Docker with buildx. Open ports 22, 80 and 443 in the portal firewall before running anything.
- A server: a Boost (2 vCPU, 4 GB) is comfortable for an app plus Postgres. Spark works for a single small service.
- DNS:
app.example.com A 203.0.113.10. No AAAA record, since there is no IPv6 on FastNode. - Your app must answer
200on/up, the default health check path, or you set a different one.
Which SSH user should Kamal connect as?
Kamal connects as root unless you set ssh.user. Root is simplest because kamal setup can then install Docker. A dedicated user is tidier, but Docker must already be installed and that user must be in the docker group, which is effectively root-equivalent anyway. Either way, use key authentication only.
# on the server, if you choose a deploy user (Ubuntu 24.04)
sudo adduser --disabled-password --gecos '' deploy
sudo usermod -aG docker deploy
sudo install -d -m 700 -o deploy -g deploy /home/deploy/.ssh
sudo cp ~/.ssh/authorized_keys /home/deploy/.ssh/ && sudo chown deploy: /home/deploy/.ssh/authorized_keys
Disable password logins before the first deploy; the SSH hardening guide walks through it. If you need Docker on the host first, follow installing Docker on Ubuntu or Debian.
Install Kamal and generate the config
gem install kamal
cd ~/code/myapp
kamal init
kamal init writes three things: config/deploy.yml, .kamal/secrets and a .kamal/hooks/ folder of sample hook scripts. Rails 8 apps ship with these already.
A working config/deploy.yml
service: myapp
image: example/myapp
servers:
web:
- 203.0.113.10
proxy:
ssl: true
host: app.example.com
app_port: 3000
healthcheck:
path: /up
registry:
server: ghcr.io
username: example
password:
- KAMAL_REGISTRY_PASSWORD
builder:
arch: amd64
ssh:
user: deploy
env:
clear:
NODE_ENV: production
DB_HOST: myapp-db
secret:
- DATABASE_PASSWORD
- SECRET_KEY_BASE
accessories:
db:
image: postgres:16
host: 203.0.113.10
port: "127.0.0.1:5432:5432"
env:
clear:
POSTGRES_USER: myapp
secret:
- POSTGRES_PASSWORD
directories:
- data:/var/lib/postgresql/data
redis:
image: redis:7
host: 203.0.113.10
port: "127.0.0.1:6379:6379"
directories:
- data:/data
Points worth checking line by line:
app_portis the port your app listens on inside the container. kamal-proxy defaults to 80, so set it if you use 3000 or 8080.builder.arch: amd64matters if you build on an ARM laptop; FastNode instances are x86-64.- Accessories bind to
127.0.0.1. Docker-published ports skip ufw rules, so a bare"5432:5432"would expose Postgres to the internet. - Accessory containers are named
service-accessory, so the app reaches Postgres atmyapp-dbon Kamal's Docker network.
Where do secrets go?
Secrets live in .kamal/secrets, a dotenv file Kamal reads at deploy time and injects into containers as environment variables. Keep raw values out of it: reference variables from your shell or CI, or call a password manager. Add the file to .gitignore if it holds anything literal.
# .kamal/secrets
KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD
DATABASE_PASSWORD=$DATABASE_PASSWORD
POSTGRES_PASSWORD=$DATABASE_PASSWORD
SECRET_KEY_BASE=$SECRET_KEY_BASE
Only names listed under env.secret reach the container. A variable sitting in the secrets file but not listed is ignored.
First deploy, then every deploy after
kamal setup # first time: Docker (if root), proxy, accessories, app
kamal deploy # every release after that
A deploy builds and pushes the image tagged with the git commit, pulls it on the server, starts the new container, waits for /up to pass, tells kamal-proxy to route to it, then stops the old one. If the health check never passes, the old container keeps serving and the deploy fails loudly.
How does kamal-proxy get the certificate?
With ssl: true and a host set, kamal-proxy requests a Let's Encrypt certificate on the first HTTPS request and renews it on its own. The challenge runs on port 443, so that port must be reachable and the A record must already resolve to your server. This automatic mode suits a single server per hostname.
Check it with:
dig +short app.example.com A # should print 203.0.113.10
kamal proxy logs
curl -I https://app.example.com/up
Let's Encrypt only works when one server answers for the name. With several web servers, load your own certificate through ssl.certificate_pem and ssl.private_key_pem secrets, or terminate TLS in front and turn ssl off.
Logs, consoles and rollback
kamal app logs -f # follow app output
kamal app details # running containers and versions
kamal app exec -i 'bin/rails console'
kamal accessory logs db
kamal rollback 4f1c2ab # earlier commit tag still on the server
Rollback restarts an older image that is still present on the host, so it takes seconds. Kamal prunes old images after deploys and keeps only recent ones, so a release from months ago may need redeploying from git instead. Schema changes need care: roll the code back only if the previous release tolerates the current database.
What doesn't Kamal do for you?
Kamal deploys containers and routes traffic. It does not harden SSH, configure a firewall, apply OS security updates, back up accessory volumes or monitor the host. Those jobs stay with you, and leaving them undone is the usual way a tidy Kamal setup turns into an incident.
- Updates: enable
unattended-upgradeson Ubuntu 24.04 and reboot on a schedule. - Firewall: allow 22 from your addresses, 80 and 443 from anywhere, in the portal per-VM firewall.
- Database backups: dump on a timer, for example
docker exec myapp-db pg_dump -U myapp myapp | gzip > /var/backups/myapp-$(date +%F).sql.gz, and copy the files off the server. If backups are enabled on your server, check the portal for schedule and retention; take a snapshot before risky migrations. See backups and snapshots. - Monitoring: Kamal will not tell you the disk is full. Old images and Postgres data fill a 20 GB Spark faster than expected; watch
df -h.
If you would rather run everything from one Compose file on the server, compare with Docker Compose in production.