basecamp/kamal · critical · Kamal::Cli::Healthcheck::Error

container not ready after #{KAMAL.config.deploy_timeout} sec

Error message

container not ready after #{KAMAL.config.deploy_timeout} seconds (#{status})

What it means

During `kamal deploy` (and healthcheck-driven operations), Kamal::Cli::Healthcheck::Poller polls the app container's status on each host via the provided block (e.g. `docker inspect` health/running state). If the status is neither 'running' nor 'healthy' (e.g. 'unhealthy', 'exited', 'restarting') when the overall deadline (KAMAL.config.deploy_timeout seconds) expires, it raises Kamal::Cli::Healthcheck::Error with the last observed status. The poller retries with a growing sleep until time runs out, then surfaces this error, failing the deploy before traffic is switched.

Source

Thrown at lib/kamal/cli/healthcheck/poller.rb:22

  def wait_for_healthy(&block)
    attempt = 1
    timeout_at = Time.now + KAMAL.config.deploy_timeout
    readiness_delay = KAMAL.config.readiness_delay

    begin
      status = block.call

      if status == "running"
        # Wait for the readiness delay and confirm it is still running
        if readiness_delay > 0
          info "Container is running, waiting for readiness delay of #{readiness_delay} seconds"
          sleep readiness_delay
          status = block.call
        end
      end

      unless %w[ running healthy ].include?(status)
        raise Kamal::Cli::Healthcheck::Error, "container not ready after #{KAMAL.config.deploy_timeout} seconds (#{status})"
      end
    rescue Kamal::Cli::Healthcheck::Error => e
      time_left = timeout_at - Time.now
      if time_left > 0
        sleep [ attempt, time_left ].min
        attempt += 1
        retry
      else
        raise
      end
    end

    info "Container is healthy!"
  end

  private
    def info(message)
      SSHKit.config.output.info(message)

View on GitHub (pinned to eee0083b38)

Solutions

  1. Read the status in the message: 'unhealthy' → fix the Docker HEALTHCHECK (path, port, curl/wget present in image); 'exited'/'restarting' → check `kamal app logs -h <host>` for the crash.
  2. Test the healthcheck manually on the host: `docker exec <container> curl -fsS localhost:3000/up` (or your configured path) to see the real response.
  3. Raise the deadline in config/deploy.yml: `deploy_timeout: 300` for slow-booting apps.
  4. If the healthcheck is wrong (route moved, port changed), fix the Dockerfile/composer healthcheck and rebuild, then redeploy.

Example fix

# before (Dockerfile)
HEALTHCHECK --interval=1s --timeout=1s CMD curl -fsS http://localhost:3000/up || exit 1
# image has no curl -> always 'unhealthy'
# after
HEALTHCHECK --interval=1s --timeout=1s CMD wget -qO- http://localhost:3000/up || exit 1
# or, if the app is just slow to boot (config/deploy.yml):
# deploy_timeout: 300
Defensive patterns

Strategy: retry

Validate before calling

# Before deploying, verify the healthcheck passes inside the image:
# docker build -t app:test . && docker run -d --name t app:test && sleep 5
# docker inspect --format '{{.State.Health.Status}}' t  # expect healthy
# docker rm -f t

Type guard

def container_healthy?(image, wait: 10)
  `docker run -d --name hc-check #{image} >/dev/null` or return false
  sleep wait
  status = `docker inspect --format '{{.State.Health.Status}}' hc-check`.strip
  `docker rm -f hc-check >/dev/null`
  status == "healthy"
end

Try / catch

begin
  Kamal::CLI::Deploy.new.perform
rescue Kamal::Cli::Healthcheck::Error => e
  status = e.message[/\((.*)\)\z/, 1]
  warn "container status at timeout: #{status}"
  `kamal app logs` if %w[exited restarting].include?(status) # crash vs bad healthcheck
  raise
end

Prevention

When it happens

Trigger: `kamal deploy` where the new container starts but its Docker HEALTHCHECK never reports healthy within deploy_timeout (missing curl in image, wrong healthcheck path/port), or the container exits/restarts (crash on boot). Also triggered with an unrealistic deploy_timeout for slow-booting apps; status is interpolated into the message so 'unhealthy' vs 'exited' tells you which case it is.

Common situations: Dockerfile healthcheck curling /up (Rails 7.1) while the image lacks curl or the app binds another port; app boots slowly (migrations, warmup) exceeding the default timeout; DB unreachable from the host so the readiness endpoint returns 500; healthcheck path returns 404 after a route change.

Understand the failure class

Related errors


AI-assisted analysis of basecamp/kamal@eee0083b38 (2026-08-21). Data as JSON: /api/errors/b4fccbd6f18b6342. Report an issue: GitHub.