How I Deploy Rails + SQLite to Hetzner with Kamal

Today is shipping day for my little SQLite-backed Rails product, and the winning combo is still Hetzner Cloud plus Kamal. Hetzner keeps delighting me with price/performance and generous networking, while Kamal turns deploys into a pleasant single-command ritual. Here's the full playbook I followed this week.

Why Hetzner + Kamal is the ideal indie stack

  • Hetzner Cloud servers: every VM can be spun up with Primary IPv4/IPv6 addresses, private networks, snapshots, and placement groups. Even the cost-optimized plans feel snappy, and upgrading to dedicated cores is a simple dropdown.
  • Zero-cost firewalls: Hetzner's firewall docs spell out the default-deny inbound policy. I love that I can tag servers with role=rails and have the firewall auto-attach wherever that label appears.
  • Triple-redundant volumes: Their volume guide promises every block lives on three separate physical machines. Selecting the automatic mount option drops the disk under /mnt/HC_Volume_*, which is perfect for parking a SQLite file.
  • Kamal's boring deploys: From the installation doc, kamal setup handles SSH, Docker install, registry logins, building, pushing, pulling, booting kamal-proxy, health checks, traffic swaps, and pruning. The configuration reference shows how little YAML is required before the first deploy.

Prerequisites

  • Rails 7/8 app that uses SQLite in production.
  • Multi-stage Dockerfile that precompiles assets and runs bundle exec puma.
  • Container registry (GHCR, Docker Hub, etc.) with a token exported as KAMAL_REGISTRY_PASSWORD.
  • Hetzner Cloud project with quota for one server, one Primary IPv4, and one firewall.
  • Domain pointed to the server's Primary IP (Kamal can handle TLS once DNS resolves).

Step 1: Provision the Hetzner server

  1. In the Hetzner Cloud Console, create a CX22 or CPX21 instance in the closest region. The server overview reminds us a Primary IP is mandatory for public networking, so attach one while creating the VM.
  2. Upload your SSH key and choose Ubuntu 24.04 LTS. Hetzner boots it in seconds.
  3. Create a firewall with inbound TCP rules for 22, 80, and 443. All other inbound traffic is blocked by default, so you start in a safe state.

Step 2: Attach a persistent volume for SQLite

I attach a 20 GB volume in the same data center and pick the automatic mount mode. Hetzner formats and mounts it at /mnt/HC_Volume_<id> on every reboot, and the triple replication means I'm not sweating a SSD failure.

On the server:

sudo mkdir -p /mnt/HC_Volume_123456/{db,storage}
sudo chown -R deployer:deployer /mnt/HC_Volume_123456

Later, I'll bind those paths into the container so the SQLite database and Active Storage blobs live on that resilient disk.

Step 3: Initialize Kamal inside the Rails app

In the repo root:

  1. gem install kamal (or add it to the Gemfile) and run kamal init.
  2. Edit config/deploy.yml to point at the Hetzner server and registry:
service: rails-sqlite-hetzner
image: ghcr.io/example/rails-sqlite-hetzner
servers:
  - 65.109.10.42
registry:
  username: ghcr-user
  password:
    - KAMAL_REGISTRY_PASSWORD
env:
  clear:
    RAILS_ENV: production
  secret:
    - RAILS_MASTER_KEY
volumes:
  - /mnt/HC_Volume_123456/db:/app/db
  - /mnt/HC_Volume_123456/storage:/app/storage
healthcheck:
  path: /up
  interval: 5
  timeout: 3
  1. Populate .kamal/secrets so Kamal can read sensitive values:
KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD
RAILS_MASTER_KEY=$(cat config/master.key)

Step 4: Run the first deploy

From the laptop:

kamal setup
kamal deploy

kamal setup SSHs into the Hetzner box as root, installs Docker via get.docker.com if needed, logs into the registry locally and remotely, builds the app image, pushes it, pulls it on the server, boots kamal-proxy on ports 80/443, starts the new container, waits for /up to return 200 OK, routes traffic, and prunes the old release. Every deploy after that is just kamal deploy.

Because SQLite is local, I run migrations right after:

kamal app exec web -- bundle exec rails db:migrate

Step 5: Production habits that keep things calm

  • Backups: nightly snapshot the volume or copy the SQLite file to object storage. The volume docs make it easy to resize later, but backups save you from human error.
  • Firewalls: any time I clone a server, I re-apply the firewall rules (ports 22/80/443) before running Kamal. Hetzner makes this free.
  • Monitoring: kamal app logs -f web tails Puma logs over SSH. For resource usage, kamal app exec web -- env or kamal app exec web -- rails runner keeps me in-container without extra agents.
  • Scaling strategy: since SQLite is on a volume, I scale vertically-either resize the volume (no downtime) or bump the server class. Hetzner’s volume throughput (200 MB/s sustained, 300 MB/s burst) has been plenty for write-heavy admin tasks.
  • TLS: once DNS resolves, Kamal's proxy can request certificates. I make sure ports 80/443 are open in the firewall beforehand.

Hetzner keeps showering indie builders with fast hardware, honest pricing, and thoughtful niceties. Kamal the raw horsepower into a one-command deploy pipeline. If you're trimming your stack for 2026, this pairing belongs at the top of the list.


← Back to all posts