My First Rails Deployment Journey with Kamal

I recently deployed my first Rails application using Kamal. Coming from managing a blog with Docker Compose and Caddy, I initially assumed deploying Rails would follow a similar pattern. It turned out to have a few friction points along the way, mostly around reverse proxy setups and Docker storage configurations.

Here are the notes and solutions from that setup.

The Architecture Decision: Avoiding Proxy Conflicts

My initial plan was to deploy everything to my existing server, where my blog was already running behind Caddy.

However, Kamal uses kamal-proxy by default. Having two reverse proxies (kamal-proxy and Caddy) competing on the same server quickly becomes messy unless you either route all traffic through Caddy or migrate everything to kamal-proxy.

Instead of adding configuration complexity or upgrading that single server to handle multiple apps, I took the straightforward route: spin up a separate VPS with matching specs dedicated to the Rails app. It kept both environments clean and isolated.

The Working deploy.yml

Here is the final config/deploy.yml configuration:

service: <your-application-name>
image: <your-name>/<your-application-name>

ssh:
  user: <your-ssh-user-name>

servers:
  web:
    - <server ip address>
  job:
    hosts:
      - <server ip address>
    cmd: bin/jobs

# Critical if behind Cloudflare
proxy:
  ssl: true
  host: <your-host-name>
  forward_headers: true

registry:
  username: <docker-hub-username>
  password:
    - KAMAL_REGISTRY_PASSWORD

env:
  secret:
    - RAILS_MASTER_KEY
  clear:
    HOST: <your-host-name>
    RAILS_SERVE_STATIC_FILES: true
    RAILS_LOG_TO_STDOUT: true
    DB_HOST: <same-as-server-name>-db

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"

asset_path: /rails/public/assets

volumes:
  - "pulse_storage:/rails/storage"

builder:
  arch: amd64

accessories:
  db:
    image: postgres:18
    host: <your-server-ip>
    env:
      clear:
        POSTGRES_USER: <application-name>
        POSTGRES_DB: <application-name>_production
      secret:
        - POSTGRES_PASSWORD
    files:
      - config/init.sql:/docker-entrypoint-initdb.d/setup.sql
    volumes:
      - <application>_db_data:/var/lib/postgresql/data

Key Configuration Details

1. Cloudflare and forward_headers

If you use Cloudflare with proxying enabled (the orange cloud), you need to set forward_headers: true under the proxy block:

proxy:
  ssl: true
  host: example.com
  forward_headers: true

Without this setting, kamal-proxy will not pass the client’s actual IP down to Rails, causing request.remote_ip to report Cloudflare edge IPs instead.

2. Docker Error: directories vs. volumes

During the first kamal deploy, the database container failed to start with this error:

docker: Error response from daemon: failed to create task for container:
failed to create shim task: OCI runtime create failed: runc create failed:
unable to start container process: error during container init:
error mounting "/home/<username>/<application>-db/data" to rootfs at
"/var/lib/postgresql/data": change mount propagation through procfd:
open o_path procfd: open /var/lib/docker/overlay2/[...]/merged/var/lib/postgresql/data:
no such file or directory: unknown

In Kamal accessory configurations:

  • directories binds a host path directly (which must exist on the host file system).
  • volumes creates and manages a named Docker volume.

Many tutorials configure accessories with directories, but switching to named volumes avoided host directory permission and path creation issues completely:

accessories:
  db:
    volumes:
      - <application>_db_data:/var/lib/postgresql/data

3. Initializing Multiple Databases via init.sql

With Rails 8 using Solid Queue, Solid Cache, and Solid Cable, having separate databases for production is standard. Rather than creating these databases manually over SSH, PostgreSQL allows mounting an initialization script into /docker-entrypoint-initdb.d/:

-- config/init.sql
CREATE DATABASE <application>_production;
CREATE DATABASE <application>_production_cable;
CREATE DATABASE <application>_production_cache;
CREATE DATABASE <application>_production_queue;

Map this file in deploy.yml:

accessories:
  db:
    files:
      - config/init.sql:/docker-entrypoint-initdb.d/setup.sql

When the database container spins up for the first time, all required databases are created automatically.

Summary

Once configured properly, Kamal provides a very smooth deployment workflow. The main friction points were understanding how kamal-proxy interacts with Cloudflare headers and ensuring Docker volume mounts for accessories use named volumes rather than uninitialized host directories.

Related
Rails · Database PostgreSQL 18 and Rails: Compatibility and Native UUIDv7

A look at PostgreSQL 18's release from a Rails developer's perspective: ActiveRecord compatibility patches and built-in native uuidv7() support.

Oct 04, 2025
AI · Claude Code Building Lexio with Two Claudes: A Workflow That Actually Works

How I use Claude Projects for architecture decisions and Claude Code for implementation—keeping thinking and coding deliberately separate, with Lexio as a real-world example.

Mar 13, 2026
Rails · Basecamp Inside Fizzy's Authentication Architecture: A Three-Tier Approach to Multi-Tenant SaaS

How 37signals' Fizzy structures multi-tenant authentication with an Identity → User → Account model, enabling users to belong to multiple organizations cleanly.

Jan 09, 2026
All posts