Automatically update or notify about Docker Compose container image updates.
Hoist runs against all running containers, checks for newer images, and either recreates the container or fires a notification — all controlled by Docker labels.
Inspired by pullio. Existing org.hotio.pullio.* labels are supported as-is — no migration required. The com.sumguy.hoist.* prefix takes precedence if both are present.
Copy hoist.conf.example to one of the following locations (first found wins):
$HOIST_CONFIG— explicit path via environment variable- Same directory as
hoist.sh— portable/dev installs /etc/hoist/hoist.conf— system installs
CLI flags always override config file values.
| Setting | Default | Description |
|---|---|---|
PARALLEL |
1 |
Containers to process concurrently |
CACHE_LOCATION |
/tmp |
Directory for notification dedup cache files |
DOCKER_BINARY |
$(which docker) |
Path to docker binary |
PRUNE_IMAGES |
true |
Prune dangling images after each run |
LOG_FILE |
(none) | Append log output to this file (in addition to stdout) |
TAG |
(none) | Default tag filter (same as --tag) |
GLOBAL_DISCORD_WEBHOOK |
(none) | Fallback Discord webhook for containers without a per-container label |
GLOBAL_SLACK_WEBHOOK |
(none) | Fallback Slack webhook |
GLOBAL_GENERIC_WEBHOOK |
(none) | Fallback generic webhook |
GLOBAL_TELEGRAM_BOT_TOKEN / GLOBAL_TELEGRAM_CHAT_ID |
(none) | Fallback Telegram bot credentials |
GLOBAL_GOTIFY_URL |
(none) | Fallback Gotify message URL (?token= in URL) |
GLOBAL_NTFY_URL / GLOBAL_NTFY_TOKEN |
(none) | Fallback ntfy.sh topic URL and optional bearer token |
GLOBAL_TEAMS_WEBHOOK |
(none) | Fallback Microsoft Teams incoming webhook |
GLOBAL_MATRIX_HOMESERVER / GLOBAL_MATRIX_ROOM_ID / GLOBAL_MATRIX_TOKEN |
(none) | Fallback Matrix room credentials |
HEALTHCHECKS_PING_URL |
(none) | Healthchecks.io heartbeat URL — pinged at start (/start), end (success), or /fail if any update failed |
WEBHOOK_ROLLUP |
false |
Send one summary webhook per run instead of per-container, for listed channels |
WEBHOOK_ROLLUP_CHANNELS |
discord,slack,generic |
Comma list of channels to roll up. Per-container labels are ignored for rolled-up channels. |
HEALTHCHECK_TIMEOUT |
120 |
Default seconds to wait when healthcheck.wait is enabled (per-container override via healthcheck.timeout). |
HEALTHCHECK_INTERVAL |
2 |
Poll interval in seconds while waiting for healthy. |
ROLLBACK_DEFAULT |
false |
When true, rollback applies to every container even without the rollback label. |
MAINTENANCE_WINDOW |
(none) | Only run during this time window (e.g. 02:00-06:00). Midnight-spanning works: 22:00-04:00. Dry-run bypasses this. |
VERBOSE |
false |
Log containers skipped due to missing hoist labels. Auto-enabled by --dry-run. |
CURL_TIMEOUT |
30 |
Maximum time in seconds for webhook HTTP requests. |
UPDATE_CHECK |
notify |
Self-update behavior on every run: notify (log + webhook alert), update (auto-apply new releases), or off. See Self-update. |
Global webhooks fire for any container with update or notify enabled that has no per-container webhook label. Per-container labels always take precedence.
One-line install (latest release):
curl -fsSL https://raw.githubusercontent.com/KingPin/hoist/master/install.sh | bash
⚠️ Always inspect scripts before piping to a shell. Review first:curl -fsSL https://raw.githubusercontent.com/KingPin/hoist/master/install.sh | less
Pin a specific version:
HOIST_VERSION=v1.2.0 curl -fsSL https://raw.githubusercontent.com/KingPin/hoist/master/install.sh | bashOverride paths (e.g. install per-user without sudo):
INSTALL_DIR=$HOME/.local/bin CONFIG_DIR=$HOME/.config/hoist \
curl -fsSL https://raw.githubusercontent.com/KingPin/hoist/master/install.sh | bashThe installer downloads hoist.sh and hoist.conf.example from the GitHub release, verifies SHA256 checksums, installs the binary to INSTALL_DIR/hoist, and seeds CONFIG_DIR/hoist.conf from the example only if it doesn't already exist.
Manual install:
sudo cp hoist.sh /usr/local/bin/hoist
sudo chmod +x /usr/local/bin/hoistFor system-wide config, place hoist.conf at /etc/hoist/hoist.conf.
- Docker with the
composesubcommand (docker compose) jq- Bash 4.3+ (macOS ships 3.2 by default —
brew install bash)
bash hoist.sh [options]| Flag | Description |
|---|---|
--tag <value> |
Use a label subset (e.g. --tag nightly reads com.sumguy.hoist.nightly.* labels) |
--dry-run |
Show what would be updated without pulling, recreating, or notifying (implies --verbose) |
--verbose |
Log containers skipped because they have no hoist labels |
--parallel <N> |
Process containers concurrently with N workers |
--only <names> |
Comma-separated list of container names to include (others ignored). Names not currently running emit a warning. |
--exclude <names> |
Comma-separated list of container names to skip. Highest precedence — wins over --only. |
--list, --status |
Print a table of all running containers with their label config and last-cached digest, then exit. No pulls or updates are performed. |
--update |
Self-update hoist to the latest GitHub release (interactive — prompts before replacing) |
--force |
With --update, skip the confirmation prompt and reinstall even if already up to date |
--version |
Print version and exit |
-h, --help, -? |
Show help and exit |
After every run, hoist prints a one-line summary:
[HH:MM:SS] Run complete: 3 updated (1 failed), 2 notified, 5 no-change, 12 skipped
In --dry-run mode this becomes Run complete (dry-run): N would update, N would notify, ....
Add labels to your Docker Compose services to opt containers in:
services:
myapp:
image: myapp:latest
labels:
com.sumguy.hoist.update: "true" # pull + recreate on new image
com.sumguy.hoist.notify: "true" # notify without recreating
com.sumguy.hoist.discord.webhook: "https://discord.com/api/webhooks/..."
com.sumguy.hoist.slack.webhook: "https://hooks.slack.com/services/..."
com.sumguy.hoist.generic.webhook: "https://example.com/webhook"
com.sumguy.hoist.telegram.bot_token: "123456:ABC..."
com.sumguy.hoist.telegram.chat_id: "-1001234567890"
com.sumguy.hoist.gotify.url: "https://gotify.example.com/message?token=XXX"
com.sumguy.hoist.ntfy.url: "https://ntfy.sh/your-topic"
com.sumguy.hoist.ntfy.token: "tk_..." # optional bearer token
com.sumguy.hoist.teams.webhook: "https://outlook.office.com/webhook/..."
com.sumguy.hoist.matrix.homeserver: "https://matrix.org"
com.sumguy.hoist.matrix.room_id: "!abc123:matrix.org"
com.sumguy.hoist.matrix.token: "syt_..."
com.sumguy.hoist.script.update: "/opt/scripts/pre-update.sh"
com.sumguy.hoist.script.notify: "/opt/scripts/on-notify.sh"
com.sumguy.hoist.registry.authfile: "/run/secrets/registry.json"
com.sumguy.hoist.pause_until: "2026-06-01" # skip until this date/datetime
com.sumguy.hoist.constraint: "^1.2" # semver pin (^, ~, >=, <=, >, <, =)
com.sumguy.hoist.group: "db" # any group member's pull failure aborts the rest
com.sumguy.hoist.healthcheck.wait: "true" # poll for healthy after recreate
com.sumguy.hoist.healthcheck.timeout: "180" # seconds to wait (overrides config)
com.sumguy.hoist.rollback: "true" # re-tag prior SHA on update or healthcheck failureupdate and notify can both be set on the same container — it will update and then notify.
Use --tag to target a subset of containers without affecting others:
labels:
com.sumguy.hoist.nightly.update: "true" # only updated when run with --tag nightlyThe registry.authfile label points to a JSON file:
{
"registry": "ghcr.io",
"username": "myuser",
"password": "mytoken"
}Three labels gate whether an update is applied (notifications still fire so you know what was held back):
pause_until— ISO date (2026-06-01) or datetime (2026-06-01T15:00:00Z). Hoist skips the container entirely until current time ≥ value. Emits thepausedsummary token. Unparseable values fail open with a warning, so a typo never silently pauses forever.constraint— semver-style pin against the new image'sorg.opencontainers.image.versionlabel. Supported operators:^1.2,~1.2.3,>=1.0,<=2.0,>1.0,<2.0,=1.2.3, or an exact1.2.3. If the candidate version violates, hoist skips the update, still notifies, and emitsconstraint_blocked. Images without an OCI version label fail open (cannot enforce). Caveat — caret on 0.x: hoist treats^0.1.2as "any0.x≥0.1.2" (same major). This differs from npm's "no minor bumps when major is 0" semantics. If you want npm-style behavior at major 0, use~0.1.2or an explicit range like>=0.1.2,<0.2.group— free-form group name. If any container in the group has a pull failure, the others' updates are aborted (group_abortedtoken). Group atomicity is "soft" under--parallel N: a sibling may finish updating before its peer fails — accepted limitation. Group flags reset at the start of each run.
--only foo,bar and --exclude baz,qux filter which running containers hoist processes. --exclude wins over --only. Names that aren't currently running emit a warning but don't fail the run. Filters apply before label inspection — useful for ad-hoc maintenance without editing labels.
Two opt-in safety nets stack on top of the normal compose up flow:
healthcheck.wait— aftercompose upsucceeds, hoist pollsdocker inspect --format '{{.State.Health.Status}}'. Reacheshealthy: success.unhealthy/exited/timeout: emits theunhealthytoken. Per-containerhealthcheck.timeoutoverrides the globalHEALTHCHECK_TIMEOUT(default 120s, polled everyHEALTHCHECK_INTERVALseconds — default 2). Images without aHEALTHCHECKdirective fall back to.State.Status, which only distinguishesrunningfromexited. A warning is logged and the wait succeeds the moment the container isrunning— add a realHEALTHCHECKto the image if you need stronger guarantees.rollback— on update failure OR unhealthy outcome, hoist re-aliases the prior image SHA back onto the original tag and re-runscompose up --no-pull. Emitsrolled_backon success,rollback_failedon failure. Caveat: the old image must still be present locally. IfPRUNE_IMAGES=trueremoved it between runs, rollback fails clearly withrollback_failedrather than silently. SetROLLBACK_DEFAULT=truein config to enable rollback for every container without per-container labels.
When script.update or script.notify fires, these environment variables are available:
| Variable | Value |
|---|---|
HOIST_CONTAINER |
Container name |
HOIST_IMAGE |
Image name |
HOIST_OLD_IMAGE_ID |
Previous image digest |
HOIST_NEW_IMAGE_ID |
New image digest |
HOIST_OLD_VERSION |
Previous org.opencontainers.image.version label |
HOIST_NEW_VERSION |
New org.opencontainers.image.version label |
HOIST_OLD_REVISION |
Previous org.opencontainers.image.revision label |
HOIST_NEW_REVISION |
New org.opencontainers.image.revision label |
HOIST_COMPOSE_SERVICE |
Compose service name |
HOIST_COMPOSE_WORKDIR |
Compose project working directory |
| Symptom | Cause | Fix |
|---|---|---|
docker: command not found |
DOCKER_BINARY not set or docker not in PATH |
Set DOCKER_BINARY=/usr/bin/docker in config |
jq: command not found |
jq not installed | Install jq (apt install jq, brew install jq, etc.) |
compose workdir missing / container skipped silently |
Container has no com.docker.compose.project.working_dir label |
Container wasn't started via docker compose — hoist only manages Compose-managed containers |
| Container never updates despite new image | Label typo or wrong tag | Check label spelling; if using --tag nightly, labels must be com.sumguy.hoist.nightly.* |
| Notifications fire every run | CACHE_LOCATION is cleaned between runs (e.g. tmpfs) |
Set CACHE_LOCATION to a persistent path |
| Script runs but exits immediately | MAINTENANCE_WINDOW set and current time is outside it |
Expected behavior — adjust window or run with --dry-run to bypass |
On every run, hoist checks the GitHub releases API for a newer version. Behavior is controlled by UPDATE_CHECK:
notify(default) — logs a message and fires global webhooks (Discord/Slack/generic) once per new version, then continues normal operation. A sentinel file inCACHE_LOCATIONsuppresses repeat notifications for the same version.update— automatically downloads, SHA256-verifies, and replaces the script on disk. Webhooks still fire before the update is applied. Avoid this on cron — a breaking release will affect every subsequent unattended run.off— skips the check entirely.
To trigger an interactive update manually:
hoist --update # prompts before replacing
hoist --update --force # skip prompt, reinstall even if up to date
hoist --update --dry-run # show what would be downloaded, then exitUpdates require write access to the running script path. The downloaded asset is verified against hoist.sh.sha256 from the same release before any replacement happens.
Hoist can install its own scheduled run on Linux:
hoist --cron install # interactive: prompts for schedule, user, backend
hoist --cron install --schedule hourly --user root --backend cron # non-interactive
hoist --cron status # show what's currently installed
hoist --cron print # preview the file(s) that would be written
hoist --cron remove # uninstall the hoist-managed schedule
hoist --cron # interactive menu--cron install auto-detects systemd vs. cron and writes either /etc/cron.d/hoist
or a hoist.service + hoist.timer pair in /etc/systemd/system/. Files carry a
# Managed by hoist --cron install marker; hoist refuses to overwrite a file at
the same path that lacks it.
Schedule presets: 30min, hourly, 6hourly, daily (03:00), weekly (Sun 03:00),
or pass any cron expression / systemd OnCalendar value.
On macOS, hoist points you at a launchd plist example. See
docs/scheduling.md for the full reference (cron, systemd, launchd).
Discord — sends a rich embed with image name, digest diff, and version/revision if the image exposes org.opencontainers.image.version and org.opencontainers.image.revision labels.
Slack — sends a plain text message: [container] Update available: image:tag
Telegram — sends a plain text message via the Bot API. Requires bot_token (from BotFather) and chat_id (negative for groups, positive for users).
Gotify — sends a {title, message, priority} payload. Token goes in the URL: https://gotify.example.com/message?token=XXX.
ntfy — POSTs the message body to your ntfy topic URL. Title goes in the Title: header. Optional token adds bearer auth for protected topics.
Microsoft Teams — sends an MessageCard (Office 365 connector format). Use the incoming-webhook URL for your channel.
Matrix — PUTs an m.text message into a room. Requires homeserver (e.g. https://matrix.org), room_id, and an access_token.
Healthchecks.io — set HEALTHCHECKS_PING_URL to a check URL. Hoist pings /start at run start, the bare URL on success, and /fail if any container's update failed. Best-effort — failed pings don't block the run.
Set WEBHOOK_ROLLUP=true (and adjust WEBHOOK_ROLLUP_CHANNELS) to receive one summary message per run instead of one per container. The rolled-up message is sent to the corresponding GLOBAL_* webhook for each listed channel. Per-container webhook labels are ignored for rolled-up channels; channels not in the rollup list keep their normal per-container behavior.
Generic webhook — POST with JSON body:
{
"type": "update_success",
"container": "myapp",
"image": "myapp:latest",
"old_image_id": "abc123",
"new_image_id": "def456",
"old_version": "1.0.0",
"new_version": "1.1.0",
"old_revision": "aabbcc",
"new_revision": "ddeeff",
"timestamp": "2026-05-04T04:00:00.000Z"
}type is one of: update_available, update_success, update_failure