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.

  1. bin/kamal setup, bootstraps the server, builds and pushes the image, starts the proxy, boots accessories, deploys the app.
  2. 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.
  3. Put Heroku in maintenance mode: heroku maintenance:on.
  4. Dump and load:
    pg_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
    
    The -x -O flags drop Heroku's grant and ownership statements, which reference roles that will not exist on your box.
  5. Flip DNS, watch kamal logs, then heroku maintenance:off only 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.

Looking for a fractional Rails engineer or CTO?

I take on a limited number of part-time clients.

Get in touch