---
title: Compose Doctor
description: Run a preflight check on a stack before you deploy. Compose Doctor renders the effective Compose model and reports common failure modes, grouped by severity, with a clear fix for each.
---

The **Doctor** tab in the right-hand **Anatomy** panel answers one question before you apply a change: *what will Docker actually run, and is it safe on this node?* Compose Doctor renders the effective Compose model (the fully resolved result after interpolation, includes, profiles, `.env`, and `env_file` are applied) and then runs a set of deterministic checks against it and the live Docker state on the node it would deploy to.

The check is advisory: on its own it never blocks a deploy or changes a stack. (One rule is the exception: see [Self-management](#self-management) below for the one case where Sencho actively blocks an action, independent of this report.) It runs on demand: press **run preflight** and Sencho renders the model, runs all 42 checks, and stores the result so the tab still shows it the next time you open the stack.

## Where to find it

<Frame>
  <img
    src="/images/compose-doctor/compose-doctor-tab.png"
    alt="The tab bar at the top of the Anatomy panel showing the Doctor tab selected"
  />
</Frame>

The **Doctor** tab appears in the Anatomy panel header when the active node supports Compose Doctor. Click any stack in the sidebar, then switch to the **Doctor** tab.

## How it works

Every preflight run follows three steps:

1. **Render** the effective model. Sencho calls `docker compose config` on the stack, which resolves all variable interpolation, `include` directives, profile overrides, and `env_file` references into a single, normalized model.
2. **Snapshot** live Docker state. Sencho reads which host ports are in use, which containers are running, and which named networks and volumes exist on the target node.
3. **Run 42 deterministic rules** against the combination. Each rule is pure and produces zero or more findings with a severity, a message, and a suggested fix.

Sencho stores exactly one run per stack per node, so a new run immediately overwrites the previous one; there is no history.

## Privacy and environment values

Compose Doctor reads the structure of the effective model (service names, images, ports, volumes, and the *names* of environment variables), never their values. A secret injected through `environment:` or `env_file:` never appears in the report, the stored run, or the logs.

One caveat: a secret interpolated into a structural field (such as a port, an image tag, or a volume path built from `${VAR}`) is resolved before the model is read, so its value does show in the rendered model. Render errors are redacted before storage; Sencho never writes the raw `docker compose config` error to the database. See [Environment and Secrets Guardrails](/features/environment-guardrails).

## Severity levels

Every finding is graded so you can triage at a glance. The tab groups findings under four headings and the summary reflects the most severe one:

| Severity | Meaning |
|----------|---------|
| **Blocker** | The deploy will fail or the model cannot be rendered. Fix these before applying. |
| **High risk** | The deploy will likely succeed but does something dangerous or surprising, such as exposing a port to every network or mounting the Docker socket. |
| **Warning** | A reliability or reproducibility concern, such as a moving `latest` tag or a missing restart policy. |
| **Info** | Something to be aware of, such as a network or volume that will be created on first deploy. |

## Run status

The summary card at the top of the Doctor tab reflects the overall outcome of the last run:

| Status | When it appears | Summary card |
|--------|-----------------|--------------|
| **No run yet** | Preflight has never been triggered for this stack on this node | Placeholder with "no preflight yet" and a Stethoscope icon |
| **All clear** | Model rendered; no findings | Green card with "No issues found in the effective model." |
| **Cannot render** | `docker compose config` failed | Red card with the (redacted) render error message |
| **Blocker** | Highest active finding is a blocker | Red card with a count of active findings by severity |
| **High risk** | Highest active finding is high risk | Amber card with a count of active findings by severity |
| **Warning** | Highest active finding is a warning | Blue card with a count of active findings by severity |
| **Info** | All active findings are informational | Muted card with a count of active findings by severity |
| **All clear · findings acknowledged** | Model rendered; every finding from the last run has been acknowledged | Green card with "No active findings remain. One or more detected issues were reviewed and acknowledged by an authorized operator." |

The summary card also shows when the run happened and who triggered it: "ran 5 minutes ago by admin". When active findings remain alongside acknowledgements, the line adds a count: "3 active (1 blocker · 2 warning) · 2 acknowledged".

A small colored dot appears on the **Doctor** tab label when the last run's active findings include a blocker (red) or a high-risk finding (amber). Click the **X** on the summary card to dismiss it; this also clears the tab dot. The dismissal is remembered per stack and node, and clears itself automatically the moment the finding set changes (a rule clearing, a new rule firing, or a severity change), so a stale dismissal can never hide a genuinely new problem. It never clears just by opening the tab or re-running preflight to the same result, and it does not sync across browsers or teammates: it is stored in your browser only.

<Frame>
  <img
    src="/images/compose-doctor/compose-doctor-severity-dot.png"
    alt="The tab bar showing a small amber dot on the Doctor tab label indicating a high-risk finding from the last preflight run"
  />
</Frame>

## What it checks

All 42 rules are listed below, organized by topic.

### Model rendering

| Rule | Severity | What it detects |
|------|----------|----------------|
| Compose model could not be rendered | Blocker | `docker compose config` failed; the model cannot be assessed and all other rules are skipped. Fix the reported error first. |

### Environment

| Rule | Severity | What it detects |
|------|----------|----------------|
| Unset variable | High | An intentional `${VAR}` or `$VAR` reference has no value in the environment or any consulted env file. Compose silently substitutes an empty string, which often breaks the container without a clear error. |
| Literal dollar in value | High | A literal `$` inside an environment value (common in bcrypt hashes and other secrets) was treated as Compose interpolation. Sencho reports the env key when it can identify one, never a fragment of the value. |
| Missing env file | High | A path listed under `env_file:` does not exist in the stack directory. Compose fails to start the stack when a required env file is absent. |

### Port conflicts

| Rule | Severity | What it detects |
|------|----------|----------------|
| Host port is already in use | Blocker | Another stack or unmanaged container on this node already binds the same host port. The deploy will fail. Requires live Docker state; skipped when the daemon is unreachable. |
| Two services publish the same port | Blocker | Two services within this stack both claim the same host port on overlapping interfaces. Only one can bind it. |
| Port exposed on all interfaces | High | A port is published on all interfaces (`0.0.0.0`), making it reachable from every network the host is attached to. |

### Bind mounts

| Rule | Severity | What it detects |
|------|----------|----------------|
| Bind mount path is missing | High | A relative host path referenced by the model does not exist inside the node's Compose base directory. Docker will create it as a root-owned directory on deploy, which often leaves the container unable to write to it. |
| Bind mount may have wrong ownership | Warning | A resolvable path is owned by root but the container runs as a non-root user. Applies to POSIX nodes only. |

### Security

| Rule | Severity | What it detects |
|------|----------|----------------|
| Docker socket mounted | High | A service mounts `docker.sock` directly, granting broad control over the Docker host. When the stack already runs a socket proxy, the fix names that proxy instead of suggesting you adopt one. |
| Docker socket proxy detected | Info | A dedicated Docker socket proxy service mounts `docker.sock`. A known proxy image is enough on its own; a proxy-shaped service name counts only alongside a read-only socket or an enabled API group flag (value exactly `1`), and without either hint the service needs a read-only socket and at least two enabled API group flags. Verify trust boundaries and enabled API groups. |
| Docker socket proxy mounts the socket read-write | High | A service classified as a socket proxy mounts `docker.sock` without `:ro`, so compromising the proxy grants full control over the Docker host. |
| Docker socket proxy publishes a host port | High | A classified socket proxy publishes any host port, which can make the proxy reachable beyond the Compose network. |
| Docker socket proxy allows mutating API access | Warning | A classified socket proxy enables `POST` and/or `DELETE` on the proxied Docker API (flag value exactly `1`). |
| Docker socket proxy on a non-internal network | Warning | A classified socket proxy joins a network that is not declared `internal`, including the implicit default network and any network the rendered model does not describe. |
| Docker API access routed through socket proxy | Note | A service does not mount `docker.sock`, shares a network with a classified proxy, and points at that same proxy through a `tcp://` host in `DOCKER_HOST`, `command`, or `entrypoint`. Coverage is treated as satisfied for All Clear; the note explains the topology. |
| Privileged container | High | A service runs with `privileged: true`, disabling most container isolation. |
| Host network mode | High | A service uses `network_mode: host`, bypassing Docker's network isolation and ignoring published-port mappings. |
| Check UID/GID alignment | Warning | A service sets a UID or GID and mounts host paths that Sencho cannot inspect from inside its container. Mismatched ownership between the host path and the container user is a common source of permission errors. |

For guidance on which API groups a socket proxy needs for each Sencho feature, see [Self-Hosting: Docker socket proxy](/operations/self-hosting#docker-socket-proxy).

### Reliability

| Rule | Severity | What it detects |
|------|----------|----------------|
| Image uses a moving tag | Warning | A service uses `:latest` or a tag-less image reference, making deploys non-reproducible and subject to unexpected changes. |
| No restart policy | Warning | A service has no restart policy and will not come back after a crash or host reboot. |
| No effective healthcheck detected | Warning | Sencho verified that the service has no effective healthcheck in the Compose model, running containers, or a locally available image. |
| Healthcheck explicitly disabled | Warning | The Compose model disables the healthcheck (`disable: true` or `test: NONE`). |
| Healthcheck inherited from image | Note | The Compose model does not declare a healthcheck, but the running container or a local image provides one. Coverage is treated as satisfied (All Clear); the note explains the origin. |
| Healthcheck inheritance could not be verified | Info | No Compose healthcheck is declared, and Sencho could not verify inheritance (Docker unreachable, image not present locally, or incomplete inspection). Doctor never pulls images. |
| Replica healthcheck coverage is inconsistent | Warning | Running replicas for the service disagree on effective healthcheck coverage. |

### Compose semantics

| Rule | Severity | What it detects |
|------|----------|----------------|
| Swarm-only deploy fields | Warning | A service sets `deploy` keys (`placement`, `update_config`, `rollback_config`, `endpoint_mode`) that standalone Compose ignores; these apply to Swarm only. |
| Duplicate container_name | Blocker | Two services within this stack both set the same `container_name`. Docker requires unique names; the deploy will fail. |
| Anonymous volume in use | Info | A service mounts one or more anonymous volumes. Anonymous volumes have no name, so they are easy to miss when backing up and are orphaned when the container is recreated. |
| Effective model adds services | Info | The effective model includes services not visible in this file, pulled in via `include`, `extends`, or profiles. What deploys may differ from what you see here. |

### Node state

The rules in this category (except "Node-state checks skipped" itself) read live Docker state. When the daemon is unreachable, they are all skipped and the "Node-state checks skipped" info finding is added in their place. "Host port is already in use" in Port conflicts also reads node state and is skipped under the same conditions. See [Node-state checks and graceful degradation](#node-state-checks-and-graceful-degradation).

| Rule | Severity | What it detects |
|------|----------|----------------|
| Node-state checks skipped | Info | The Docker daemon could not be reached; node-state rules did not run and this result is partial. |
| External network not found | Blocker | A network declared `external: true` does not exist on this node. Deploy is blocked until the network exists. For safe default bridge networks, Doctor may offer a create remediation; advanced drivers and custom options stay manual. |
| External volume not found | Blocker | A volume declared `external: true` does not exist on this node. The deploy will fail. |
| New network will be created | Info | A non-external network that does not yet exist on this node will be created on first deploy. |
| New volume will be created | Info | A named volume that does not yet exist on this node will be created on first deploy. |
| container_name already in use | Blocker | A service's `container_name` is already used by a different stack or unmanaged container on this node. The deploy will fail with a name conflict. |

### Exposure intent

These rules activate when the stack publishes at least one host port. They use the exposure intent you set in the Networking tab and any access URLs documented in the Stack Dossier to flag mismatches. See [Exposure intent checks](#exposure-intent-checks).

| Rule | Severity | What it detects |
|------|----------|----------------|
| Classified internal but publishes a host port | High | A service is classified as internal or same-node exposure but publishes a host port that contradicts that intent. |
| Sensitive service exposed on all interfaces | High | A service that looks like a database or admin tool (postgres, redis, adminer, and similar) publishes on all interfaces. |
| Stack publishes ports without an exposure intent | Warning | The stack publishes host ports but no exposure intent has been set in the Networking tab. |
| Published port not in documented access URLs | Warning | A host port is not referenced by any access URL recorded in the Stack Dossier; the documentation may be stale. |
| Has reverse-proxy labels but no documented URL | Warning | A service carries Traefik, Caddy, or similar reverse-proxy labels but has no documented access URL or reverse-proxy intent set. |

### Self-protection

| Rule | Severity | What it detects |
|------|----------|----------------|
| This stack is the running Sencho instance | Warning | Sencho discovered its own compose project among the managed stacks. See [Self-management](#self-management) below. |

## Exposure intent checks

The five rules in the [Exposure intent](#exposure-intent) category above cross-reference the stack's exposure intent and the access URLs documented in the Stack Dossier. These rules only fire when the stack publishes at least one host port.

To resolve exposure-related findings:

- **Set an exposure intent** in the [Compose Networking](/features/compose-networking) tab. Options are: internal (no host access), same-node (loopback only), LAN, reverse proxy (traffic arrives via a proxy), temporary (a short-lived exposure, tracked but not otherwise enforced), and public.
- **Document access URLs** in the [Stack Dossier](/features/stack-dossier). When a dossier URL references a host port, the port-vs-dossier rule uses that to confirm the port is intentional and expected.

Once an intent and access URLs are set, Sencho can detect when the configuration contradicts the documented intent, making future runs more precise.

## Self-management

Sencho recognizes when the stack it is checking is its own running compose project, and adds a warning finding: **This stack is the running Sencho instance**.

This is the one place Compose Doctor's findings connect to an enforcement that is not advisory. Independent of this report, and independent of whether the finding is acknowledged, Sencho refuses generic **deploy, update, stop, down, and delete** actions on its own compose project, because any of them could recreate or remove the dashboard you are using to manage it. Attempting one of those actions returns an error explaining why; Compose Doctor's finding is the heads-up that surfaces the same fact before you try.

To update Sencho itself, use **Fleet -> Node Update** instead of the stack's own update action. To manage the stack normally (for example, to deliberately restructure it), move its compose project outside the node's configured Compose directory first.

## Networking-relevant findings on the Networking page

The networking-relevant rules on this page (host mode, exposure intent, port conflicts, and the exposure-intent checks above) also surface on the node-wide [Networking](/features/networking) page's Findings tab, using the result of your last run here. A finding both engines detect is merged into one card; a finding only these Doctor rules catch appears there labeled with the run's timestamp. Acknowledging a finding here also excludes it from the Networking page.

## Node-state checks and graceful degradation

Six of the 42 rules require live Docker state to run: five are in the Node state category (external networks and volumes, new-resource notices, and container_name collision) and one is "Host port is already in use" in Port conflicts. All six are skipped when the Docker daemon is unreachable. Healthcheck inheritance checks also read Docker when Compose does not declare a healthcheck; those degrade to an Info finding when the daemon or image is unavailable.

When the daemon is unreachable:

- All model-only rules still run and produce findings as normal.
- All node-state rules are skipped.
- An info finding ("Node-state checks skipped") is added to make the gap visible.
- The run completes with a valid status reflecting whatever model-only rules found.

A partial run (where node-state checks were skipped) is stored and displayed like any other run. Run preflight again once the node is reachable to get full coverage.

## Health-Gated Updates

The last stored preflight result feeds directly into the [Health-Gated Updates](/features/health-gated-updates) readiness check as one of several signals. When you trigger **Update** from a stack, Sencho opens a readiness dialog before pulling anything. The dialog reads your most recent preflight result (active findings only; acknowledged findings are excluded):

- A blocker finding sets the update verdict to **blocked**.
- A model that failed to render, or one or more high-risk findings, sets the verdict to **review required**.
- Warning-only findings do not block the verdict on their own, but keep it from reading a clean **ready**; combined with an otherwise clean update they surface as **ready with warnings**.
- A clean pass (no active findings, or only info-level ones) contributes a positive signal toward **ready**.
- Preflight that has never run is noted in the dialog as **unknown** for that signal, without dragging the overall verdict down by itself.

Other signals (current container health, the pending image change, the rollback backup, and disk space) are combined with this one; the dialog's overall verdict reflects the worst signal across all of them, not preflight alone. Running preflight before updating gives the readiness check the most accurate signal for this stack.

## Running preflight

<Frame>
  <img
    src="/images/compose-doctor/compose-doctor-never-run.png"
    alt="The Doctor tab in the never-run state, showing the 'no preflight yet' placeholder with a Stethoscope icon and the run preflight button in the top-right corner"
  />
</Frame>

1. Click any stack in the left sidebar to open it.
2. Switch to the **Doctor** tab in the Anatomy panel header.
3. Press **run preflight**. Sencho renders the effective model, runs all checks, and updates the tab with the result.
4. Fix anything that matters, then run it again to confirm the findings clear.

Preflight runs against the **active node**, so selecting a remote node checks the stack against that machine's live Docker state. On mobile, the same report appears under the **Compose** section of the stack detail view.

## Acknowledging findings

When a finding is valid in general but intentional for your stack, you can acknowledge it so it no longer counts toward the active warning total, the Doctor tab badge, or the update readiness check.

1. Open the **Doctor** tab and run preflight if you have not already.
2. On any active finding row, click **acknowledge**.
3. Optionally add a note explaining why the finding is acceptable.
4. Choose when Sencho should show the finding again:
   - **Forever** until you clear the acknowledgement manually.
   - **Until Compose changes** when the stack's compose fingerprint changes (for example after you edit `compose.yaml`).
   - **30 days** on a rolling timer.
   - **Until image changes** for a specific service when that service's image reference in the effective model changes. This tracks tag or reference changes, not silent digest re-pulls of the same tag.

Acknowledged findings move to a collapsible **Acknowledged** section at the bottom of the report. You can clear an acknowledgement there to restore the finding to the active list immediately.

Acknowledgements are stored per node, stack, rule, and service. They do not sync across nodes in a fleet. Clearing an acknowledgement or letting an expiry mode lapse restores the finding on the next preflight read without re-running checks.

<Frame>
  <img
    src="/images/compose-doctor/compose-doctor-findings.png"
    alt="The Doctor tab showing an amber high-risk summary card with a dismiss X button, run time, and counts for high-risk and warning findings, followed by grouped finding rows each showing a service tag, title, explanation, suggested fix, and an acknowledge button, grouped under HIGH RISK and WARNING section headers"
  />
</Frame>

## Capability gating

The **Doctor** tab only appears when the active node reports the `compose-doctor` capability. A node running an older version of Sencho does not advertise this capability and the tab is hidden for that node until it is updated.

There is no tier gate: Compose Doctor is available on all plans.

## Limitations

- **Advisory only, with one exception.** Compose Doctor's report never blocks a deploy or changes any stack configuration; act on findings or ignore them. The one exception is the self-management guard: Sencho refuses generic deploy, update, stop, down, and delete actions on its own compose project regardless of whether that finding is acknowledged. See [Self-management](#self-management).
- **Dismissing the summary is local to your browser.** The **X** on the summary card and the Doctor tab dot share a per-stack, per-node dismissal stored in your browser's local storage. It is not synced across devices or between teammates, so a dismissal you make is invisible to anyone else looking at the same stack.
- **Bind-mount checks are scoped.** Only paths that resolve inside the node's Compose base directory can be checked for existence and ownership. Absolute host paths outside that directory (such as `/mnt/media`) are not reported as missing.
- **Healthcheck coverage uses Compose, runtime, and local image evidence.** Doctor checks the rendered Compose model first, then running containers for the service, then the locally available declared image. It never pulls an image. When evidence is incomplete, it reports that inheritance could not be verified instead of claiming a definitive absence.
- **One run stored per node.** There is no history. Each new run overwrites the previous one for that stack on that node.
- **Node-state rules require a reachable Docker daemon.** See [Node-state checks and graceful degradation](#node-state-checks-and-graceful-degradation).
- **Port conflict check excludes the checked stack.** Host ports already held by the stack being checked are ignored, so redeploying a running stack does not generate a false conflict with itself.
- **Exposure intent rules require published ports.** All five exposure intent rules are skipped entirely when the stack publishes no host ports.

## Troubleshooting

<AccordionGroup>
  <Accordion title="The report says it cannot render the model">
    Compose Doctor runs `docker compose config` to resolve the effective model, and that command failed. Common causes are a YAML syntax error, an unresolved `include` or `merge` key, or a required variable with no value. Fix the Compose or env file and run preflight again. Sencho does not echo the raw error in the UI because it can contain values from your files.
  </Accordion>
  <Accordion title="A bind mount I use every day is flagged as missing">
    Compose Doctor can only check paths that resolve inside the node's Compose base directory, such as a relative `./data` mount. An absolute host path like `/mnt/media` is outside what Sencho can see from inside its container and is never reported as missing. A missing relative path is a real finding: Docker would create it as a root-owned directory on deploy.
  </Accordion>
  <Accordion title="Every service is flagged for no healthcheck">
    Doctor reports missing healthcheck coverage only after checking the Compose model, running containers for the service, and any locally available declared image. An Info finding means inheritance could not be verified (for example the image is not present locally). Doctor does not pull images during a run. Add an explicit Compose healthcheck, or make sure the image is available on the node and run preflight again.
  </Accordion>
  <Accordion title="A port conflict is flagged for a port my own stack uses">
    Preflight ignores ports already held by the stack being checked, so redeploying a running stack does not flag its own bindings. A conflict finding means a different stack or an unmanaged container holds that host port on this node.
  </Accordion>
  <Accordion title="The report says node-state checks were skipped">
    The node-state checks (external networks and volumes, host-port conflicts, and container_name collisions) read live Docker state. When the Docker daemon on the target node is unreachable, those checks are skipped and the report shows an info finding instead of those results. The model checks still run. Confirm the daemon is reachable on the node, then run preflight again for full coverage.
  </Accordion>
  <Accordion title="The Doctor tab is not visible on a remote node">
    The tab appears when the active node reports that it supports Compose Doctor. A node running an older version of Sencho does not advertise that capability, so the tab is hidden for that node until it is updated.
  </Accordion>
  <Accordion title="Exposure intent rules are firing unexpectedly">
    The five exposure intent rules activate when the stack publishes at least one host port. The "unclassified" warning clears as soon as you set an exposure intent in the Networking tab. The "port not in dossier" warning clears once you add a matching access URL in the Stack Dossier. If an intent is already set and the finding still fires, check that the intent is configured on the correct node and for the correct service.
  </Accordion>
  <Accordion title="Doctor warns about literal dollar signs in a hash or secret">
    Compose treats unescaped `$` in environment values as variable references. Bcrypt hashes and other secrets often contain `$` characters, so an unquoted value in Compose YAML or a double-quoted `.env` line can be partially blanked out at runtime. Doctor flags this as **Literal dollar in value** and names the env key when it can, without showing part of the secret. Fix it by escaping each literal `$` as `$$` in Compose YAML, or by single-quoting the value in an env file.
  </Accordion>
  <Accordion title="I want to see who ran preflight last">
    The summary card shows the username and relative time: "ran 5 minutes ago by admin". This reflects the most recent run stored for this stack on the currently active node.
  </Accordion>
  <Accordion title="The report warns that this stack is the running Sencho instance">
    Sencho detected that the stack you are checking is its own compose project. Generic deploy, update, stop, down, and delete actions are refused for this stack no matter what the preflight report says, because any of them could recreate or remove the dashboard you are using right now. Update Sencho from **Fleet -> Node Update** instead. See [Self-management](#self-management).
  </Accordion>
  <Accordion title="I dismissed a finding but it still shows on another machine or for a teammate">
    Dismissing the summary card (the **X** button) is stored in your browser's local storage, keyed to the stack and node. It does not sync across devices, browsers, or user accounts, so a dismissal on your machine has no effect on what anyone else sees.
  </Accordion>
</AccordionGroup>

## Related features

<CardGroup cols={2}>
  <Card title="Compose Networking" icon="network-wired" href="/features/compose-networking">
    Set the exposure intent that drives the exposure intent preflight rules.
  </Card>
  <Card title="Stack Dossier" icon="book-open" href="/features/stack-dossier">
    Document access URLs that Compose Doctor uses to verify published ports are intentional.
  </Card>
  <Card title="Health-Gated Updates" icon="shield-alt" href="/features/health-gated-updates">
    Preflight results feed directly into the update readiness check before every update.
  </Card>
  <Card title="Environment Guardrails" icon="key" href="/features/environment-guardrails">
    Keep secrets out of structural Compose fields to avoid them appearing in the rendered model.
  </Card>
  <Card title="Stack Drift" icon="code-branch" href="/features/stack-drift">
    Drift detection checks whether what is running still matches the Compose file.
  </Card>
  <Card title="Compose Storage" icon="database" href="/features/compose-storage">
    Understand named and anonymous volumes before running preflight on storage-heavy stacks.
  </Card>
</CardGroup>
