docs(web/guides): align deployment guides with verified wheels deploy behavior (audit batch 2) - #3102
Conversation
…oy behavior Audit batch 2 (p1-16-deploy) corrections across seven deployment guides: broken invocations replaced with the working flat aliases, env.secret delivery marked pending (#2956/#2957), setup==deploy orchestration gap called out (#2957), failure-hook masking documented (#3087), accessory files:/labels corrected (#3088), and all four broken observability examples fixed against a live Lucee 7 harness. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com>
… secrets flow The flow diagram asserted the registry password is fed to docker login over stdin unconditionally, but that is develop-only behavior (post-#3008). The released 4.0.3 CLI still inlines it via -p <password>, exposing it in the remote process table and --dry-run output. Qualify the claim to match the 4.0.3-vs-develop split the rest of the page already uses. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Peter Amiri <peter@alurium.com>
There was a problem hiding this comment.
Wheels Bot — Reviewer
TL;DR: This PR aligns seven deployment guides with the verified behavior of the wheels deploy Phase 1 CLI (audit batch 2). I cross-checked every checkable claim in the diff against the CLI and framework source and found zero factual errors — an unusually high bar for a docs wave this size. Verdict: comment (two non-blocking editorial nits below; nothing rises to a change request).
Correctness
All of the following diff claims were verified against source on this head:
| Claim in diff | Evidence |
|---|---|
wheels deploy init creates four files; Dockerfile overwrite refused without --force |
cli/lucli/services/deploy/cli/DeployMainCli.cfc:334-378 (init_stub) |
setup is an alias for deploy (no proxy/accessory boot) |
DeployMainCli.cfc:162-165 — body is literally return deploy(arguments.opts); with a "Phase 2 will add accessory boot" comment |
| Rollback version is positional | cli/lucli/Module.cfc:2194-2199 — opts.version = positional[2], throws without it |
Flat fetch-secrets / extract-secrets / print-secrets verbs; nested secrets/server intercepted |
Module.cfc:2397-2417; cli/CLAUDE.md confirms LuCLI reserves nested server/secrets and --version |
develop rejects env.secret with Wheels.Deploy.EnvSecretUnsupported |
cli/lucli/services/deploy/commands/Base.cfc:59; AccessoryCommands.cfc:92 calls $rejectEnvSecrets |
| Missing secret keys resolve to empty string, no "unresolved secret" error | SecretResolver.cfc:45-47 (variables.resolved[arguments.key] ?: "") |
| Empty registry password fails fast on develop | DeployRegistryCli.cfc:49 (DeployRegistryCli.MissingPassword) |
Registry password fed over docker login --password-stdin |
RegistryCommands.cfc:13-26 |
KAMAL_PERFORMER = git config user.name, $USER fallback; no KAMAL_ROLE/KAMAL_SERVICE set |
HookCommands.cfc:47-63; repo-wide grep finds no KAMAL_ROLE assignment |
Non-zero post-deploy-failure hook exit masks the original deploy error (#3087) |
DeployMainCli.cfc:113-115 fires the hook inside the deploy catch; $fireHook throws DeployMainCli.HookFailed on non-zero exit (DeployMainCli.cfc:506-507), replacing e |
Accessory files: parsed but never delivered (#3088) |
config/Accessory.cfc:56-58 exposes it; AccessoryCommands.cfc builds run from labels/ports/volumes/env only — no upload |
Accessory label is service=<service>-<accessory>; details inspects by container name |
AccessoryCommands.cfc:7,44-46,68-74 |
Proxy config dir computed as /home/<user> even for root |
ProxyCommands.cfc:30 (hardcoded /home/#…user()#/.config/kamal-proxy) |
No CLI --env override step in ${VAR} interpolation |
ConfigLoader.cfc:23-28 — envOverride is constructor-injection only (tests); no --env flag anywhere in the deploy CLI |
.kamal/secrets lookup relative to deploy.yml's directory (#3084) |
ConfigLoader.cfc:53-58 — projectRoot: getDirectoryFromPath(arguments.path) |
Middleware context has no cgi key |
vendor/wheels/Dispatch.cfc:420-425 — exactly {params, route, pathInfo, method} |
$writeRequestId private-helper pattern for Adobe request-parameter shadowing |
mirrors the framework's own vendor/wheels/middleware/RequestId.cfc:32-40 verbatim — documented prior art, and private is correct here (middleware is not a mixin, so Cross-Engine Invariant 7 doesn't apply) |
renderWith takes status, not statusCode |
vendor/wheels/controller/rendering.cfc — every render signature declares string status = $statusCode() |
queueStats() exists; no wheels jobs status CLI |
vendor/wheels/Job.cfc:464; no jobs subcommand in Module.cfc (the root CLAUDE.md worker-CLI block is indeed stale — agree with deferring that to #3090, outside this PR's file scope) |
No wheels dbmigrate subcommand |
only a template-comment mention in Module.cfc:3101 |
trustProxyHeaders gate for X-Forwarded-Proto |
vendor/wheels/Global.cfc, events/onapplicationstart.cfc, tests/specs/security/TrustProxyHeadersSpec.cfc |
One subtlety I checked and chose not to flag: documenting --release= as the flag for redeploy overrides and app details. Current brew/scoop wrappers rewrite --version → --release before picocli (header of DeployArgsParser.cfc), so --version can work on a fresh install — but --release is the spelling that works everywhere, and the runtime genuinely reserves --version (cli/CLAUDE.md). The doc's guidance is the safe universal form.
Docs
Two non-blocking nits:
-
accessories.mdx(env.secret aside) — the aside says the features are "parsed but not implemented," but its siblings infirst-deploy.mdxandsecrets.mdxalso spell out that CLIs built fromdevelophard-rejectenv.secretwithWheels.Deploy.EnvSecretUnsupported. That rejection applies to accessories too (AccessoryCommands.cfc:92calls$rejectEnvSecrets), so a develop-CLI user with an accessoryenv.secret:block gets an error, not a silent skip. Worth one matching sentence for consistency. -
first-deploy.mdxstep 1 ("Creates four files") — accurate for a fresh app, but the two generated Docker files behave asymmetrically on conflict: an existingDockerfilemakesinitrefuse without--force(documented), while an existing.dockerignoreis silently preserved (DeployMainCli.cfc:371-378— "preserved existing .dockerignore"). Optional one-liner; the current text isn't wrong for the guide's fresh-app audience.
Tests
Docs-only PR; no spec changes required. The removal of the {test:cli} tag on the bootstrap block is justified — the corrected command genuinely requires reachable SSH hosts, and the old tag only passed because the broken nested form printed help and exited 0. PR body reports pnpm verify:docs at 13/13 tagged blocks passing.
Commits
Single commit 139d40b0f docs(web/guides): fix deployment guides to match verified wheels deploy behavior — valid type, header well under 100 chars, DCO sign-off present and matching the author. PR title is likewise a valid conventional header for the squash merge.
There was a problem hiding this comment.
Wheels Bot — Reviewer
TL;DR: Re-review at head 5cb5e08. The delta since the previous review round (139d40b) is a single commit that version-qualifies the docker-login stdin claim in secrets.mdx — and it is exactly right: I verified both sides of the 4.0.3-vs-develop split against source. The rest of the diff is unchanged from the previously reviewed commit, and my independent spot-checks of its load-bearing claims all confirmed. Verdict: comment — no blocking findings; two minor docs nits from the previous round remain open and are repeated below for tracking.
Correctness
The new hunk (secrets.mdx:153) claims develop feeds the registry password over docker login --password-stdin while released 4.0.3 inlines it via -p <password>. Both verified:
- develop:
cli/lucli/services/deploy/commands/RegistryCommands.cfc:20-28buildsdocker login <server> -u <user> --password-stdin, with a doc comment explaining the deliberate divergence from Kamal so the password "can't leak through dry-run output, exception summaries, or the remote process table (#2956)". - 4.0.3:
git show v4.0.3:cli/lucli/services/deploy/commands/RegistryCommands.cfcshowsdocker("login", reg.server(), "-u", reg.username(), "-p", arguments.opts.password ?: "")— inline, exactly as the doc now states. - #3008 is merged (2026-06-11) and is the fix PR; #2956 is the open tracking issue. The "(4.0.4+)" qualifier is consistent with
v4.0.3being the latest tag.
The qualified paragraph also matches the 4.0.3-vs-develop framing the page already uses at lines 14 and 46, so the page reads consistently.
Independent spot-checks of the unchanged bulk (sampling the highest-impact claims):
| Doc claim | Evidence |
|---|---|
Rollback version is positional; --version= intercepted |
cli/lucli/Module.cfc:2194-2199 — opts.version = positional[2], throws without it |
renderWith argument is status, not statusCode |
vendor/wheels/controller/rendering.cfc:26,131,205 — every render signature declares string status = $statusCode() |
Middleware context has no cgi key |
vendor/wheels/Dispatch.cfc:420-425 — exactly {params, route, pathInfo, method} |
$writeRequestId Adobe-shadowing helper pattern |
mirrors vendor/wheels/middleware/RequestId.cfc verbatim, including its comment citing CLAUDE.md cross-engine anti-pattern #11 |
KAMAL_PERFORMER = git config user.name, $USER fallback |
cli/lucli/services/deploy/commands/HookCommands.cfc:54-63 |
Flat fetch-secrets/extract-secrets/print-secrets verbs; nested secrets intercepted by the picocli root |
Module.cfc:2397-2419 — the comment block documents #2697 and the alias rationale |
develop rejects env.secret with Wheels.Deploy.EnvSecretUnsupported |
cli/lucli/services/deploy/commands/Base.cfc:55-59 |
X-Forwarded-Proto honored only behind set(trustProxyHeaders=true) |
vendor/wheels/Global.cfc:2390-2392 ($trustProxyHeaders()) |
No factual errors found.
Docs
Two non-blocking nits carried forward from the previous review round — both still present at this head (only secrets.mdx changed since):
accessories.mdx(env.secret aside, ~line 34) — the aside says accessoryenv.secret:is "parsed but not implemented," but develop CLIs hard-reject it:AccessoryCommands.cfc:92calls$rejectEnvSecrets, which throwsWheels.Deploy.EnvSecretUnsupported(Base.cfc:55-59). The sibling asides infirst-deploy.mdxandsecrets.mdxspell out this 4.0.3-silently-drops vs develop-rejects split; one matching sentence here would make the three asides consistent.first-deploy.mdxstep 1 ("Creates four files") — accurate for a fresh app, but the two Docker files behave asymmetrically on conflict: an existingDockerfilemakesinitrefuse without--force(documented), while an existing.dockerignoreis silently preserved (DeployMainCli.cfcinit_stub). Optional one-liner.
Tests
Docs-only; no spec changes required. PR body reports pnpm verify:docs at 13/13 tagged blocks passing across the scoped files.
Commits
Both commits are valid conventional headers (docs(web/guides): …, 81 and 83 chars) with DCO sign-offs matching the author (Signed-off-by: Peter Amiri <peter@alurium.com>). The new commit's body explains the why (4.0.3 process-table exposure vs develop stdin) rather than restating the diff — good.
Summary
Docs-fix wave for the guide behavioral audit (batch 2, work item p1-16-deploy — "all other deployment guides"). Every correction below traces to a verified claim in the audit manifest; each broken-but-unfixed behavior is documented as current behavior with the tracking issue cited, never as an aspirational fix.
Sibling scope note:
architecture.mdxandconfig-reference.mdxare handled in a separate PR of this wave.docker-deployment.mdxwas checked and contains none of the flagged patterns — no changes needed.Corrections by file
first-deploy.mdx
wheels deploy initcreates four files (config/deploy.yml,.kamal/secrets,Dockerfile,.dockerignore), not two — verified againstDeployMainCli.cfc::init_stuband a liveinitrun.wheels deploydoesn't generate a Dockerfile" claim —initwrites one and refuses to overwrite without--force.env.secretexample replaced with a "not yet supported" callout — 4.0.3 silently drops the keys, develop hard-fails withWheels.Deploy.EnvSecretUnsupported(review remediation: deploy CLI remote-execution security — secret delivery, shell escaping, registry password redaction #2956, review remediation: deploy CLI orchestration correctness — multi-host lock bypass, fresh-host bootstrap, discarded stdout/audit log, flat-alias ssh config, roll-up defects #2957).wheels deploy server bootstrap→ flatwheels deploy bootstrap; the nested form is picocli-intercepted (wheels deploy server bootstrapis interpreted as LuCLI server command instead of deploy subcommand #2677). The{test:cli}tag was removed because the corrected command genuinely needs reachable SSH hosts (the old tag only "passed" because the broken form printed help and exited 0).setupis an alias forwheels deployand boots neither proxy nor accessories; explicitproxy boot+accessory boot alldocumented (review remediation: deploy CLI orchestration correctness — multi-host lock bypass, fresh-host bootstrap, discarded stdout/audit log, flat-alias ssh config, roll-up defects #2957).wheels deploy app details→wheels deploy app containers(details requires--releaseand reportsdocker inspectstatus).--version=abc1234→ positionalwheels deploy rollback abc1234(LuCLI intercepts --version flag causing failure in wheels deploy command #2674).--hosts=/--role=" paragraph (neither flag exists for deploy/rollback);--version=<tag>redeploy override →--release=<tag>.secrets.mdx
unresolved secret: <key>error — missing keys resolve to empty string (SecretResolver.cfc); the only guard is develop'sDeployRegistryCli.MissingPasswordon empty registry passwords.wheels deploy secrets fetch|extract|printforms replaced with the working flat aliasesfetch-secrets/extract-secrets <key> --from=…/print-secrets(wheels deploy secretsfetch invokes LuCLI global secrets command instead of Wheels deploy subcommand #2697); extract documented as positional-key +--from, never stdin/--key.docker loginstdin; theenv.secret → docker run -eleg marked pending (review remediation: deploy CLI remote-execution security — secret delivery, shell escaping, registry password redaction #2956/review remediation: deploy CLI orchestration correctness — multi-host lock bypass, fresh-host bootstrap, discarded stdout/audit log, flat-alias ssh config, roll-up defects #2957).hooks.mdx
KAMAL_ROLErow (never set), correctedKAMAL_PERFORMERtogit config user.namewith$USERfallback, softened "matches Ruby Kamal's contract exactly" to "subset".post-deploy-failurerows corrected — a non-zero hook exit currently throws and masks the original deploy error (deploy: failing post-deploy-failure hook throws inside the catch and masks the original deploy error #3087); example hardened with|| trueand guidance to always exit 0.accessories.mdx
files:documented as accepted-but-ignored (no upload mechanism, deploy: Validator allowlists ~20 deploy.yml keys/sub-keys the runtime never reads — accepted-but-ignored config (boot, logging, retain_containers, minimum_version, hooks.path, builder.*, ssh.*, proxy.*, role options/labels/env, accessory files) #3088); init-file example removed in favor of host-managedvolumes:.env.secretexample replaced with the same pending callout (review remediation: deploy CLI remote-execution security — secret delivery, shell escaping, registry password redaction #2956/review remediation: deploy CLI orchestration correctness — multi-host lock bypass, fresh-host bootstrap, discarded stdout/audit log, flat-alias ssh config, roll-up defects #2957).service=<service>-<accessory>, not the app's bareservice=label;wheels deploy detailsfinds them by per-container inspect.wheels deploy setupboots every declared accessory" corrected — it doesn't (alias fordeploy, review remediation: deploy CLI orchestration correctness — multi-host lock bypass, fresh-host bootstrap, discarded stdout/audit log, flat-alias ssh config, roll-up defects #2957);accessory boot alldocumented;removeteardown confirmed working.vm-deployment.mdx
wheels dbmigrate latest|down→wheels migrate latest|down(nodbmigratesubcommand exists).X-Forwarded-Protoaside now states the header is only honored afterset(trustProxyHeaders=true)— verified live (header alone →isSecure()false).observability-and-logging.mdx
InboundRequestIdexample fixed — middleware context has nocgikey, so it now reads the realcgi.http_x_request_idscope and writes via a$writeRequestId()helper (Adobe request-parameter shadowing trap, mirrorsRequestId.cfc). Verified echoing the inbound header live.Log.cfcbareget("environment")(throws in a plain component) →application.wheels.environment. Verified end-to-end./healthzexample fixed two ways — addedprovides("html,json")(was 500 view-not-found) andstatusCode=→status=(renderWithhas nostatusCodearg; degraded path previously returned HTTP 200 to the load balancer).Job.cfcline numbers (345/368 no longer match).wheels jobs status(doesn't exist) → programmatic(new wheels.Job()).queueStats()with docs/cli:wheels jobs ...worker CLI is advertised (observability guide + root CLAUDE.md) but does not exist #3090 cited. Root CLAUDE.md has the same stale block; left to docs/cli:wheels jobs ...worker CLI is advertised (observability guide + root CLAUDE.md) but does not exist #3090 since it's outside this PR's scoped files.migrating-from-kamal.mdx
wheels deploy rollback VERSION; secrets row →fetch-secrets; the inverted "positionals become named flags" paragraph replaced with the truth (Kamal's positionals are required;--name=/--version=forms don't exist) citing LuCLI intercepts --version flag causing failure in wheels deploy command #2674/wheels deploy server bootstrapis interpreted as LuCLI server command instead of deploy subcommand #2677/wheels deploy secretsfetch invokes LuCLI global secrets command instead of Wheels deploy subcommand #2697.KAMAL_*subset;post-deploy-failuremasking caveat (deploy: failing post-deploy-failure hook throws inside the catch and masks the original deploy error #3087).kamalnetwork never created, audit log never written, proxy config dir wrong for root — all pending review remediation: deploy CLI orchestration correctness — multi-host lock bypass, fresh-host bootstrap, discarded stdout/audit log, flat-alias ssh config, roll-up defects #2957.--envoverrides" resolution step; noted${VAR}→.kamal/secretscurrently resolvesconfig/.kamal/secretsrelative todeploy.yml(deploy: ${VAR} interpolation resolves .kamal/secrets relative to deploy.yml's directory (config/.kamal/secrets), not the project root #3084); ERB conversion table--version=→--release=.env.secretPhase 1 gap so Kamal users don't migrate configs that can't work yet.Verification
pnpm verify:docsover all 8 scoped files: 13 tagged blocks, 13 passed, exit 0.cli/lucli/Module.cfc/DeploySecretsCli.cfc/DeployArgsParser.cfc/RequestId.cfc/rendering.cfcsource.Issue refs: #2674, #2677, #2697, #2956, #2957, #3084, #3087, #3088, #3090.
🤖 Generated with Claude Code