Skip to content

Looking inside a running deployment

morzer status says what is deployed and whether it is up. morzer doctor says whether the machine is configured correctly. Neither answers why is this container restarting, and that question is usually the reason somebody is logged in at all.

Four commands answer it, all read-only, none of them taking the deployment lock:

Command The question
morzer logs What did it say?
morzer ps What is running?
morzer stats What is it using?
morzer exec What does it think is in the database?

The reason they exist rather than a page telling you to run docker compose is that docker compose needs three things you would have to reconstruct: the project name (runtimes.compose.options.project, which defaults to the product name and need not equal it), the list of Compose files the deployment profile selected, and the environment the manager interpolates into them. Reconstructing that by hand under pressure is how an operator ends up looking at the wrong deployment.

None of the four takes the deployment lock, deliberately. They are what you run while an update is in flight, which is exactly the case a lock would break.

logs

morzer logs                      # the last 100 lines
morzer logs app db               # two services
morzer logs --follow             # until you press ctrl-C
morzer logs --since 15m          # the last quarter of an hour
morzer logs --tail 0             # the whole retained backlog
Flag Meaning
--follow, -f Keep the stream open. Ctrl-C ends it and exits 0 — you finished reading, nothing failed.
--tail Lines of history. Default 100, and the whole backlog with --follow. 0 means all of it.
--since A duration back from now (10m, 2h) or an RFC 3339 instant with a zone (2026-08-10T09:12:33Z).
--no-redact Stream the bytes the container wrote, unfiltered.

The scope is the Compose project, not the service list in the manifest. A sidecar the vendor's own Compose file starts is included, because what an operator debugging wants is what is there.

Nothing is stored, rotated, shipped or indexed. The Docker daemon's logging driver owns that, and a manager keeping a second copy would be the thing filling the disk it monitors.

Redaction, and what it cannot do

This installation's secret values are scrubbed from the stream before it reaches your terminal. A product that logs its own connection string — with a password the manager itself generated and wrote into a config file — is the ordinary case, not a pathological one.

The filter holds bytes until a line ends before matching, so a value split across two reads is still caught. A line longer than 64 KiB is dropped with a marker rather than passed through unmatched: a filter that gave up on the hard case would leak precisely when a service prints something enormous.

It is best effort and the limit is worth stating plainly. The redactor matches values it has been told about. A service that logs something derived from a secret — a token minted from it, a hash, a partial — is not covered and cannot be. Logs are still not something to paste into a ticket unread.

--no-redact turns it off, prints a warning to stderr saying so, and exists for one case: debugging the secret itself, where a redacted value is the thing you are trying to look at. If the secret state cannot be read at all — a missing age key, a machine mid-recovery — the stream is shown anyway with a warning that nothing could be scrubbed. Refusing would take the tool away at the moment it is most wanted, and pretending the stream was filtered would be worse than either.

The one streaming contract

morzer logs --json is the single exception to the one-object rule: it emits one JSON object per line, and no envelope. A stream has no end at which to write one.

{"ts":"2026-08-10T09:12:33.481Z","container":"demo-app-1","service":"app","line":"listening on :8080"}

ts is the instant the container wrote the line, not the moment the manager read it. service is empty for a line the runtime itself wrote about the stream — demo-app-1 exited with code 0 — which is often the line that explains why the rest stopped, so it is passed through rather than dropped.

Redaction applies exactly as it does to the human stream: a --json consumer is not more trusted than a terminal. Anything the manager needs to say about the stream goes to stderr, never into it, so a consumer parsing lines never has to tell the vendor's output from the manager's opinion of it. The exit code is the contract for whether the stream ended cleanly.

ps

morzer ps
SERVICE   CONTAINER      STATE    HEALTH    IMAGE                STATUS
✓ app     demo-app-1     running  healthy   ghcr.io/demo/app     Up 3 hours
✓ app     demo-app-2     running  starting  ghcr.io/demo/app     Up 12 seconds
✓ db      demo-db-1      running  -         postgres             Up 3 hours
✗ worker  demo-worker-1  exited   -         ghcr.io/demo/worker  Exited (1) 2 minutes ago

The same table morzer status draws under its services heading, on its own. It exists because status answers four questions at once and an operator watching a crash loop wants one of them repeatedly.

The container column is what tells two replicas of one service apart, so it is never dropped on a narrow terminal. A - in the health column means the service declares no healthcheck, which is not a verdict.

stats

morzer stats
morzer stats --watch --interval 5s
SERVICE  CONTAINER      CPU     MEMORY          NET I/O          BLOCK I/O
app      demo-app-1  12.34%    67MiB / 512MiB  1.2KiB / 3.3KiB   8KiB / 0
app      demo-app-2   0.50%    32MiB / 512MiB            0 / 0      0 / 0
db       demo-db-1    3.10%            128MiB  1.2KiB / 3.3KiB          -
total                15.94%            227MiB

One row per container, never an aggregate per service. docker stats reports containers, so a scaled service is several rows — and one row under the service's name would be one replica's numbers wearing the whole service's label.

The total line covers the two figures that add: CPU and memory. A memory limit does not add, so the total stops there. --json emits the rows and no total: a machine reader can add and cannot un-add.

A - is not a zero. Block I/O is unaccounted under a rootless daemon, which is an ordinary configuration — and a container that has written nothing also reports zero, so the two must not print the same thing. In --json those fields are null.

The memory limit shown for a container with no limit of its own is the host's memory. That is what the runtime reports, and inventing "unlimited" from it would be the manager guessing.

Watching

--watch re-samples on a timer, floor 1 second — below that the reading is mostly the sampler, since docker stats walks every container's cgroup. A failed sample prints its reason and the watch continues; two consecutive failures end it non-zero, because by then it is not a hiccup.

At a terminal it redraws one frame. In a pipe or a journal it appends a block per sample, which is the opposite of what status --watch does — that one is refused without a terminal. The difference is deliberate: a status watch is a dashboard with nothing worth keeping, while a sequence of samples is a time series, and morzer stats --watch > samples.txt for ten minutes is a real way to catch a leak.

--watch with --json is refused. Loop around morzer stats --json instead, which is also the form that composes with sleep.

exec

morzer exec db -- psql -U demo -c 'select count(*) from users'
morzer exec app -- ls -la /var/lib/demo

Everything after -- is the command line inside the container and nothing else. The exit code is the command's own, so this works in a script:

if ! morzer exec db -- pg_isready -q; then
    echo "the database is not accepting connections"
fi

It is not an interactive shell. There is no TTY and no stdin, so a command that prompts will wait for an answer nobody can give. One command, named on the command line.

There is no --user, and no shortcut for running as root. A manager flag that made privilege escalation a keystroke would be a manager opinion about something it cannot audit. A service that is not running is refused by name rather than left to produce a message about a container that does not exist.

It is written down

Every exec is journaled — the operation type, the service, the argv and the exit code — because the fact that a human was inside the deployment at 03:14 and what they asked it to do is exactly what an incident review needs, and nothing else in the journal would carry it.

The argv is redacted first, with this installation's known secret values scrubbed. That matters because a password in an argv is the ordinary case:

morzer exec db -- psql 'postgresql://demo:hunter2@localhost/demo'

On a machine whose secret state will not decrypt, the redactor knows no values — so the record keeps the service, the exit code and the time, and carries argv_omitted in place of the command line:

{"type":"exec","flags":{"service":"db","exit_code":"0",
 "argv_omitted":"this installation's secret values could not be loaded, so the command line could not be scrubbed before writing it down"}}

Three answers rather than two, and the third is the point. Refusing to journal would lose the fact that a human was inside the deployment, which is the record's whole job. Refusing the command would take exec away on a machine somebody is already debugging. So the record keeps what it can promise is clean and says what it dropped.

What the redactor cannot catch is a credential the manager has never been told about — one you pasted from a password manager. An argv is still not the place for a secret, on this machine or any other: /proc exposes it to every local user while the command runs, which is a separate problem from the file this manager writes and keeps.

The output is never recorded. It is arbitrary vendor data plus whatever your command printed, and a journal holding it would be a second copy of the product's data in a file nobody thinks of as one.

What these are not

  • Not a log management feature. No storage, rotation, search or shipping; configure a logging driver for that.
  • Not a dashboard. stats --watch redraws a table. It grows no graphs, no history and no thresholds.
  • Not a way into an unpublished port. Published ports are declared in the manifest and already reachable; a tunnel to one that is not is a different security posture.
  • Not a way to restart one service. Converging is apply's job, and a restart outside it would leave a container running a definition the manager did not choose.