DEV Community

EXO
EXO

Posted on

The complete Kamal 2 deploy.yml for a real Rails app: Postgres, Sidekiq, Redis and backups on one Hetzner box

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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: 30 means 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 /up mean 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
Enter fullscreen mode Exit fullscreen mode

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_storage volume above, with config/storage.yml pointing at Rails.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
Enter fullscreen mode Exit fullscreen mode

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)