Most Kamal tutorials stop at "hello world on one server". Real apps have a database, a background queue, a cache, cron jobs, file uploads that must survive a deploy, and a healthcheck that has to pass before traffic switches over. Here is the whole config/deploy.yml I use for that, on a single Hetzner box, with an explanation of every block that isn't obvious.
This is Kamal 2 (the one with kamal-proxy, not Traefik) and Rails 8.
The shape of the thing
One server, one app container, two accessories: Postgres and Redis. A separate role for the Sidekiq worker, running the same image with a different command. kamal-proxy terminates TLS and holds requests during the swap.
service: myapp
image: yourname/myapp
servers:
web:
hosts:
- 5.161.0.0
worker:
hosts:
- 5.161.0.0
cmd: bundle exec sidekiq -C config/sidekiq.yml
proxy:
ssl: true
host: app.example.com
app_port: 3000
healthcheck:
path: /up
interval: 3
timeout: 30
registry:
server: ghcr.io
username: yourname
password:
- KAMAL_REGISTRY_PASSWORD
builder:
arch: amd64
cache:
type: registry
env:
clear:
RAILS_ENV: production
RAILS_LOG_TO_STDOUT: "1"
RAILS_SERVE_STATIC_FILES: "1"
DB_HOST: myapp-db
REDIS_URL: redis://myapp-redis:6379/0
secret:
- RAILS_MASTER_KEY
- POSTGRES_PASSWORD
accessories:
db:
image: postgres:16
host: 5.161.0.0
port: "127.0.0.1:5432:5432"
env:
clear:
POSTGRES_USER: myapp
POSTGRES_DB: myapp_production
secret:
- POSTGRES_PASSWORD
directories:
- data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
host: 5.161.0.0
port: "127.0.0.1:6379:6379"
cmd: redis-server --appendonly yes
directories:
- data:/data
volumes:
- "myapp_storage:/rails/storage"
asset_path: /rails/public/assets
aliases:
console: app exec --interactive --reuse "bin/rails console"
shell: app exec --interactive --reuse "bash"
logs: app logs -f
Now the parts that bite.
DB_HOST is the accessory's container name, not localhost
Kamal names accessory containers <service>-<accessory>. So the db accessory of service myapp is reachable from the app container at the hostname myapp-db. Not localhost, not 127.0.0.1 — those point at the app container itself. This single line is behind a large share of "the app boots and immediately dies" reports.
Bind accessory ports to 127.0.0.1
Note the "127.0.0.1:5432:5432" rather than "5432:5432". The second form publishes Postgres on the public interface, and Docker writes its own iptables rules that bypass ufw — so your firewall reports port 5432 as closed while the internet can reach it. Bind to loopback and reach the database over an SSH tunnel when you need psql from your laptop:
ssh -L 5432:localhost:5432 deploy@5.161.0.0
Two roles, one server, one image
The worker role has the same host as web. Kamal builds one image and starts a second container from it with cmd overridden. You do not need a separate Dockerfile, a separate build, or a separate server to run Sidekiq. When you outgrow the box, you change the hosts list under worker and nothing else.
If you're on Solid Queue instead, set SOLID_QUEUE_IN_PUMA to false and run it as its own role the same way — leave it inside Puma and your jobs get killed mid-flight on every deploy.
The healthcheck decides whether your deploy is a deploy
kamal-proxy will not send traffic to the new container until /up returns 200. Rails 8 routes that by default. Two things people get wrong:
-
interval: 3,timeout: 30means ten attempts. If your app takes 40 seconds to boot (heavy initializers, slow migrations), the deploy fails while the app is perfectly fine. Raise the timeout instead of removing the check. - Make
/upmean something. The default only proves Rails booted. If a broken database connection should block a deploy, point it at a controller that touches the database:
# config/routes.rb
get "up" => "health#show"
# app/controllers/health_controller.rb
class HealthController < ApplicationController
def show
ActiveRecord::Base.connection.execute("SELECT 1")
head :ok
rescue StandardError
head :service_unavailable
end
end
Do not check Redis or third-party APIs here. A healthcheck that fails when Stripe is slow will refuse to deploy your app during a Stripe outage.
asset_path prevents the 404 flash
During the swap, both containers run. A browser that loaded the old HTML asks for the old fingerprinted asset, and the new container doesn't have it. asset_path: /rails/public/assets tells Kamal to keep both sets available during the overlap. One line, and the mystery 404s during deploys go away.
Volumes are the only thing that survives
Everything written inside a container is gone on the next deploy. Two consequences:
- Active Storage's local disk service needs the
myapp_storagevolume above, withconfig/storage.ymlpointing atRails.root.join("storage"). - The Postgres accessory needs its
directories:entry, or your database goes away the first time you rebuild that accessory. Yes, really.
Cron
Do not install cron on the host. Use solid_queue's recurring tasks or sidekiq-cron, both of which live inside the worker you already have. A separate cron container that runs rails runner is a third thing to debug at 2am, and it will drift from your app's code the first time you forget to redeploy it.
Backups, because a volume is not a backup
A volume protects you from deploys, not from DROP TABLE, a disk failure, or your provider having a bad day. The smallest thing that works, on the host, nightly:
docker exec myapp-db pg_dump -U myapp myapp_production \
| gzip > /var/backups/myapp-$(date +%F).sql.gz
Then push it off the machine — any S3-compatible bucket will do. A backup on the same disk as the database is a rehearsal, not a backup. And a backup you have never restored is a guess: restore one into a scratch database this week, before you need it.
What this costs
A CPX21 at Hetzner (3 vCPU, 4 GB) runs about €8/month and comfortably holds Rails, Postgres, Redis and Sidekiq for a small production app. The €4 tier works too if you add swap — Tailwind and esbuild will otherwise get OOM-killed during assets:precompile.
I keep this as a kit: the deploy.yml above, plus scripts that create and harden the Hetzner box through the API, set up the Cloudflare DNS record and SSL mode, run 15 preflight checks before the first kamal setup, and install the nightly backup to S3. It's $10 here: https://payhip.com/b/yrO3i
And if you'd rather not do any of it: I'll do the whole setup on your server for $150 — server created and hardened, DNS and SSL, Kamal configured, first deploy done, backups running, and a walkthrough at the end. You keep every account; nothing to pay until it's deployed and working. Email me at gilbergarciata@gmail.com.
Either way, ask in the comments — I'll answer Kamal, Hetzner and Cloudflare questions whether or not you buy anything.
Top comments (0)