Deploying Rails 8 with Kamal 2: A Heroku Refugee's Runbook
You didn't leave Heroku because it was bad. You left because a two-dyno app with a Standard-0 database and a Redis add-on crossed some threshold where the invoice stopped looking like infrastructure and started looking like a subscription. Rails 8 ships Kamal 2 in the box, which means the escape hatch is already in your repo. This is the runbook for actually walking through it.
What you're trading
Heroku sold you a build pipeline, a routing layer, TLS, a managed Postgres with point-in-time recovery, log aggregation, and a rollback button. Kamal gives you the build pipeline, the routing layer, TLS, and the rollback button. The database and the log aggregation are now yours. Be honest about that before you provision anything, the savings are real, and so is the pager.
What you get in return: no 1000 MB slug ceiling, no dyno cycling every 24 hours, a request timeout you control (proxy.response_timeout, 30 seconds by default, the same number as Heroku's, but one config line away from a different one), SSH into the actual machine, and a docker container that runs identically on your laptop.
Step 0: the server
One VPS with 4 GB of RAM handles a surprising amount of Rails. Provision it, add your SSH public key, and stop there, do not install Ruby, do not install nginx. Kamal's kamal server bootstrap installs curl and Docker for you, and kamal setup invokes it as its first step. Plain kamal deploy does not bootstrap, so run setup the first time.
Two things must be true before you go further: DNS for your domain points at the server's IP, and ports 80 and 443 are open. Let's Encrypt validation happens over 443, and if the record hasn't propagated your first deploy will hang on certificate issuance with an unhelpful error.
Step 1: read the deploy.yml you already have
Rails 8 generated config/deploy.yml, .kamal/secrets, and a Dockerfile with Thruster in it. Open the first one. The proxy: block and the job role ship commented out; uncomment them and you land somewhere like this:
service: myapp
image: yourname/myapp
servers:
web:
- 203.0.113.10
job:
hosts:
- 203.0.113.10
cmd: bin/jobs
proxy:
ssl: true
host: myapp.com
app_port: 3000
healthcheck:
path: /up
interval: 3
timeout: 3
registry:
server: ghcr.io
username: yourname
password:
- KAMAL_REGISTRY_PASSWORD
env:
clear:
RAILS_LOG_LEVEL: info
SOLID_QUEUE_IN_PUMA: false
secret:
- RAILS_MASTER_KEY
- DATABASE_URL
builder:
arch: amd64
aliases:
console: app exec --interactive --reuse "bin/rails console"
logs: app logs -f
dbc: app exec --interactive --reuse "bin/rails dbconsole"
Three lines deserve attention. builder.arch: amd64 matters if you're on Apple Silicon, cross-building an image for x86 is slow, so consider remote: ssh://root@203.0.113.10 to build on the target instead. proxy.healthcheck.path points at /up, the endpoint Rails already mounts (and already silences in the logs). And servers.job is your worker dyno; if you'd rather run jobs inside the web process, drop the role and set SOLID_QUEUE_IN_PUMA: true, which is what a fresh Rails 8 app does out of the box.
Note what is not in there: RAILS_LOG_TO_STDOUT. Rails 8's generated production.rb logs to STDOUT unconditionally, so that Heroku-era variable does nothing now. RAILS_LOG_LEVEL is the one that still works.
Kamal 2 replaced Traefik with kamal-proxy, written by 37signals specifically for this job. Setting ssl: true alongside a host is the entire TLS configuration, it assumes a single web server, and it expects config.assume_ssl and config.force_ssl turned on in production.rb. There is no certbot cron.
Step 2: secrets
.kamal/secrets is the canonical secrets file, and it is not a .env file to commit. It's a shell-ish file whose values Kamal resolves at deploy time:
KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD
RAILS_MASTER_KEY=$(cat config/master.key)
SECRETS=$(kamal secrets fetch --adapter 1password --account myteam --from MyVault/MyItem DATABASE_URL)
DATABASE_URL=$(kamal secrets extract DATABASE_URL ${SECRETS})
That two-line dance is deliberate: fetch pulls a blob of key/value pairs in one round trip, and extract picks one out of it. Adapters exist for 1Password, Bitwarden (both the password manager and Secrets Manager), LastPass, Doppler, AWS Secrets Manager, GCP Secret Manager, and Passbolt. For a solo app, cat config/master.key plus Rails encrypted credentials is genuinely sufficient, if you haven't sorted out that layer, the complete guide to Rails credentials and secrets management covers the multi-environment setup this depends on.
Run kamal config to see the resolved configuration before you trust it. Its own help text warns "including secrets!", so don't paste that output into a ticket.
Step 3: the database decision
This is the fork in the road, and most migration guides skip past it.
Option A, Postgres as a Kamal accessory. Cheapest, and it's one more block in deploy.yml:
accessories:
db:
image: postgres:17
host: 203.0.113.10
port: "127.0.0.1:5432:5432"
env:
clear:
POSTGRES_USER: myapp
POSTGRES_DB: myapp_production
secret:
- POSTGRES_PASSWORD
directories:
- data:/var/lib/postgresql/data
Bind the port to 127.0.0.1, publishing 5432 to the world is the single most common way people get owned in week one. Accessories have their own lifecycle: kamal accessory boot db, kamal accessory logs db, kamal accessory reboot db. They are deliberately not updated by kamal deploy, and they get no zero-downtime treatment.
Option B, a managed Postgres from your VPS provider or Neon. Costs more than a container, far less than Heroku Standard, and someone else owns your backups. If you're a solo operator without a tested restore procedure, take Option B. You can always move later.
Either way, Rails 8's Solid Cache, Solid Queue, and Solid Cable each get their own database in the generated database.yml. Watch out: DATABASE_URL applies only to the primary entry. The cache, queue, and cable entries inherit host and credentials from the YAML anchor above them, not from your URL, so they'll quietly try a local Unix socket and fail with an error that tells you nothing. Set CACHE_DATABASE_URL, QUEUE_DATABASE_URL, and CABLE_DATABASE_URL explicitly, Rails builds those names from the connection name plus _DATABASE_URL. Redis is now optional; that's one add-on you can cancel outright.
Step 4: the cutover
Rehearse this against a staging destination (config/deploy.staging.yml, deployed with -d staging) first.
bin/kamal setup, bootstraps the server, builds and pushes the image, starts the proxy, boots accessories, deploys the app.- Verify the app answers on its domain with a fresh database. Don't skip this; debugging a bad deploy and a bad data import at once is miserable.
- Put Heroku in maintenance mode:
heroku maintenance:on. - Dump and load:
Thepg_dump -Fc -x -O "$(heroku config:get DATABASE_URL -a myapp)" > prod.dump pg_restore --no-owner --no-acl -d "$NEW_DATABASE_URL" prod.dump-x -Oflags drop Heroku's grant and ownership statements, which reference roles that will not exist on your box. - Flip DNS, watch
kamal logs, thenheroku maintenance:offonly if you need to fall back.
Time the dump/restore beforehand on a copy. A 20 GB database is not a five-minute window.
Step 5: unlearning Heroku
| Heroku | Kamal |
|---|---|
git push heroku main |
kamal deploy |
heroku run rails console |
kamal console |
heroku logs -t |
kamal logs |
heroku ps |
kamal details |
heroku releases:rollback |
kamal rollback [VERSION] |
heroku config:set |
edit env, redeploy |
kamal console and kamal logs are the aliases from the generated deploy.yml, not built-in commands, they expand to app exec and app logs. Rollback works because Kamal keeps the last five containers and images around (retain_containers, default 5). It's a container swap, not a data rollback, migrations still need to be backward-compatible.
Deploys are zero-downtime by the same mechanism: kamal-proxy polls /up on the new container, switches traffic once it's healthy, then drains in-flight requests from the old one.
Step 6: what you now own
Container filesystems are ephemeral, but Rails 8's generated deploy.yml already mounts a named volume at /rails/storage, so Active Storage :local files do survive deploys. The catch is subtler: they now live on one box's disk, inside a volume nothing is backing up. Move to S3 or back that volume up, pick one deliberately.
Then set up, this week: automated pg_dump to object storage with a tested restore, uptime monitoring from outside the box, unattended-upgrades, and disk-space alerting. kamal deploy already prunes at the end of every deploy, so containers and images past retain_containers clear themselves, build cache and orphaned volumes don't.
That's the whole trade. Kamal handed you back the platform layer. The operations discipline Heroku was quietly doing on your behalf is the part you now have to write down.