My First Rails Deployment Journey with Kamal
My first experience deploying a Rails app with Kamal—from hitting a wall with proxy conflicts to solving mysterious Docker volume errors. Here's what I learned along the way

Deploying a Rails application for the first time can feel like navigating uncharted waters. After running my blog successfully with Docker Compose, I thought deploying a Rails app would follow the same pattern. Spoiler alert: it didn't, and I learned some valuable lessons along the way.
The Initial Plan (That Didn't Work)
My original idea was simple: deploy everything to one server. After all, my blog was already running smoothly there with Docker Compose and Caddy handling the routing. How hard could it be to add a Rails app to the mix?
Turns out, pretty hard (for me).
The problem? Kamal-proxy and Caddy don't play nice together. Both want to be the traffic manager, and having two reverse proxies fighting for control is a recipe for confusion. I researched ways to make them coexist—either migrate everything to kamal-proxy or keep Caddy in charge of all services.
But here's the thing: I'm not a DevOps expert, and honestly, I didn't want to become one just to deploy an app. Plus, keeping everything on one server meant upgrading the specs, which would double my hosting costs.
So I took the pragmatic route: spin up a new server with the same specs and deploy the Rails app there. Sometimes the easiest path is the best path.
Setting Up Kamal Deploy Configuration
Here's my working config/deploy.yml
file. I've annotated the key parts that tripped me up:
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
# This is crucial if you're using Cloudflare
proxy:
ssl: true
host: <your-host-name>
forward_headers: true # Don't forget this!
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: # Note: volumes, not directories!
- <application>_db_data:/var/lib/postgresql/data
The Cloudflare Detail That Matters
Since I use Cloudflare with the orange cloud enabled (Proxied), I needed to add forward_headers: true
to my proxy configuration. This ensures that the original client IP and other important headers are passed through Cloudflare's proxy to your app.
proxy:
ssl: true
host: example.com
forward_headers: true
Without this, your Rails app might see Cloudflare's IP instead of your actual users' IPs.
When Things Went Wrong: The Directory vs. Volumes Mystery
After running kamal deploy
, I hit a wall. The deployment failed with this cryptic 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
Not exactly beginner-friendly, right?
After some digging through Kamal's documentation, I discovered the distinction between directories
and volumes
:
Directories
These are created on the host before being mounted into the container:
directories:
- mysql-logs:/var/log/mysql
Volumes
These are Docker volumes that aren't automatically created or managed by Kamal:
volumes:
- /path/to/mysql-logs:/var/log/mysql
Most tutorials use directories
, but in my case, switching to volumes
solved the problem. I'm still not entirely sure why, but it works, and sometimes that's good enough when you're learning.
Database Initialization Made Easy
One neat trick I learned: the init.sql
file executes automatically when the PostgreSQL container is created. This is perfect for setting up multiple databases:
CREATE DATABASE <application>_production;
CREATE DATABASE <application>_production_cable;
CREATE DATABASE <application>_production_cache;
CREATE DATABASE <application>_production_queue;
Drop this file in your config
folder, reference it in your deploy.yml
, and you're set. No manual database creation needed!
Lessons Learned
Looking back, here's what I wish I knew before starting:
- Don't overcomplicate things. If your existing setup works, sometimes it's better to separate concerns rather than cramming everything onto one server.
- Read the error messages carefully. That "no such file or directory" error was actually telling me exactly what was wrong—I just needed to understand Kamal's volume system.
- Documentation is your friend, but so is experimentation. Sometimes the "wrong" approach (using volumes instead of directories) turns out to be right for your specific situation.
- Cloudflare users: remember
forward_headers: true
. Save yourself the debugging headache.
What's Next?
Now that my Rails app is deployed and running smoothly, I'm planning to explore:
- Better monitoring and logging strategies
- Understanding why volumes worked better than directories (still curious!)
If you're deploying your first Rails app with Kamal, I hope this helps you avoid some of the pitfalls I encountered. Feel free to reach out if you run into similar issues—sometimes just knowing someone else faced the same problem makes it less daunting.
Happy deploying! 🚀