KamalRailsVPSSelf hostingSQLiteSolid Queue

Self-Hosting Rails with Kamal: Part 2 - How to Deploy

11 min read

Part 2 of the series: Self-Hosting Rails with Kamal

In Part 1, we made the case for self-hosting your Rails API on a VPS instead of a managed PaaS. Now it’s time to actually do it. By the end of this article, you’ll have a Rails app running in production on your own server — with SQLite, Solid Queue, SSL, and zero-downtime deploys.


Here’s what we’re building toward: a Rails app deployed to a €4–6/month Hetzner Cloud server, using Kamal 2 to manage everything — the container lifecycle, the reverse proxy, SSL via Let’s Encrypt, and your background job worker. One config file, one command, deployed.

Let’s get into it.

What You’ll Need Before We Start

  • A Rails app (Rails 7.1+ works; Rails 8 is ideal and ships with Kamal pre-configured)
  • Docker installed on your local machine
  • A GitHub account (we’ll use ghcr.io as our container registry)
  • A domain name you control, with the ability to create an A record
  • A Hetzner account — any VPS provider works, but we’ll use Hetzner here

Any VPS will work. The steps are identical for DigitalOcean, Vultr, Linode, or any provider that gives you a fresh Ubuntu server and an IP address. Hetzner is just exceptionally good value per euro.


Step 1: Provision Your Server on Hetzner

Log into console.hetzner.cloud and create a new project. Inside it, click Add Server.

The choices that matter:

  • Location: Pick the region closest to your users. For Europe, Falkenstein (FSN1) or Nuremberg (NBG1) are solid defaults.
  • Image: Ubuntu 24.04 LTS
  • Type: CX22 (2 vCPUs, 4GB RAM) is a comfortable starting point for most hobby Rails apps. The CAX11 (2 ARM vCPUs, 4GB RAM) is an equally good value option if your Dockerfile supports ARM builds.
  • SSH keys: Add your public key here. Kamal authenticates via SSH, so this is essential. If you don’t have one, generate one with ssh-keygen -t ed25519.
  • Firewall (optional but recommended): Create a firewall that allows inbound traffic on ports 22 (SSH), 80 (HTTP), and 443 (HTTPS). Deny everything else.

Once created, you’ll get a public IP address. Note it — you’ll use it throughout this guide. We’ll call it YOUR_SERVER_IP.

Point Your Domain at the Server

Before deploying, create a DNS A record pointing your domain to your server’s IP address. For example:

yourapp.com  →  A  →  YOUR_SERVER_IP

Kamal will use this domain to request an SSL certificate from Let’s Encrypt automatically. DNS propagation typically takes a few minutes but can take up to an hour.


Step 2: Set Up Your Container Registry (ghcr.io)

Kamal builds your app into a Docker image, pushes it to a registry, then pulls it onto your server. We’ll use GitHub Container Registry (ghcr.io), which is free for public and private images up to your GitHub account’s storage limits.

Create a GitHub Personal Access Token

Go to GitHub → Settings → Developer settings → Personal access tokens → Tokens (classic) and generate a new token with the write:packages and read:packages scopes. Copy and store it somewhere safe — you’ll need it in the secrets file shortly.

Your image name on ghcr.io will follow the pattern:

ghcr.io/YOUR_GITHUB_USERNAME/YOUR_APP_NAME

Step 3: Configure config/deploy.yml

This is the heart of your Kamal setup. Here’s a complete configuration for a Rails app with SQLite and Solid Queue, annotated so every line is clear:

# Name of your application. Used to uniquely configure containers.
service: my_rails_app # <APP_NAME>

# Name of the container image (use your-user/app-name on external registries).
image: ghcr.io/<YOUR_GITHUB_USERNAME>/<APP_NAME>

# Deploy to these servers.
servers:
  web:
    # Replace with your server's IP address.
    - 192.168.0.1

# Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server.
# If used with Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption.
#
# Using an SSL proxy like this requires turning on config.assume_ssl and config.force_ssl in production.rb!
#
# Don't use this when deploying to multiple web servers (then you have to terminate SSL at your load balancer).
#
proxy:
  ssl: true
  # Replace with your actual domain.
  host: app.example.com

# Container registry credentials.
registry:
  server: ghcr.io
  username: <YOUR_GITHUB_USERNAME>
  password:
    - KAMAL_REGISTRY_PASSWORD

# Inject ENV variables into containers (secrets come from .kamal/secrets).
env:
  secret:
    - RAILS_MASTER_KEY
  clear:
    # Run the Solid Queue Supervisor inside the web server's Puma process to do jobs.
    # When you start using multiple servers, you should split out job processing to a dedicated machine.
    SOLID_QUEUE_IN_PUMA: true

    # Set number of processes dedicated to Solid Queue (default: 1)
    # JOB_CONCURRENCY: 3

    # Set number of cores available to the application on each server (default: 1).
    # WEB_CONCURRENCY: 2

    # Match this to any external database server to configure Active Record correctly
    # Use <APP_NAME>-db for a db accessory server on same machine via local kamal docker network.
    # DB_HOST: 192.168.0.2

    # Log everything from Rails
    # RAILS_LOG_LEVEL: debug

# Aliases are triggered with "bin/kamal <alias>". You can overwrite arguments on invocation:
# "bin/kamal logs -r job" will tail logs from the first server in the job section.
aliases:
  console: app exec --interactive --reuse "bin/rails console"
  shell: app exec --interactive --reuse "bash"
  logs: app logs -f
  dbc: app exec --interactive --reuse "bin/rails dbconsole"

# Persist the SQLite database and Active Storage files across deploys.
# Without this, your data is lost every time a new container starts.
volumes:
  - "my_rails_app_storage:/rails/storage"

# Bridge fingerprinted assets between versions to avoid 404s on in-flight requests.
asset_path: /rails/public/assets

# Configure the image builder.
builder:
  # # Build image via remote server (useful for faster amd64 builds on arm64 computers)
  arch: amd64
  cache:
    type: registry
    image: ghcr.io/<YOUR_GITHUB_USERNAME>/<APP_NAME>-build-cache
    options: mode=max,compression=zstd,compression-level=3
# Use a different ssh user than root
# ssh:
#   user: app

# Use accessory services (secrets come from .kamal/secrets).
accessories:
#   For MySQL
#   db:
#     image: mysql:8.0
#     host: 192.168.0.2
#     # Change to 3306 to expose port to the world instead of just local network.
#     port: "127.0.0.1:3306:3306"
#     env:
#       clear:
#         MYSQL_ROOT_HOST: '%'
#       secret:
#         - MYSQL_ROOT_PASSWORD
#     files:
#       - config/mysql/production.cnf:/etc/mysql/my.cnf
#       - db/production.sql:/docker-entrypoint-initdb.d/setup.sql
#     directories:
#       - data:/var/lib/
#
#   For Redis
#   redis:
#     image: valkey/valkey:8
#     host: 192.168.0.2
#     port: 6379
#     directories:
#       - data:/data

Step 4: Configure .kamal/secrets

.kamal/secrets is a dotenv-format file that Kamal reads locally before each command. The trick is to reference secrets from your shell environment or from files that are already gitignored, so the secrets file itself stays clean.

Here’s the complete file for our setup:

# .kamal/secrets

# Pulled from your shell environment — no literal value stored here.
KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD

# Rails master key — read from config/master.key, which is gitignored.
# This unlocks config/credentials.yml.enc, where all other app secrets live.
RAILS_MASTER_KEY=$(cat config/master.key)

Before running any Kamal command, export your GitHub token:

export KAMAL_REGISTRY_PASSWORD=ghp_your_personal_access_token_here

Why just two entries?

With SQLite there is no separate database server to configure — the database is a file on disk, managed entirely by Rails. The only secrets Kamal needs are the registry password (to push and pull your Docker image) and the Rails master key (to decrypt your credentials at runtime). That’s it.

Best practice: use a secret manager. Exporting variables in your shell works, but the production-grade approach is to pull them from a tool like Doppler at deploy time. We’ll set that up in an upcoming part of this series.


Step 5: Setup the Server

With your config in place, run Kamal’s setup command. It SSHes into your server, installs Docker if needed, and starts kamal-proxy:

kamal setup

Step 6: Deploy

Now deploy the full application:

kamal deploy

This command does a lot:

  1. Authenticates with the container registry locally and on your server
  2. Builds your Docker image locally (targeting amd64 as configured), pushes it to ghcr.io, and pulls it onto your server
  3. Ensures kamal-proxy is running and accepting traffic on ports 80 and 443
  4. Starts a new container tagged with the current Git version hash
  5. Routes traffic to the new container once it responds 200 OK to GET /up
  6. Stops and removes the old container
  7. Prunes unused images and stopped containers

On the first deploy, kamal-proxy automatically requests an SSL certificate from Let’s Encrypt as part of step 3. Your domain must already point to the server for this to succeed.

The whole process typically takes 2–4 minutes on the first deploy (image build dominates). Subsequent deploys are faster because Docker layer caching means only changed layers are rebuilt and pushed.

When it finishes, visit https://yourapp.com. You should see your app, served over HTTPS.


Step 7: Verify Everything is Working

Check the status of your deployment:

# Overview of all running containers
kamal details

# Tail your app's logs
kamal app logs -f

To SSH into your server for ad-hoc inspection:

kamal server exec "docker ps"

Subsequent Deploys

Every time you want to ship new code:

git add -A && git commit -m "Your changes"
kamal deploy

That’s it. Zero-downtime, SSL, health-checked. Kamal handles the rest.

If you only want to redeploy with updated environment variables (without bootstrapping servers or touching the proxy), use:

kamal redeploy

This skips the proxy setup, server bootstrapping, pruning, and registry login — it just pushes your updated secrets and restarts the app containers.


Running One-Off Commands

Need to run a Rails console or a migration in production?

# Rails console
kamal app exec --interactive --reuse "bin/rails console"

# Run a specific migration
kamal app exec --reuse "bin/rails db:migrate"

# Any arbitrary command
kamal app exec --reuse "bin/rails runner 'puts User.count'"

Rolling Back

If a deploy introduces a bug, rolling back is one command:

# List running and recently stopped containers to find the image hash
kamal app containers

# Roll back to a previous version using its image hash
kamal rollback <image-hash>

By default, Kamal retains the last 5 old containers and images on your server. You can adjust this in deploy.yml:

retain_containers: 3

Rollback targets beyond that window are pruned, so if you need to go further back you’ll have to redeploy an older image manually.


A Note on Solid Queue

Because we set SOLID_QUEUE_IN_PUMA=true, Solid Queue’s supervisor runs as a thread inside Puma. This works well for most hobby apps with light-to-moderate background job loads — sending emails, processing webhooks, running periodic tasks.

Your jobs are stored in your SQLite database (that’s what Solid Queue uses by default in Rails 8). No Redis required.

When you’re ready to scale, splitting Solid Queue into a dedicated worker role in deploy.yml looks like this:

servers:
  web:
    - YOUR_SERVER_IP
  worker:
    hosts:
      - YOUR_SERVER_IP
    cmd: bin/jobs
    env:
      clear:
        SOLID_QUEUE_IN_PUMA: "false"

Troubleshooting Common Issues

Deploy fails at the health check

The most common cause is that your app is crashing on startup. Check the logs:

kamal app logs

Look for startup errors — missing environment variables, database connection failures, or asset precompilation errors are the usual suspects.

SSL certificate not issuing

Let’s Encrypt needs to reach your server on port 80 to complete the HTTP challenge. Make sure your server’s firewall allows inbound traffic on port 80, and that your DNS A record has propagated. You can check propagation with dig yourapp.com A.

Build fails on Apple Silicon

If you’re on an M-series Mac and your server is AMD64, make sure you have arch: amd64 in the builder: section. Without it, Kamal builds an ARM image that won’t run on your server.


What You’ve Got

At this point, you’re running:

  • A Rails app with zero-downtime deployments
  • SQLite persisted to disk with a named Docker volume
  • Solid Queue processing background jobs inside Puma
  • HTTPS with automatic Let’s Encrypt certificate renewal
  • A single YAML config file that fully describes your production setup
  • Kamal’s built-in proxy (kamal-proxy) handling traffic routing

All of this on a server that costs a few euros a month. No platform lock-in, no sleeping containers, no metered pricing surprises.


What’s Next

A few things worth adding once you’re comfortable with this setup:

Backups. Your SQLite database is a single file at /rails/storage/production.sqlite3. Set up automated off-server backups — Litestream streams it continuously to S3-compatible storage, and Hetzner’s snapshot feature covers the whole server.

Monitoring. A lightweight uptime monitor (Better Uptime, UptimeRobot, or similar) pointed at your /up endpoint will alert you if the app goes down.

Multiple apps on one server. Kamal 2 is designed for this. Each app gets its own deploy.yml, and kamal-proxy routes traffic by hostname.

CI/CD pipeline. Right now you’re deploying by running kamal deploy from your machine. In Part 3, we’ll wire up a CircleCI pipeline so every push to main automatically builds, tests, and deploys your app — no manual steps required.


This article is Part 2 of the series “Self-Hosting Rails with Kamal.” Part 3 will cover setting up a CI/CD pipeline with CircleCI.

Filed under

KamalRailsVPSSelf hostingSQLiteSolid Queue