<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: EXO</title>
    <description>The latest articles on DEV Community by EXO (@exo02934).</description>
    <link>https://dev.to/exo02934</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4111658%2F784e728b-48b6-448b-9a88-b6e414d1117b.png</url>
      <title>DEV Community: EXO</title>
      <link>https://dev.to/exo02934</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/exo02934"/>
    <language>en</language>
    <item>
      <title>The complete Kamal 2 deploy.yml for a real Rails app: Postgres, Sidekiq, Redis and backups on one Hetzner box</title>
      <dc:creator>EXO</dc:creator>
      <pubDate>Sat, 05 Sep 2026 23:36:58 +0000</pubDate>
      <link>https://dev.to/exo02934/the-complete-kamal-2-deployyml-for-a-real-rails-app-postgres-sidekiq-redis-and-backups-on-one-dhd</link>
      <guid>https://dev.to/exo02934/the-complete-kamal-2-deployyml-for-a-real-rails-app-postgres-sidekiq-redis-and-backups-on-one-dhd</guid>
      <description>&lt;p&gt;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 &lt;code&gt;config/deploy.yml&lt;/code&gt; I use for that, on a single Hetzner box, with an explanation of every block that isn't obvious.&lt;/p&gt;

&lt;p&gt;This is Kamal 2 (the one with &lt;code&gt;kamal-proxy&lt;/code&gt;, not Traefik) and Rails 8.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the thing
&lt;/h2&gt;

&lt;p&gt;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. &lt;code&gt;kamal-proxy&lt;/code&gt; terminates TLS and holds requests during the swap.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;myapp&lt;/span&gt;
&lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;yourname/myapp&lt;/span&gt;

&lt;span class="na"&gt;servers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;hosts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;5.161.0.0&lt;/span&gt;
  &lt;span class="na"&gt;worker&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;hosts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;5.161.0.0&lt;/span&gt;
    &lt;span class="na"&gt;cmd&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bundle exec sidekiq -C config/sidekiq.yml&lt;/span&gt;

&lt;span class="na"&gt;proxy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ssl&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app.example.com&lt;/span&gt;
  &lt;span class="na"&gt;app_port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3000&lt;/span&gt;
  &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/up&lt;/span&gt;
    &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
    &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;

&lt;span class="na"&gt;registry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io&lt;/span&gt;
  &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;yourname&lt;/span&gt;
  &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;KAMAL_REGISTRY_PASSWORD&lt;/span&gt;

&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;arch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;amd64&lt;/span&gt;
  &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;registry&lt;/span&gt;

&lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;clear&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;RAILS_ENV&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production&lt;/span&gt;
    &lt;span class="na"&gt;RAILS_LOG_TO_STDOUT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;
    &lt;span class="na"&gt;RAILS_SERVE_STATIC_FILES&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;
    &lt;span class="na"&gt;DB_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;myapp-db&lt;/span&gt;
    &lt;span class="na"&gt;REDIS_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis://myapp-redis:6379/0&lt;/span&gt;
  &lt;span class="na"&gt;secret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;RAILS_MASTER_KEY&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;POSTGRES_PASSWORD&lt;/span&gt;

&lt;span class="na"&gt;accessories&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16&lt;/span&gt;
    &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5.161.0.0&lt;/span&gt;
    &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:5432:5432"&lt;/span&gt;
    &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;clear&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;myapp&lt;/span&gt;
        &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;myapp_production&lt;/span&gt;
      &lt;span class="na"&gt;secret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;POSTGRES_PASSWORD&lt;/span&gt;
    &lt;span class="na"&gt;directories&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;data:/var/lib/postgresql/data&lt;/span&gt;
  &lt;span class="na"&gt;redis&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis:7-alpine&lt;/span&gt;
    &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5.161.0.0&lt;/span&gt;
    &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:6379:6379"&lt;/span&gt;
    &lt;span class="na"&gt;cmd&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis-server --appendonly yes&lt;/span&gt;
    &lt;span class="na"&gt;directories&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;data:/data&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;myapp_storage:/rails/storage"&lt;/span&gt;

&lt;span class="na"&gt;asset_path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/rails/public/assets&lt;/span&gt;

&lt;span class="na"&gt;aliases&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;console&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app exec --interactive --reuse "bin/rails console"&lt;/span&gt;
  &lt;span class="na"&gt;shell&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app exec --interactive --reuse "bash"&lt;/span&gt;
  &lt;span class="na"&gt;logs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app logs -f&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the parts that bite.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;DB_HOST&lt;/code&gt; is the accessory's container name, not localhost
&lt;/h2&gt;

&lt;p&gt;Kamal names accessory containers &lt;code&gt;&amp;lt;service&amp;gt;-&amp;lt;accessory&amp;gt;&lt;/code&gt;. So the &lt;code&gt;db&lt;/code&gt; accessory of service &lt;code&gt;myapp&lt;/code&gt; is reachable from the app container at the hostname &lt;code&gt;myapp-db&lt;/code&gt;. Not &lt;code&gt;localhost&lt;/code&gt;, not &lt;code&gt;127.0.0.1&lt;/code&gt; — those point at the app container itself. This single line is behind a large share of "the app boots and immediately dies" reports.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bind accessory ports to 127.0.0.1
&lt;/h2&gt;

&lt;p&gt;Note the &lt;code&gt;"127.0.0.1:5432:5432"&lt;/code&gt; rather than &lt;code&gt;"5432:5432"&lt;/code&gt;. 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-L&lt;/span&gt; 5432:localhost:5432 deploy@5.161.0.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Two roles, one server, one image
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;worker&lt;/code&gt; role has the same host as &lt;code&gt;web&lt;/code&gt;. Kamal builds one image and starts a second container from it with &lt;code&gt;cmd&lt;/code&gt; 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 &lt;code&gt;hosts&lt;/code&gt; list under &lt;code&gt;worker&lt;/code&gt; and nothing else.&lt;/p&gt;

&lt;p&gt;If you're on Solid Queue instead, set &lt;code&gt;SOLID_QUEUE_IN_PUMA&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The healthcheck decides whether your deploy is a deploy
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;kamal-proxy&lt;/code&gt; will not send traffic to the new container until &lt;code&gt;/up&lt;/code&gt; returns 200. Rails 8 routes that by default. Two things people get wrong:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;interval: 3&lt;/code&gt;, &lt;code&gt;timeout: 30&lt;/code&gt;&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;Make &lt;code&gt;/up&lt;/code&gt; 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:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# config/routes.rb&lt;/span&gt;
&lt;span class="n"&gt;get&lt;/span&gt; &lt;span class="s2"&gt;"up"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"health#show"&lt;/span&gt;

&lt;span class="c1"&gt;# app/controllers/health_controller.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;HealthController&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationController&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;show&lt;/span&gt;
    &lt;span class="no"&gt;ActiveRecord&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"SELECT 1"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;head&lt;/span&gt; &lt;span class="ss"&gt;:ok&lt;/span&gt;
  &lt;span class="k"&gt;rescue&lt;/span&gt; &lt;span class="no"&gt;StandardError&lt;/span&gt;
    &lt;span class="n"&gt;head&lt;/span&gt; &lt;span class="ss"&gt;:service_unavailable&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;asset_path&lt;/code&gt; prevents the 404 flash
&lt;/h2&gt;

&lt;p&gt;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. &lt;code&gt;asset_path: /rails/public/assets&lt;/code&gt; tells Kamal to keep both sets available during the overlap. One line, and the mystery 404s during deploys go away.&lt;/p&gt;

&lt;h2&gt;
  
  
  Volumes are the only thing that survives
&lt;/h2&gt;

&lt;p&gt;Everything written inside a container is gone on the next deploy. Two consequences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Active Storage's local disk service needs the &lt;code&gt;myapp_storage&lt;/code&gt; volume above, with &lt;code&gt;config/storage.yml&lt;/code&gt; pointing at &lt;code&gt;Rails.root.join("storage")&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The Postgres accessory needs its &lt;code&gt;directories:&lt;/code&gt; entry, or your database goes away the first time you rebuild that accessory. Yes, really.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Cron
&lt;/h2&gt;

&lt;p&gt;Do not install cron on the host. Use &lt;code&gt;solid_queue&lt;/code&gt;'s recurring tasks or &lt;code&gt;sidekiq-cron&lt;/code&gt;, both of which live inside the worker you already have. A separate cron container that runs &lt;code&gt;rails runner&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Backups, because a volume is not a backup
&lt;/h2&gt;

&lt;p&gt;A volume protects you from deploys, not from &lt;code&gt;DROP TABLE&lt;/code&gt;, a disk failure, or your provider having a bad day. The smallest thing that works, on the host, nightly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;myapp-db pg_dump &lt;span class="nt"&gt;-U&lt;/span&gt; myapp myapp_production &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;gzip&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /var/backups/myapp-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;.sql.gz
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this costs
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;assets:precompile&lt;/code&gt;.&lt;/p&gt;




&lt;p&gt;I keep this as a kit: the &lt;code&gt;deploy.yml&lt;/code&gt; 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 &lt;code&gt;kamal setup&lt;/code&gt;, and install the nightly backup to S3. It's $10 here: &lt;a href="https://payhip.com/b/yrO3i" rel="noopener noreferrer"&gt;https://payhip.com/b/yrO3i&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;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 &lt;a href="mailto:gilbergarciata@gmail.com"&gt;gilbergarciata@gmail.com&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Either way, ask in the comments — I'll answer Kamal, Hetzner and Cloudflare questions whether or not you buy anything.&lt;/p&gt;

</description>
      <category>rails</category>
      <category>devops</category>
      <category>docker</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>The 14 errors you will hit deploying Rails with Kamal on Hetzner behind Cloudflare (and the fix for each)</title>
      <dc:creator>EXO</dc:creator>
      <pubDate>Sat, 05 Sep 2026 21:50:29 +0000</pubDate>
      <link>https://dev.to/exo02934/the-14-errors-you-will-hit-deploying-rails-with-kamal-on-hetzner-behind-cloudflare-and-the-fix-for-3ai3</link>
      <guid>https://dev.to/exo02934/the-14-errors-you-will-hit-deploying-rails-with-kamal-on-hetzner-behind-cloudflare-and-the-fix-for-3ai3</guid>
      <description>&lt;p&gt;I run every one of my side projects the same way: a Rails 8 app, a €4/month Hetzner box, Kamal 2, Cloudflare in front. It's cheap and it works. But the first deploy of every new project costs me an evening, and it's almost always one of the same 14 mistakes. Here they are, with the fix for each, in the order you'll probably hit them.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. &lt;code&gt;hcloud: command not found&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;You're creating the server from a script and the Hetzner CLI isn't installed. &lt;code&gt;brew install hcloud&lt;/code&gt; on macOS, or grab the binary from the GitHub releases page. Create a &lt;strong&gt;Read &amp;amp; Write&lt;/strong&gt; API token in the Hetzner console (project → Security → API tokens).&lt;/p&gt;

&lt;h2&gt;
  
  
  2. &lt;code&gt;Permission denied (publickey)&lt;/code&gt; right after the server boots
&lt;/h2&gt;

&lt;p&gt;The key you uploaded to Hetzner isn't the one your ssh-agent offers. Run &lt;code&gt;ssh-add ~/.ssh/id_ed25519&lt;/code&gt;, or point your bootstrap at the key you actually use. If the box was created with the wrong key, delete it and recreate — it's 30 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. &lt;code&gt;docker: permission denied&lt;/code&gt; during &lt;code&gt;kamal setup&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Your deploy user isn't in the &lt;code&gt;docker&lt;/code&gt; group yet (cloud-init still running) or you connected before &lt;code&gt;usermod&lt;/code&gt; ran. Wait a minute, or &lt;code&gt;sudo usermod -aG docker deploy&lt;/code&gt; and reconnect. Kamal 2 defaults to &lt;code&gt;root&lt;/code&gt;; if you use a non-root user, set &lt;code&gt;ssh.user&lt;/code&gt; in &lt;code&gt;deploy.yml&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. &lt;code&gt;Missing secret 'KAMAL_REGISTRY_PASSWORD'&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Kamal reads &lt;code&gt;.kamal/secrets&lt;/code&gt;, which reads your &lt;strong&gt;environment&lt;/strong&gt;. If your secrets live in a &lt;code&gt;.env&lt;/code&gt; file, source it first: &lt;code&gt;set -a; . ./.env; set +a&lt;/code&gt;. A Makefile that sources the file before every &lt;code&gt;kamal&lt;/code&gt; command removes this class of bug entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. &lt;code&gt;denied: permission_denied: write_package&lt;/code&gt; (ghcr.io)
&lt;/h2&gt;

&lt;p&gt;The GitHub token needs &lt;code&gt;write:packages&lt;/code&gt; &lt;strong&gt;and&lt;/strong&gt; &lt;code&gt;read:packages&lt;/code&gt;. Fine-grained tokens don't support packages — use a classic token.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. &lt;code&gt;exec format error&lt;/code&gt; on the server
&lt;/h2&gt;

&lt;p&gt;You built the image on an Apple Silicon Mac and shipped an arm64 image to an amd64 box. Set &lt;code&gt;builder.arch: amd64&lt;/code&gt; in &lt;code&gt;deploy.yml&lt;/code&gt;, and make sure Docker Desktop has a multi-arch builder (&lt;code&gt;docker buildx create --use&lt;/code&gt; once).&lt;/p&gt;

&lt;h2&gt;
  
  
  7. "Waiting for app to boot" then the healthcheck fails
&lt;/h2&gt;

&lt;p&gt;Get the real error with &lt;code&gt;kamal app logs&lt;/code&gt;. Four usual suspects:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;RAILS_MASTER_KEY&lt;/code&gt; is wrong → &lt;code&gt;ActiveSupport::MessageEncryptor::InvalidMessage&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The DB isn't reachable → &lt;code&gt;DB_HOST&lt;/code&gt; must equal the accessory container name (&lt;code&gt;&amp;lt;service&amp;gt;-db&lt;/code&gt;), and &lt;code&gt;POSTGRES_PASSWORD&lt;/code&gt; must match what the accessory was booted with. Changed the password after first boot? &lt;code&gt;kamal accessory remove db&lt;/code&gt; then &lt;code&gt;kamal accessory boot db&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Your app listens on a port other than 3000 → set &lt;code&gt;proxy.app_port&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/up&lt;/code&gt; isn't routable (Rails &amp;lt; 7.1) → add &lt;code&gt;get "up" =&amp;gt; "rails/health#show"&lt;/code&gt; or change &lt;code&gt;proxy.healthcheck.path&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  8. &lt;code&gt;ERR_TOO_MANY_REDIRECTS&lt;/code&gt; in the browser
&lt;/h2&gt;

&lt;p&gt;Cloudflare's SSL mode is &lt;strong&gt;Flexible&lt;/strong&gt;. Set it to &lt;strong&gt;Full (strict)&lt;/strong&gt; — kamal-proxy already has a valid Let's Encrypt cert, so Cloudflare can talk HTTPS to your origin.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Let's Encrypt fails: &lt;code&gt;acme: error 403&lt;/code&gt; / connection refused
&lt;/h2&gt;

&lt;p&gt;Cloudflare's proxy (orange cloud) was on during the &lt;strong&gt;first&lt;/strong&gt; certificate issuance, or DNS hadn't propagated. Keep the record DNS-only (grey cloud) for the very first &lt;code&gt;kamal setup&lt;/code&gt;, confirm &lt;code&gt;dig +short app.example.com&lt;/code&gt; returns the server IP, run &lt;code&gt;kamal proxy reboot&lt;/code&gt;, retry. Turn the proxy on afterwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Assets 404 for a few seconds after every deploy
&lt;/h2&gt;

&lt;p&gt;The old container stops serving old fingerprints during the swap. Add &lt;code&gt;asset_path: /rails/public/assets&lt;/code&gt; to &lt;code&gt;deploy.yml&lt;/code&gt; so both containers can serve both sets of assets during the overlap.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. Uploads disappear after a deploy
&lt;/h2&gt;

&lt;p&gt;They were written inside the container. Active Storage's local disk must live on a volume: mount &lt;code&gt;&amp;lt;app&amp;gt;_storage:/rails/storage&lt;/code&gt; in &lt;code&gt;deploy.yml&lt;/code&gt; and keep &lt;code&gt;config/storage.yml&lt;/code&gt; pointing at &lt;code&gt;Rails.root.join("storage")&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  12. The build dies with an out-of-memory error during &lt;code&gt;assets:precompile&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Small boxes plus Tailwind/esbuild. Add 2 GB of swap in your cloud-init (&lt;code&gt;fallocate -l 2G /swapfile …&lt;/code&gt;), build locally instead of &lt;code&gt;builder.remote&lt;/code&gt;, or move up one server size.&lt;/p&gt;

&lt;h2&gt;
  
  
  13. Every deploy takes 5+ minutes
&lt;/h2&gt;

&lt;p&gt;Docker layer cache isn't reused. Your Dockerfile must copy &lt;code&gt;Gemfile*&lt;/code&gt; and run &lt;code&gt;bundle install&lt;/code&gt; &lt;strong&gt;before&lt;/strong&gt; &lt;code&gt;COPY . .&lt;/code&gt; (the Rails 8 Dockerfile does this). Also check you're not pushing a 2 GB image: &lt;code&gt;docker images | head&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  14. You have no idea what Kamal is doing
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;kamal deploy --verbose&lt;/code&gt;. To see the configuration exactly as Kamal parsed it: &lt;code&gt;kamal config&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The part I got tired of redoing
&lt;/h2&gt;

&lt;p&gt;The fixes above are easy once you know them. What's annoying is doing the setup by hand every time: creating the server, hardening it, opening the firewall, the Cloudflare records, the SSL mode, the secrets, checking everything before &lt;code&gt;kamal setup&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So I scripted it. &lt;code&gt;make server&lt;/code&gt; creates and hardens the box through the Hetzner API (Docker, &lt;code&gt;deploy&lt;/code&gt; user, ufw 22/80/443, fail2ban, unattended upgrades, swap, sshd lockdown). &lt;code&gt;make dns&lt;/code&gt; creates the A record and sets SSL Full (strict) through the Cloudflare API. &lt;code&gt;make check&lt;/code&gt; runs 15 preflight checks. &lt;code&gt;make first&lt;/code&gt; runs &lt;code&gt;kamal setup&lt;/code&gt;. It also includes nightly Postgres backups to any S3-compatible bucket, a GitHub Actions deploy workflow, and this troubleshooting doc.&lt;/p&gt;

&lt;p&gt;If it's useful to you, it's $10 here: &lt;a href="https://payhip.com/b/yrO3i" rel="noopener noreferrer"&gt;https://payhip.com/b/yrO3i&lt;/a&gt; — with a refund if it doesn't save you an evening. Either way, happy to answer Kamal/Hetzner/Cloudflare questions in the comments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't want to do it at all?
&lt;/h2&gt;

&lt;p&gt;If you'd rather not spend the evening, I'll do the whole setup on your server for $150: Hetzner box created and hardened, Cloudflare DNS and SSL, Kamal 2 configured, first deploy done, nightly Postgres backups to S3, and a walkthrough of how to deploy from then on. You keep everything — the server, the domain, the accounts are all yours; I just set it up and hand it over. Nothing to pay until it's deployed and working.&lt;/p&gt;

&lt;p&gt;Email me at &lt;a href="mailto:gilbergarciata@gmail.com"&gt;gilbergarciata@gmail.com&lt;/a&gt; and tell me what the app is.&lt;/p&gt;

</description>
      <category>rails</category>
      <category>docker</category>
      <category>tutorial</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
