You’ve Got 15 Containers. Do They All Update the Same Way?
Here’s the thing: you’re running a Compose stack with 15 services. Some of them need to update immediately—the security patch for that reverse proxy can’t wait. Some are stable; you review them on your own schedule. One’s in beta and you want to freeze it until next month. Another is mission-critical and you just want notification so you can QA the image first.
Watchtower? That’s a sledgehammer. It updates everything or nothing. Diun? Great tool, but now you’re managing a separate config file that can drift from your Compose file. You want control—granular, per-container control—and you want that policy to live where the container is defined.
Enter Hoist.
Hoist is a bash script you run on a schedule (or manually) that inspects your running containers, checks the registry for newer images, and either auto-updates them or sends you a notification—all controlled by labels you set directly in your docker-compose.yml. No separate daemon. No sidecar. No config file creep. The policy lives with the container.
What Makes Hoist Different
Most update tools give you binary choices: update everything, or manage a parallel config. Hoist flips that:
- Labels control policy per-container. Your
docker-compose.ymlis already the source of truth. Hoist reads it. - Two modes coexist. Some services get
com.sumguy.hoist.update: "true"(pull and recreate), others getcom.sumguy.hoist.notify: "true"(alert you, hands off). Same run, different behavior per container. - Rich notifications. When there’s an update available, Hoist can send a Discord embed, a Slack message, ping ntfy, hit a Gotify endpoint, or ping Healthchecks.io—all defined by labels.
- Policy gates. Pause updates until a date, pin to a semver constraint, group containers for soft atomic updates. All in labels.
Think of it like having a per-container update policy baked into your stack definition. No external config to sync. No daemon to manage. Just labels + a script you run on a cron job.
Five-Minute Setup
Install
curl -fsSL https://raw.githubusercontent.com/KingPin/hoist/master/install.sh | bashThis downloads the binary, verifies SHA256 checksums, installs to /usr/local/bin/hoist, and seeds a config file at /etc/hoist/hoist.conf. Requires Docker with the compose subcommand, jq, and Bash 4.3+.
Basic Compose File
version: "3.8"
services: caddy: image: caddy:latest labels: com.sumguy.hoist.update: "true" # ...
homeassistant: image: ghcr.io/home-assistant/home-assistant:latest labels: com.sumguy.hoist.notify: "true" # ...
myapp: image: myregistry.com/myapp:1.0.0 labels: com.sumguy.hoist.pause_until: "2026-06-01" # ...Three containers, three policies:
- Caddy auto-updates whenever a new image is available.
- Home Assistant notifies you when an update exists but doesn’t touch it.
- Myapp stays frozen until June 1st.
Run It
hoist --dry-runThis shows what would happen without making changes. Useful for testing your labels.
hoistThis runs the actual update/notify logic. At the end, you get a summary:
[14:32:18] Run complete: 3 updated (0 failed), 2 notified, 5 no-change, 12 skippedDone. Your containers that should update are updated. Your containers that should notify sent their messages. Everything else sat still.
Notifications: Show Me What’s Available
Notifications are where Hoist shines in a homelab. You don’t always want to auto-update—especially with mission-critical services. You want to know there’s an update, review it, test it, then pull the trigger yourself.
Discord (Rich Embeds)
plex: image: plexinc/pms-docker:latest labels: com.sumguy.hoist.notify: "true" com.sumguy.hoist.discord.webhook: "https://discord.com/api/webhooks/YOUR_WEBHOOK_ID/YOUR_WEBHOOK_TOKEN"When Hoist runs and finds a new Plex image, it sends a rich embed to Discord:
🐳 Plex has an update!Image: plexinc/pms-dockerCurrent digest: sha256:abc123...Available digest: sha256:def456...Version: 1.41.0 (was 1.40.2)Click a link, see the registry, pull when you’re ready.
ntfy (Simple HTTP POST)
ntfy is lightweight—no auth, no webhook management, just a simple HTTP endpoint. Perfect for homelabs.
grafana: image: grafana/grafana:latest labels: com.sumguy.hoist.notify: "true" com.sumguy.hoist.ntfy.url: "https://ntfy.sh/my-homelab-updates"When there’s an update, Hoist POSTs to that endpoint. You receive a push notification on your phone or check the ntfy feed online.
Other Channels
Hoist also supports Slack, Telegram, Gotify, Microsoft Teams, and Matrix—same pattern, different label. Check the GitHub repo for specifics, but the idea’s consistent: set a webhook URL in a label, Hoist handles the rest.
The Config File: Set It Once, Forget It
Repeating your Discord webhook URL in every single docker-compose.yml across your homelab is a nightmare. If you rotate the webhook, you’re hunting down 12 files. Hoist solves this with a global config file at /etc/hoist/hoist.conf.
Set your notification endpoints once:
GLOBAL_DISCORD_WEBHOOK=https://discord.com/api/webhooks/YOUR_ID/YOUR_TOKENGLOBAL_NTFY_URL=https://ntfy.sh/my-homelab-updatesPARALLEL=4PRUNE_IMAGES=trueWEBHOOK_ROLLUP=trueWEBHOOK_ROLLUP_CHANNELS=discord,slack,genericNow any container with com.sumguy.hoist.notify: "true" fires the global Discord webhook automatically — no webhook URL needed in the Compose file. Your Compose labels stay clean:
services: plex: image: plexinc/pms-docker:latest labels: com.sumguy.hoist.notify: "true" # that's it — uses global webhook
grafana: image: grafana/grafana:latest labels: com.sumguy.hoist.notify: "true" com.sumguy.hoist.discord.webhook: "https://discord.com/api/webhooks/OTHER/TOKEN" # per-container label overrides the global — grafana goes to a different channelWEBHOOK_ROLLUP=true is worth enabling once you have more than a handful of containers. Instead of five separate Discord embeds in a row, you get one summary message per run. Per-container overrides still fire individually — rollup only affects the global channels.
The config file also controls PARALLEL (concurrent container processing), PRUNE_IMAGES (clean up dangling images after updates), and LOG_FILE if you want a persistent run log alongside stdout.
Policy Labels: Granular Control
Hoist provides several labels to gate and control updates. These are fantastic in production because they let you express intent right in your Compose file.
Pause Until a Date
homeassistant: image: ghcr.io/home-assistant/home-assistant:latest labels: com.sumguy.hoist.update: "true" com.sumguy.hoist.pause_until: "2026-06-01"Hoist skips updates until June 1st. Useful when you know a breaking change is coming and you’re on vacation until then. No need to disable the label—just set a pause date.
Semver Constraints
immich: image: ghcr.io/immich-app/immich-server:latest labels: com.sumguy.hoist.update: "true" com.sumguy.hoist.constraint: "^1.2"This pins Immich to the 1.x major version. Hoist will update to 1.9.0 but not 2.0.0. Supported operators: ^ (caret), ~ (tilde), >=, <=, >, <, =. If you’re reading that and thinking “semver in a label is janky”—fair, but it works, and your policy is right next to the image definition.
Container Groups (Soft Atomicity)
postgres: image: postgres:16 labels: com.sumguy.hoist.update: "true" com.sumguy.hoist.group: "data-tier"
pgbackrest: image: pgbackrest/pgbackrest:latest labels: com.sumguy.hoist.update: "true" com.sumguy.hoist.group: "data-tier"Both services are in the data-tier group. If Postgres pulls a new image but pgBackRest fails, Hoist aborts both updates and rolls back Postgres. Not a true transaction—hence “soft atomicity”—but it prevents partial updates from cascading.
Scheduling: Run Hoist Automatically
You don’t want to SSH into your homelab every time and run hoist manually. Let’s automate it.
Hoist has a built-in cron scheduler:
hoist --cron installThis drops you into an interactive prompt:
Enter schedule (30min / hourly / 6hourly / daily / weekly): dailyRun as which user? (default: root): rootUse systemd timer or cron? (auto-detect, or choose): autoHoist detects whether you’re on a systemd system or pure cron and installs accordingly. On modern distros it’ll write a systemd service + timer. On older systems it uses /etc/cron.d/hoist. Both get the job done.
Check the installation:
hoist --cron statusRemove it later with:
hoist --cron removeAdvanced Moves
Dry Run Before Committing
hoist --dry-runSee what will update, what will notify, what’s pinned. No side effects. Handy before your first run or after you’ve tweaked labels.
Parallel Processing
hoist --parallel 4Process up to 4 containers concurrently. On larger stacks this speeds things up. Default is sequential (safer for shared registries or slow networks).
Filter by Container
hoist --only caddy,grafanaOnly check Caddy and Grafana. Useful if you’re testing updates to a subset.
List All Containers and Their Labels
hoist --listPrints a table of all running containers, their current image, and which Hoist labels are set. Great for auditing—“did I actually set update: true on this service?”
Pre/Post-Update Scripts
Sometimes you need to do something before or after an update. Hoist runs custom scripts if you define them:
myapp: image: myregistry.com/myapp:latest labels: com.sumguy.hoist.update: "true" com.sumguy.hoist.script.update: "/opt/scripts/backup-config.sh"Before pulling the new image, Hoist runs /opt/scripts/backup-config.sh with these environment variables available:
HOIST_CONTAINER— container nameHOIST_IMAGE— image name (e.g.,myregistry.com/myapp)HOIST_OLD_IMAGE_ID— digest of current imageHOIST_NEW_IMAGE_ID— digest of new imageHOIST_COMPOSE_SERVICE— service name in compose fileHOIST_COMPOSE_WORKDIR— directory containing compose file- Plus any OCI version/revision labels from the image
Your script can back up config volumes, dump the database, send a Slack message—whatever. Hoist waits for it to complete before proceeding.
How Hoist Compares to Watchtower and Diun
We go deep on Watchtower and Diun comparisons in the Watchtower vs Diun article, but here’s the TL;DR:
Watchtower: Updates everything it can see. Great for “just keep it fresh.” Terrible for “I want this exact container to stay pinned for a month.” Policy is global, not per-container.
Diun: Beautiful UI, excellent notifications, flexible. But you define policy in a separate config file that lives outside your Compose stack. Drift is a risk. Another tool to manage.
Hoist: Policy lives in your Compose file via labels. No separate daemon. No UI (intentionally). Per-container control, can notify or auto-update, supports groups and constraints, runs on a simple cron. If you’re the type who version-controls your docker-compose.yml and treats it as your infrastructure definition, Hoist fits perfectly.
Who Should Use Hoist?
- Homelab runners who have 5+ containers and want different update policies for each.
- Self-hosters who are paranoid about breaking changes and want to be notified before auto-updating critical services.
- DevOps folks who don’t want another daemon running and prefer a simple, stateless script.
- Anyone who’s comfortable with bash, doesn’t mind reading code, and likes owning their infrastructure.
If you’re running a single container or you just want “update everything,” Watchtower is simpler. If you’re building a SaaS and need enterprise-grade image management, look at a registry with lifecycle policies. But if you’re a homelab enthusiast who wants granular, label-driven control baked into your stack definition, Hoist is exactly what you’ve been looking for.
Getting Started
- Install:
curl -fsSL https://raw.githubusercontent.com/KingPin/hoist/master/install.sh | bash - Add labels to your
docker-compose.ymlfor the services you want to manage. - Run
hoist --dry-runto see what would happen. - Run
hoistto apply updates or send notifications. - Schedule it with
hoist --cron installand forget about it.
The repo is on GitHub at github.com/KingPin/hoist—full documentation, examples, and issue tracker there. Source is readable bash, so if you want to understand what it’s doing or tweak it, it’s all there.
Your homelab deserves a way to manage updates that doesn’t feel like a chore. Hoist is it.