Skip to content
Go back

Hoist: Label-Driven Docker Updates

By SumGuy 10 min read
Hoist: Label-Driven Docker Updates

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:

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

Terminal window
curl -fsSL https://raw.githubusercontent.com/KingPin/hoist/master/install.sh | bash

This 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

docker-compose.yml
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:

Run It

Terminal window
hoist --dry-run

This shows what would happen without making changes. Useful for testing your labels.

Terminal window
hoist

This 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 skipped

Done. 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)

docker-compose.yml
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-docker
Current 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.

docker-compose.yml
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:

/etc/hoist/hoist.conf
GLOBAL_DISCORD_WEBHOOK=https://discord.com/api/webhooks/YOUR_ID/YOUR_TOKEN
GLOBAL_NTFY_URL=https://ntfy.sh/my-homelab-updates
PARALLEL=4
PRUNE_IMAGES=true
WEBHOOK_ROLLUP=true
WEBHOOK_ROLLUP_CHANNELS=discord,slack,generic

Now 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:

docker-compose.yml
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 channel

WEBHOOK_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:

Terminal window
hoist --cron install

This drops you into an interactive prompt:

Enter schedule (30min / hourly / 6hourly / daily / weekly): daily
Run as which user? (default: root): root
Use systemd timer or cron? (auto-detect, or choose): auto

Hoist 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:

Terminal window
hoist --cron status

Remove it later with:

Terminal window
hoist --cron remove

Advanced Moves

Dry Run Before Committing

Terminal window
hoist --dry-run

See 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

Terminal window
hoist --parallel 4

Process 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

Terminal window
hoist --only caddy,grafana

Only check Caddy and Grafana. Useful if you’re testing updates to a subset.

List All Containers and Their Labels

Terminal window
hoist --list

Prints 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:

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?

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

  1. Install: curl -fsSL https://raw.githubusercontent.com/KingPin/hoist/master/install.sh | bash
  2. Add labels to your docker-compose.yml for the services you want to manage.
  3. Run hoist --dry-run to see what would happen.
  4. Run hoist to apply updates or send notifications.
  5. Schedule it with hoist --cron install and 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.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Previous Post
GPU Passthrough on Proxmox: Run LLMs in a VM
Next Post
Immich Hardware Acceleration: Stop Cooking Your CPU

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts