Deploying

Reading the deploy report

Every deploy prints where its time went and what to change about your Dockerfile. No flag, no extra commands.

What prints#

deploy, redeploy, setup, rollback and a standalone build push all end with the same report. Nothing about it is opt-in, and none of it costs an extra SSH round trip or an extra docker command: the phase times come from the clock, the build steps are parsed out of the buildx output dash already streams, and the advice is read off your Dockerfile on the machine you deployed from.

Finished all in 196.2 seconds
  Startup (load, config)                  0.9s
  Validate config and secrets             2.1s
  Build and push app image              140.0s
    build context                                                   3.2s (356.5MB)
    [build 5/9] RUN bundle install                                 84.1s
    cached steps                                                  9 of 14
    export + push                                                  41.4s (cache export 29.4s)
  Acquire deploy lock                     0.3s   2 ssh     0.2s
  Boot                                   55.2s  12 ssh    41.0s
    web 10.0.0.1                         55.0s   6 ssh    20.4s (healthy after 31.2s)
  Advice
    warn  Dockerfile:14   COPY . . runs before `bundle install` (line 21), so dependencies reinstall on every commit (measured 84.1s uncached)
                          → copy the dependency manifests first (Gemfile, package.json, lockfiles), install, then copy the rest of the tree
    info  builder.cache   exporting the build cache took 29.4s of 140.0s with mode=max
                          → try mode=min under builder: cache: options:

The phase table#

One row per phase, in the order they started, indented under the phase that contains them. After the seconds, a phase that talked to a server shows what that cost:

  • 12 ssh — how many commands dash ran over SSH while that phase was current, including the ones its child rows ran. local in place of ssh means the commands ran on the deploying machine (a build, mostly).
  • 41.0s — how much of the phase's wall time was spent inside those commands. A phase whose seconds are much larger than its time-in-SSH was waiting on something else: a health check, a lock, a sleep.

Startup (load, config) covers everything before the first phase could be timed — loading the gem, parsing deploy.yml, resolving secrets adapters. It is the one row you cannot change from deploy.yml, which is exactly why it is printed.

The build rows#

Under the build phase, what the build itself spent its time on:

  • build context — how many bytes were shipped to the builder before the first instruction ran, and how long that took. Decimal units, the same ones buildx prints.
  • The five slowest uncached steps — labelled the way buildx labelled them ([build 5/9] RUN bundle install), so you can match a row to a line in your Dockerfile. Cached steps cost nothing and are not listed.
  • cached steps — how many of your own Dockerfile steps came from the cache. BuildKit's own bookkeeping (metadata lookups, auth tokens) is not counted, because you cannot do anything about it.
  • export + push — exporting the image and pushing it to the registry, with the cache export called out separately when there is one.

A build that fails prints the same rows plus the step that broke. A pack (Cloud Native Buildpacks) build prints none of them: buildpacks emit no per-step progress to read.

The Advice block#

Below the table, what dash would change about your Dockerfile and your build context. Each finding names a severity, where to look, what is wrong, and — on the line under it — what to do instead.

Some rules are measured: when a build actually ran, they quote its numbers. Three of them (context-size, cache-export-cost, uncached-install) have nothing to say without a build; the other two fire on the file alone and add the measurement when there is one. "Your COPY . . busts the bundle install" is a hint; "…and that install cost 84.1 seconds in this build" is a decision.

Advice is never in the deploy's way. It runs after the image is delivered and before the boot, so it prints even when the boot fails, and anything that goes wrong inside it costs one yellow Deploy report unavailable line and nothing else.

The same rules — the static ones, without the measurements — run under dash doctor, so you can read them before a deploy rather than after.

The rules#

Every finding carries a rule id. Rules marked ⏱ quote the build's numbers when there is a build; context-size, cache-export-cost and uncached-install fire only then.

RuleWhat it means
copy-before-installA copy of the whole tree runs before a dependency install, so every commit reinstalls. Copy the manifests and lockfiles first.
context-sizeThe build shipped more than 50 MB of context before it started. Trim it with .dockerignore or point builder: context: somewhere smaller.
missing-dockerignoreThe build context has no .dockerignore, so all of it goes to the builder.
dockerignore-gapsThe .dockerignore misses .git, or a directory that is actually in the context (node_modules, tmp, log, storage, coverage, .env*). Becomes a warning when the measured context is large.
latest-baseA FROM with no tag or :latest. A digest or an ARG-interpolated tag counts as pinned.
secret-in-build-argAn ARG or ENV named like a credential. Build args live in the image history forever — use --mount=type=secret with builder: secrets:.
single-stage-build-depsThe only stage installs compilers or -dev packages, which then ship. Split build from runtime.
apt-hygieneAn apt-get install in a shipped stage without --no-install-recommends, or that leaves /var/lib/apt/lists behind.
cache-busting-argAn ARG that changes every commit (a SHA, a build timestamp) is referenced before the dependency install, invalidating it every time.
cache-export-costExporting the build cache took more than a fifth of the build, with mode=max.
curl-pipe-shellA download piped straight into a shell. Pin a version and verify a checksum.
inline-env-blobMore than twenty inline KEY=value assignments in front of one command, so editing any of them rebuilds the layer.
no-cache-mountA dependency install without --mount=type=cache. The suggestion names the directory your package manager expects.
uncached-installAn install missed the cache and cost real time, and no broad copy above it explains why.
root-userThe final stage sets no USER, so the container runs as whatever the base image does — usually root.

Saved reports#

After the advice, dash writes the whole report as JSON and prints where it went:

Report written to .dash/reports/2026-09-10T12-00-00Z-production-deploy.json

Files are named for the UTC time the run started, the destination, and the command. dash keeps the newest report: history: of them per destination (20 by default) and deletes the rest; history: 0 writes none. The directory gets its own .gitignore the first time it is used, so a project that commits .dash/ does not start committing a report on every deploy.

A deploy that failed is written too, with "status": "failed", the error that ended it, and every phase that had finished — which is usually the run you most want to read afterwards.

{
  "schema": 1,
  "dash_version": "4.0.8",
  "command": "deploy",
  "service": "app",
  "destination": "production",
  "version": "abc1234",
  "started_at": "2026-09-10T12:00:00Z",
  "runtime": 196.2,
  "status": "succeeded",
  "phases": [
    { "name": "Startup (load, config)", "depth": 0, "seconds": 0.9, "detail": null,
      "commands": 0, "command_seconds": 0.0, "connect_seconds": 0.0, "local": true },
    { "name": "Build and push app image", "depth": 0, "seconds": 140.0, "detail": null,
      "commands": 0, "command_seconds": 0.0, "connect_seconds": 0.0, "local": true },
    { "name": "Boot", "depth": 0, "seconds": 55.2, "detail": null,
      "commands": 12, "command_seconds": 41.0, "connect_seconds": 1.2, "local": false }
  ],
  "build_phase": 1,
  "build": {
    "context_bytes": 356515840, "context_seconds": 3.2,
    "cached_steps": 9, "total_steps": 14,
    "export_seconds": 12.0, "cache_export_seconds": 29.4, "push_seconds": 12.0,
    "steps": [
      { "number": 7, "kind": "instruction", "label": "[build 5/9] RUN bundle install",
        "stage": "build", "ordinal": 5, "steps_in_stage": 9,
        "instruction": "RUN bundle install", "seconds": 84.1, "cached": false }
    ]
  },
  "advice": [
    { "rule": "copy-before-install", "severity": "warn", "location": "Dockerfile:14",
      "message": "…", "suggestion": "…" }
  ]
}

schema is the promise: a reader that does not recognise the number should skip the file rather than guess. build_phase is the index into phases that the build rows belong under. The command counts on a phase are subtree totals — a parent and its children must not be summed.

dash report#

dash report prints the last saved report for the current destination, rendered exactly as the deploy printed it — same table, same build rows under the same phase, same advice. It is entirely local: no lock, no SSH, nothing that can change a server, so it is safe to run while a deploy is in flight.

dash report                 # the latest report for this destination
dash report -d production   # …for another destination
dash report --last 5        # one row per report, oldest first
dash report path            # where the reports are written
Last 3 reports for app to production
  started              version      total    build     boot  advice
  2026-09-08T09-12-44Z abc1234     118.9s    61.0s    49.1s  2 (1 warn)
  2026-09-09T17-40-02Z bcd2345     121.4s    63.2s    49.8s  2 (1 warn)
  2026-09-10T12-00-00Z cde3456     196.2s   140.0s    55.2s  3 (1 warn)

OpenTelemetry#

With an OTel logger configured, the same numbers ship as events at the end of the run, alongside the kamal.complete / kamal.failed events that were already there:

EventOne perAttributes
dash.phasetable rowdash.phase.name, .depth, .seconds, .detail, .commands, .command_seconds, .connect_seconds
dash.buildbuilddash.build.context_bytes, .context_seconds, .cached_steps, .total_steps, .export_seconds, .cache_export_seconds, .push_seconds
dash.build.stepDockerfile stepdash.build.stage, .ordinal, .instruction, .seconds, .cached
dash.advicefindingdash.advice.rule, .severity, .location, .message

Each carries the same deployment.id as the rest of the run, so a backend can group them. Nothing is uploaded anywhere except the endpoint you configured, and a shipping failure never fails a deploy that succeeded.

Hooks#

The post-deploy hook gets the summary as environment variables — DASH_BUILD_RUNTIME, DASH_BOOT_RUNTIME, DASH_ADVICE_COUNT, DASH_ADVICE_WARNINGS and DASH_REPORT_PATH, each with its KAMAL_* twin. A phase that did not run contributes no variable at all rather than a zero that reads as "instant". See Hooks.

Two things about the ordering are worth knowing:

  • Under dash setup, the hook fires from the deploy it wraps, before the outer report is finalised. DASH_REPORT_PATH and the trend findings are therefore absent from that one hook run — the report itself is written as usual, a moment later.
  • status in the saved report covers whatever ran inside the frame that finalised it. A post-deploy hook fires after a standalone deploy has written its report, so a hook failure fails the command but the report says succeeded — the deploy did. Under dash setup the hook fires inside the outer frame, so there the same failure marks the deferred report failed.

Turning it down#

The report: key controls the advice. The table itself always prints.

report:
  advice: true          # print the Advice block at all
  hadolint: auto        # also run hadolint when it is on PATH; false to never (anything else is an error)
  history: 20           # JSON reports to keep per destination; 0 writes none
  ignore:
    - root-user         # any rule id above, or a hadolint code like DL3008

With hadolint: auto, dash runs hadolint --format json --no-fail on the same file when the binary is on your PATH and folds its findings in under their own codes. Its exit status never touches the deploy, and if it cannot run you get one informational line instead.

See Deploy report for the full configuration reference.