Monitoring Multiple Docker Hosts with Dozzle — the Easy Way

Monitoring Multiple Docker Hosts with Dozzle — the Easy Way

If you run Docker on more than one machine, you know the drill: something misbehaves, and you start SSH-hopping between hosts, running docker ps and docker logs -f until you find the culprit. It works, but it doesn't scale — and it's miserable at 2 a.m.

Dozzle fixes this. It's a lightweight, real-time log viewer for Docker that runs as a single container, needs no database, and — the killer feature — can aggregate logs from multiple hosts through its agent mode. One browser tab, all your machines.

In my homelab, that's four hosts: two servers (quasar and darkstar) and two DGX Spark nodes (pulsar and magentar). Then, there's another four VMs I'm running in the cloud.

Dozzle's main overview page

I can access a server with a click, see all its containers, and watch their logs in real time.

A container's logs on one of the hosts

If needed, I can open a shell in the container.

Dozzle opening a shell in the container

There is a menu that lets me filter logs, restart or update containers etc.

Dozzle's menu

Here's the full setup, including two gotchas that cost me some debugging time.

Architecture

Dozzle has two roles, both served by the same image:

  • Agents run on every host you want to monitor. They talk to the local Docker socket and expose it over a TLS-secured connection on port 7007.
  • One main instance runs the web UI and connects to all agents.

Agents are the recommended approach over the older remote-host/socket-proxy setups: you never expose the raw Docker socket over the network, and all agent communication is TLS-encrypted out of the box.

Step 1: Deploy an agent on every host

On each machine you want to monitor, create a compose.yml:

services:
  dozzle-agent:
    image: amir20/dozzle:latest
    restart: unless-stopped
    command: agent
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
    ports:
      - 7007:7007

Then:

docker compose up -d

Two details worth noting:

  • restart: unless-stopped makes the agent survive host reboots (as long as the Docker daemon itself is enabled at boot — check with systemctl is-enabled docker). Unlike restart: always, it respects a manual docker compose stop across reboots, which is what you usually want for maintenance.
  • The socket is mounted read-only (:ro). The agent only needs to read logs and container metadata.

Step 2: Deploy the main instance

On your "hub" host — quasar in my case — the compose file looks like this:

services:
  dozzle:
    image: amir20/dozzle:latest
    restart: unless-stopped
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ./data:/data
    ports:
      - 8080:8080
    environment:
      # Enable container actions (stop, start, restart)
      - DOZZLE_ENABLE_ACTIONS=true
      # Allow access to container shells
      - DOZZLE_ENABLE_SHELL=true
      # File-based authentication (users.yml in ./data)
      - DOZZLE_AUTH_PROVIDER=simple
      # Label for this local host in the UI
      - DOZZLE_HOSTNAME=quasar
      # Remote agents: endpoint|name|group
      - DOZZLE_REMOTE_AGENT=192.168.0.1:7007|Darkstar|Servers,192.168.0.2:7007|Pulsar|Sparks,192.168.0.3:7007|Magentar|Sparks,external1.server.com:7007|External1|VMs,external2.server.com:7007|External2|VMs

The interesting bits:

DOZZLE_REMOTE_AGENT takes a comma-separated list of agents. Each entry uses the format endpoint|name|group, where name and group are optional. The name overrides what's shown in the UI; the group turns the sidebar into collapsible sections — mine are Servers , Sparks and VMs, each with a "merge all" button that streams the combined logs of every host in the group. If you manage more than a couple of machines, groups alone are worth the setup.

DOZZLE_ENABLE_ACTIONS and DOZZLE_ENABLE_SHELL let you start/stop/restart containers and open a shell in them straight from the browser. Powerful — which is exactly why you want authentication in front of it.

Bind mount instead of a named volume for /data. Dozzle's simple auth provider reads /data/users.yml. With a named volume you'd have to reach into /var/lib/docker/volumes/... or docker cp the file into the container. A bind mount (./data:/data) keeps users.yml next to your compose file, versionable and editable.

Step 3: Create users.yml

Don't hand-craft the bcrypt hash — Dozzle's image ships a generator:

mkdir -p data
docker run -it --rm amir20/dozzle generate admin \
  --password 'YOUR_PASSWORD' \
  --email admin@example.com \
  --name "Admin" > data/users.yml

The result looks like:

users:
  admin:
    email: admin@example.com
    name: Admin
    password: $2a$11$...   # bcrypt hash

Start it up:

docker compose up -d

Browse to http://your.dozzle.com:8080, log in, and you'll see all hosts in the sidebar, grouped and labeled.

Gotcha 1: Duplicate host IDs on cloned machines

After connecting all agents, one of my Spark nodes refused to show up, and the main instance logged:

{"level":"warn","name":"Magentar","message":"An agent with an existing ID was
found. Removing the duplicate host. ..."}

Dozzle identifies each host by Docker's system ID (the node ID in Swarm mode). That ID lives in /var/lib/docker/engine-idand is generated when the Docker daemon first starts. If machines were provisioned from the same image or restored from the same backup — very common with identically set up nodes like my DGX Sparks — they end up with the same engine ID, and Dozzle silently drops the "duplicate."

Verify on both hosts:

docker system info --format '{{ .ID }}'

If two hosts print the same ID, fix it on one of them:

sudo rm /var/lib/docker/engine-id
sudo systemctl restart docker        # regenerates a fresh UUID
docker compose restart dozzle-agent  # agent caches the ID at startup

Heads-up: restarting the Docker daemon restarts every container on that host, so pick a convenient moment. After that, both nodes appeared as distinct hosts.

Gotcha 2: Putting the local host into a group

The endpoint|name|group syntax only applies to remote agents. The localhost connection is special: DOZZLE_HOSTNAMEcan rename it, but there's no documented way to assign it to a group — so it sits ungrouped in the sidebar while everything else is neatly sorted.

The clean workaround: treat the hub like any other host. Run an agent next to the main instance in the same compose project, drop the socket mount from the main service, and connect to the local agent through the group syntax:

services:
  dozzle:
    image: amir20/dozzle:latest
    restart: unless-stopped
    volumes:
      - ./data:/data          # docker.sock no longer needed here
    ports:
      - 8080:8080
    environment:
      - DOZZLE_ENABLE_ACTIONS=true
      - DOZZLE_ENABLE_SHELL=true
      - DOZZLE_AUTH_PROVIDER=simple
      - DOZZLE_REMOTE_AGENT=localhost:7007|Quasar|Servers,192.168.0.1:7007|Darkstar|Servers,192.168.0.2:7007|Pulsar|Sparks,192.168.0.3:7007|Magentar|Sparks,external1.server.com:7007|External1|VMs,external2.server.com:7007|External2|VMs

  dozzle-agent:
    image: amir20/dozzle:latest
    restart: unless-stopped
    command: agent
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro

Three things make this work:

  1. Both services share the compose project's default network, so the main instance reaches the agent at dozzle-agent:7007 — no ports: mapping needed on the agent.
  2. Without the socket mounted on the main instance, the UI shows only agent-connected hosts — no duplicate "localhost" entry.
  3. The |Quasar|Servers label handles naming, making DOZZLE_HOSTNAME redundant.

Now every host — including the hub itself — lives in a group.

Wrap-up

The full setup is two small compose files and one generated users.yml:

  • an agent per host, socket mounted read-only, restart: unless-stopped
  • one main instance with simple auth, actions and shell enabled, and grouped agents via endpoint|name|group

Total effort: maybe an hour, most of which I spent on the engine-id issue you now get to skip. In return you get real-time logs, container actions, and shell access for your whole fleet in a single browser tab — with zero databases, zero log shippers, and zero SSH-hopping.