Skip to content
All posts
rustdesign

Why we built a Docker updater in Rust

Most Docker update tools are Go; freshdock is Rust. The reasoning: a tiny static binary, modern Docker via bollard, and a safety-first update state machine.

Almost every Docker auto-updater in the wild is written in Go: Watchtower, Diun, What's Up Docker, the lot. So why write another one in Rust? Not for novelty. Three concrete reasons.

A small binary that doesn't manage your homelab with a runtime

The thing that updates your containers shouldn't be the heaviest container on the box. freshdock compiles to a single static-musl binary, ≤ 10 MB, with no runtime dependencies: no JVM, no language runtime, no 100 MB image. The multi-arch container image (amd64, arm64, armv7) is a thin wrapper around that binary.

This matters most on the hardware homelabbers actually run: a Raspberry Pi, an old NUC, a NAS. A tool whose footprint is rounding error is a tool you forget is even there.

Modern Docker, via bollard

Watchtower's fatal flaw wasn't the language. It was an embedded Docker SDK pinned to API 1.25 that can't talk to Docker Engine 29+. freshdock uses bollard, a mature Rust Docker client that auto-negotiates the API version. It's tested against Docker 24.x through 29+, and it speaks Podman's Docker-compatible socket without changes.

The language helped here in a quieter way: bollard's types make the hardest part of the whole project, faithfully recreating a container with the exact same config, something the compiler helps you get right.

The recreate problem wants a state machine

"Restart the container with the same options" is the single most error-prone thing an updater does. Get a network alias, a mount, a capability, or a restart policy wrong and you've silently broken a service.

freshdock captures the running container's full config, maps it onto a fresh container with the new image, and a dedicated round-trip test asserts the recreated config comes back byte-identical except for the image and container ID. That test is the project's quality gate. Rust's enums and exhaustive matching make the update lifecycle (inspect, pull, stop, rename, create, start, health-gate, then either succeed or roll back) a state machine where the "what if this step fails?" branch is impossible to forget, because the compiler won't let you.

That rollback path is the whole point. An update either proves healthy and stays, or it's reverted automatically. Async is handled with Tokio; the cron parser and scheduler are hand-rolled to keep the dependency surface small (chrono is pulled in only for DST-correct local-time math).

Being honest about it

Rust didn't make freshdock automatically better than the Go tools. Go is a perfectly good choice and those tools are mature. What Rust bought us, specifically, is a tiny static binary and a type system that makes the safety-critical recreate logic hard to get subtly wrong. For a tool whose failure mode is "your service is down and you're asleep," that trade was worth making.

If that resonates, the features page walks through the health-gated lifecycle in detail, and the install guide gets you running in a minute. The original design rationale (goals, non-goals, and the phased roadmap) lives in the architecture doc.