Deployment
Architecture of wheels deploy
This page is for readers who want to understand how wheels deploy is put together — whether you're extending it, debugging it, evaluating it against other deploy tools, or just curious about the port strategy. If you're just trying to ship an app, start with Your First Deploy instead.
You'll learn:
- Why
wheels deployis a port of Basecamp's Kamal rather than a new tool - The byte-compatibility contract that makes a Kamal-managed server takeoverable by
wheels deploywithout cleanup - The deliberate divergences from Kamal's config schema (ERB removed, unimplemented top-level keys rejected;
${VAR}interpolation kept unchanged) and why - The commands-are-strings invariant that makes
--dry-runtrivial and lets the test suite run offline - How the three third-party JARs load in isolation so they don't collide with the rest of the JVM
Port strategy
Section titled “Port strategy”Kamal solves the hard parts of container-based Linux deploys: zero-downtime rolling cutover, container naming conventions, a proxy tier for traffic draining, secret-manager adapters for 1Password / Bitwarden / AWS / LastPass / Doppler, and a battle-tested on-server layout. Basecamp spent three years building it in the open.
Kamal's proxy component (kamal-proxy) is already a standalone Go binary — no Ruby runtime. What's Ruby-specific is only the developer-side orchestrator: the CLI that opens SSH connections, uploads config, and runs docker commands.
wheels deploy ports that orchestrator into the Wheels CLI. The Go proxy is unchanged — we invoke the same basecamp/kamal-proxy:v0.8.6 image Kamal does. This means the hardest and most error-prone piece of the puzzle (atomic traffic cutover under load) is not reimplemented. It's the same code path Kamal users have been exercising in production for years.
Byte-compatibility contract
Section titled “Byte-compatibility contract”The design bet: a server managed by Ruby Kamal can be taken over by wheels deploy without cleanup, and vice versa. This is enforced by matching the Kamal 2.4.0 on-server contract exactly — container naming and labels, the kamal network, the proxy image and config path, lock and audit files, hooks, and secrets. The full concern-by-concern table — including the rows still pending on a live host (#2957) — lives in Migrating from Kamal — What's byte-compatible; this page keeps to the why.
Two choices in that contract warrant commentary.
Why KAMAL_* and not WHEELS_*. Every hook script ever written for Ruby Kamal reads environment variables named KAMAL_SERVICE, KAMAL_VERSION, and so on. Renaming to WHEELS_* would be slightly more consistent with the rest of the Wheels CLI — and it would break every user's existing hook scripts for zero benefit.
Why .kamal/ and not .wheels/deploy/. Teams evaluating a switch from Ruby Kamal can run both tools against the same hosts during the transition. Both read the same .kamal/secrets and .kamal/hooks/. There's no migration step and no point of no return.
See Migrating from Kamal for the complete switch-over checklist.
The big divergence: ERB removed
Section titled “The big divergence: ERB removed”config/deploy.yml does not support ERB. This is the main schema-level incompatibility with Ruby Kamal, and it's deliberate. (The other is that Kamal top-level keys the port hasn't implemented — boot, logging, retain_containers, … — are rejected by the validator instead of silently ignored.)
ERB is a Ruby template language — it executes arbitrary Ruby at render time. To support it, wheels deploy would need to embed a Ruby runtime, which defeats the purpose of the port. We considered preserving ERB by shelling out to a system Ruby; the problem is that it turns a single-binary install into a "works if you also have Ruby" story, and every user without Ruby installed gets a cryptic error on their first deploy. Naming the divergence up front — and keeping Kamal's ${UPPER_SNAKE} env-var interpolation untouched — is the honest trade-off.
The ${VAR} mechanics (lookup chain, the uppercase-only token rule, the #3084 secrets-path caveat) are documented once, in the config reference; the ERB → ${VAR} conversion recipes live in Migrating from Kamal. Architecture-level takeaway: the interpolator (ConfigLoader.$interpolate) walks the parsed YAML tree — interpolation happens after parsing, never by templating the raw file.
Commands-are-strings invariant
Section titled “Commands-are-strings invariant”The most important structural property of the codebase: every method on a *Commands.cfc component returns a plain string containing a shell command. Only the CLI layer and the orchestrator actually execute those strings.
public string function boot(required struct role, required string version) { return "docker run -d --name #role.containerName# --network kamal #role.image#:#version#";}The AppCli.cfc or orchestrator is what runs boot() and passes the returned string to the SSH pool for execution.
This invariant is why --dry-run is trivial — instead of swapping out the SSH transport, --dry-run just prints the strings. It's also why the unit test suite runs completely offline: a FakeSshPool records every command string without opening a socket, and assertions check the recorded strings against expected values. No Docker, no sshd, no network required for the commands-layer suite.
wheels deploy --dry-run # any verb, no SSH connections openedwheels deploy rollback v1 --dry-runwheels deploy app boot --dry-runCode layout
Section titled “Code layout”cli/lucli/services/deploy/├── cli/*.cfc # User-facing CLI entry points (DeployMainCli, DeployAppCli, ...)├── commands/*.cfc # Pure string-returning command builders (AppCommands, ProxyCommands, ...)├── config/*.cfc # Config model + loader (snakeyaml-backed)├── lib/*.cfc # JarLoader, Mustache, Yaml, SshClient, SshPool, FakeSshPool, SecretResolver└── secrets/*.cfc # Secret-manager adapters (OnePassword, Bitwarden, AwsSecrets, LastPass, Doppler)
cli/lucli/lib/deploy/*.jar # snakeyaml, jmustache, sshj (+ BouncyCastle transitives)cli/lucli/templates/deploy/ # Mustache templates used by `wheels deploy init`The separation of cli/ (user-facing orchestration) from commands/ (pure command builders) is what enforces the commands-are-strings invariant. A commands/ method that executed something directly would be a structural bug.
URLClassLoader-isolated JARs
Section titled “URLClassLoader-isolated JARs”wheels deploy depends on three third-party libraries:
- snakeyaml — YAML parsing for
config/deploy.yml - sshj (with BouncyCastle transitives) — SSH transport
- jmustache — template rendering for the
wheels deploy initscaffolding (deploy.yml.mustache,secrets.mustache,Dockerfile.mustache,dockerignore.mustache); not applied todeploy.ymlat runtime
These JARs load through a dedicated URLClassLoader isolated from the main JVM classpath. That isolation matters because CFML engines (Lucee, Adobe CF) ship their own copies of some transitive dependencies (older BouncyCastle versions, in particular). Without isolation, class-resolution order would be nondeterministic and you'd get obscure NoSuchMethodError failures depending on load order.
The JarLoader.cfc helper in lib/ handles this. It's also why wheels deploy doesn't pollute the rest of the Wheels runtime — if you're running a Wheels app that uses BouncyCastle elsewhere (CSRF encryption, for instance), wheels deploy's copy is invisible to it.
Testing
Section titled “Testing”Tests live in cli/lucli/tests/specs/deploy/ and extend wheels.wheelstest.system.BaseSpec.
- Unit tests assert against recorded command strings via
FakeSshPool. No Docker, no network. Run viabash tools/test-cli-local.sh. - Integration tests for
SshClientandSshPoolexercise real SSH against a disposable sshd fixture atcli/lucli/tests/_fixtures/deploy/sshd/, brought up bytools/deploy-sshd-up.sh. - Config fixtures at
cli/lucli/tests/_fixtures/deploy/configs/coverminimal.yml,full.yml,with-accessories.yml, and intentionally-broken configs underinvalid/.
Non-goals
Section titled “Non-goals”Naming what wheels deploy deliberately isn't helps calibrate when to reach for it:
- Not Kubernetes.
wheels deploydrivesdockerremotely over SSH, not the Kubernetes API. For k8s, use your existing pipeline; see Docker Deployment for image hygiene guidance. - Not systemd-native. For servlet-container deploys under systemd (CommandBox, Tomcat, Jetty), stay on that path — see VM and bare-metal deployment.
- Not Compose-only. Single-host Docker Compose is simpler via Docker Deployment.
wheels deployadds value at 2+ servers. - Not a Windows server target. Kamal doesn't support Windows servers;
wheels deployinherits that limitation. Windows developer workstations are best-effort. - Not Ruby-Kamal-plugin-compatible. Shell-script hooks in
.kamal/hooks/work unchanged; the RubyKamal::Commandsplugin API is Ruby-specific. - Not a Wheels reload mechanism. It ships the container; the in-process
?reload=trueendpoint is a separate concern.