Watchtower upstream is archived, but the ecosystem did not die with it. A community fork exists. So does WUD. So do half a dozen smaller projects. I built watchdocker anyway, and this is the honest write-up of why a 350-line bash script earns its place next to the survivors, plus how to fork it, contribute to it, and help it land in the hands of operators who would benefit.

watchdocker: A Bash-Native Successor To Watchtower, Honestly Compared

It was Tuesday afternoon. I was migrating Docker storage off the system disk on a friend’s new laptop, a Lenovo Legion Pro 7 Gen 10 that we had spent the previous day turning into a small sovereign-AI box. The migration itself was boring. Stop docker, bind-mount the data directory to a roomier partition, start docker again, verify the containers were still healthy. Standard sysadmin choreography.

Then I tried to re-enable the auto-updater. Watchtower 1.7.1, the same version that has been running on my own DGX Spark for months without complaint, returned this:

client version 1.25 is too old. Minimum supported API version is 1.40

I read the line twice. Watchtower had been the seven-year default for “keep my homelab containers up to date.” Every Docker tutorial that touches the subject reached for it. If you had asked me a year earlier whether watchtower would still be the default in 2027, I would have shrugged and said probably. It had earned that level of trust by surviving.

I checked the repository. The upstream containrrr/watchtower was archived on 2025-12-17. The last upstream release was November 2023. The maintainer’s parting note recommended switching to Kubernetes for anyone needing continued auto-updates. For the demographic that actually used watchtower, that is not real advice. Single-host operators, small-business basements, friend-laptop sovereign boxes, none of those people are running k3s. The entire point was to avoid an orchestrator.

So I closed the browser tab and built a replacement in one afternoon. This post is about what I built, why it exists alongside the honest alternatives that already do exist, and how you can use it, fork it, or help it grow.

The honest market scan, including the survivors

I am going to be precise here, because the lazy version of this story is “watchtower died, I built the only successor.” That story is wrong. Watchtower upstream is archived, but the ecosystem around it is not.

nicholas-fedor/watchtower. A community fork that picked up where the upstream archive left off. It is actively maintained as of February 2026. The Docker image lives at nickfedor/watchtower. The fork maintains Docker Engine 29.x compatibility, which is the exact failure my AMD64 host was hitting. For an operator who wants a literal drop-in replacement for the watchtower they already had, this is the right answer. Pull a different image, keep the same compose file, move on with your day. I want to name this clearly because it would be dishonest to pretend the alternative does not exist. It does, it works, and for many operators it is the correct pick.

WUD (What’s Up Docker). Actively maintained TypeScript project from getwud, with version 8.2.2 shipping in February 2026. Container-based, with a web UI, notification triggers, registry watchers, and a metrics endpoint. For an operator who wants a dashboard and is comfortable running one more long-lived container, WUD is genuinely good. The trade-offs are real. It is a Node runtime, which means a memory footprint that is not zero, an attack surface that exists, and a separate authentication story for the web UI.

WatchZ (vigsterkr/watchz). A Go reimplementation positioned as a drop-in watchtower replacement. Same daemon-and-poll model, different binary lineage.

Diun. A Go binary that watches registries and sends a notification when a new tag appears. Does not auto-update. For an operator who wants a human in the loop, Diun is the right tool. For my use case, the human in the loop is the friend, and the friend has explicitly asked for “the boring stuff handled without me thinking about it.” Notification-only is the wrong answer here.

Renovate. A CI bot that opens pull requests against your repository when dependencies have updates. If you already have your compose files in a git repo with CI, Renovate is the rigorous answer. It is overkill if you do not, and most homelab operators do not.

docker-watchdog, compose-updater, jakowenko/watchtower, plus several smaller forks. The ecosystem fragmented after the upstream archive. Some of these are healthy, some are abandoned, some are personal one-off scripts that got uploaded to GitHub. The fragmentation itself is informative. It says that the gap left by upstream watchtower is real, that operators are scrambling to fill it, and that no single successor has yet absorbed the demand.

I read the field carefully before writing any code. The honest conclusion is that the right answer depends on which operator you are. If you want a UI, run WUD. If you want a drop-in container, run nicholas-fedor/watchtower. If you want zero running processes between weekly runs, you have not had a good answer until now. That last gap is what watchdocker fills.

Why I built it anyway

The other options all share one property. They are long-running processes. A container. A daemon. A Node runtime. Something that sits on the box twenty-four hours a day, polling registries, holding open sockets, presenting an attack surface during the 167 hours per week when it is not actually doing useful work.

For a single-host homelab that runs roughly fifteen containers, adding a sixteenth whose only job is to update the other fifteen felt like the wrong shape. The math is simple. The actual work of “check for new images and restart if needed” takes about ninety seconds, once a week, on my hardware. Spending the other 604,710 seconds of that week with a process resident in memory, with a port open or a socket bound, is paying a continuous tax for a discrete job.

So I asked a different question. What does the smallest tool look like that does exactly this job and disappears the rest of the time? The answer is a bash script, fired by a systemd timer, with a lockfile and a config file. No daemon. No port. No container around the updater itself. When the script is not actively running, the only thing watchdocker contributes to the host is one timer entry in systemctl list-timers. That is the niche.

The trade is also clear. You lose the web UI. You lose live registry-poll responsiveness. You lose the metric endpoints WUD exposes. If you wanted those things, you would not have wanted bash. The watchdocker target is the operator who explicitly does not want those things, because each one is a thing to authenticate, audit, and patch over time.

Why bash, not Go, not Python

This is the question I asked myself hardest. The honest answer is that bash plus systemd is already on every Linux host I will ever touch, and the entire dependency story is “install nothing.” A Go binary would have been faster to write, would have given me a cleaner option parser, and would have produced a single artifact to ship. It would also have introduced a Go runtime to the trust chain, a build step, a release pipeline, and the multi-arch question that every Go project eventually trips on.

Bash plus systemd has none of those problems. The trust chain is “is your bash version 4 or newer.” The install step is “copy a file to /usr/local/bin.” The multi-arch question does not exist because bash does not have an architecture. The whole package is roughly 350 lines of script and two unit files. I can audit it in fifteen minutes from a cold start. A motivated reader can audit it in an hour with a coffee.

There is a counter-argument, and I want to name it honestly. Bash parsers are fragile. The script includes a hand-rolled YAML parser that handles exactly the keys my config schema defines and not one more. If a user puts a multi-line string or a nested map in the config file, the parser will misbehave silently. I chose this trade deliberately, because pulling in yq or python3-yaml as a dependency to handle a config file with five keys felt like the wrong shape again. The trade is real. If the schema grows, the right answer is to detect yq at runtime and fall back to the hand-rolled parser only if yq is not present. For 2026-current scope, the limitation is documented and the config example is shaped to fit.

The seven design constraints

When I started writing I forced myself to write the constraints down first. This is the rule I follow whenever building anything I might still be running in two years. State the constraints out loud so the future me cannot pretend they were not there.

  1. Pure bash and standard POSIX tools. No Python runtime, no Node, no Rust. Bash 4 or newer, docker compose v2, standard find, grep, sed. If a host can run docker, it can run watchdocker.

  2. No daemon, no network port. A systemd timer is the entire scheduler. The script runs, does its work, exits. Attack surface between runs is zero by construction. There is no web UI to forget to put behind authentication.

  3. Opt-out, not opt-in. Container-level opt-out via the label watchdocker.skip=true. Project-level opt-out via config. The default behavior is “update everything.” This matches what the homelab operator actually wants. Auto-update is the goal, exceptions are the small minority.

  4. Smart restart, not blind restart. Only restart a stack if docker compose pull actually pulled a new image. No “restart everything every week just in case.” If nothing changed, nothing moves. Stable containers stay stable.

  5. Idempotent install with no overwrite. The install script never clobbers an existing config. The new example lands next to the old config with a .new suffix for manual diff. This is the boring kind of correctness that prevents losing config to an upgrade you did not pay full attention to.

  6. Hooks for the ten percent who need them. A pre-hook path and a post-hook path in the config. Pre-hook fires before any pull, intended for backup. Post-hook fires only after a successful update, intended for notification or dashboard refresh. Most users will not need either. The ten percent who do get the seam without having to fork the script.

  7. No telemetry, ever. No external API call. No phone-home. No usage stats. The only network the script touches is whatever docker compose pull decides to hit. The code is auditable in fifteen minutes and contains nothing that would make a network-monitoring sweep light up.

These constraints are the project. Everything else is implementation detail.

Architecture walkthrough

The runtime model is the simplest one that works. A systemd timer fires on a schedule, by default Sundays at 03:00 local time with thirty minutes of randomized delay to avoid thundering-herd against registries. The timer triggers a oneshot service. The service runs the bash script. The script reads its config, iterates over projects, pulls and restarts as needed, runs the post-hook if updates happened, and exits. The next run is the next timer fire.

The script structure is roughly:

The whole thing fits in one file. The systemd units are eleven lines each. The install script is fifty lines. The config example is twenty lines. That is the entire ground truth of the project.

The lockfile semantics deserve a sentence. The script writes its PID into the lockfile, then traps on EXIT INT TERM to remove it. If a previous run crashed without cleaning up, the next run checks whether the PID in the lockfile is still alive via kill -0. If not, the lockfile is treated as stale and the run continues. The --once flag bypasses the check entirely. This is the minimum-viable correctness for “do not run two of these at the same time,” which is the only real concurrency hazard a once-a-week timer has.

Real-world receipts: three hosts, three architectures

I deployed watchdocker on three production-adjacent hosts this week and ran the smoke suite on each. The numbers are real.

Host 1: DGX Spark, ARM64, Docker Engine 29.2, Ubuntu 26.04. Sixteen containers across nine compose projects. Smoke suite: 14 of 14 pass. First weekly run pulled three updates (one for a model-serving container, two for tooling), restarted those three stacks, left the other six untouched. Run time: 47 seconds including image pulls. Memory peak during the run: 8 MB resident for bash, the docker CLI does the rest.

Host 2: Lenovo Legion Pro 7 Gen 10, AMD64, Docker Engine 29.5, Ubuntu 26.04. Fifteen containers across seven compose projects. Smoke suite: 14 of 14 pass. First run pulled one update, restarted one stack. Run time: 22 seconds. This is the host that started the whole project, the one where watchtower 1.7.1 hit the API version error. The replacement worked on first try.

Host 3: a FlokiNET VPS, AMD64, Docker Engine 29.3, Debian 13. Six containers across three compose projects, including the blog itself. Smoke suite: 14 of 14 pass. First run pulled zero updates because the VPS was already current from the previous manual sweep. Run time: 4 seconds for the pull-no-op check. This was the cleanest deployment, because the VPS was the simplest layout.

Three hosts, three architectures across two CPU families (ARM64 and AMD64), three Docker Engine point-releases, two Linux distributions. The script ran on all three without modification. The systemd units ran on all three without modification. That is not a feature, that is the bash-plus-systemd thesis paying off in practice. There is no architecture-specific code because there is no compiled artifact.

The interesting wrinkle is that watchtower 1.7.1 still ran fine on Host 1 (ARM64, Engine 29.2) when I checked, because Engine 29.2 was still tolerant of the older API client. Host 2 (Engine 29.5) is where the API minimum bumped. The next time Host 1 updates Docker, watchtower 1.7.1 will fail there too on its own schedule. The replacement was therefore both a fix for one host and a fix-ahead for the other two.

Honest comparison table

The thing I owe any reader who got this far is a side-by-side that does not lie about the trade-offs. Here it is.

Propertywatchdockernicholas-fedor/watchtowerWUDDiuncontainrrr/watchtower
StatusActive (v0.1)Active forkActive (v8.2.2)ActiveArchived 2025-12
Runtimebash + systemd timerGo container, daemonNode container, daemonGo container, daemonGo container, daemon
Processes between runs01111
Network ports00 (default)1 (UI)00 (default)
Web UINoNoYesNoNo
NotificationsPost-hook scriptBuilt-inBuilt-inBuilt-inBuilt-in
Auto-updateYesYesYesNoYes
Multi-archTrivial (bash)Multi-imageMulti-imageMulti-imageMulti-image
Audit time~15 minutesDaysDaysHoursDays
Right pick whenZero-process operatorDrop-in replacementUI-first operatorHuman-in-loop(Use the fork)

There is no “best” row. There are five different shapes for five different operators. The watchdocker row is the only one with a zero in the “processes between runs” column, which is exactly the niche I built it for. If that zero does not matter to you, one of the other tools is probably your better pick.

Fork it, contribute to it: the roadmap

The watchdocker repository ships with a TASKS.md that lists the work I know about. This is the honest version of the contribution-invitation, written as actual tasks instead of vague encouragement.

Configurable search roots. The discovery function currently walks /opt, /data, /srv, /home with find -maxdepth 4. This is fine for my layouts and wrong for anyone whose compose files live under /etc or in a deeper user home tree. The right answer is a search_roots and search_depth pair in the config, with the current four as defaults.

Optional yq fallback. Detect yq at runtime. If present, use it for YAML parsing. If absent, fall back to the hand-rolled parser. This keeps the install story “copy a file” for the simple case while leaving headroom for richer config schemas.

Per-project schedule overrides. Right now there is one timer. A reasonable feature request is to let some projects run on a different schedule, for example a database project that should only auto-update during a defined maintenance window. The right shape is probably a schedule key per project in the config, with the global timer treating missing keys as the default.

Notification adapters. Right now the post-hook is a script path that the user wires up themselves. A small library of example post-hook scripts (one for ntfy, one for Discord webhook, one for Gotify, one for SMTP) would lower the floor for users who want notifications but do not want to write the glue.

Shellcheck and bats coverage in CI. The repo has a smoke suite but no shellcheck step and no formal bats test layer. Both would catch regressions earlier.

Documentation translations. The README is English-only. German, French, Spanish, and Mandarin translations would surface the project to operators who currently bounce off the English-only docs.

Architecture matrix in CI. GitHub Actions can run the smoke suite on AMD64 runners trivially and on ARM64 with QEMU emulation. Wiring this up gives the project a green-badge guarantee that the bash actually runs on both.

None of these tasks require deep familiarity with the codebase. They are each scoped small enough to be a first PR. If you want to contribute and any of those items resonate, open an issue first so we can agree on the shape, then send the PR. I will review within a week.

Upstream-update plan: how this project will accept change

The reason the upstream archive of watchtower hit so many operators in the face is that there was no public plan for what would happen if the maintainer went quiet. Watchdocker is a single afternoon’s work, but it is also going to outlast that afternoon in real deployments. So here is the plan, written down before there is any pressure on it.

Semver discipline. Versions are tagged MAJOR.MINOR.PATCH. PATCH releases are bugfixes only and never change config schema. MINOR releases can add config keys but must default to behavior that matches the previous minor. MAJOR releases can break compatibility but must ship a migration note in the release body and a deprecation warning in the previous minor. The current public version is v0.1. v1.0 will not ship until the project has been running on at least five independent operators’ hosts for a calendar quarter without a config-shape regression report.

PR acceptance criteria. A PR is mergeable when it includes a smoke-suite pass on at least one architecture, a one-line entry in CHANGELOG.md, and either a shellcheck-clean diff or an explicit # shellcheck disable= with a comment justifying the exception. PRs that touch the YAML parser must include a parser-specific bats test. PRs that change default behavior must update the README in the same commit.

Release cadence. No fixed cadence. Releases happen when a meaningful change accumulates. Empty releases for the sake of activity are not the model. A six-month gap with no release is a signal that the project is stable, not that it is dead. A twelve-month gap is the signal that someone should probably ask whether the maintainer is still around. If the answer is no, the project carries an explicit “fork it” note in the README that names the criteria for a community fork to be considered the legitimate continuation.

Breaking-change communication. Any breaking change ships with three things in the release notes: the exact config diff a user has to apply, the failure mode if they do not apply it, and the version that introduced the deprecation warning. No silent breaks.

Maintainer-transition note. This is the explicit answer to “what happens when this gets archived.” The README contains a paragraph that says: if upstream goes quiet for twelve months and no maintainer responds to issues, any community fork that adopts the constraints in this section is a legitimate continuation. This is not legal language, it is a social handoff written down in advance so the next operator does not face the same vacuum I faced.

These rules are not heavy. They are the minimum required to keep a tiny project trustworthy as it ages.

Why your stars matter, without begging

I want to be honest about this section because the alternative is a paragraph that sounds like every other open-source pitch on the internet. The honest version is structural.

GitHub stars are not a vanity metric. They are the signal that surfaces a project to the algorithms that other operators use to find tools. Hacker News, lobste.rs, the GitHub Explore feed, the r/selfhosted weekly threads, the Awesome-* lists, all of them use stars as one input among several to decide what to show. A project with twelve stars does not get surfaced. A project with two hundred does. The operators who would benefit from watchdocker are the ones who currently do not know it exists. The path from “exists” to “they know it exists” runs through visibility, and visibility runs partly through stars.

I am not asking for stars as approval. I am explaining the mechanism. If you read the constraints section above, looked at the comparison table, and concluded that watchdocker fits your operator-shape, a star at github.com/cipherfoxie/watchdocker is the cheap and accurate way to push the project one step up the visibility curve. If it does not fit your shape, do not star it. Star one of the other tools that does. The ecosystem wins either way.

The same logic applies to forks. A fork is a stronger signal than a star, because it says someone wanted to change something. Forks tell me what the next version should look like. If you fork watchdocker, please leave the fork visible so the upstream can see it, and consider opening an issue describing what you wanted to change. Even if you never send a PR, the issue is useful.

Open source, together, strong

There is a version of this section that I am tempted to write that uses larger words than the situation deserves. I am going to resist it.

What I actually want to say is this. The sovereign-tooling layer in 2026 is held together by a small number of unpaid maintainers and a slightly larger number of operators who notice when something breaks. When the upstream watchtower went quiet, the community responded. nicholas-fedor picked up the fork. getwud kept WUD shipping. A dozen smaller projects appeared. I added watchdocker to the pile. None of these projects compete in the cynical sense. They compete in the honest sense, where each one is shaped for a different operator and the operator picks the one that fits.

If you maintain one of those other projects and you are reading this, the door is open. Cross-link, cross-test, send a PR that imports one of your good ideas into the bash side, copy a good idea from watchdocker back into your codebase. The MIT license on watchdocker is not just a legal note, it is an invitation to take what is useful.

If you are an operator and you have not yet picked a successor, read the comparison table. Pick the one whose trade-offs match yours. Use it. If something breaks, file the issue. If you find a better tool than any of these, write the comparison post and link it back. The ecosystem keeps working because operators keep talking to each other about what works and what does not.

The story of watchtower’s archive is not “the maintainer abandoned us.” It is “one maintainer ran out of energy and the rest of us had to figure out what to do.” That is a normal and recurring shape in open source. The interesting question is never “why did it happen” but “what did the community build in response.” This post is one entry in the larger answer.

Closing

The repository lives at github.com/cipherfoxie/watchdocker, MIT-licensed, with v0.1.0 tagged as the first release. It is small enough to read in a single sitting. If you want to use it, the install path is a clone, a sudo install, and an edit to one config file. If you want to fork it and make it yours, the whole thing is MIT-licensed and the design constraints are spelled out in the README so you know which knobs are safe to turn and which ones break the model. If you want to contribute, the TASKS.md file lists actual work that needs doing, scoped small enough for a first PR. watchdocker is one of the tools this stack releases back into the open; the full list of releases and the upstream bugs they came from lives on the upstream page.

There is a final consideration that I want to put on record honestly. Sometimes the right answer is to use the actively-maintained third-party tool, accept the operational surface, and move on. Building a bespoke replacement every time something gets archived is its own kind of trap. The reason watchdocker made sense here is that the design target was so small that the cost of building was lower than the cost of evaluating, installing, hardening, and learning a third-party alternative for the specific zero-process niche. That math will not always work out the same way. For most operators, the nicholas-fedor fork or WUD will be the better answer. For the operator who explicitly wants zero processes between weekly runs, this is now an option that did not exist before.

This is what the sovereign-tool path looks like when you are honest about the trade. Small surface, full audit, your problem to fix, your problem to ship, and an explicit invitation to other operators to make it better.

Was this worth it? Zap the article.

Value for value, no signup. Sats go straight to the writer.