Keep your running docker images updated. Like watchtower, but with a cooldown before using new images.
  • Go 99.1%
  • Dockerfile 0.9%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
w33ble 0988396cb2
All checks were successful
test / test (pull_request) Successful in 2m6s
build-next / docker (push) Successful in 1m5s
docs(agents): add development branch/PR workflow note
2026-09-14 15:46:39 -06:00
.forgejo/workflows fix(ci): correct MAJOR tag derivation, was producing vv1 image tag 2026-09-06 13:59:32 -06:00
cmd/lagom init: lagom — supply-chain-safe Docker image updater 2026-07-18 16:42:26 -06:00
internal fix: replace deprecated client.NewClientWithOpts with client.New 2026-08-30 14:37:40 -06:00
.dockerignore feat: add Dockerfile, .dockerignore, and Docker section to README 2026-07-21 09:50:33 -06:00
.gitignore feat: add build-dist cross-compile task (linux/darwin, amd64/arm64); build-all now depends on it 2026-07-24 17:03:46 -06:00
.golangci.yml chore(lint): drop dead errcheck exclusions 2026-08-22 13:22:22 -06:00
AGENTS.md docs(agents): add development branch/PR workflow note 2026-09-14 15:46:39 -06:00
Dockerfile feat(docker): replace org.label-schema with org.opencontainers.image annotations 2026-09-14 15:30:41 -06:00
Dockerfile.ci feat(docker): replace org.label-schema with org.opencontainers.image annotations 2026-09-14 15:30:41 -06:00
go.mod feat: add notification support via shoutrrr (ntfy, smtp, webhook, etc) 2026-07-21 09:24:06 -06:00
go.sum feat: add notification support via shoutrrr (ntfy, smtp, webhook, etc) 2026-07-21 09:24:06 -06:00
mise.toml docs: add staticcheck details to AGENTS.md; include mise.toml 2026-08-30 14:37:30 -06:00
PROJECT-INTENT-BRIEF.md init: lagom — supply-chain-safe Docker image updater 2026-07-18 16:42:26 -06:00
README.md docs: AGENTS.md CI section + README fixes (#6) 2026-09-06 13:46:15 -06:00
SECURITY-AUDIT.md docs: add security section to README, mark resolved audit items 2026-07-20 21:34:27 -06:00

lagom

Supply-chain-safe Docker image updater. Tracks image digests over time and enforces a minimum-age policy — never pull an image published less than N days ago.

Why

Auto-updaters like Watchtower pull :latest immediately. If a compromised image hits a public registry, it's on your servers within minutes. Lagom adds a configurable safety window — e.g., "only use images published 7+ days ago" — so new tags must survive in the wild before they're trusted.

Install

Clone and build from source:

git clone https://git.w33ble.com/w33ble/lagom
cd lagom
go build -o lagom ./cmd/lagom
# or with mise:
mise run build

Requires Go 1.26+. Mise users get the Go toolchain automatically (mise install).

Usage

Config

Lagom looks for config.json in ~/.config/lagom/ by default. Override with LAGOM_CONFIG_PATH.

{
  "min_age_days": 7,
  "require_labels": false,
  "keep_old_images": false,
  "max_pull_candidates": 5,
  "docker_socket": "unix:///var/run/docker.sock",
  "images": []
}
Field Default Description
min_age_days 7 Global minimum age before pulling
require_labels false When true, only manage containers with lagom.enable=true. When false, manage all but respect lagom.enable=false as opt-out.
docker_socket unix:///var/run/docker.sock Docker daemon socket
images [] Explicit image list (overrides auto-detection)
keep_old_images false Preserve old images after --recreate instead of pruning
max_pull_candidates 5 Maximum historic digest candidates to try when the current tag is too new
notify "" Comma-delimited notification URLs (shoutrrr format)
server_name "" Label prepended to notification titles (e.g. [app-server-1])

If images is populated, lagom manages only those images. Otherwise, it auto-detects from running containers — all of them by default, or only labeled ones when require_labels is true.

Environment variables

Each config field can be set via a LAGOM_-prefixed environment variable. ENVs take priority over the JSON config file.

Variable Type Maps to
LAGOM_MIN_AGE_DAYS int min_age_days
LAGOM_REQUIRE_LABELS bool require_labels
LAGOM_KEEP_OLD_IMAGES bool keep_old_images
LAGOM_MAX_PULL_CANDIDATES int max_pull_candidates
LAGOM_DOCKER_SOCKET string docker_socket
LAGOM_IMAGES string (comma-separated) images
LAGOM_DAEMON_INTERVAL_HOURS int daemon_interval_hours
LAGOM_DAEMON_TRACK_ONLY bool daemon_track_only
LAGOM_SKIP_FIRST_RUN bool skip_first_run
LAGOM_NOTIFY string notify
LAGOM_SERVER_NAME string server_name
LAGOM_CONFIG_PATH string config directory path (overrides ~/.config/lagom)

Per-container overrides

Add labels to containers for fine-grained control:

Label Description
lagom.enable=true Opt this container in. When require_labels is true, management is opt-in. When false (default), setting lagom.enable=false opts a container out.
lagom.min-age-days=N Override the global minimum age for this image
lagom.no-recreate=true Skip this container when --recreate is used

Commands

lagom scan         List installed images with digests and ages
lagom track        Record current image digests to local state
lagom status       Show tracked images and the next digest to update to
lagom inspect      Dump cached digest history from local state
lagom update       Pull newer images that meet the minimum-age policy
lagom daemon       Periodically track and update with health endpoint

lagom scan

Lists all locally installed images with repo, tag, digest, creation date, age, and size.

$ lagom scan
REPOSITORY:TAG              DIGEST        CREATED     AGE   SIZE
alpine:latest              28bd5fe8b56d  2025-06-15  398d  12.4MB
nginx:alpine               54f2a904c251  2025-06-22  391d  89.0MB

Flags: --all — include untagged/dangling images.

lagom track

Records local image digests to BoltDB and inspects the remote registry for each image, accumulating digest history for both local and remote observations. Shows whether each image is new, unchanged, or has a changed digest since the last track.

$ lagom track
alpine:latest              NEW
nginx:alpine               unchanged
redis:7-alpine             CHANGED (was a1b2c3, now d4e5f6)

Tracked 3 image(s).

Flags: --all — track every local image, not just running containers.

lagom status

Reads local state to show each tracked image's current digest and the next digest lagom will advance to once it meets the minimum-age policy. The LOCAL DIGEST column is verified against the local Docker daemon, so it reflects what is actually running rather than only the last recorded state. No registry or pull queries are made; new observations are only recorded by lagom track or the daemon. Running-container labels are read for per-image lagom.min-age-days overrides.

$ lagom status
IMAGE                    LOCAL DIGEST   NEXT DIGEST    AGE    STATUS
alpine:latest            28bd5fe8b56d   -              32d    current
nginx:alpine             54f2a904c251   1d40e3eb3bf4   2d     TOO NEW (ready in 5d more)

lagom inspect

Dumps the full digest archive from local state — every digest lagom has recorded for each tracked image, including digests that were observed but never pulled. History reads only the local BoltDB (no remote registry call); the "current" digest is verified against the local Docker daemon so the marker reflects what is actually running.

Pass an image name to filter to refs containing that substring:

$ lagom inspect chromium
REF                                  DIGEST        FIRST SEEN        LAST SEEN         CREATED           STATUS
ghcr.io/browserless/chromium:latest  0123456789ab  2026-08-18 10:29  2026-08-19 10:29  2026-08-17 10:29  current
ghcr.io/browserless/chromium:latest  c0aaefc21970  2026-08-13 10:29  2026-08-14 10:29  2026-08-12 10:29
ghcr.io/browserless/chromium:latest  2ed0183564d7  2026-08-05 10:29  2026-08-16 10:29  2026-08-04 10:29

3 digest(s) across 1 image(s).

Useful for diagnosing why a particular digest was chosen by the fallback path — e.g. checking what the remote tag pointed at when an unexpected digest was pulled.

lagom update

Pulls images that have newer remote tags AND meet the minimum-age policy. Always pulls by digest, then retags locally. With --recreate, stops and recreates containers to pick up the new image.

When the current remote tag's digest is too new for the age policy, update first checks whether the running image already meets the age policy and holds steady if it does — avoiding unnecessary downgrades. Otherwise it falls back to historic digests tracked during previous lagom track runs, pulling the newest eligible historic digest. Up to max_pull_candidates candidates are tried if earlier ones fail (e.g., deleted from the registry).

$ lagom update -n
--dry-run: would perform the following:
ACTION  REF                DIGEST        CREATED     REASON
PULL    alpine:latest      79ff19e9084a  2026-06-16  -
SKIP    nginx:alpine       1d40e3eb3bf4  2026-07-15  no eligible image yet; newest ready in 5d
$ lagom update --recreate
ACTION    REF                DIGEST        RESULT
PULL      alpine:latest      79ff19e9084a  ok
RECREATE  alpine             8a3f2c        ok

Summary: 1 pulled, 1 skipped, 1 recreated, 2 images pruned.

Flags:

  • -n, --dry-run — preview without making changes
  • --force — bypass the minimum-age check; still limited to digests lagom has already observed (run lagom track first if nothing is recorded)
  • --recreate — bounce containers after pulling new images
  • --keep — preserve old images after recreate (overrides keep_old_images config)

lagom daemon

Runs lagom as a persistent background process, periodically tracking and updating images. Exposes a health endpoint for monitoring.

The daemon runs a track+update cycle immediately at startup to initialize local state, then every daemon_interval_hours. Each cycle fetches the running-container list once and reuses it for both the track and update phases. Use --skip-first-run (or skip_first_run / LAGOM_SKIP_FIRST_RUN) to skip the startup cycle and wait until the first interval instead.

$ lagom daemon
2026/07/21 15:30:00 health listening on 0.0.0.0:8080
2026/07/21 15:30:02 [2026-07-21T21:30:02Z] track: 3 images (0 changed)
2026/07/21 15:30:03 [2026-07-21T21:30:02Z] update: 0 pulled, 3 skipped
2026/07/21 15:30:03 [2026-07-21T21:30:02Z] next run at 2026-07-22T03:30:02Z

Config fields that control daemon behavior:

Field Default Description
daemon_interval_hours 24 Time between track+update cycles
daemon_track_only false Skip update phase, only track digests
skip_first_run false Wait until the first interval instead of running at startup

Debug logging

Pass --debug to any command for an audit trail of actions taken:

$ lagom update --debug
SKIP alpine:latest: already at latest
SKIP nginx:alpine: no eligible image yet; newest ready in 5d
snapshot redis:7-alpine → sha256:a1b2c3...
PULL redis:7-alpine → redis:7-alpine@sha256:d4e5f6...
orphan redis:7-alpine: old=sha256:a1b2c3... new=sha256:d4e5f6...
PRUNE sha256:a1b2c3...: removed 1 digest ref(s)

Useful for troubleshooting pull, retag, recreate, and prune decisions.

Notifications

When notify is configured, the daemon sends a batch summary after each update cycle. URLs use the shoutrrr format — a comma-delimited list of service URLs:

{
  "notify": "ntfy://mytopic,smtp://user:[email protected]:587"
}

Supported services include ntfy, SMTP email, Discord, Slack, Telegram, generic webhooks (generic+https://...), and 20+ others. Each notification contains the update summary (pulled/skipped/recreated/failed counts) and a per-image breakdown.

Set server_name to distinguish notifications from multiple hosts:

LAGOM_NOTIFY="ntfy://lagom-updates" LAGOM_SERVER_NAME="app-server-1" lagom daemon

The notification title becomes lagom update [app-server-1] — 2 pulled, 1 skipped instead of lagom update — 2 pulled, 1 skipped.

The title is passed to shoutrrr as the notification title, so services that support one use it natively — SMTP email uses it as the Subject header, and ntfy/Pushover/Pushbullet/Gotify/Join/etc. display it as the per-message title. Chat-style services (Slack, Discord, Telegram, generic webhooks) have no title field, so the title is also prepended to the message body for them.

Cron workflow

After v1 stabilization, lagom is designed to be cron-friendly:

0 3 * * * /usr/local/bin/lagom update

State is stored alongside config, so digest history persists across runs.

Docker

Pre-built images at git.w33ble.com/w33ble/lagom. Build with version info from git:

mise run build-docker
# or manually:
VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo dev)
docker build \
  --build-arg VERSION="$VERSION" \
  --build-arg COMMIT="$(git rev-parse --short HEAD)" \
  --build-arg DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  -t git.w33ble.com/w33ble/lagom:"$VERSION" \
  -t git.w33ble.com/w33ble/lagom:latest .

Check the version:

$ docker run --rm git.w33ble.com/w33ble/lagom --version
lagom version v1.0.0 (commit: 17373b7, built: 2026-07-21T16:47:36Z)

Running

The container needs the Docker socket and a volume for persistent state:

docker run -d \
  --name lagom \
  --label lagom.no-recreate=true \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v lagom-data:/data \
  -p 8080:8080 \
  git.w33ble.com/w33ble/lagom
Default Value
User root
Config path /data (LAGOM_CONFIG_PATH)
Health check :8080/healthz
Entrypoint /lagom
Command --daemon

Override the command for one-shot operations:

docker run --rm \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v lagom-data:/data \
  git.w33ble.com/w33ble/lagom update --dry-run

Compose

services:
  lagom:
    image: git.w33ble.com/w33ble/lagom:latest
    restart: unless-stopped
    labels:
      lagom.no-recreate: "true"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - lagom-data:/data
    ports:
      - "8080:8080"
    environment:
      LAGOM_MIN_AGE_DAYS: "14"

volumes:
  lagom-data:

Architecture

cmd/lagom/main.go           Entrypoint — wires config, Docker client, BoltDB
internal/
  cmd/                      Cobra commands
    root.go                 Root command, PersistentPreRun/PostRun lifecycle
    daemon.go               `lagom daemon` — periodic track+update loop
    scan.go                 `lagom scan`
    track.go                `lagom track`
    status.go               `lagom status`
    inspect.go              `lagom inspect`
    update.go               `lagom update`
    actions.go              Shared track/update logic used by daemon and CLI
    helpers.go              gatherImageRefs, cleanRefs, normalizeName, splitRef
  config/config.go          JSON config loader
  docker/client.go          Docker SDK wrapper (list, inspect, pull, tag)
  docker/recreate.go        Container recreation (stop/remove/create/start)
  docker/prune.go           Old image cleanup
  notify/notify.go          Notification dispatch via shoutrrr
  policy/policy.go          Age-gate logic
  registry/inspect.go       Remote manifest inspection (go-containerregistry)
  store/
    store.go                Store interface
    bolt.go                 BoltDB implementation (digests, history, metadata)

Image reference resolution

Lagom gathers image references from three sources, in priority order:

  1. Config file — images array in config.json
  2. Running containers — docker ps with label opt-in support
  3. All local images — when --all flag is used

Container image refs often lack tags (e.g., alpine instead of alpine:latest). Lagom's cleanRefs() normalizes these against tagged tracked refs and filters out invalid entries (sha256: IDs, @sha256: repo digests).

Age policy

policy.CheckAge(created, minAgeDays) compares the image creation timestamp against the current time. The creation timestamp comes from the remote registry's image config — no layers are pulled (go-containerregistry inspects the manifest directly).

Creation times observed from the remote registry are capped at the observation time when recorded, so a backdated registry timestamp cannot make an observed-but-unpulled digest eligible before lagom has known about it for min_age_days.

State

BoltDB stores three buckets:

Bucket Key Value
digests image ref latest locally-installed digest
history image ref JSON array of {digest, first_seen, last_seen} entries
meta digest JSON {created, size}

Development

Prerequisites

  • Go 1.23+
  • Docker daemon (for integration testing)

Build

mise run build          # binary only (current platform)
mise run build-dist     # cross-compile: linux/darwin, amd64/arm64 → dist/
mise run build-docker   # Docker image only
mise run build-all      # distribution binaries + Docker image
# or directly:
go build -o lagom ./cmd/lagom

Run (without installing)

LAGOM_CONFIG_PATH=./testdata go run ./cmd/lagom scan

Test

go test ./...

Check

mise run vet
# or directly:
go vet ./...

Format

mise run fmt
# runs: goimports -w .

Lint

mise run lint
# runs: go vet ./... && golangci-lint run ./...

Linter config is in .golangci.yml. Enabled linters: errcheck, govet, ineffassign, misspell, staticcheck, unconvert, unused.

Dependencies

Package Purpose
github.com/spf13/cobra CLI framework
github.com/moby/moby/client Docker Engine API client
github.com/google/go-containerregistry Registry manifest inspection (no layer pull)
github.com/nicholas-fedor/shoutrrr Multi-service notification routing
go.etcd.io/bbolt Embedded key/value store

CI/CD

Forgejo Actions workflows live in .forgejo/workflows/ and run on the self-hosted Forgejo runner (git.w33ble.com).

Workflow Trigger What it does
build-next.yaml push to main Cross-compiles linux binaries and pushes a multi-arch (amd64/arm64) Docker image as git.w33ble.com/w33ble/lagom:next
release.yaml push of a v* tag Cross-compiles binaries, builds/pushes a multi-arch (amd64/arm64) image as :vX.Y.Z plus :vX and :latest (rolling tags skipped for prerelease tags like v1.2.0-rc1), and publishes a Forgejo release with the binaries attached
test.yaml pull request Runs go test -v ./... on the PR branch

Multi-arch images are built via Go cross-compilation (native-speed, no QEMU): binaries are prebuilt per-arch in the workflow, then assembled into platform images by Dockerfile.ci, which consumes prebuilt binaries instead of compiling. The source-build Dockerfile (used by mise run build-docker) is unchanged and amd64-only.

Required repo secrets (Settings → Actions → Secrets):

Secret Value
REGISTRY_USER Forgejo username for the container registry
REGISTRY_TOKEN Forgejo PAT with write:package and read:package scopes
RELEASE_TOKEN Forgejo PAT with write:repository scope (for publishing releases)

Security

Docker socket access

Lagom requires access to the Docker socket (/var/run/docker.sock), which is equivalent to root on the host. This is necessary for inspecting, pulling, and recreating containers. When running lagom as a container:

  • Mount the socket read-only if you don't need container recreation (lagom track and lagom update without --recreate only read from Docker):

    docker run -v /var/run/docker.sock:/var/run/docker.sock:ro git.w33ble.com/w33ble/lagom track
    
  • Use a socket proxy such as docker-socket-proxy to limit API surface. Lagom needs: /containers/json, /images/*, /containers/{id}/*.

  • Set lagom.no-recreate=true on the lagom container to prevent it from attempting to recreate itself.

Error monitoring

In daemon mode, lagom exposes a health endpoint at http://0.0.0.0:8080/healthz (bound to all interfaces so it stays reachable when published from a container). The JSON response includes error_count and last_error for monitoring BoltDB write failures and Docker API errors:

{
  "status": "ok",
  "last_run": "2026-07-20T15:30:00Z",
  "next_run": "2026-07-20T21:30:00Z",
  "error_count": 0
}

Docker HEALTHCHECK:

HEALTHCHECK --interval=60s --timeout=3s \
  CMD curl -sf http://localhost:8080/healthz || exit 1

v1 Scope

  • CLI commands: scan, track, status, inspect, update
  • Minimum image age enforcement
  • JSON config + container label overrides
  • BoltDB local state with digest history
  • All-running-containers mode + label opt-in
  • Pull by digest, retag locally
  • --recreate flag to bounce containers after update (compose-safe)
  • Old image pruning after successful recreate
  • Historic digest fallback (pull aged digests when current tag is too new)
  • Registry inspection during track (archive accumulation without pulling)
  • Public registry support (Docker Hub, ghcr.io, any OCI-compatible)

Not yet (v2 candidates)

  • Central registry or multi-server coordination
  • Self-update (lagom container recreating itself)
  • Private registry authentication