Prometheus metrics for Docker Compose hosts: stack and service health plus image update detection (new tags and re-pushed digests), via node-exporter's textfile collector. Includes a Grafana dashboard and alert rules.
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
nanos 8a365bee59
All checks were successful
test / test (push) Successful in 18s
add license
2026-10-27 08:26:43 +00:00
.forgejo/workflows Rename to compose-exporter 2026-10-26 21:29:19 +00:00
test Rename to compose-exporter 2026-10-26 21:29:19 +00:00
compose-exporter-dashboard.json Rename to compose-exporter 2026-10-26 21:29:19 +00:00
compose-exporter.alerts.yml Rename to compose-exporter 2026-10-26 21:29:19 +00:00
compose-exporter.env.example Rename to compose-exporter 2026-10-26 21:29:19 +00:00
compose-exporter.overrides.example Rename to compose-exporter 2026-10-26 21:29:19 +00:00
compose-exporter.service Rename to compose-exporter 2026-10-26 21:29:19 +00:00
compose-exporter.sh Rename to compose-exporter 2026-10-26 21:29:19 +00:00
compose-exporter.timer Rename to compose-exporter 2026-10-26 21:29:19 +00:00
LICENSE add license 2026-10-27 08:26:43 +00:00
README.md add license 2026-10-27 08:26:43 +00:00
screenshot.png Add dashboard screenshot 2026-10-26 17:04:23 +00:00

compose-exporter

Exports Docker Compose stack and service status and image version drift as Prometheus metrics. It runs on each Docker host from a systemd timer and writes the metrics for node-exporter's textfile collector, so they're scraped along with the host's other metrics (it can also push straight to VictoriaMetrics). A Grafana dashboard aggregates across hosts.

Grafana dashboard

It covers every container started by Compose (those with a com.docker.compose.project label); containers started with plain docker run are ignored.

An update is detected two ways:

  • Newer tag: a newer tag of the same shape exists (16.4-alpine → 16.5-alpine).
  • Re-pushed tag: the registry digest of the running tag no longer matches the local image (catches :latest, :16, etc.).

Registry answers are cached (1h by default), so the timer can run every 5 minutes without hitting rate limits.

Requirements

On each Docker host: bash 4.4+, docker, curl, jq, coreutils, systemd, and node-exporter (unless you push to VictoriaMetrics instead). The script runs as root, for the Docker socket and root's registry logins. The docker compose plugin is only needed if STACK_GLOB is set.

Elsewhere: Prometheus or VictoriaMetrics scraping those node-exporters, and Grafana for the dashboard and alerts.

Setup

Clone the repo on each host. Then, as root, symlink the script and the units, so git pull updates them. Copy the config, so each host has its own:

REPO=/opt/compose-exporter   # wherever you cloned it

ln -s $REPO/compose-exporter.sh      /usr/local/bin/compose-exporter.sh
ln -s $REPO/compose-exporter.service /etc/systemd/system/compose-exporter.service
ln -s $REPO/compose-exporter.timer   /etc/systemd/system/compose-exporter.timer

install -m 600 $REPO/compose-exporter.env.example       /etc/compose-exporter.env
install -m 644 $REPO/compose-exporter.overrides.example /etc/compose-exporter.overrides
mkdir -p /var/lib/node_exporter/textfile_collector

systemctl daemon-reload
systemctl enable --now compose-exporter.timer

In /etc/compose-exporter.env:

  • STACK_GLOB: point it at your compose files. Without it, a stack that's been docker compose downed has no containers left and simply disappears, instead of reporting as down.
  • HOST_LABEL: the metrics carry a host label, hostname -s by default. If your scrape config already gives this node-exporter target a host label, set HOST_LABEL to the same value, or the series end up with an extra exported_host label.

node-exporter has to read the textfile directory. For node-exporter in a container, add a flag and a mount:

    command:
      # ...existing flags...
      - '--collector.textfile.directory=/textfile'
    volumes:
      # ...existing mounts...
      - /var/lib/node_exporter/textfile_collector:/textfile:ro

Ubuntu's prometheus-node-exporter package already reads /var/lib/prometheus/node-exporter; point TEXTFILE_DIR there instead.

Check with compose-exporter.sh --stdout, which prints the metrics instead of writing them, and then curl -s localhost:9100/metrics | grep ^compose_ (or wherever node-exporter listens).

Finally, import compose-exporter-dashboard.json into Grafana and pick your Prometheus/VictoriaMetrics data source.

Configuration

  • /etc/compose-exporter.env: where to write or push metrics, host label, expected stacks, cache timings, registry auth. Every option is documented in the header of compose-exporter.sh.
  • Container label monitor.periodic: "true": for services that run a job, exit and are restarted by their restart policy. They count as up while exited or restarting, as long as the last run exited with 0. Long-running services that exit cleanly now and then (e.g. queue:work --max-time) don't need it: a service with an always or unless-stopped policy counts as up while it's being restarted, if it exited with 0 after running for at least 10s, less than a minute ago.
  • /etc/compose-exporter.overrides: per-image rules (digest, regex:<ERE>, ignore) for images whose tags shouldn't be compared by shape, e.g. staying on Postgres 16.
  • The timer runs every 5 minutes. The dashboard and alerts look back 10 minutes and treat a check older than 15 minutes as stale, so if you run it less often, raise those to match or hosts will drop out of the dashboard and fire stale alerts.

Alerts

compose-exporter.alerts.yml has Grafana alert rules. Copy it into Grafana's provisioning/alerting/ directory, adjust it to your setup, and restart Grafana. Copy rather than symlink: the provisioning directory is usually mounted into the Grafana container, and a symlink out of it won't resolve there.

To adjust:

  • datasourceUid: the uid of your Prometheus/VictoriaMetrics data source (in its URL under Connections → Data sources). It's set once per rule:
    sed -i 's/datasourceUid: victoriametrics/datasourceUid: <your-uid>/' compose-exporter.alerts.yml
    
  • folder: the alert folder to file the rules under. Grafana creates it if it doesn't exist.
  • orgId: only if the rules belong in an organisation other than the default.

The rules carry the labels service=compose-exporter and severity (critical, warning or info). Without a notification policy matching them, every alert goes to your default contact point, including the low-priority update alerts.

Alert Fires when
compose-exporter check is stale a host's last check is over 15 min old (script failing, Docker down, host down). Also fires if no host reports at all. Stops after a day, so decommissioned hosts clear themselves.
Compose stack down a stack has been down for 10 min
compose-exporter registry lookups failing registry lookups have been failing for an hour
Image update available a service's image has a newer tag, or its tag was re-pushed, for 15 min. Fires again for each further release

The stale check matters most with the textfile collector. If the script stops, node-exporter keeps serving the last file, and the dashboard shows frozen data that looks live. The alert reads the timestamp inside the metrics, so it catches this.

Images on floating tags (:latest, nightly builds) might be re-pushed often, and each re-push fires the update alert. To alert on new tags only, change update=~"tag|digest" to update="tag" in that rule's query. To mute it for particular images, add an ignore override.

Authentication

Public images need no credentials.

Registries (private images, or higher Docker Hub rate limits): run docker login <registry> as root. The script reads /root/.docker/config.json, but only inline auths entries. Credential helpers (credsStore / credHelpers) aren't supported. If one is configured, lookups fail, and the image reports update="unknown" and counts towards compose_registry_errors. A read-only token is enough, e.g. a Forgejo token with read:package scope.

VictoriaMetrics (only when pushing with VM_URL): set VM_AUTH=user:password in /etc/compose-exporter.env, which is root-only.

Metrics

Every series has a host label. Stack metrics add stack, and service and image metrics add stack and service.

Metric Meaning
compose_stack_up 1 if the stack has containers and all its services are up
compose_stack_containers, compose_stack_containers_running container counts per stack
compose_service_up 1 if all of the service's containers are running and not unhealthy, or are one-shot or periodic jobs whose last run succeeded
compose_image_info labels image, tag, current, latest, update (none, tag, digest, unknown, local, pinned, ignored)
compose_image_update_available 1 if a newer tag or re-pushed image exists
compose_image_checked_timestamp_seconds when the registry was last asked about the service's image (answers are cached for CACHE_TTL). Missing for local, pinned and ignored images
compose_registry_errors number of failed registry lookups
compose_check_timestamp_seconds, compose_check_duration_seconds when the last run finished and how long it took

compose_image_info's value is the time of the check, not 1. When an image's labels change (say, after an update), the old series lingers for the query's lookback window, so keep only the newest per service:

last_over_time(compose_image_info[10m])
  == on (host, stack, service) group_left
max by (host, stack, service) (last_over_time(compose_image_info[10m]))

Tests

bats test/

Needs bats-core (brew install bats-core / apt install bats) and bash 4.4+. docker and curl are replaced by stubs in test/stubs/, so no Docker or network access is needed.

License

MIT, see LICENSE.