basecamp/kamal · error · Kamal::Cli::HookError
Hook `#{hook}` failed:\n#{e.message}
Error message
Hook `#{hook}` failed:\n#{e.message} What it means
Kamal runs local hook scripts from .kamal/hooks/ (e.g. pre-build, pre-deploy, post-deploy) around many CLI commands, passing details like KAMAL_VERSION and KAMAL_HOSTS as environment variables. When the hook script is executed locally via SSHKit and exits with a non-zero status, SSHKit raises SSHKit::Command::Failed, which Kamal converts into Kamal::HookError with the hook's name and the underlying failure message. The wrapping command (deploy, build, etc.) then aborts, so a broken hook blocks the whole deployment.
Source
Thrown at lib/kamal/cli/base.rb:251
hooks_output = KAMAL.config.hooks_output_for(hook)
# CLI flags override config: -q hides all, -v shows all
# Config setting :verbose forces output, :quiet forces silence
hook_verbosity = if KAMAL.verbosity == :info && hooks_output
VERBOSITY.fetch(hooks_output)
else
KAMAL.verbosity
end
with_env KAMAL.hook.env(**details, **extra_details) do
KAMAL.with_verbosity(hook_verbosity) do
run_locally do
execute *KAMAL.hook.run(hook)
end
end
rescue SSHKit::Command::Failed => e
raise HookError.new("Hook `#{hook}` failed:\n#{e.message}")
end
end
end
def on(*args, &block)
pre_connect_if_required
super
end
def pre_connect_if_required
if !KAMAL.connected?
run_hook "pre-connect", secrets: true unless options[:skip_hooks]
KAMAL.connected = true
end
end
def commandView on GitHub (pinned to eee0083b38)
Solutions
- Read the message after the colon: it contains the hook's stderr/stdout that caused the non-zero exit — fix that root cause in .kamal/hooks/<hook-name>.
- Run the hook manually with the same env Kamal would set: `cd .kamal/hooks && KAMAL_VERSION=x ./pre-deploy` to reproduce locally.
- Ensure the script is executable and has a valid shebang: `chmod +x .kamal/hooks/<hook-name>` and `head -1` shows #!/bin/sh or similar.
- If the hook is optional or flaky, guard its body so transient failures exit 0, or temporarily rename it (e.g. pre-deploy.skip) to unblock a deploy.
- Verify any binaries the hook calls exist on the machine running kamal (`which curl jq docker`).
Example fix
# before (.kamal/hooks/pre-deploy) #!/bin/sh curl -fsSL https://internal.example/notify | jq .status # after: tolerate optional endpoint failures #!/bin/sh curl -fsSL https://internal.example/notify | jq -e '.status == "ok"' || echo "warn: notify failed, continuing"
Defensive patterns
Strategy: try-catch
Validate before calling
# Validate a hook before relying on it (run where kamal runs):
hook = ".kamal/hooks/pre-deploy"
File.executable?(hook) || abort("#{hook} missing or not executable")
system("#{hook} >/dev/null") || abort("hook exits non-zero; fix before deploy") # dry-run where possible Type guard
def failing_hook?(hook_name)
path = File.join(".kamal/hooks", hook_name.to_s)
!File.exist?(path) || !File.executable?(path)
end Try / catch
begin
Kamal::CLI::Deploy.new([].tap { }).call # or invoke via Kamal::Commander
rescue Kamal::HookError => e
warn "deploy hook failed: #{e.message}"
# surface hook stderr in CI, decide: abort or continue with `kamal deploy --skip_hooks` style flow
raise
end Prevention
- Keep hooks short, exit 0 on non-fatal problems, and reserve non-zero exits for true blockers.
- Test hooks in CI with the same env kamal sets (KAMAL_VERSION, KAMAL_HOSTS, KAMAL_COMMAND).
- chmod +x every script in .kamal/hooks and commit a valid shebang in each.
- Invoke binaries by absolute path or verify them with `command -v` at the top of the hook.
When it happens
Trigger: Any Kamal command that fires a hook (kamal deploy, kamal build, kamal app start, etc.) when the matching script in .kamal/hooks/<hook-name> exits non-zero: a Ruby/shell script with a raising step, a script referencing a missing file, a non-executable or bad-shebang script, or a hook calling a tool not on the local PATH.
Common situations: A pre-deploy hook that curls a health endpoint which is down; hooks written on macOS with env-dependent paths that fail in CI; hook scripts copied from templates without chmod +x; a hook using `set -e` plus a failing grep; hook output polluted by a missing dependency (jq, curl, docker).
Related errors
- Failed to get endpoint for #{role} on #{host}, did the conta
- Failed to get endpoint for #{role} on #{host}, did the conta
- container not ready after #{KAMAL.config.deploy_timeout} sec
- Missing required configuration for image
- No servers or accessories specified
AI-assisted analysis of basecamp/kamal@eee0083b38 (2026-08-21).
Data as JSON: /api/errors/962755f0933d8191.
Report an issue: GitHub.