- Go 99.1%
- Dockerfile 0.9%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .forgejo/workflows | ||
| cmd/lagom | ||
| internal | ||
| .dockerignore | ||
| .gitignore | ||
| .golangci.yml | ||
| AGENTS.md | ||
| Dockerfile | ||
| Dockerfile.ci | ||
| go.mod | ||
| go.sum | ||
| mise.toml | ||
| PROJECT-INTENT-BRIEF.md | ||
| README.md | ||
| SECURITY-AUDIT.md | ||
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 (runlagom trackfirst if nothing is recorded)--recreate— bounce containers after pulling new images--keep— preserve old images after recreate (overrideskeep_old_imagesconfig)
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:
- Config file —
imagesarray inconfig.json - Running containers —
docker pswith label opt-in support - All local images — when
--allflag 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 trackandlagom updatewithout--recreateonly 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=trueon 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
--recreateflag 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