<?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>Heroku to Hetzner with Kamal 2: the real bill, the real work, and when not to bother</title>
      <dc:creator>EXO</dc:creator>
      <pubDate>Mon, 07 Sep 2026 18:57:11 +0000</pubDate>
      <link>https://dev.to/exo02934/heroku-to-hetzner-with-kamal-2-the-real-bill-the-real-work-and-when-not-to-bother-3fj5</link>
      <guid>https://dev.to/exo02934/heroku-to-hetzner-with-kamal-2-the-real-bill-the-real-work-and-when-not-to-bother-3fj5</guid>
      <description>&lt;p&gt;I moved a Rails app off Heroku onto a single Hetzner box with Kamal 2 last month. The bill went from $121/month to about EUR 14. That headline is the easy part; what follows is the part nobody writes down: what actually has to be rebuilt, what it costs you in time, and when you should not do it at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bill, honestly
&lt;/h2&gt;

&lt;p&gt;The app: Rails 8, Postgres, Sidekiq, Redis, a few gigabytes of uploads, moderate traffic. On Heroku:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;2 x Standard-1X dynos, $50&lt;/li&gt;
&lt;li&gt;Standard-0 Postgres, $50&lt;/li&gt;
&lt;li&gt;Mini Redis, $3&lt;/li&gt;
&lt;li&gt;Papertrail and a couple of small add-ons, $18&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;About $121/month.&lt;/p&gt;

&lt;p&gt;After: one Hetzner CPX21 (3 vCPU, 4 GB) at roughly EUR 8, plus a EUR 6 volume for uploads and backups. Cloudflare free. Roughly EUR 14, call it $15.&lt;/p&gt;

&lt;p&gt;That is a real saving of about $1,270 a year. It is also not free money: you just bought a second job. Read the rest before you decide it is worth it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Heroku was actually doing for you
&lt;/h2&gt;

&lt;p&gt;This is the list people underestimate. Heroku was running, at minimum:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Postgres backups&lt;/strong&gt;, automatically, with point-in-time recovery.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Postgres upgrades and patching.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OS patching&lt;/strong&gt; on the dyno hosts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TLS certificates&lt;/strong&gt;, issued and renewed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero-downtime releases&lt;/strong&gt; with an automatic rollback if boot fails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log retention&lt;/strong&gt;, searchable, without you thinking about it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A restart when your process dies&lt;/strong&gt;, and a restart of the whole dyno every 24 hours, which quietly papered over your memory leaks.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Kamal gives you 4, 5 and 7 for free. Everything else is now yours. That is the actual trade: about $100/month against owning backups, upgrades, patching and log retention.&lt;/p&gt;

&lt;h2&gt;
  
  
  The migration, in the order I would do it again
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Get the app running in Docker locally, first
&lt;/h3&gt;

&lt;p&gt;Do not touch the server until &lt;code&gt;docker build&lt;/code&gt; and &lt;code&gt;docker run&lt;/code&gt; work on your machine. Rails 8 generates a production Dockerfile that is good enough. The two things that bite:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Anything you read from Heroku's environment. &lt;code&gt;DATABASE_URL&lt;/code&gt; is provided by Heroku in a specific format; you are now providing it yourself.&lt;/li&gt;
&lt;li&gt;Anything that assumed an ephemeral filesystem. If your app writes to &lt;code&gt;tmp/&lt;/code&gt; and reads it back later, it worked on Heroku only by accident of a single dyno; now it works until your second container, and then breaks confusingly.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. Build for the right architecture
&lt;/h3&gt;

&lt;p&gt;If you are on an Apple Silicon Mac, your local build is arm64 and your server is amd64. Set it explicitly:&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;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Otherwise your first deploy dies with &lt;code&gt;exec format error&lt;/code&gt;, which tells you nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Move the database with the boring method
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# on your machine, with the Heroku CLI&lt;/span&gt;
heroku pg:backups:capture &lt;span class="nt"&gt;--app&lt;/span&gt; your-app
heroku pg:backups:download &lt;span class="nt"&gt;--app&lt;/span&gt; your-app   &lt;span class="c"&gt;# gives you latest.dump&lt;/span&gt;

&lt;span class="c"&gt;# copy it to the server, then restore into the accessory container&lt;/span&gt;
scp latest.dump deploy@your.server.ip:/tmp/
ssh deploy@your.server.ip
docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; myapp-db pg_restore &lt;span class="nt"&gt;--clean&lt;/span&gt; &lt;span class="nt"&gt;--no-owner&lt;/span&gt; &lt;span class="nt"&gt;--no-acl&lt;/span&gt; &lt;span class="se"&gt;\\&lt;/span&gt;
  &lt;span class="nt"&gt;-U&lt;/span&gt; myapp &lt;span class="nt"&gt;-d&lt;/span&gt; myapp_production &amp;lt; /tmp/latest.dump
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to check before you cut over: that the Postgres major version on the server is &lt;strong&gt;not older&lt;/strong&gt; than Heroku's, and that any extensions you use (&lt;code&gt;pgcrypto&lt;/code&gt;, &lt;code&gt;pg_trgm&lt;/code&gt;, &lt;code&gt;postgis&lt;/code&gt;) exist in your image. The &lt;code&gt;postgres:16&lt;/code&gt; image does not ship PostGIS; you need &lt;code&gt;postgis/postgis&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Move the uploads before you move the traffic
&lt;/h3&gt;

&lt;p&gt;If you are on S3, there is nothing to do. If you were using Active Storage's local disk on Heroku, you have already been losing files on every deploy and probably do not know it. Either way, on the new box the storage has to live on a volume:&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;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  5. Cut over with a low TTL
&lt;/h3&gt;

&lt;p&gt;Drop your DNS TTL to 60 seconds a day before. Then: put Heroku in maintenance mode, take a final dump, restore, flip DNS, watch. Keep the Heroku app alive and paid for a week. The rollback is one DNS change, and that is worth $30.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four things that will actually bite you
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Backups.&lt;/strong&gt; A Docker volume is not a backup. The smallest thing that works, 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, to any S3-compatible bucket. And restore one into a scratch database this week: a backup you have never restored is a guess, not a backup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Memory.&lt;/strong&gt; Heroku restarted your dynos every 24 hours. Your server will not. If your app leaks, you now find out at 4am instead of never. Put a memory limit on the container and let it restart, or fix the leak. Two gigabytes of swap in cloud-init will also save you during &lt;code&gt;assets:precompile&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Logs.&lt;/strong&gt; &lt;code&gt;kamal app logs&lt;/code&gt; reads the Docker logs of the running container. Deploy, and the old container's logs are gone. Configure Docker's json-file driver with &lt;code&gt;max-size&lt;/code&gt; and &lt;code&gt;max-file&lt;/code&gt;, or ship logs somewhere. Otherwise the first time you need to investigate something from yesterday, it is not there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Postgres upgrades.&lt;/strong&gt; Nobody's job until it is yours. Pin the major version in &lt;code&gt;deploy.yml&lt;/code&gt;, and put a calendar reminder for the day that version leaves support.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you should stay on Heroku
&lt;/h2&gt;

&lt;p&gt;I would rather say this plainly than sell you a migration.&lt;/p&gt;

&lt;p&gt;Stay if the app earns money, you are the only developer, and you have no interest in servers. A hundred dollars a month is cheap insurance against your own 3am.&lt;/p&gt;

&lt;p&gt;Stay if you need real high availability. One box is one box. Kamal handles multiple servers fine, but then you are running a load balancer and replicated Postgres, and you have rebuilt the thing you were paying to avoid.&lt;/p&gt;

&lt;p&gt;Stay if the app is pre-revenue and your time is worth more shipping features than saving $1,200 a year.&lt;/p&gt;

&lt;p&gt;Move if you have several apps. The second, third and fourth cost almost nothing on the same box, and that is where the saving stops being marginal.&lt;/p&gt;

&lt;p&gt;Move if you already run Docker somewhere and the operational part does not scare you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually takes
&lt;/h2&gt;

&lt;p&gt;For a straightforward app: an evening for the Dockerfile and &lt;code&gt;deploy.yml&lt;/code&gt;, an hour for the server and DNS, an hour for the data, and a week of watching. Say two evenings if nothing surprises you, and something always surprises you.&lt;/p&gt;




&lt;p&gt;I keep the whole setup as a kit: the &lt;code&gt;deploy.yml&lt;/code&gt;, plus scripts that create and harden the Hetzner box through the API, set up the Cloudflare record and SSL mode, run 15 preflight checks before the first &lt;code&gt;kamal setup&lt;/code&gt;, and install the nightly Postgres backup to S3. It is $10: &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 would rather not spend the two evenings, I will do the migration for you for $150: server created and hardened, DNS and SSL, Kamal configured, database and uploads moved, backups running, and a walkthrough at the end so you can deploy yourself from then on. You keep every account. Nothing to pay until it is running. 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;

&lt;p&gt;Either way, ask in the comments. I answer Kamal, Hetzner and Heroku-migration questions whether or not you buy anything.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>rails</category>
      <category>devops</category>
      <category>heroku</category>
      <category>docker</category>
    </item>
    <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>
