Why I switched to Kamal
I was on Render before. It worked fine for one app, but once I had 3-4 projects going, the bill added up fast. Each app needed its own instance, its own database, its own everything. For side projects and small SaaS products, that's hard to justify.
Kamal caught my attention because it ships with Rails now (since 7.1), and it does the one thing I actually needed — get my Docker containers running on a cheap VPS with zero-downtime deploys and automatic SSL.
The server
One Hetzner CPX31 — 8GB RAM, 4 vCPUs. That's it.
All my apps share this box. Kamal's built-in proxy (kamal-proxy) handles routing based on hostname, so each app gets its own domain with automatic Let's Encrypt SSL. No nginx config to manage.
For bigger client projects, I use dedicated servers or split across multiple machines. Kamal handles that fine — you just list more IPs under servers. The setup I'm describing here is for my own products and smaller projects where I want to keep costs down.
The deploy config
Here's a stripped-down version of one of my deploy.yml files:
service: myapp
image: myuser/myapp
servers:
web:
- your.server.ip
ssh:
user: deploy
registry:
server: localhost:5555
proxy:
ssl: true
hosts:
- myapp.com
env:
secret:
- RAILS_MASTER_KEY
clear:
RAILS_ENV: production
SOLID_QUEUE_IN_PUMA: "1"
aliases:
console: app exec --interactive --reuse "bin/rails console"
shell: app exec --interactive --reuse "bash"
logs: app logs -f
volumes:
- "myapp_storage:/rails/storage"
asset_path: /rails/public/assets
builder:
arch: amd64
remote: ssh://[email protected]
cache:
type: gha
options: mode=maxA few things worth pointing out:
Non-root SSH
I use a dedicated deploy user instead of root. Kamal just needs a user that can run Docker. Set it up once with ssh: user: deploy and you're done.
Registryless deploy
See registry: server: localhost:5555? That means I'm not pushing images to Docker Hub or any external registry. Kamal spins up a small registry on the server itself, pushes the image there, and pulls from it locally. One less account to manage, one less thing to pay for, and deploys are faster because the image never leaves the server.
Remote builder
I build images directly on the server. My laptop is ARM (and sometimes I'm on slow wifi), so building locally and pushing a 500MB image isn't great. The server builds it, the server runs it.
Solid Queue in Puma
For small apps, I don't bother running a separate worker process. SOLID_QUEUE_IN_PUMA: "1" runs background jobs inside the Puma process. One less container to manage.
GHA build cache
The cache: type: gha tells Kamal to use GitHub Actions' built-in cache for Docker layers. First deploy builds everything from scratch, but after that it only rebuilds layers that changed. This cut my deploy times from 8-10 minutes down to about 3. You need to add the crazy-max/ghaction-github-runtime@v3 action in your workflow for this to work.
Aliases
kamal console, kamal shell, kamal logs — these save me from remembering long docker exec commands every time I need to check something in production.
The Dockerfile
I use the standard Rails Dockerfile that ships with rails new, with a few tweaks:
ARG RUBY_VERSION=3.3.6
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
WORKDIR /rails
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \
ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so
ENV RAILS_ENV="production" \
BUNDLE_DEPLOYMENT="1" \
BUNDLE_PATH="/usr/local/bundle" \
BUNDLE_WITHOUT="development" \
LD_PRELOAD="/usr/local/lib/libjemalloc.so"
# Build stage
FROM base AS build
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config
COPY Gemfile Gemfile.lock ./
RUN bundle install && \
rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache
COPY . .
RUN bundle exec bootsnap precompile --gemfile app/ lib/ && \
SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
# Final stage
FROM base
RUN groupadd --system --gid 1000 rails && \
useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash
USER 1000:1000
COPY --chown=rails:rails --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
COPY --chown=rails:rails --from=build /rails /rails
ENTRYPOINT ["/rails/bin/docker-entrypoint"]
EXPOSE 80
CMD ["./bin/thrust", "./bin/rails", "server"]jemalloc
LD_PRELOAD swaps out the default memory allocator. Rails apps tend to bloat over time without it. With jemalloc, my apps sit around 150-200MB instead of growing to 400MB+.
Thruster
That ./bin/thrust at the bottom is Basecamp's HTTP proxy. It handles asset caching headers and gzip compression so you don't need to configure that yourself.
Non-root user
The app runs as uid 1000, not root. Small thing, but it matters.
CI/CD with GitHub Actions
Every push to main runs tests, then deploys with Kamal. Here's the deploy job:
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Expose GitHub Actions Runtime for Docker cache
uses: crazy-max/ghaction-github-runtime@v3
- uses: docker/setup-buildx-action@v3
- uses: ruby/setup-ruby@v1
with:
ruby-version: "3.3"
- run: gem install kamal:2.8.2
- uses: webfactory/[email protected]
with:
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
- run: echo "${{ secrets.RAILS_MASTER_KEY }}" > config/master.key
- run: kamal lock release || true
- run: kamal deploy -v
- if: always()
run: kamal lock release || trueThe key piece here is crazy-max/ghaction-github-runtime@v3 — this exposes the GitHub Actions cache backend to Docker Buildx, which Kamal uses under the hood. Combined with cache: type: gha in your deploy.yml, Docker will reuse cached layers between deploys. Gem install, asset precompilation — all cached unless those files actually change.
The kamal lock release || true before and after deploy is a workaround for stale locks. Sometimes a deploy fails halfway and leaves a lock behind. Without this, your next deploy just hangs.
What it costs
| Render/Heroku | My setup | |
|---|---|---|
| 5 web apps | ~$125/month (5 x $25) | ~$10/month (one server) |
| SSL | included | included (Let's Encrypt via kamal-proxy) |
| CI/CD | separate or included | GitHub Actions free tier |
| Database | $15-25/app extra | SQLite on disk / self-managed Postgres |
| Background jobs | extra dyno per app | Solid Queue in Puma (free) |
For side projects, the math is pretty clear. For bigger client projects where uptime matters more, I use dedicated Hetzner servers with more resources — same Kamal setup, just beefier hardware and sometimes multiple servers behind kamal-proxy.
Would I recommend this?
For solo developers and small teams — absolutely. Kamal took me maybe a day to learn, and now spinning up a new app takes about 15 minutes: copy a deploy.yml, change the service name and domain, push.
It scales up too. I use essentially the same workflow for client projects that handle real traffic — just with bigger servers, staging environments, and separate deploy configs per environment. Kamal doesn't care if you're deploying to one server or five.
The main thing I'd tell anyone starting out: get the Dockerfile right first. Most of the issues I ran into were Docker problems, not Kamal problems. Once the container builds and runs locally, Kamal just moves it to the server and keeps it running.
Frequently Asked Questions
How much does it cost to deploy Rails with Kamal on Hetzner?
About $10/month for a Hetzner CPX31 (8GB RAM, 4 vCPUs) that can run 6+ Rails apps. Compare that to ~$125/month on Render or Heroku for the same number of apps.
Can you run multiple Rails apps on one server with Kamal?
Yes. Kamal's built-in proxy (kamal-proxy) handles hostname-based routing, so each app gets its own domain with automatic Let's Encrypt SSL. No nginx configuration needed.
Is Kamal better than Heroku for Rails deployment?
For solo developers running multiple apps, Kamal is significantly cheaper while providing zero-downtime deploys, automatic SSL, and full server control. Heroku is simpler for single apps where you don't want to manage any infrastructure.
Do I need a Docker registry for Kamal?
No. Kamal supports registryless deploys using a local registry on the server (localhost:5555). Images are built and stored on the server itself — no Docker Hub account needed.
Vibol Teav
Software engineer with 13+ years of experience building and deploying Rails applications. Currently focused on Rails infrastructure, Kamal deployments, and helping teams ship faster with less overhead.
I maintain Rails apps like these for other teams too. If you've got a Rails project that needs ongoing care — upgrades, deploys, performance tuning:
Check out Rails Care