Deployment
Migrating from Kamal
This page is for teams already running Ruby Kamal who want to replace kamal with wheels deploy. The short version: your .kamal/secrets and .kamal/hooks/* work unchanged, and config/deploy.yml largely does too, with two schema exceptions — ERB inside deploy.yml, and a set of Kamal top-level keys the validator rejects because the port doesn’t implement them — and a version caveat: env.secret delivery to containers requires a CLI built from develop (4.0.4+) — the released 4.0.3 silently drops those entries (#2957). Keep reading for the full story.
You’ll learn:
- What
wheels deploykeeps byte-compatible with Ruby Kamal - The two deliberate schema divergences — ERB removed (Kamal’s
${VAR}interpolation kept unchanged), and unimplemented Kamal top-level keys rejected instead of silently ignored - The coexistence guarantee — running both tools against the same host during evaluation
- A concrete switch-over checklist
What’s byte-compatible
Section titled “What’s byte-compatible”The on-server state managed by wheels deploy and Ruby Kamal is identical by design — that’s the Kamal 2.4.0 contract the port targets. A few rows are still pending in the current Phase 1 CLI (tracked in #2957); they’re annotated below.
| Concern | Value |
|---|---|
| Container name | <service>-<role>-<version> (e.g. myapp-web-abc1234) |
| Container labels | service=, role=, destination=, version= |
| Docker network | kamal — pending #2957: the network is not created yet |
| Proxy image | basecamp/kamal-proxy:v0.8.6 |
| Proxy config dir | /home/<user>/.config/kamal-proxy/ — pending #2957: currently computed as /home/<user> even when deploying as root (Kamal uses /root) |
| Lock file path | /tmp/kamal_deploy_lock_<service> |
| Audit log | /tmp/kamal-audit.log — pending #2957: never written yet |
| Hook directory | .kamal/hooks/ |
| Hook env prefix | KAMAL_* (not WHEELS_*) |
| Secret file | .kamal/secrets, .kamal/secrets.<destination> |
Why keep the KAMAL_* prefix? Because every hook anyone has ever written for Ruby Kamal uses it. Renaming would break every user’s existing scripts for zero benefit. One honesty note: the env block is a subset of Ruby Kamal’s — KAMAL_VERSION, KAMAL_HOSTS, KAMAL_PERFORMER, KAMAL_DESTINATION, KAMAL_RUNTIME, and KAMAL_ERROR are set; KAMAL_ROLE, KAMAL_SERVICE, and the rest are not. Hooks reading only the supported subset port unchanged. See Hooks for the full table.
Why keep the .kamal/ directory name? Because you can sit on both tools during evaluation — kamal deploy and wheels deploy read the same secrets and the same hooks. Switch back and forth freely; nothing on the server changes.
Divergence 1: ERB removed
Section titled “Divergence 1: ERB removed”Ruby Kamal allows ERB inside deploy.yml:
# Ruby Kamal — NOT SUPPORTED in wheels deployservice: <%= ENV["APP_NAME"] %>image: <%= ENV["REGISTRY"] %>/<%= ENV["APP_NAME"] %>ERB is a Ruby template language — it calls into Ruby code at render time. We can’t render ERB from a CFML CLI without embedding a Ruby runtime, which defeats the purpose of the port.
What wheels deploy keeps unchanged is Kamal’s other built-in interpolation syntax — ${UPPER_SNAKE} env-var tokens — so most ERB-using configs convert mechanically by dropping the <%= ENV["..."] %> wrapper and using a bare ${VAR} reference:
service: ${APP_NAME}image: ${REGISTRY}/${APP_NAME}${VAR} references resolve in this order:
.kamal/secrets(and the destination overlay.kamal/secrets.<destination>when--destinationis set). Caveat: the lookup is currently relative todeploy.yml’s directory, so it readsconfig/.kamal/secretsrather than the project-root.kamal/secrets(#3084).System.getenv(VAR)on the machine runningwheels deploy.- Empty string (Kamal’s behavior for unset vars).
Only uppercase-and-underscore tokens are expanded — ${APP_NAME} matches; lowercase ${service} is left alone (this prevents accidental capture of shell-style placeholders elsewhere in the config). This is the same rule Kamal applies.
If your existing config uses ERB logic — <%= "blue" if ENV["ROLE"] == "staging" %> — move that logic into .kamal/secrets (or a .kamal/secrets.<destination> overlay), or into a shell wrapper that exports the resolved value, then reference the result with ${ROLE}.
Common ERB → ${VAR} conversions
Section titled “Common ERB → ${VAR} conversions”| Ruby Kamal ERB | wheels deploy |
|---|---|
<%= ENV["FOO"] %> | ${FOO} |
<%= ENV.fetch("FOO", "bar") %> | ${FOO} — set FOO=bar in .kamal/secrets for the default |
<%= git rev-parse —short HEAD.chomp %> | pass --release=$(git rev-parse --short HEAD) on the CLI, or set VERSION in .kamal/secrets and use ${VERSION} |
<%= ENV["STAGE"] == "prod" ? 1 : 0 %> | set the resolved value in .kamal/secrets.production or .kamal/secrets.staging — destination overlays replace in-file logic |
Divergence 2: rejected top-level keys
Section titled “Divergence 2: rejected top-level keys”Kamal accepts a number of top-level deploy.yml keys that wheels deploy has not implemented. Rather than accepting them and silently doing nothing, the validator rejects them — any unimplemented key makes every command fail with unknown top-level key: '<name>' (#3088).
The accepted top-level keys are service, image, servers, registry, builder, env, ssh, proxy, and accessories. Everything else must come out of the file before it loads, including:
boot— hosts always deploy one at a time, sequentially (#2957)- top-level
healthcheck— move it underproxy.healthcheck:instead hooks— the hooks feature works (scripts are always loaded from.kamal/hooks/); only the path-override key is rejectedvolumes,labels,logging— no app-container mounts, extra labels, or logging flags are emittedretain_containers— pass--keep=<n>towheels deploy pruneinstead (default keeps the 5 most recent)minimum_version,asset_path,require_destination,allow_empty_roles,run_directory,readiness_delay
See the Rejected Kamal keys table in the config reference for what each key does in Kamal and the wheels deploy behavior you get instead.
What changes in the CLI
Section titled “What changes in the CLI”The verb surface mirrors Kamal’s. Most invocations are identical save the leading verb:
| Ruby Kamal | wheels deploy |
|---|---|
kamal setup | wheels deploy setup — currently an alias for wheels deploy; unlike Kamal it doesn’t boot the proxy or accessories yet (#2957) — run wheels deploy proxy boot and wheels deploy accessory boot all yourself |
kamal deploy | wheels deploy |
kamal redeploy | wheels deploy redeploy |
kamal rollback VERSION | wheels deploy rollback VERSION |
kamal config | wheels deploy config |
kamal app logs --follow | wheels deploy app logs --follow |
kamal proxy boot | wheels deploy proxy boot |
kamal accessory boot db | wheels deploy accessory boot db |
kamal secrets fetch --from op://... | wheels deploy fetch-secrets --adapter=op --from=op://... |
kamal version | wheels deploy version |
Two CLI-level differences worth flagging:
- Some nested verbs are flat. The CLI runtime claims a few top-level subcommand names (
server,secrets) and the root parser claims--version, so those Kamal forms get intercepted before the deploy module sees them. The working spellings are flat:wheels deploy bootstrap(notserver bootstrap, #2677),wheels deploy fetch-secrets/extract-secrets/print-secrets(notsecrets fetch|extract|print, #2697), and positionalrollback VERSION(never--version=, #2674). Kamal’s positional arguments (accessory boot db,rollback VERSION) are required, exactly as in Kamal — named-flag spellings likeaccessory boot --name=dbdo not exist. --dry-runis everywhere. Everywheels deployverb accepts--dry-runand prints the commands it would have run, prefixed by host. This is wider coverage than Kamal’sKAMAL_DEBUG=1logs.
The coexistence guarantee
Section titled “The coexistence guarantee”Because on-server state is byte-identical, you can run both tools against the same host during a transition:
- Deploy with
kamal deployon Tuesday,wheels deployon Wednesday,kamal deployon Thursday. Nothing on the server notices. - Run
kamal configandwheels deploy configside-by-side to compare resolved output. The wheels output is a subset of the Kamal output — it names fewer keys, but every key it names matches. - Locks, audit logs, and the
kamal-proxycontainer are single-owner on the server; whichever tool holds the lock has it until release.
This exists for evaluation, not long-term mixed use. Pick a tool per repo and stick with it.
Switch-over checklist
Section titled “Switch-over checklist”Before removing kamal from your ecosystem:
- Scan
deploy.ymlfor ERB.grep -n '<%' config/deploy.yml. Every match needs conversion. - Remove rejected top-level keys. Delete any key outside the accepted list (or move
healthcheckunderproxy:) — see Divergence 2.wheels deploy configfails loudly on the first offender, so it doubles as the checker. - Review
.kamal/secretsfor shell compatibility.wheels deployevaluates the file throughbash; if you relied onsh-only behavior, nothing changes, but confirm. - Install the Wheels CLI on every control machine that runs deploys. See installing the CLI.
- Run
wheels deploy configand diff againstkamal config. Every field wheels emits should match kamal’s. The reverse isn’t true — wheels emits a subset during Phase 1. - Run
wheels deploy --dry-run. Every command it would run, printed with host prefixes. Confirm it matches what you expect. - Deploy a non-critical environment first. Staging before production. Verify logs, health checks, rollback.
- Update CI. Replace any
bundle exec kamal ...orgem install kamallines with your Wheels CLI install command pluswheels deploy .... - Remove
Gemfile/Gemfile.lockKamal entries. Only after at least one successful production deploy withwheels deploy.
What’s explicitly not supported
Section titled “What’s explicitly not supported”These Ruby Kamal features have no wheels deploy equivalent and won’t get one:
- Kamal plugins (
Kamal::Commandsextension points in Ruby). The plugin API is Ruby-specific. Shell-script.kamal/hooks/are supported — that’s the language-agnostic extension story. - Arbitrary ERB in
deploy.yml. Covered above. - Windows servers. Kamal doesn’t support them, and
wheels deployinherits that.