Keep your Docker Compose image tags up to date — like npm-check-updates, but for compose.yaml.
ccu # open the TUI and pick what to update
ccu check # just print what's outdated
ccu check -u # print and write the new tagsPoint it at a directory, it scans every Compose file below it, asks each registry what's newer, and — if you want — rewrites the tags for you. One static binary, no daemon, no runtime.
Install · Commands · ccu check flags · Config reference · Recipes · TUI keys · JSON output · Troubleshooting
curl -fsSLO https://github.com/p-arndt/compose-check-updates/releases/latest/download/ccu-linux-amd64
mv ccu-linux-amd64 ccu && chmod +x ccu && sudo mv ccu /usr/local/bin/
ccu versionlinux/macOS/windows on amd64, arm64, arm, 386 — see
Releases, every one
ships a checksums.txt. On Windows just put the .exe on your PATH.
Afterwards ccu self-update replaces the binary in place.
cd into the directory holding your stacks and run ccu. Everything below it is
scanned recursively. Nothing is written unless you ask — A in the TUI, -u for
check — and every modified file gets a .ccu backup beside it.
| Command | What it does |
|---|---|
ccu |
The TUI: browse what's outdated, pick rows, apply. Default. |
ccu check |
One-shot report for scripts, cron and CI. No UI. |
ccu config |
Show the resolved configuration and where it came from |
ccu config -image <name> |
Explain how one image's settings were resolved |
ccu self-update |
Download, verify and replace the running binary |
ccu check-update |
Only report whether a newer ccu exists |
ccu help / ccu version |
— |
Tip
Piped or redirected, ccu runs the check report (as JSON) instead of the
TUI and says so on stderr — old cron and CI entries keep working.
ccu check # report only (patch updates by default)
ccu check -f # consider every newer version, not just patches
ccu check -u -r # write the new tags, then restart the services
ccu check -d ./stacks # scan a different directory| Flag | Description | Default |
|---|---|---|
-d |
Directory to scan | . |
-exclude |
Directories to exclude, comma-separated | none |
-config |
Read this config file instead of searching for one | none |
-u |
Update the Compose files with the new image tags | false |
-r |
Restart the services after updating | false |
-f |
Full mode — consider every newer version, not just patches | false |
-major / -minor / -patch |
Only suggest that level | -patch |
-versioning |
Default tag-reading scheme: semver or loose |
semver |
-format |
Output format: auto, pretty or json |
auto |
-pin-floating |
Pin floating tags (latest, main, …) to the digest they resolve to |
false |
-dockerfiles |
Also check the base images of Dockerfiles built by a compose service | true |
Only -d, -exclude, -config, -pin-floating and -dockerfiles also apply
to the TUI — it picks levels in the UI instead and always resolves every level.
Exit codes: 0 nothing to do · 1 updates available, not applied ·
2 something failed. So a CI gate needs no parsing:
ccu check -f || echo "images are behind"Two files, neither required — ~/.config/ccu/config.yaml for preferences across
every project, .ccu.yaml in the scan root or any parent for settings that
travel with the stacks. Project layers over global, flags over both.
ccu config prints what was read.
# .ccu.yaml — every key is optional
exclude: [node_modules, services/legacy]
pin_floating: true
dockerfiles: false
versioning: semver
floating_tags: [release, canary]
images:
library/traefik:
max: minor
nousresearch/hermes-agent:
versioning: loose
acme/dated:
versioning: regex
versioning_pattern: '^(?P<major>\d{4})-(?P<minor>\d{2})-(?P<patch>\d{2})$'
internal/thing:
reference_tag: stable
floating_tags: [release]| Key | Values | Default | Meaning |
|---|---|---|---|
exclude |
list of paths | none | Directories never scanned. Unioned with -exclude, not replaced. |
pin_floating |
bool | false |
Offer floating tags the digest they resolve to |
dockerfiles |
bool | true |
Also check FROM lines of Dockerfiles built by a service |
versioning |
semver, loose |
semver |
How tags are read as versions, run-wide |
floating_tags |
list of tags | see below | Extra tags treated as moving. Added to the built-in set. |
images |
map | — | Per-image settings, keyed by image name without tag or digest |
exclude matches backup at any depth, services/legacy only that path,
/mnt/backups that absolute location — all three with * wildcards.
| Key | Values | Meaning |
|---|---|---|
max |
major, minor, patch |
Cap: never offer a bump above this level |
versioning |
semver, loose, regex |
Tag-reading scheme for this image. Outranks the flag. |
versioning_pattern |
Go regex | Required by, and only valid with, versioning: regex |
reference_tag |
tag name | The moving tag digest mode compares against, instead of latest |
floating_tags |
list of tags | Extra moving tags for this repository only |
Tip
A setting not arriving is almost always the key: lookup is exact, on the
name as ccu reports it — library/traefik, never traefik. Ask about the one
image with ccu config -image library/traefik; it names the layer and the file
that decided each value, and offers a near-miss as a hint.
Private registries and rate limits
Any OCI registry works — Docker Hub, GHCR, Quay, Harbor, ECR, a self-hosted
registry:2. Private repos need no setup: ccu reads the credentials
docker login already stored. Logging in also lifts Docker Hub's anonymous rate
limit, which starts to matter past a few dozen images.
"no tag matches the newest digest" — tags that aren't semver
Plenty of images publish something that is a version but not a semantic one:
nousresearch/hermes-agent tags by date and sometimes rebuilds the same day —
v2026.7.7 and v2026.7.7.2 side by side. A fourth segment is not semver, so by
default those tags are not read as versions at all. Loosen the rule for that
image:
images:
nousresearch/hermes-agent:
versioning: loose| Scheme | Reads |
|---|---|
semver |
up to three segments, no leading zeros — the default |
loose |
up to six numeric segments, leading zeros, any suffix |
regex |
whatever your pattern names — per image only |
The first three segments stay major, minor and patch; anything past the third
orders the version but is reported as a patch. The suffix rule holds either
way: 3.19-alpine only ever moves to another -alpine tag.
Three places can set the scheme, most specific first: a per-image entry, then
--versioning=loose for one run, then a run-wide versioning: in either config
file. A per-image entry outranks both defaults including the flag — a flag
meant as a quick try should not silently undo a preference written down on
purpose.
Tags no fixed rule can read — versioning: regex
A dashed date — 2024-01-01 — reads under both schemes above as release 2024
with -01-01 mistaken for a prerelease, which orders 2024-12-31 before
2024-02-01: the alphabet, not the calendar. For those, say what a tag looks
like:
images:
acme/dated:
versioning: regex
versioning_pattern: '^(?P<major>\d{4})-(?P<minor>\d{2})-(?P<patch>\d{2})$'A Go regular expression with named groups. Four carry numbers — major,
minor, patch, build — plus suffix for whatever trails them, separator
included. A group you leave out is 0, so naming only major is enough; a group
named anything else is ignored, which lets you match parts you don't need. The
four order exactly as under loose.
The pattern must describe the whole tag — it is anchored, so a date buried in
build-2024-01-01-x is not one — and a tag it does not match is simply not a
version. There is no run-wide versioning: regex and no --versioning=regex: a
pattern fitting one repository's tags is meaningless for the next one's.
Patterns are checked when the config is read, so a typo fails the run and names the image rather than quietly reading no tags:
$ ccu config
ERROR Error reading config error=/repo/.ccu.yaml: image "acme/dated": versioning_pattern: "^(?P<major>\\d+$" is not a valid regular expressionDigest-pinned and commit-tagged images
Some images are pinned by digest, others tag every build with its commit
(sha-e1c83ba). For those ccu compares the manifest digest instead of the
version number and reports level digest:
| In your Compose file | What ccu does |
|---|---|
image: vert:sha-438f91a |
Moves the tag to the one currently matching latest, e.g. sha-e1c83ba |
image: vert@sha256:abc… |
Rewrites the digest to the one latest now resolves to |
image: vert:1.2.3@sha256:abc… |
Bumps tag and digest together, so they stay consistent |
image: vert:latest |
Pinned to today's digest, with -pin-floating |
All of it hangs on one tag: latest, whose digest is what "newest" means for an
image with no readable version. A repository publishing no latest is skipped
with no latest tag to compare against. Name the moving tag it does publish
and it is back in the game:
images:
internal/thing:
reference_tag: stableThe reference tag itself is never offered as the new tag — trading a fixed reference for a moving one is not an update.
[!NOTE] This queries tags individually, so the first check of such an image is noticeably slower. At most 250 tags of the same naming scheme are inspected.
Pinning floating tags (latest, main, …)
Floating tags always resolve to whatever is newest, so there is never a newer tag
to offer — and nothing in the Compose file to tell you the image behind the tag
changed. -pin-floating writes that down:
- image: nginx:latest
+ image: nginx:latest@sha256:b34848eff6db…The tag still reads latest, but the digest now decides: docker compose pull
gets that exact build and stops following the tag. In exchange ccu can
see the drift — every later run compares the pinned digest against what
latest resolves to and reports a digest update. You trade automatic pulls for
a reviewable bump.
ccu check -pin-floating # report them (level: pin)
ccu check -pin-floating -u # and write thempin_floating: trueBuilt-in floating tags: latest, main, master, edge, stable, nightly,
dev, develop. If your registry moves a differently spelled one, add it —
globally is usually right, since a registry spells its moving tag the same way
across repositories:
floating_tags: [release, canary] # every image
images:
internal/thing:
floating_tags: [release, canary] # this one onlyEverything adds up rather than replacing, and nothing ever takes a name away:
the built-in names are a fact about how registries work, not a preference. A repo
publishing release almost certainly publishes latest beside it, and if naming
one made ccu forget the others, that latest would turn back into an ordinary,
pinnable tag.
In the TUI they sit behind the bar's floating stop (p); pin_floating
decides which way it starts. If the run wasn't asked to pin, the first press
fetches the digests then and there. Caps do not apply — pinning moves no version.
[!NOTE] Once a digest is in the file, the image is that exact build until
ccumoves it — this suits stacks you update deliberately, not ones relying on a nightlypull. It also costs one registry request per floating image, the other reason it's off by default.-pin-floating=falseoverridespin_floating: truefor a single run.
Images you build yourself (build: + Dockerfile)
A service with a build: has no image tag to check — the tag deciding what it
runs sits on the FROM line of its Dockerfile. Those are scanned too:
-FROM quay.io/keycloak/keycloak:26.0.7 AS builder
+FROM quay.io/keycloak/keycloak:26.7.2 AS builderThe Dockerfile appears in the list under its own path, next to the compose file
that builds it, and behaves like any other row: levels, caps, targets, a .ccu
backup. Two specifics:
- Every stage moves together. A multi-stage build names its base as builder and as runtime; both are rewritten as one update — a runtime left a release behind its builder is a broken image, not a partial one.
- A restart rebuilds.
docker compose up -d --build, since a new base image reaches the container only through a build.
Skipped, because no registry can answer for them: FROM scratch, a stage
referring to an earlier one (FROM builder), a reference assembled from build
args (FROM ${BASE}), dockerfile_inline:, and non-local contexts.
-dockerfiles=false or dockerfiles: false turns it off.
The update notice
A ccu check run also checks at most once every 24 hours whether a newer
release exists and prints one line to stderr. It never installs anything by
itself; CCU_NO_UPDATE_CHECK=1 turns the check off.
Updates grouped per Compose file, streaming in as registries answer.
Arrows move; space/enter act on whatever has the focus. tab reaches the
detail column on an image and the settings bar anywhere else. A applies, ?
shows every key.
show ‹ all › target ‹ major › [ issues 1 ] [ apply 2 ]
You decide per row which version gets written, so a major bump never sneaks in.
Afterwards ccu offers to docker compose up -d the affected files.
All keys
| Key | Action |
|---|---|
↑/↓ or k/j |
Move the cursor. At the top of the list, ↑ carries on into the bar; on the bar, ↓ comes back |
pgup/pgdn |
Page up / down (home/end for first / last) |
←/h, →/l |
On a header: collapse / expand. On an image: open the details. In the details column: previous / next option. On the bar: previous / next stop |
space / enter |
Act on what has the focus: select the row, step a setting, press a button |
- |
Step the focused setting backwards |
z |
Fold/unfold the node under the cursor |
C / E |
Collapse all / expand all |
a / n |
Select / deselect everything under the cursor |
ctrl+a / ctrl+n |
Select / deselect the whole list |
f |
Cycle the display filter (which rows are shown) |
t |
Cycle the target level for all rows |
p |
List or hide the floating tags |
tab |
On an image: the details column (tab or esc returns). Otherwise: the top bar |
shift+tab |
Step back along the bar |
m |
The top bar, from anywhere; again for the next stop |
i |
Show the issues logged during the scan |
A |
Apply the selected updates |
u |
Apply only the highlighted row |
y / n |
Answer the restart prompt |
esc |
Back out of whatever has the keyboard (never quits) |
? / q |
Help / quit |
On a terminal too narrow for two columns the detail column moves below the list rather than disappearing — the per-image target and cap have no keys of their own.
Filter vs. target — which version actually gets written
show decides which rows are visible; target decides which version gets
written. Both sit on the top bar, both have a key (f and t); the row's own
target lives in the detail column. The target defaults to major, so out of the
box you are offered the highest available version. At minor, an image on
traefik:v2.9.3 with 3.7.8 available re-points to the latest 2.11.x; at
patch, to 2.9.4.
The sidebar only offers levels an image actually has — (+2) after a version
means two other levels exist. A row with nothing at the current target shows as
[-] … no patch update and cannot be applied.
An image whose tags ccu could read nothing from shows as [!] … unreadable · …
and is never applied. On that row the sidebar grows a versioning field:
stepping it to loose saves the scheme to your config and re-checks that image
straight away — a repository ccu could not read is fixed where the problem is
shown.
The TUI always resolves all levels, regardless of -patch, -minor,
-major or -f. Those flags govern ccu check only.
On a terminal the report is the aligned, colour-coded listing; in a pipe it is
JSON Lines — one object per line, written as the scan resolves, so jq reads
it streaming. --format=pretty / --format=json force either one. Only the
report goes to stdout; warnings and notices go to stderr, so a pipe stays
parseable.
{"kind":"update","image":"library/traefik","reference":"traefik:v2.9.3","services":["proxy"],"file":"proxy/compose.yaml","current":"v2.9.3","latest":"v3.2.0","level":"major","targets":{"minor":"v2.11.4","major":"v3.2.0"}}
{"kind":"unreadable","image":"ghcr.io/vert-sh/vert","reference":"ghcr.io/vert-sh/vert:sha-e1c83ba","file":"vert/compose.yaml","current":"sha-e1c83ba","level":"unreadable","reason":"no-tag-for-digest","message":"none of this image's tags matches its newest digest…"}
{"kind":"error","file":"data/docker-compose.yml","error":"fetching tags: 429"}Every key
| Key | On | Meaning |
|---|---|---|
kind |
every line | update, unreadable or error — dispatch on this |
image / reference |
update | the image name, and the reference as the Compose file writes it |
services |
update | the Compose services that declare it; a list, because identical references are reported once |
file |
both | the Compose file, or the Dockerfile when the update sits on a FROM line |
compose_file |
Dockerfile updates | the Compose file that builds it, i.e. the one to hand docker compose -f |
current / latest |
update | the tag now, and the one this run picked |
level |
update | major, minor, patch, digest or pin |
current_digest / latest_digest |
digest-pinned images | only present when the digest actually moved |
targets |
update | the tag available at each level, so you can pick a different one |
cap |
capped images | the ceiling recorded in your config |
applied / restarted |
with -u / -r |
whether the write or restart succeeded — false means it was asked for and failed |
reason / message |
unreadable | why nothing could be resolved: a stable name to dispatch on, and the same thing in a sentence |
error |
error | the failure, as text |
No new versions found, but newer versions exist
ccu check only looks for patch versions by default. With 1.0.0 current
and 1.1.0 latest there is no newer patch, so nothing is suggested. Use
ccu check -f, or the TUI, which always resolves every level.
Image tags with only x.y versions
Alpine has 3.14, 3.14.1 and 3.14.0 — if you use 3.14, ccu suggests
3.14.1. But Postgres has 13, 13.3 and 13.4: on 13.2, ccu will not
suggest 13.4, because 13 is not a valid semver version.
Coming from v0.6.x?
The report used to be the default and -i opened the TUI; that is now the other
way round. Both old spellings still work — -i is accepted as a no-op, and a
report-only flag without check (ccu -u) still runs the report with a one-line
hint. The old -self-update / -check-update flags work too.
Issues and PRs welcome — registry quirks and real-world Compose files that trip the parser especially. Plain Go, no codegen; see CONTRIBUTING.md.
MIT © P. Arndt

