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:
directoriesbinds a host path directly (which must exist on the host file system).volumescreates 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.