Skip to content

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 deploy keeps 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

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.

ConcernValue
Container name<service>-<role>-<version> (e.g. myapp-web-abc1234)
Container labelsservice=, role=, destination=, version=
Docker networkkamalpending #2957: the network is not created yet
Proxy imagebasecamp/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.logpending #2957: never written yet
Hook directory.kamal/hooks/
Hook env prefixKAMAL_* (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.

Ruby Kamal allows ERB inside deploy.yml:

config/deploy.yml (illustrative — do not type)
# Ruby Kamal — NOT SUPPORTED in wheels deploy
service: <%= 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:

config/deploy.yml (illustrative — do not type)
service: ${APP_NAME}
image: ${REGISTRY}/${APP_NAME}

${VAR} references resolve in this order:

  1. .kamal/secrets (and the destination overlay .kamal/secrets.<destination> when --destination is set). Caveat: the lookup is currently relative to deploy.yml’s directory, so it reads config/.kamal/secrets rather than the project-root .kamal/secrets (#3084).
  2. System.getenv(VAR) on the machine running wheels deploy.
  3. 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}.

Ruby Kamal ERBwheels 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

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 under proxy.healthcheck: instead
  • hooks — the hooks feature works (scripts are always loaded from .kamal/hooks/); only the path-override key is rejected
  • volumes, labels, logging — no app-container mounts, extra labels, or logging flags are emitted
  • retain_containers — pass --keep=<n> to wheels deploy prune instead (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.

The verb surface mirrors Kamal’s. Most invocations are identical save the leading verb:

Ruby Kamalwheels deploy
kamal setupwheels deploy setupcurrently 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 deploywheels deploy
kamal redeploywheels deploy redeploy
kamal rollback VERSIONwheels deploy rollback VERSION
kamal configwheels deploy config
kamal app logs --followwheels deploy app logs --follow
kamal proxy bootwheels deploy proxy boot
kamal accessory boot dbwheels deploy accessory boot db
kamal secrets fetch --from op://...wheels deploy fetch-secrets --adapter=op --from=op://...
kamal versionwheels 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 (not server bootstrap, #2677), wheels deploy fetch-secrets / extract-secrets / print-secrets (not secrets fetch|extract|print, #2697), and positional rollback VERSION (never --version=, #2674). Kamal’s positional arguments (accessory boot db, rollback VERSION) are required, exactly as in Kamal — named-flag spellings like accessory boot --name=db do not exist.
  • --dry-run is everywhere. Every wheels deploy verb accepts --dry-run and prints the commands it would have run, prefixed by host. This is wider coverage than Kamal’s KAMAL_DEBUG=1 logs.

Because on-server state is byte-identical, you can run both tools against the same host during a transition:

  • Deploy with kamal deploy on Tuesday, wheels deploy on Wednesday, kamal deploy on Thursday. Nothing on the server notices.
  • Run kamal config and wheels deploy config side-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-proxy container 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.

Before removing kamal from your ecosystem:

  1. Scan deploy.yml for ERB. grep -n '<%' config/deploy.yml. Every match needs conversion.
  2. Remove rejected top-level keys. Delete any key outside the accepted list (or move healthcheck under proxy:) — see Divergence 2. wheels deploy config fails loudly on the first offender, so it doubles as the checker.
  3. Review .kamal/secrets for shell compatibility. wheels deploy evaluates the file through bash; if you relied on sh-only behavior, nothing changes, but confirm.
  4. Install the Wheels CLI on every control machine that runs deploys. See installing the CLI.
  5. Run wheels deploy config and diff against kamal config. Every field wheels emits should match kamal’s. The reverse isn’t true — wheels emits a subset during Phase 1.
  6. Run wheels deploy --dry-run. Every command it would run, printed with host prefixes. Confirm it matches what you expect.
  7. Deploy a non-critical environment first. Staging before production. Verify logs, health checks, rollback.
  8. Update CI. Replace any bundle exec kamal ... or gem install kamal lines with your Wheels CLI install command plus wheels deploy ....
  9. Remove Gemfile / Gemfile.lock Kamal entries. Only after at least one successful production deploy with wheels deploy.

These Ruby Kamal features have no wheels deploy equivalent and won’t get one:

  • Kamal plugins (Kamal::Commands extension 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 deploy inherits that.