Kamal 2: Your First Rails Deploy
Porting a Kamal 1 config to Kamal 2 fails on unknown key: traefik. Here is what each dead key became, and what a working first deploy on one server looks like.

Kamal 2 moved the pieces that most published Kamal tutorials spend their time on. Copy one of those configs forward and the deploy stops before it opens an SSH connection:
ERROR (Kamal::ConfigurationError): unknown key: traefik
Kamal validates every top-level key, so a stale config fails at once instead of misbehaving quietly. That part is a gift. The catch is that the error names what died without naming its replacement, and traefik is only the first key you’ll hit.
This walks a first deploy onto one server against Kamal 2.12, with each moved piece mapped to what replaced it.
What kamal init actually creates #
Add the gem and initialize:
# Gemfile
gem "kamal", "~> 2.0"
bundle install
bundle exec kamal init
Three things land, and one of them is the one old guides miss:
config/deploy.yml # main configuration
.kamal/secrets # secret references - safe to commit
.kamal/hooks/ # sample deploy hooks
There is no .env.erb. If you’re hunting for one because a tutorial promised it, that file belonged to Kamal 1 and hasn’t shipped since 2.0. bin/kamal doesn’t appear either unless you pass --bundle, which defaults to false.
Rails 8 ships Kamal in new apps already, so kamal init may be redundant for you - we covered that change when it landed.
Secrets moved to .kamal/secrets #
.kamal/secrets is built to be committed, because it holds references rather than values:
# .kamal/secrets
KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD
RAILS_MASTER_KEY=$(cat config/master.key)
Each line either reads an environment variable or shells out. The file carries no credentials itself, which is why the generated template opens with “DO NOT ENTER RAW CREDENTIALS HERE! This file needs to be safe for git.”
Already keeping secrets in a password manager? Kamal will fetch them:
SECRETS=$(kamal secrets fetch --adapter 1password --account my-account \
--from MyVault/MyItem KAMAL_REGISTRY_PASSWORD RAILS_MASTER_KEY)
KAMAL_REGISTRY_PASSWORD=$(kamal secrets extract KAMAL_REGISTRY_PASSWORD $SECRETS)
Adapters ship for 1Password, LastPass, and Bitwarden.
The config that gets you deployed #
Here’s a working single-server deploy.yml, shorter than you’d expect:
# config/deploy.yml
service: my-app
image: my-user/my-app
servers:
web:
- 192.168.0.1
proxy:
ssl: true
host: app.example.com
# kamal-proxy talks to your container on port 80 by default
# app_port: 3000
registry:
server: ghcr.io
username: my-user
password:
- KAMAL_REGISTRY_PASSWORD
builder:
arch: amd64
env:
clear:
DB_HOST: 192.168.0.2
secret:
- RAILS_MASTER_KEY
The proxy: block sits where Traefik used to. Kamal 2 runs kamal-proxy instead, and it handles Let’s Encrypt itself - ssl: true plus a host: is the entire TLS setup on one server.
Notice what env: looks like now. Plain values go under clear:, and anything sensitive goes under secret: as a name that resolves out of .kamal/secrets.
Health checks moved too. They live under proxy:, not at the top level:
proxy:
ssl: true
host: app.example.com
healthcheck:
path: /health
interval: 3
timeout: 3
A top-level healthcheck: block with port: and max_attempts: is Kamal 1 syntax, and it earns you the same treatment as traefik::
ERROR (Kamal::ConfigurationError): unknown key: healthcheck
Useful property to lean on while you port a config: if your deploy.yml validates on your machine, every key in it is real for your version. Work through the errors one at a time and Kamal will name each dead key for you.
Sizing the box #
Kamal publishes no minimum RAM figure, and it can’t - your app decides that number, not the deploy tool.
What Kamal does decide is where the build happens, and that’s the part people size wrong. builder.local defaults to true, so docker build runs on your laptop and Kamal pushes the finished image to your registry. Your server pulls it.
Asset precompilation, bundle install, and the whole Node toolchain never touch the production box. Size the server for your app’s runtime plus whatever accessories share it, not for a build.
Set builder.remote to offload builds elsewhere and the arithmetic changes - but that’s a deliberate choice, not the default.
Kamal will install Docker for you, so skip the curl get-docker.sh step older guides open with:
bundle exec kamal server bootstrap
The first deploy #
First time out, you want setup, not deploy:
bundle exec kamal setup
setup installs Docker on the servers, then runs a deploy with accessories booted. Every deploy after that is kamal deploy, and once servers are already bootstrapped, kamal redeploy skips the bootstrap and proxy work.
That local-build step is why a slow home connection hurts a Kamal deploy more than a slow server does.
When the first deploy stalls #
A health check that never goes green is the failure we see most on first deploys, and the error names the target rather than the cause. We wrote up that specific error and how to read it separately, because it has more causes than fit here.
Three commands do most of the diagnosis:
bundle exec kamal app logs -f # application output
bundle exec kamal proxy logs # kamal-proxy, formerly where you'd read Traefik
bundle exec kamal config # roles, hosts, image, builder, accessories
One caveat on kamal config, because its own help text oversells it. The command prints a redacted subset - roles, hosts, resolved image and version, builder, accessories - and leaves out env and proxy entirely. Mistype a secret name and kamal config still exits 0 with unchanged output, so it will not confirm your secrets wiring. Its job is telling you which hosts and image a deploy resolved to.
There’s also kamal docs proxy - the gem ships its configuration reference offline, so you can check a key without leaving the terminal.
Rolling back #
bundle exec kamal rollback 9f8a2c1
Rollback is a container swap, not a rebuild, and it takes a real version rather than a placeholder. Kamal checks that a container still exists for the version you named, which is the constraint that catches people: kamal prune keeps only the last five containers by default, so five deploys after a bad one, that version is no longer a rollback target. Run kamal app containers to see what’s actually still there.
Worth knowing before you need it: rollback swaps the code, not your database. A deploy that ran a destructive migration doesn’t become safe because you rolled the container back.
Coming from Kamal 1 #
Don’t hand-edit your way across. Kamal ships the migration:
bundle exec kamal upgrade
It confirms once, then replaces Traefik with kamal-proxy and restarts accessories. Rename two files before you run it: Kamal 2 refuses any config carrying pre-traefik-reboot or post-traefik-reboot hooks, and the error tells you what it wants - “these should be renamed to (pre|post)-proxy-reboot”. Rename them rather than deleting; the hooks themselves still run.
When Kamal is the wrong call #
Running one app on one box, with traffic that fits in a small droplet? Kamal earns its keep fast. The calculus shifts as you add servers.
Kamal doesn’t load-balance across hosts, and ssl: true raises a ConfigurationError the moment you list a second web server. Neither is a bug - that’s the boundary where you’re expected to bring a load balancer. The multi-server post covers that transition, including the roles split and the TLS answer.
Skip Kamal entirely if you want zero infrastructure ownership. Somebody on your team now patches a Linux box, and a PaaS bill is often cheaper than the hours that takes.
For deploys triggered on push rather than from your laptop, wire it into GitHub Actions - and if you want per-PR environments, review apps work the same way.
Hand the port to an agent #
The migration is mechanical, and kamal config gives an agent something most refactors don’t: a validator that fails loudly on every key it doesn’t recognise. That turns the port into a loop the agent can close on its own.
One caveat worth stating up front. Most Kamal material an agent absorbed during training describes Kamal 1, so it will reach for traefik: and .env on instinct - and some published guides contain keys like strategy: blue_green that Kamal has never had. The prompt below is built to stop the agent trusting its own recall.
Port this Rails app's Kamal config from Kamal 1 to Kamal 2 (target: 2.12).
Ground rules:
- Do NOT rely on your training data for Kamal syntax. Much of it predates
Kamal 2 and will steer you wrong.
- `bundle exec kamal config` is your verification loop. It validates every
top-level key and raises Kamal::ConfigurationError: unknown key: <name>
on anything that isn't real. Run it after each edit. Do not declare the
port finished until it exits 0.
- `bundle exec kamal docs <section>` (proxy, builder, env, accessories,
boot, registry) is the offline reference that ships with the installed
gem. Check it there rather than guessing. If a key you want isn't in
those docs, it does not exist - do not invent one.
Changes to make in config/deploy.yml:
- Replace the `traefik:` block with `proxy:`. On a single web server,
`ssl: true` plus `host: <domain>` is the whole TLS setup; kamal-proxy
handles Let's Encrypt itself. Carry the domain over from the old
certificatesresolvers args. Drop the acme/letsencrypt volume and args.
- Move any top-level `healthcheck:` under `proxy:`, and convert its keys:
Kamal 2 takes path/interval/timeout. `port` and `max_attempts` are gone.
- Convert `env:` to the clear/secret split. Plain values go under `clear:`.
Anything sensitive becomes a NAME under `secret:`, resolved from
.kamal/secrets.
Secrets:
- Create .kamal/secrets if it's missing (`bundle exec kamal init` writes a
template). It holds references, never values - each line reads an env var
or shells out, e.g. RAILS_MASTER_KEY=$(cat config/master.key).
- Migrate the NAMES from the old .env / .env.erb. Do not copy any real
credential into the repo, and do not delete the old .env until I confirm
the values are stored elsewhere. Tell me which names you moved.
Hooks:
- If .kamal/hooks/pre-traefik-reboot or post-traefik-reboot exist, RENAME
them to (pre|post)-proxy-reboot. Kamal 2 refuses to run while the old
names are present. Keep the file contents as they are.
When `kamal config` exits 0, stop and report:
1. every key you removed, and what replaced it
2. the secret names you migrated
3. anything you could not map, with the kamal docs section you checked
Do not run `kamal deploy`, `kamal setup`, or `kamal upgrade`. I'll run the
first deploy myself once I've read the diff.
The last line matters. Let the agent do the editing and the validating, then read the diff before anything touches a server - kamal upgrade restarts accessories on real infrastructure, and that’s a decision worth making yourself.
Sources #
- Kamal documentation - official guides and the full configuration reference
- Kamal on GitHub - source, changelog, and the 2.x release notes
- kamal-proxy - the proxy that replaced Traefik in 2.0
- Configuration reference - every key, including the ones this post skipped
- Rails 8 release notes - where Kamal became the default deploy path
Reading this because something is going wrong?
A free code audit gives you a written assessment of your codebase in plain English.
Get a Free Code AuditRated 4.8/5 on Clutch · you keep the write-up either way