freshdock documentation
Everything beyond the project README. Start with the quickstart, then dive into the reference pages.
Getting started
- Quickstart — opt a container in and run the daemon in ~1 minute.
- Coming from Watchtower? — label/flag translation.
- Troubleshooting — symptom-first fixes for common first-run issues.
Reference
- Configuration — the single source of truth: labels, environment variables (the primary path), and the optional
freshdock.toml. - CLI reference —
check,recreate,run, and every flag. - Scheduling & modes — update modes and cron syntax.
- Notifications — webhook, Discord, Telegram, SMTP.
- Health gating & rollback — the recreate lifecycle and image cleanup.
- Registry authentication — private registries and credentials.
- Deployment — container, systemd, socket permissions, compatibility.
Project & process
- Architecture & roadmap — design, phases, goals, risks.
- Release runbook — how a release is cut.
- Changelog
- Contributing
- Manual test playbooks — maintainer smoke tests.
Runnable example stacks: examples/compose/.
Quickstart
Get freshdock watching a container in about a minute. No config file required —
freshdock is driven by container labels and environment variables; a freshdock.toml
is only needed later if you want notifications.
1. Install
Pick one (full options in the README):
cargo install freshdock # from crates.io
# or
docker pull ghcr.io/turbootzz/freshdock:latest # container image
2. See what freshdock would do (read-only)
Label a container to opt it in, then run check — it never changes anything:
services:
web:
image: nginx:1.27
labels:
- "freshdock.enable=true" # opt in; mode defaults to watch
freshdock check
You’ll get a table of opted-in containers and whether each has an update available. (See the CLI reference for what the status cells mean.)
3. Run the daemon
freshdock is opt-in and defaults enabled containers to watch (notice updates,
never restart). Start the scheduler:
docker run -d \
--name freshdock \
--restart unless-stopped \
-v /var/run/docker.sock:/var/run/docker.sock:ro \
ghcr.io/turbootzz/freshdock:latest run
This is the minimal-watch.yml example. A
read-only socket is enough for watch.
4. Let it actually update something
Switch a container to an updating mode and give the daemon a writable socket:
labels:
- "freshdock.enable=true"
- "freshdock.mode=nightly" # check at 04:00 daily; recreate if newer
- "freshdock.notify=true" # if you've configured a notifier
Updates are health-gated with automatic rollback — a broken new image is reverted, not left running.
Next steps
- Configuration reference — every label, setting, and env var.
- Scheduling — modes and cron syntax.
- Notifications — webhook, Discord, Telegram, SMTP.
- Registry authentication — private images.
- Deployment — systemd, socket permissions, compatibility.
- Troubleshooting — something not behaving? Start here.
- Coming from Watchtower?
Coming from Watchtower?
Watchtower was archived in December 2025. freshdock is a from-scratch successor, so the concepts map closely but the spelling differs. This page translates the labels and flags you already know.
The single biggest difference: freshdock is opt-in. Watchtower updates every
container unless you exclude it; freshdock ignores every container unless you set
freshdock.enable=true. And an enabled container with no explicit mode defaults
to watch (detect-and-notify, never restart) — nothing is recreated until you
ask for it with a mode like live or nightly.
Config is environment-first, like Watchtower. freshdock’s fleet-wide settings, registry credentials, and
runflags are all environment variables — a container deployment needs no config file. The only thing that still wants afreshdock.tomlis declaring a notification target (its secret can stay in the environment). See the configuration reference.
Label translation
| Watchtower label | freshdock label | Notes |
|---|---|---|
com.centurylinklabs.watchtower.enable=true | freshdock.enable=true | Opt in. |
com.centurylinklabs.watchtower.enable=false (with global watch) | omit the labels, or freshdock.mode=off | freshdock ignores unlabelled containers, so there’s usually nothing to disable. |
com.centurylinklabs.watchtower.monitor-only=true | freshdock.mode=watch | Detect + notify, never pull/recreate. |
| (no per-container schedule) | freshdock.mode=nightly/weekly/monthly + freshdock.schedule=<cron> | Scheduling is per container in freshdock, not a single global cron. |
com.centurylinklabs.watchtower.no-pull=true | (no equivalent) | freshdock always pulls before recreate; there is no “recreate without pull”. |
com.centurylinklabs.watchtower.depends-on | (no equivalent in v1) | Dependency ordering is out of v1 scope; containers are processed independently. |
Flag / environment translation
| Watchtower flag / env | freshdock equivalent | Notes |
|---|---|---|
--interval / WATCHTOWER_POLL_INTERVAL | freshdock run --interval <seconds> or FRESHDOCK_INTERVAL | Cadence for live/watch containers. |
--schedule / WATCHTOWER_SCHEDULE (global cron) | per-container freshdock.mode + freshdock.schedule | freshdock schedules each container on its own mode. |
--monitor-only / WATCHTOWER_MONITOR_ONLY | freshdock.mode=watch | Per container, not global. |
--label-enable / WATCHTOWER_LABEL_ENABLE | (always on) | freshdock is always label-gated; freshdock.enable=true is required. |
| (no global default mode) | [settings] default_mode or FRESHDOCK_DEFAULT_MODE | Sets the fallback mode for enabled containers with no freshdock.mode label. A freshdock.mode label still wins per container. |
--cleanup / WATCHTOWER_CLEANUP | [settings] cleanup = true, FRESHDOCK_CLEANUP=true, or freshdock.cleanup=true per container | Off by default. Removes the replaced image after a healthy update; add [settings] prune_dangling = true (or FRESHDOCK_PRUNE_DANGLING=true) for a daemon-wide dangling prune. The replaced container archive is always removed regardless. |
--remove-volumes / WATCHTOWER_REMOVE_VOLUMES | (no equivalent) | freshdock never removes volumes; recreate preserves all mounts. |
--rolling-restart / WATCHTOWER_ROLLING_RESTART | (not applicable) | freshdock recreates one container at a time and health-gates each. |
--notifications + WATCHTOWER_NOTIFICATION_URL (shoutrrr) | a [notifications.<name>] table in freshdock.toml | The one thing that needs a file — env vars can carry the secret (FRESHDOCK_NOTIFY_<NAME>_*) but can’t declare the target. Webhook / Discord / Telegram / SMTP. See notifications. |
WATCHTOWER_NOTIFICATIONS_LEVEL / per-event config | per-target triggers = ["available","succeeded","failed"] | Subscribe each target to the events it cares about. |
REPO_USER / REPO_PASS (registry auth) | FRESHDOCK_REGISTRY_* env (or a [registry.<name>] table) | Per-registry credentials. An env token alone is enough; no file needed. |
DOCKER_HOST | DOCKER_HOST | Same — bollard honours the standard Docker env. |
A worked example
Watchtower (global daily updates, one excluded container, Discord notifications):
docker run -d --name watchtower \
-v /var/run/docker.sock:/var/run/docker.sock \
-e WATCHTOWER_SCHEDULE="0 0 4 * * *" \
-e WATCHTOWER_NOTIFICATION_URL="discord://token@id" \
containrrr/watchtower
services:
db:
labels:
- "com.centurylinklabs.watchtower.enable=false"
The freshdock equivalent — schedule and notify-opt-in move onto the containers as
labels. The only file you need is a freshdock.toml to declare the Discord
target (everything else is labels and environment):
services:
app:
image: ghcr.io/example/app:latest
labels:
- "freshdock.enable=true"
- "freshdock.mode=nightly" # 04:00 daily by default
- "freshdock.notify=true"
db:
image: postgres:16
# no freshdock.* labels → ignored entirely (no need to "disable" it)
# freshdock.toml
[notifications.discord]
type = "discord"
webhook_url = "https://discord.com/api/webhooks/<id>/<token>"
triggers = ["succeeded", "failed"]
freshdock run # foreground daemon; or run it as the freshdock container
See examples/compose/ for complete, docker compose config-valid stacks.
Troubleshooting
Symptom-first fixes for the most common first-run issues. Each section links to the reference page with the full story.
Contents
permission deniedon the Docker socket- freshdock sees my container but never updates it
- My container doesn’t appear in
checkat all - A container is reported as
pinned (no check) - Updates fail with a read-only socket
- Where are the logs?
permission denied on the Docker socket
freshdock can’t reach /var/run/docker.sock. As a container, make sure the
socket is mounted; as a host binary, the user needs to be in the docker group
(or run via systemd with SupplementaryGroups=docker).
→ Deployment: Docker socket permissions
freshdock sees my container but never updates it
Almost certainly the container is in watch mode — the default for an
enabled container with no freshdock.mode label. watch detects and notifies,
but never pulls or restarts; that’s the opt-in-by-design safety net, not a
bug. To actually update, set an updating mode:
labels:
- "freshdock.enable=true"
- "freshdock.mode=nightly" # or live / weekly / monthly
…or change the fleet-wide fallback with [settings] default_mode /
FRESHDOCK_DEFAULT_MODE.
My container doesn’t appear in check at all
The freshdock.enable=true label is missing (or typo’d). freshdock is opt-in:
an unlabelled container is invisible by design, and a misspelled label name is
indistinguishable from no label — there’s no error for it. Verify with:
docker inspect --format '{{json .Config.Labels}}' <container> | grep freshdock
A container is reported as pinned (no check)
Its image is pinned to a digest (repo@sha256:…), so there is no moving tag to
follow — freshdock will never update it. Switch to a tag (repo:1.27) if you
want updates.
→ Configuration: pinned images
Updates fail with a read-only socket
A socket mounted :ro is enough for check and watch, but an updating mode
(live / nightly / weekly / monthly) has to stop, create, and start
containers — that needs a writable socket mount (drop the :ro).
→ Deployment: socket read-only vs writable
Where are the logs?
- Container:
docker logs freshdock - systemd:
journalctl -u freshdock
Verbosity is controlled by RUST_LOG (default info); RUST_LOG=freshdock=debug
is the useful next step. Secrets (tokens, passwords, webhook URLs) are redacted
at every level, including trace.
→ Configuration: environment variables
Still stuck? Open an issue with
the freshdock check output and a RUST_LOG=freshdock=debug log excerpt.
Configuration reference
This is the single source of truth for everything freshdock reads. Configuration comes from three places:
- Labels on each container — what to update and when (per-container behaviour).
- Environment variables — fleet-wide settings, registry credentials, the
runflags, and notification secrets. This is the primary way to configure a deployment: nothing to mount. - An optional
freshdock.tomlfile — needed only to declare notification targets, and to hold credentials for a registry whose host can’t be spelled as an env-var name. Environment variables override the file, per field.
You usually don’t need a file. Registry credentials, the
[settings]defaults, and therunflags all have environment variables — a container deployment never has to mount afreshdock.toml. The one thing env vars can’t do is declare a notification target; that block has to live in the file (its secrets can still come from the environment). See notifications.
- New here? Start with the quickstart.
- Just need the command flags? See the CLI reference.
Contents
- Labels — per-container behaviour
- Environment variables — the primary fleet-wide config
- The optional
freshdock.tomlfile — when you need one [settings]— fleet-wide defaults[registry.<name>]— registry credentials[notifications.<name>]— notification targets- A complete example
Labels
freshdock is opt-in: a container with no freshdock.enable=true is ignored
entirely. All behaviour is driven by these Docker labels (set them in compose under
labels: or with docker run --label).
| Label | Values | Default | Meaning |
|---|---|---|---|
freshdock.enable | true / false | false | Master switch. Without true, the container is invisible to freshdock and every other label is ignored. |
freshdock.mode | live / nightly / weekly / monthly / watch / off | watch (or [settings] default_mode) | How and when this container updates. See scheduling. |
freshdock.schedule | 5-field cron | the mode’s default | Override the cron for a calendar mode. Ignored for live / watch / off. See cron syntax. |
freshdock.notify | true / false | false | Emit notifications for this container’s update events. Requires a configured [notifications.*] target. See notifications. |
freshdock.cleanup | true / false | [settings] cleanup (else false) | After a healthy update, remove the image the old container ran. Overrides the global [settings] cleanup. See health & cleanup. |
Values are case-insensitive and tolerate surrounding whitespace. An invalid value is reported with the offending label named.
When freshdock.enable=true but freshdock.mode is absent, the mode is watch
(detect-and-notify, never mutate) — a non-destructive default. Change this
fleet-wide fallback with [settings] default_mode (or
FRESHDOCK_DEFAULT_MODE); an explicit freshdock.mode label always wins.
Mode vs. schedule.
freshdock.scheduleonly refines the calendar modes (nightly/weekly/monthly).liveandwatchare polled on the daemon’srun --intervalinstead and ignore the label. See scheduling.
Health-gate timings. The post-update health timeout and the grace period for containers without a healthcheck are currently hardcoded — not label/config/env-configurable. See health & rollback: timings.
Pinned images. A container whose image is pinned to a digest (
repo@sha256:…) has no moving tag to follow. freshdock reports it aspinned (no check)and never updates it.
Environment variables
Environment variables are the primary way to configure freshdock — a container
deployment can run entirely from them, no file mounted. They also override the
file per field (a lone …_TOKEN replaces the file token while keeping the file
username). For registry and notification targets, the <NAME> is the table name,
upper-cased, with - → _.
| Variable | Sets / overrides | Notes |
|---|---|---|
FRESHDOCK_CONFIG | config file path | The --config flag wins over it. |
FRESHDOCK_REGISTRY_<NAME>_USERNAME | [registry.<name>] username | <NAME> = alias (DOCKERHUB, GHCR, QUAY, LSCR). Hosts with dots can’t be expressed unambiguously — configure those in the file. |
FRESHDOCK_REGISTRY_<NAME>_TOKEN | [registry.<name>] token | A token (with or without a username) is enough to create a registry entry from the environment alone — no file needed. |
FRESHDOCK_NOTIFY_<NAME>_BOT_TOKEN | a Telegram target’s bot_token | Overrides a secret on a target declared in the file — env can’t create the target itself. |
FRESHDOCK_NOTIFY_<NAME>_PASSWORD | an SMTP target’s password | Same: overrides a secret on a file-declared target. |
FRESHDOCK_DEFAULT_MODE | [settings] default_mode | One of live/nightly/weekly/monthly/watch/off. An invalid value warns and the file value (else watch) applies. |
FRESHDOCK_CLEANUP | [settings] cleanup | true/false/1/0, case-insensitive. An invalid value warns and the file value applies. |
FRESHDOCK_PRUNE_DANGLING | [settings] prune_dangling | Same boolean forms as FRESHDOCK_CLEANUP. |
FRESHDOCK_INTERVAL, FRESHDOCK_TICK, FRESHDOCK_STOP_TIMEOUT | the run flags of the same name | The flag wins over the env var. An invalid value is a startup error (it is the flag). See the CLI reference. |
NO_COLOR | --no-color | Any non-empty value disables colored output. |
RUST_LOG | log verbosity | e.g. info, freshdock=debug, trace. Default info. |
DOCKER_HOST | Docker daemon endpoint | Honoured by the underlying Docker client (bollard). |
freshdock --help prints the same override list (after_long_help).
The optional freshdock.toml file
The file is optional — freshdock runs without it. Reach for it only when you need something environment variables can’t express:
- Declaring a notification target (a
[notifications.<name>]block). Env vars can supply that target’s secret, but the target itself must be declared here. - Registry credentials for a custom host with dots (e.g.
registry.example.com), whose name can’t be spelled as an env-var name.
Everything else — the four registry aliases, the [settings] defaults, the run
flags — has an environment variable, so most deployments need no file at all.
When present, it is resolved in this order:
--config <path>flag$FRESHDOCK_CONFIG./freshdock.tomlin the working directory
An explicit path (flag or env) that doesn’t exist is an error; a missing default
./freshdock.toml is fine (you get an empty config). Secrets in the file are
redacted in all log output, even at RUST_LOG=trace, and can be supplied via
environment variables instead.
The file has three top-level tables, all optional: [settings], [registry.*],
and [notifications.*].
[settings]
Fleet-wide defaults. Every key is optional — and each has an environment variable (shown below) that overrides it.
[settings]
default_mode = "watch" # fallback mode for an enabled container with no
# freshdock.mode label. Invalid → warn + fall back to watch.
cleanup = false # remove the replaced image after a healthy update;
# overridable per container with freshdock.cleanup.
prune_dangling = false # additionally run a daemon-wide dangling-image prune
# after each successful update (no per-container override).
| Key | Env var | Type | Default | Notes |
|---|---|---|---|---|
default_mode | FRESHDOCK_DEFAULT_MODE | string (a mode name) | unset → watch | Applied to enabled containers without a freshdock.mode label. A freshdock.mode label always overrides it. |
cleanup | FRESHDOCK_CLEANUP | bool | false | Default for freshdock.cleanup. Best-effort; a shared image in use elsewhere is kept, and a cleanup failure never fails the update. |
prune_dangling | FRESHDOCK_PRUNE_DANGLING | bool | false | Daemon-wide; prunes untagged images after a success. Best-effort. |
[registry.<name>]
One table per registry. <name> may be a friendly alias (dockerhub, ghcr,
quay, lscr) or a literal host ("registry.example.com"); both fold onto the
same registry as the matching image reference. For the four aliases you can skip
the file entirely and set FRESHDOCK_REGISTRY_<NAME>_TOKEN (and optionally
_USERNAME) instead.
[registry.ghcr]
username = "octocat" # any non-empty value works for a GHCR PAT
token = "ghp_xxx" # personal access token (read:packages for GHCR)
[registry.dockerhub]
username = "myuser" # required for Docker Hub
token = "dckr_pat_xxx"
[registry."registry.example.com"]
token = "…" # username optional
| Key | Type | Required | Notes |
|---|---|---|---|
username | string | depends on registry | Docker Hub needs the real account name; GHCR and most others accept any non-empty value with a PAT. |
token | string (secret) | yes | Password or personal access token. Redacted in logs. |
For per-registry guidance (PAT scopes, the alias list, a smoke test, and what’s out of scope), see registry-auth.md.
[notifications.<name>]
One table per target, selected by type. This is the one thing that requires the
file — env vars can supply a target’s secret but can’t declare the target. Every
target may set an optional triggers list to subscribe to a subset of events; omit
it (or use []) to receive all three (available, succeeded, failed). Payload
formats and the event/mode matrix are documented in notifications.md.
[notifications.ops-webhook]
type = "webhook"
url = "https://example.com/hooks/freshdock"
# triggers omitted → all of available, succeeded, failed
[notifications.discord]
type = "discord"
webhook_url = "https://discord.com/api/webhooks/123/abc"
triggers = ["succeeded", "failed"]
[notifications.tg]
type = "telegram"
bot_token = "123456:ABC-DEF" # or FRESHDOCK_NOTIFY_TG_BOT_TOKEN
chat_id = "987654321"
triggers = ["failed"]
[notifications.email]
type = "smtp"
host = "smtp.example.com"
port = 587 # default 587
username = "freshdock@example.com" # username + password together, or neither
password = "s3cr3t" # or FRESHDOCK_NOTIFY_EMAIL_PASSWORD
from = "freshdock@example.com"
to = ["admin@example.com"] # non-empty list
starttls = true # default true; false → implicit TLS (465)
triggers = ["succeeded", "failed"]
Per-type keys:
type | Keys | Notes |
|---|---|---|
webhook | url (secret) | Generic JSON POST. |
discord | webhook_url (secret) | Posts a coloured embed. |
telegram | bot_token (secret), chat_id | Plain-text message via the Bot API. |
smtp | host, port (=587), username?, password? (secret), from, to (list), starttls (=true) | username+password must be set together or both omitted (anonymous relay). |
All targets also accept triggers = ["available", "succeeded", "failed"] (subset
allowed).
A complete example
File-free (environment only)
A deployment with a private GHCR image, cleanup on, and no notifications needs no
file at all — just environment:
FRESHDOCK_DEFAULT_MODE=nightly
FRESHDOCK_CLEANUP=true
FRESHDOCK_REGISTRY_GHCR_USERNAME=octocat
FRESHDOCK_REGISTRY_GHCR_TOKEN=ghp_xxx
(Set these under environment: in compose, Environment= in a systemd unit, or
export in a shell.)
With a file (for notifications)
Once you want notifications, declare the target in a freshdock.toml and keep the
secret in the environment:
# freshdock.toml
[settings]
default_mode = "watch"
cleanup = true
prune_dangling = false
[registry.ghcr]
username = "octocat"
token = "ghp_xxx"
[notifications.discord]
type = "discord"
webhook_url = "https://discord.com/api/webhooks/123/abc"
triggers = ["succeeded", "failed"]
A copy-paste starting point with every section commented out lives at
freshdock.toml.example in the repository root.
Runnable compose stacks live in examples/compose/.
CLI reference
freshdock has three subcommands: check (read-only),
recreate (one container, manual), and
run (the scheduler daemon). Run freshdock --help or
freshdock <command> --help for the same information at the terminal.
Global options
Available on every subcommand.
| Option | Default | Meaning |
|---|---|---|
--no-color | colour on a TTY | Disable ANSI colour. Use for log files / non-interactive output. Setting NO_COLOR to any non-empty value does the same. |
--config <PATH> | see below | Path to an optional freshdock.toml. |
The config file is optional — freshdock is configured primarily through
environment variables and per-container
labels, and a file is only needed to declare notification targets. When you do use
one, the resolution order is --config <PATH> → $FRESHDOCK_CONFIG →
./freshdock.toml. An explicit path that’s missing is an error; a missing default
file is fine. See the configuration reference.
RUST_LOG controls log verbosity (default info; try freshdock=debug or
trace). Secrets are always redacted.
freshdock check
freshdock check
Read-only. Lists every opted-in container (freshdock.enable=true), resolves the
latest digest once per unique image (deduped to conserve Docker Hub’s anonymous
budget of 100 pulls / 6 h — manifest fetches count as pulls), and prints a status
table. It never pulls,
stops, or recreates anything.
The table has six columns: container, image, mode, current digest,
latest digest, and update?. The update? column is yes (a newer digest
exists), no (up to date), or -/? when no comparison was possible. When a digest
can’t be resolved, the latest digest column shows the reason instead:
latest digest value | Meaning |
|---|---|
a short digest (e.g. sha256:ab12cd…) | The resolved upstream digest; compare with update?. |
pinned (no check) | Image is pinned to a digest — no moving tag to follow. |
auth required (set credentials) | The registry needs credentials that aren’t configured. See registry-auth. |
network unavailable | The registry couldn’t be reached; nothing is assumed. |
error: … | The probe failed for another reason (the message follows). |
Examples:
freshdock check # render the table
freshdock --no-color check # ANSI-free, for logs
RUST_LOG=info freshdock check # include registry rate-limit info
freshdock recreate <NAME>
freshdock recreate <NAME>
Manually update one container by name or ID: inspect → pull → stop → rename → create → start, then health-gate the new container and roll back to the previous one if it fails.
| Argument | Meaning |
|---|---|
<NAME> | Name or ID of the running container to recreate. |
It does not consult freshdock.mode (modes drive the scheduler, not manual
intent), but it refuses a container that is freshdock.enable=false or
freshdock.mode=off — a graceful no-op, so you can’t accidentally recreate a
container you’ve explicitly opted out. Any other mode (including watch) is allowed
because you typed the command yourself.
freshdock run
freshdock run [--interval <SECS>] [--tick <SECS>] [--stop-timeout <SECS>]
The scheduler daemon. Each tick it lists running containers, parses their labels,
and acts on the ones that are due: live/nightly/weekly/monthly are updated
(health-gated, with rollback); watch is report-only. Runs in the foreground until
SIGINT/SIGTERM, then finishes the in-flight container and exits. See
scheduling for the timing model.
| Option | Env var | Default | Meaning |
|---|---|---|---|
--interval <SECS> | FRESHDOCK_INTERVAL | 300 | Poll cadence for live and watch containers. |
--tick <SECS> | FRESHDOCK_TICK | 60 | Scheduler loop granularity. Calendar (cron) modes are evaluated once per tick, so this bounds how late a fire can be. |
--stop-timeout <SECS> | FRESHDOCK_STOP_TIMEOUT | 30 | Max seconds to drain in-flight work after a shutdown signal before force-exit. |
The flag wins over its env var; an invalid env value is a startup error.
Examples:
freshdock run # poll live/watch every 5 min; cron modes on schedule
freshdock run --interval 600 # poll every 10 min instead
freshdock run --config /etc/freshdock.toml
RUST_LOG=info freshdock run # per-container scheduler events
Scheduling & update modes
Every opted-in container picks an update mode with the freshdock.mode label.
The mode decides whether freshdock acts and when. The scheduler
(freshdock run) drives all of this.
Modes
| Mode | When it acts | What it does |
|---|---|---|
live | every --interval seconds (default 300) | Pull and recreate on every new digest. |
nightly | cron 0 4 * * * (04:00 daily) | Recreate if a newer image exists. |
weekly | cron 0 4 * * 0 (04:00 Sunday) | Recreate if a newer image exists. |
monthly | cron 0 4 1 * * (04:00 on the 1st) | Recreate if a newer image exists. |
watch | every --interval seconds | Report only — emit an available notification, never pull or restart. |
off | never | Ignored by the scheduler. |
A single daemon mixes modes freely: container A live, container B nightly,
container C watch. When freshdock.enable=true but no mode is set, the default is
watch (or [settings] default_mode — see
configuration).
watch de-duplicates: it alerts once per distinct new digest, not every poll, so
you aren’t re-notified until you act (or the upstream digest changes again).
Polling cadence (live / watch)
live and watch containers are checked on a fixed interval set by
freshdock run --interval <seconds> (default 300). A container is due when it has
never been checked, or when at least --interval seconds have passed since its last
check.
Calendar modes (nightly / weekly / monthly)
These fire on a cron schedule. Override any of their default schedules with a
freshdock.schedule label (ignored for live / watch / off):
labels:
- "freshdock.enable=true"
- "freshdock.mode=weekly"
- "freshdock.schedule=0 2 * * 1" # 02:00 every Monday
The schedule is evaluated once per scheduler tick (--tick, default 60 s), so a
calendar fire can be at most one tick late.
Cron syntax
Standard 5 fields: minute hour day-of-month month day-of-week.
| Field | Range |
|---|---|
| minute | 0–59 |
| hour | 0–23 |
| day-of-month | 1–31 |
| month | 1–12 |
| day-of-week | 0–6 (Sunday = 0; names not supported) |
Each field accepts:
*— any valueN— an exact valueA-B— an inclusive range*/norA-B/norN/n— a stepN,M,O— a comma-separated list
Example: */15 9-17 * * 1-5 = every 15 minutes, 09:00–17:00, Monday–Friday.
Day-of-month vs day-of-week. When both are restricted (neither is *), a tick
matches if either matches — the classic Vixie-cron union. 0 4 13 * 5 fires at
04:00 on the 13th and every Friday.
Timezone, DST, and missed windows
- Timezone. Schedules are evaluated in the host’s system local time, not
UTC. (Set the container’s
TZ/timezone if you want a specific zone.) - DST. Across a spring-forward, a schedule landing in the skipped hour (e.g.
30 2 * * *) does not fire that day; the 04:00 defaults steer clear of the transition hour. - No backfill. Schedule state is in memory only. A window missed while the daemon was down is not caught up — it simply fires at the next occurrence.
What happens when a container is due
- The image digest is probed against its registry.
- For
watch: if a newer digest appeared, anavailablenotification is dispatched (once per distinct digest). - For the updating modes: if a newer digest exists, the container is recreated and health-gated, with rollback on failure.
Notifications
The scheduler (freshdock run) can notify you when
an opted-in container (freshdock.notify=true) reaches one of three events. Targets
are configured as [notifications.<name>]
tables in freshdock.toml.
This is the one feature that needs a config file. Everything else in freshdock is configurable through labels and environment variables, but a notification target must be declared in a
freshdock.toml— env vars can only supply its secret, not create the target. Mount the file read-only and keep the secret in the environment (see deployment).
Events (triggers)
| Trigger | When | Applies to modes |
|---|---|---|
available | A newer image exists but was not applied. | watch |
succeeded | A recreate passed its health gate. | live / nightly / weekly / monthly |
failed | A recreate failed health and was rolled back. | live / nightly / weekly / monthly |
Each target may subscribe to a subset with triggers = [...]; omitting it (or [])
subscribes to all three. The failure message includes the reason (health-check
timeout, or the container crashed before becoming healthy).
Delivery is best-effort and non-fatal: a send that fails is logged
(notification failed; continuing) and skipped. A broken notifier never blocks or
rolls back an update.
Backends
All backends render from the same message (a title and a body); only the wire
format differs.
webhook
A minimal, stable JSON object — POSTed so a receiver can route on event /
container without parsing prose:
{
"event": "succeeded",
"container": "web",
"title": "...",
"body": "..."
}
[notifications.ops]
type = "webhook"
url = "https://example.com/hooks/freshdock"
Discord
A single embed whose left-bar colour encodes severity — amber for available,
green for succeeded, red for failed — with the title and body as the embed text.
[notifications.discord]
type = "discord"
webhook_url = "https://discord.com/api/webhooks/123/abc"
triggers = ["succeeded", "failed"]
Telegram
A plain-text message via the Bot API (sendMessage).
[notifications.tg]
type = "telegram"
bot_token = "123456:ABC-DEF" # or FRESHDOCK_NOTIFY_TG_BOT_TOKEN
chat_id = "987654321"
SMTP (email)
An email with the message title as the subject. Defaults to STARTTLS on port 587;
set starttls = false for implicit TLS (typically 465). username and password
must be set together, or both omitted for an anonymous relay.
[notifications.email]
type = "smtp"
host = "smtp.example.com"
port = 587
username = "freshdock@example.com"
password = "s3cr3t" # or FRESHDOCK_NOTIFY_EMAIL_PASSWORD
from = "freshdock@example.com"
to = ["admin@example.com"]
starttls = true
triggers = ["failed"]
See the SMTP smoke-test playbook to verify delivery against a local catcher.
Secrets
Webhook URLs, Discord webhook URLs, Telegram bot tokens, and SMTP passwords are treated as secrets and redacted in all logs. Two can also come from the environment instead of the file:
FRESHDOCK_NOTIFY_<NAME>_BOT_TOKEN— a Telegram target’sbot_tokenFRESHDOCK_NOTIFY_<NAME>_PASSWORD— an SMTP target’spassword
See the full environment-variable table.
Health gating & rollback
freshdock’s core safety guarantee: a container is only considered updated once the new container proves healthy. If it doesn’t, the previous container is restored automatically. This is what makes unattended updates safe.
The recreate lifecycle
Whether triggered by freshdock recreate
or by the scheduler, an update runs the same cycle:
- inspect the running container (capture its full config).
- pull the new image.
- stop the old container.
- rename it to an archive name
<name>-old-<timestamp>(kept as the rollback source). - create the new container from the same config + new image.
- start it.
- health-gate the new container (below).
- On success: remove the archive (and optionally clean up the image). On failure: roll back.
The recreated container preserves the original config (networks, volumes, env, caps, user, etc.); a dedicated round-trip test asserts the inspected config comes back byte-identical.
Health verdicts
After start, freshdock polls the container until it reaches one of three verdicts:
| Verdict | Meaning | Outcome |
|---|---|---|
| Healthy | A declared healthcheck reported healthy, or (no healthcheck declared) the container stayed up for the grace period. | Remove the archive; success. |
| Timeout | A healthcheck was declared but never went healthy within the timeout. | Roll back; failure. |
| Crashed | The container exited before becoming healthy / before the grace period elapsed. | Roll back; failure. |
Transient probe errors are tolerated (logged and retried); a persistent probe
failure past the timeout resolves to the safe Timeout verdict.
Timings
These are currently hardcoded (not yet label/config/env-configurable):
| Setting | Value | Meaning |
|---|---|---|
| health timeout | 120 s | Max wait for a declared healthcheck to report healthy. |
| grace period | 10 s | How long a container with no healthcheck must stay running to count as healthy. |
| poll interval | 1 s | How often the new container’s state is inspected. |
A container without a
HEALTHCHECKcan only be judged by “did it stay up?”, so the grace period is the best signal available. Declare a healthcheck for stronger gating.
Rollback
On Timeout or Crashed, freshdock restores the previous container atomically:
- Stop the new (failed) container (best-effort — it may already be dead).
- Force-remove the new container.
- Rename the archive
<name>-old-<timestamp>back to the original name. - Start the restored container.
You’re left running exactly what you had before the update. If
freshdock.notify=true, a failed notification is sent with the
reason and the archive it was restored from.
Image cleanup
A successful, health-passed update always removes the replaced container archive. The superseded image is kept by default; opt into removing it:
- per container:
freshdock.cleanup=true - fleet-wide default:
[settings] cleanup = true(orFRESHDOCK_CLEANUP=true) - plus a daemon-wide dangling-image prune:
[settings] prune_dangling = true(orFRESHDOCK_PRUNE_DANGLING=true)
Cleanup is best-effort:
- The old image is removed by its ID. If another container still references it, the daemon refuses (HTTP 409) and freshdock keeps it — this is the guard against deleting a shared base image, and is not treated as a failure.
- If no image ID can be resolved (e.g. a locally-built image), cleanup is skipped.
- Any cleanup or prune failure is logged but never fails the update or triggers a rollback.
See the configuration reference for the keys and the cleanup smoke-test playbook to verify it end to end.
Registry authentication
freshdock checks digests against any OCI-compliant registry that uses the Docker registry v2 bearer-token flow — Docker Hub, GHCR, Quay.io, lscr.io, and others. Public images resolve anonymously; private images need credentials.
The simplest way to supply them is environment variables —
FRESHDOCK_REGISTRY_<NAME>_TOKEN (plus _USERNAME where the registry needs it) for
the four aliases (DOCKERHUB, GHCR, QUAY, LSCR); no config file is required.
GHCR, Quay, and lscr authenticate with a token alone, while Docker Hub also needs its
account name (FRESHDOCK_REGISTRY_DOCKERHUB_USERNAME). A [registry.<name>] table in
freshdock.toml is the alternative, and the only option for a custom host whose
name contains dots.
Syntax lives in one place. The exact
FRESHDOCK_REGISTRY_*environment variables and the[registry.<name>]table are documented in the configuration reference. This page covers the registry-specific guidance — what credentials each registry wants, the alias list, a smoke test, and what’s out of scope.
Per-registry notes
| Registry | username | token |
|---|---|---|
| Docker Hub | the real account name (required) | password or access token |
GHCR (ghcr.io) | any non-empty value | a PAT with read:packages |
| Quay.io | optional | robot-account token / password |
| lscr.io | optional | as the registry requires |
| Other OCI + bearer | as the registry requires | as the registry requires |
Anonymous Docker Hub is rate-limited (≈100 requests / 6 h); adding credentials raises the budget. freshdock dedupes to one request per unique image to stay well under it.
Aliases
A [registry.<name>] table key — and the <NAME> in the env-var form — may be a
friendly alias or a literal host; both fold onto the same registry as the matching
image reference:
| Alias | Registry |
|---|---|
dockerhub, docker, docker.io, registry-1.docker.io, index.docker.io | docker.io |
ghcr | ghcr.io |
quay | quay.io |
lscr | lscr.io |
A literal host ("registry.example.com") works as a table key too. Hosts
containing dots can’t be expressed unambiguously as an environment variable name —
configure those in the file. Tokens never appear in logs, even at
RUST_LOG=trace.
Manual PAT smoke test
Private-registry auth can’t run in CI (no secrets). To verify a real PAT end to end:
export FRESHDOCK_REGISTRY_GHCR_USERNAME=<your-gh-user>
export FRESHDOCK_REGISTRY_GHCR_TOKEN=<a-PAT-with-read:packages>
# A container whose image is a private ghcr.io/<owner>/<repo> must show a
# digest (not "auth required") in the table:
RUST_LOG=trace cargo run -- check
# Confirm the token never appears in the trace output.
Redaction is also enforced by automated tests
(config::tests::token_is_redacted_in_tracing_output and
registry::auth::tests::cached_token_debug_redacts_the_token); this manual run
is just an extra end-to-end check.
Out of scope (v1)
ECR / GCR / ACR / Harbor custom auth schemes; insecure (plain-HTTP) registries —
freshdock always talks HTTPS, so a registry reachable only over plain HTTP (the
common case for a localhost:5000 dev registry) won’t work; and reusing
~/.docker/config.json. Rate-limit headers are logged but freshdock does not yet
throttle proactively.
Deployment
freshdock is a single static binary that talks to the Docker socket. Run it however suits you: as a container alongside the ones it manages, or directly on the host (e.g. under systemd). For configuration, see the configuration reference.
As a container (recommended)
Mount the Docker socket and run the run subcommand:
docker run -d \
--name freshdock \
--restart unless-stopped \
-v /var/run/docker.sock:/var/run/docker.sock \
ghcr.io/turbootzz/freshdock:latest run
Or with compose — see the runnable stacks in
examples/compose/:
minimal-watch.yml— watch-only, read-only socket.mixed-modes.yml— live + nightly + watch on one daemon.notifications-enabled.yml— mounts afreshdock.toml.registry-authenticated.yml— private registry via env.
Socket: read-only vs writable
| Workload | Socket mount |
|---|---|
watch / check only (never recreates) | :ro is enough — -v /var/run/docker.sock:/var/run/docker.sock:ro |
Any updating mode (live/nightly/weekly/monthly, or recreate) | writable — -v /var/run/docker.sock:/var/run/docker.sock |
Configuration: environment first
Most deployments need no config file. Fleet-wide settings, registry credentials,
and the run flags are all environment variables — pass them under environment:
in compose (or Environment= in a systemd unit):
services:
freshdock:
image: ghcr.io/turbootzz/freshdock:latest
command: ["run"]
environment:
FRESHDOCK_DEFAULT_MODE: "nightly"
FRESHDOCK_REGISTRY_GHCR_USERNAME: "${GHCR_USER:-}"
FRESHDOCK_REGISTRY_GHCR_TOKEN: "${GHCR_TOKEN:-}"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
restart: unless-stopped
The full list is the env-var table.
Mounting a config file (for notifications)
You only need a freshdock.toml to declare a notification target (see
notifications). Mount it read-only and keep its secrets in the
environment:
services:
freshdock:
image: ghcr.io/turbootzz/freshdock:latest
command: ["run", "--config", "/config/freshdock.toml"]
environment:
FRESHDOCK_NOTIFY_EMAIL_PASSWORD: "${SMTP_PASSWORD:-}"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./freshdock.toml:/config/freshdock.toml:ro
restart: unless-stopped
As a host binary (systemd)
Install the binary (cargo install freshdock, a release binary, or just build),
then create a unit:
# /etc/systemd/system/freshdock.service
[Unit]
Description=freshdock container auto-updater
After=docker.service
Requires=docker.service
[Service]
ExecStart=/usr/local/bin/freshdock run
Restart=on-failure
Environment=RUST_LOG=info
# If freshdock.toml is not in the working directory:
# Environment=FRESHDOCK_CONFIG=/etc/freshdock/freshdock.toml
# Run as a user in the docker group rather than root where possible.
# SupplementaryGroups=docker
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now freshdock
journalctl -u freshdock -f
Docker socket permissions
freshdock talks to /var/run/docker.sock. permission denied on the socket means
the process isn’t allowed to use it:
- On the host: run as a user in the
dockergroup. - In a container: the socket’s group GID inside the container must match the
host socket’s owner. On some hosts you must pass
--group-add <gid>(find it withstat -c '%g' /var/run/docker.sock).
Note: access to the Docker socket is effectively root on the host — grant it deliberately.
Compatibility
| Platform | Status |
|---|---|
| Plain Docker (24.x – 29+) | Primary target. |
| Docker Desktop (Linux, macOS, Windows) | Supported. |
| Portainer (CE and Business) | Supported via the same Docker socket. |
| Podman 4+ | Supported via the Docker-compatible socket. |
| Compose-based UIs (Dockge, Komodo, …) | Containers are updated individually; compose files are not edited. |
| Kubernetes / Swarm | Out of scope — use platform-native mechanisms. |
A recreate replaces the container with a new ID, so a UI that pinned the old ID may briefly show it “out of sync” until its next refresh. freshdock never edits your compose/stack files — only the running container.
freshdock — A Modern Rust-based Watchtower Successor
Project name:
freshdock— verified available on crates.io, GitHub, Docker Hub, npm, PyPI. Status: Shipped — v1.1.0 (Phases 0–7 complete). Author: Thijs (Turboot). Date: May 2026 (original plan).
This is the original planning document, kept for design rationale and history. Every phase below has shipped; see the CHANGELOG for the released state.
A fresh dock where containers come to renew themselves. Modern Docker, health-gated rollback, single static binary.
1. Context
Watchtower (containrrr/watchtower) was archived on 17 December 2025 and is no longer maintained. Beyond being abandoned, the codebase ships an outdated embedded Docker SDK (API 1.25), which makes it incompatible with Docker Engine 29+ (which requires API ≥ 1.44). The original maintainers themselves discourage the use of the active forks. There is a clear, current gap in the ecosystem for a maintained, modern, full-cycle (check → pull → restart) container auto-updater.
This project fills that gap, while serving a secondary goal: a substantial real-world project to deepen Rust skills (async, Tokio, error modelling, traits, state machines, packaging).
2. Competitive Landscape
Go-based tools (the majority)
| Tool | What it does | Notes |
|---|---|---|
| What’s Up Docker (WUD) | Check + optional update + web UI + many notifications | Closest “smart drop-in” replacement; heavy. |
| Diun | Notifications only | Deliberately read-only. |
| Tugtainer | Web UI, multi-host agents, dependency-aware updates, manual approval | Modern, growing user base. |
| Dockwatch (Notifiarr) | Dashboard, fits *arr stacks | Niche audience. |
| dockcheck | CLI shell script | Minimal. |
nicholas-fedor/watchtower | Active fork of the original | Stop-gap, not a rewrite. |
Rust-based tools
| Tool | What it does | Gap |
|---|---|---|
Cup (sergi0g/cup) | Very fast checker (5.4 MB binary, 58 images in ~3.7s on a Pi 5), CLI + web | Deliberately does not pull or restart — checker only. |
The gap this project fills
There is no full-cycle (check → pull → recreate → restart → cleanup) Rust-based container auto-updater with active maintenance. Users who want automation today fall back on heavier Go tools that pull 100+ MB images. A Rust tool that combines Cup-level footprint with Watchtower-level capability does not exist yet.
3. Goals and Non-Goals
Goals
- Be a true drop-in replacement for Watchtower’s “set and forget” use case in homelabs.
- Support modern Docker (API ≥ 1.44) and Podman without hacks.
- Multiple update strategies on a per-container basis (live, scheduled, watch-only).
- Healthy-by-default: never leave the user with a broken container if a rollback is possible.
- Single static binary, small footprint, fast cold start.
- Be a sustainable open source project with clear scope, good docs, and tests.
Non-Goals (explicitly out of scope, at least for v1)
- Kubernetes — Kubernetes has its own update mechanisms; do not compete with them.
- Docker Swarm orchestration logic.
- Approval workflows / web UI in v1 (consider for v2).
- Multi-host agent architecture (consider for v2).
- Updating compose-managed stacks via the compose file directly (rely on label-based per-container updates instead).
- Image vulnerability scanning.
4. Differentiators
What this project will do better than the existing landscape:
- Modern Docker API. Tested against Docker 24.x through 29+, auto-negotiated.
- Health-gated updates. A container is only considered “successfully updated” when the new instance reaches its
healthcheckhealthy state (or stays running for a configurable grace period if no healthcheck exists). Failed updates trigger automatic rollback to the previous image. - Per-container schedule mixing. A single deployment can have container A on live updates, container B on nightly, container C on weekly, container D in watch-only mode — driven by Docker labels, no global compromise.
- Dependency-aware ordering. Containers with
depends_onare stopped/started in the correct order (inspired by Tugtainer). - Smaller and faster than Go alternatives while retaining the full update cycle (target: ≤ 10 MB binary, ≤ 30 MB resident memory at idle).
- OCI-correct. Works with Podman’s API socket without modification.
- Honest defaults. Watch-only is the default — opt-in to auto-update per container, not opt-out. This protects users from “Watchtower broke my server overnight” stories.
5. Core Features (MVP)
5.1 Update modes (per container, via labels)
| Mode | Behaviour |
|---|---|
live | Poll registry frequently (default 5 min); pull and recreate immediately on new digest. |
nightly | Check at a fixed daily window (default 04:00 local time). |
weekly | Check once per week (configurable day + time). |
monthly | Check on the Nth day of the month (configurable). |
watch | Detect updates and notify only — never pull or restart. |
off | Ignore the container entirely. |
Global default mode is configurable; per-container labels override.
Example:
labels:
- "freshdock.enable=true"
- "freshdock.mode=nightly"
- "freshdock.notify=true"
5.2 Update lifecycle
For each eligible container, on each scheduled tick:
- Resolve current image reference (name + tag, or digest).
- Query registry for the digest of that tag.
- If digest unchanged → skip.
- If digest changed:
- Pull new image.
- Inspect old container; capture full config (env, mounts, networks, restart policy, healthcheck, labels, command, etc.).
- Stop old container gracefully (respect stop signal + timeout).
- Rename old container
<name>-old-<timestamp>(kept for rollback). - Create new container with captured config + new image.
- Start new container.
- Wait for healthcheck to become
healthy(or grace period if no check). - On success: remove
-old-container, optionally prune old image (configurable, off by default). Implemented:[settings] cleanup/ per-containerfreshdock.cleanupremoves the replaced image;[settings] prune_danglingadds a daemon-wide dangling prune. - On failure: stop and remove new container, rename
-old-back, restart it. Send failure notification.
5.3 Scheduling
- Single async runtime (Tokio).
- One scheduler task per mode that picks up containers tagged for that mode.
- Cron-like expressions accepted for
nightly/weekly/monthly(start with0 4 * * *-style strings; document fields explicitly).
5.4 Notifications (v1 scope)
- Webhook (generic POST with JSON body).
- Discord webhook (formatted embed).
- Telegram bot (broadly used in the homelab community; keeps deployment scope small).
- Email (SMTP) — basic.
Notification triggers: update available (watch mode), update succeeded, update failed (with rollback status).
5.5 Registry support (v1 scope)
- Docker Hub (anonymous + authenticated).
- GHCR (PAT or anonymous public).
- Quay.io.
lscr.io(LinuxServer).- Generic OCI-compliant registries with bearer-token auth.
ECR, GCR, ACR, Harbor with custom auth → v2.
5.6 Configuration
Two configuration paths:
- Container labels (preferred — Watchtower-compatible style).
- Single config file (
freshdock.toml) for global defaults: poll intervals, notification endpoints, registry credentials, default schedule.
Environment variables override config file for credentials.
5.7 Compatibility targets
| Platform | How it works | Notes |
|---|---|---|
| Plain Docker (24.x, 25.x, 27.x, 28.x, 29+) | Talks to /var/run/docker.sock | Primary target. |
| Docker Desktop (Linux/macOS/Windows) | Same socket | Tested manually. |
| Portainer (CE + BE) | Talks to the same Docker socket Portainer uses | Document the “Portainer’s stack view may briefly show out-of-sync state after a recreate” caveat. |
| Podman 4+ | Talks to Podman’s socket via Bollard’s automatic discovery | Rootless and rootful. |
| Dockge / Komodo / other compose-based UIs | Updates individual containers via the daemon socket | Compose stack files are not edited; users see the new image once they re-run their compose. |
6. Technical Architecture
6.1 Stack
| Concern | Choice |
|---|---|
| Language | Rust (stable, edition 2024). |
| Async runtime | Tokio. |
| Docker client | bollard (mature, supports API 1.52, also handles Podman). |
| HTTP (registry) | reqwest with rustls. |
| Serialization | serde + serde_json + toml. |
| CLI | clap v4 with derive. |
| Logging | tracing + tracing-subscriber. |
| Errors | thiserror for libraries, anyhow for the binary entry point. |
| Scheduling | tokio-cron-scheduler or hand-rolled with tokio::time (decide during prototyping). |
| Config | figment or plain serde over TOML. |
| Tests | tokio::test + testcontainers-rs for integration. |
6.2 Crate layout
A workspace with separate crates is overkill for v1. Start as a single binary crate with internal modules; promote to a workspace only when a clear library boundary emerges.
src/
main.rs // entry, CLI parsing, daemon bootstrap
config.rs // TOML + env loading
labels.rs // label parsing → per-container policy
docker/
mod.rs // bollard wrappers
inspect.rs // capture full container spec for recreation
recreate.rs // recreate-with-same-args logic
registry/
mod.rs
auth.rs // token negotiation per registry
digest.rs // HEAD /manifests/<tag> → digest
scheduler.rs // mode → tick → container set
updater.rs // the lifecycle state machine
health.rs // healthcheck waiting + grace period
rollback.rs // -old- container handling
notify/
mod.rs
webhook.rs
discord.rs
telegram.rs
smtp.rs
errors.rs
6.3 The recreation problem (the hardest part)
Watchtower-style “restart with the same options” is the single most error-prone area. Plan:
- Use
docker inspect-equivalent (bollard::Docker::inspect_container) to get the fullContainerInspectResponse. - Map that structure into a fresh
CreateContainerOptions+Configfor the new container. - Re-attach all networks the old container was on (with the same aliases and IP if static).
- Re-attach all mounts (binds, volumes, tmpfs).
- Preserve restart policy, log driver, capabilities, security opts, sysctls, ulimits, devices, GPU options.
- Preserve labels — but strip the lifecycle labels added by this tool itself, then re-add.
Write an integration test that creates a container with a “weird” config (custom network with alias, healthcheck, capabilities, GPU stub, restart policy) and verifies that after a recreate the inspected output is byte-identical except for the image digest and the container ID.
This test is the project’s quality gate — if it passes, the tool is safe to ship. If it fails, the tool is dangerous.
7. Phased Roadmap
✅ All phases (0–7) have shipped. The tool reached v1.0.0 and is now at v1.1.0. The estimates and “cut if pacing slips” caveats below are preserved as the original plan; in the end nothing was cut. Phase-by-phase status is noted inline.
Estimates assume part-time evening/weekend work alongside the dual study programme.
Phase 0 — Reserve the name & scaffolding (1 week) — ✅ shipped
- Reserve
freshdockeverywhere before anything else. Crate names on crates.io are permanent and first-come-first-served. Order: (a) publish a 0.0.1 placeholder crate with a minimalCargo.tomland a stubmain.rs; (b) create the GitHub repo under your account or afreshdockorg — this also reserves the GHCR namespace (ghcr.io/<owner>/freshdock) for free, since GHCR uses the GitHub namespace automatically; (c) optional — registerfreshdock.devor.ioif still available. Docker Hub is intentionally skipped: v1 publishes to GHCR only (revisit post-v1 if discoverability matters). - Pick licence: AGPL-3.0 (like Cup, protects against commercial appropriation) or MIT/Apache-2.0 dual (maximum adoption). Decide before first real commit.
- Set up CI (GitHub Actions: fmt, clippy, test, cross-compile to musl for amd64 + arm64).
- Set up cargo-deny for license/dependency hygiene.
- Repo skeleton, README stub with the three differentiators (modern Docker, health-gated rollback, Rust footprint) front and centre.
Phase 1 — Read-only spike (2 weeks) — ✅ shipped
Goal: prove the concept end-to-end without touching containers.
- List running containers via Bollard.
- Parse labels into a policy struct.
- Implement digest checking against Docker Hub (anonymous).
- Print a table of “container | current digest | latest digest | update?”.
This becomes the watch-only mode for free.
Phase 2 — Single recreate (2 weeks) — ✅ shipped
- Implement the
inspect → stop → rename → create → startcycle for one container. - Handle the basic config preservation (env, mounts, networks, restart policy).
- Manual testing only at this stage.
Phase 3 — Health gating + rollback (1–2 weeks) — ✅ shipped
- Wait-for-healthy logic.
- Grace period for containers without healthchecks.
- Rollback path on failure.
- The “weird config” integration test mentioned in §6.3.
Phase 4 — Scheduling (1 week) — ✅ shipped
- The five modes (
live,nightly,weekly,monthly,watch). - Cron expression parsing.
- Per-container override via labels.
Phase 5 — Multi-registry + auth (2 weeks) — ✅ shipped
- GHCR, Quay, lscr.io, generic bearer-token registries.
- Credentials from config file + env.
- Rate-limit-aware checking (the Cup approach: HEAD requests, not pulls).
Phase 6 — Notifications (1 week) — ✅ shipped
- Webhook, Discord, Telegram, SMTP.
- Trigger matrix: success / failure / available-only.
Phase 7 — Polish and v1.0 release (2 weeks) — ✅ shipped
- Documentation site (mdBook or just a thorough README).
- Docker image (multi-arch: amd64, arm64, armv7).
- Sample compose snippets.
- Migration guide from Watchtower (label translation table).
- v1.0.0 tag.
Total estimate: ~12 weeks of part-time work to v1.0. Cut Phase 5 and Phase 6 in half if pacing slips — they extend cleanly post-v1.
Post-v1 (no commitment, just ideas)
- Optional web UI (Leptos or Axum + a simple HTMX frontend).
- Multi-host agent architecture.
- Approval workflow (queue updates for manual confirmation).
- ECR/GCR/ACR registry support.
- Vulnerability data integration (Trivy / Grype output as a notification field).
8. Risks and Mitigations
| Risk | Mitigation |
|---|---|
| Recreation loses a non-obvious container setting and silently breaks something. | The §6.3 integration test as a hard quality gate; community beta period before tagging v1.0. |
| Registry auth zoo is bigger than expected. | Scope strictly: ship v1 with five well-known registries; document a clear extension point for others. |
| Burnout from a long parallel project. | Phase boundaries are checkpoints; v1 is not “every feature” — it is “watch-only + nightly auto-update reliably”. Ship that. |
| Crowded space — “yet another Watchtower clone”. | Differentiate honestly on three things only: modern Docker, health-gated rollback, Rust footprint. Lead with these in the README. |
| Portainer users hit confusing UI desync after recreations. | Document the behaviour in a dedicated README section; don’t pretend it doesn’t happen. |
| User expectations from the dead Watchtower carry over and don’t match reality. | Provide a “Coming from Watchtower?” page that explicitly maps old labels and flags to the new ones. |
9. Success Criteria for v1.0
- Runs as a single static binary (≤ 10 MB) with no runtime dependencies.
- Successfully auto-updates a fleet of 20+ mixed containers across at least three different image registries on a real homelab for two weeks without intervention.
- Survives an intentionally bad image push (broken healthcheck) by rolling back cleanly and notifying.
- Documentation lets a Watchtower user migrate in under 15 minutes.
- At least one external user has it deployed and gives a thumbs-up.
10. Open Questions
These were left open at Phase 0 kickoff; all four are now resolved:
AGPL-3.0 (like Cup) vs MIT/Apache-2.0 dual licence?→ Apache-2.0 (Cargo.toml/LICENSE).CLI subcommand vs daemon-only?→ CLI subcommands shipped:check,recreate,run.→ Hand-rolled (tokio-cron-scheduleror hand-roll?src/cron.rs+src/scheduler.rs);chronois used only for DST-correct local-time calendar math, not scheduling.→ Not adopted. Only the fullfd.*alias forfreshdock.*?freshdock.*prefix is parsed (src/labels.rs).
11. References
- containrrr/watchtower archive announcement (Dec 2025).
sergi0g/cup— Rust container update checker.crazy-max/diun— notification-only Go tool.quenary/tugtainer— Go web UI auto-updater with dependency awareness.fjall/bollard— Rust Docker SDK (API 1.52).- Watchtower’s original recreation logic (Go) — for reference on edge cases worth replicating.
Manual smoke test: freshdock recreate
This walks through the recreate cycle on a real Docker daemon. As of Phase 3
the cycle is health-gated: the new container must reach healthy (or stay
running for a grace period if it has no healthcheck) before the run is declared
a success and the archived -old- container is removed; a new image that fails
health is rolled back to the previous container.
Canonical automated gate. The authoritative “is the tool safe to ship” check is the live round-trip test tests/recreate_roundtrip_live.rs (PLAN §6.3, P3-3). It creates a kitchen-sink container, recreates it, and asserts the inspected config round-trips byte-identical. Run it with a daemon available:
cargo test --test recreate_roundtrip_live -- --ignoredA failure of that test is a release blocker. This manual procedure remains for quick human verification on first install.
Prerequisites
- A working Docker daemon reachable on the standard socket.
freshdockbuilt locally:just build.- A checkout where
freshdock recreateis wired in (i.e. anything frommainpost-Phase-2 onward).
Steps
freshdock recreate is a manual admin tool, not the automatic update
loop. It refuses two opt-out signals — freshdock.enable not true, or
freshdock.mode=off — and otherwise honours the operator’s explicit
invocation regardless of the scheduler mode (live, nightly,
weekly, monthly, watch). This is why the test container below
uses mode=watch: a watch-mode container is never touched by the
automatic loop, but is a fine target for a manual recreate.
# 1. Launch the test container with freshdock labels so the recreate
# command is willing to act on it.
docker run -d --name fd-smoke \
--label freshdock.enable=true \
--label freshdock.mode=watch \
-p 8081:80 nginx:alpine
# 2. Confirm it's serving.
curl -fsS http://localhost:8081/ > /dev/null && echo "nginx OK"
# 3. Capture Config.Image *before* the recreate so we can assert the
# round-trip below. This must read `nginx:alpine` — not the resolved
# `Image` digest, which is a separate field.
docker inspect fd-smoke --format '{{.Config.Image}}' # → nginx:alpine
# 4. Run the recreate.
./target/release/freshdock recreate fd-smoke
Expected observations
- The CLI prints
recreated fd-smoke: healthy — removed old container fd-smoke-old-<unix-ts>, new id <id>. docker ps -ashows a singlefd-smokecontainer, running with a fresh id — the archivedfd-smoke-old-<unix-ts>was removed once the new instance passed health gating. (nginx:alpine has no healthcheck, so success is decided by the grace period; add a healthcheck to exercise thehealthypath.)- The new container has the same port mapping (
0.0.0.0:8081->80/tcp), the samefreshdock.enable=true/freshdock.mode=watchlabels, and the same nginx image. Config.Imageround-trip (regression #25). After the recreate,docker inspect fd-smoke --format '{{.Config.Image}}'must read byte-identical to the pre-recreate value (nginx:alpine). Drift tolibrary/nginx:alpineis the original bug — it must not return.curl -fsS http://localhost:8081/still returns the default nginx page from the new container.
To exercise rollback: recreate a container whose tag has been re-pointed to
an image with a failing healthcheck; the CLI prints
recreate failed for <name>: ... rolled back to the previous container and the
original container is restored under its original name.
Cleanup
docker rm -f fd-smoke fd-smoke-old-*
Weird-config round-trip smoke
Mirrors the dimensions covered by [tests/fixtures/container_inspect_weird.json]
so the manual procedure stays in lockstep with the automated round-trip suite
in [tests/spec_roundtrip.rs] (the weird_spec_* tests). Run this whenever the
recreate orchestrator or ContainerSpec projection changes.
This uses alpine running sleep rather than nginx:alpine, so the
container doesn’t fight the config (nginx fails when run as uid 1000 with
its cache dirs replaced by tmpfs — orthogonal to recreate). The point of
this smoke is to verify spec preservation, not application behaviour.
# Two networks the container will attach to.
docker network create fd-front >/dev/null
docker network create fd-back >/dev/null
# Bind-mount sources. /tmp keeps these cleanup-friendly and avoids needing
# root for /host/* paths; the container-side destinations match the
# fixture so `weird_spec_preserves_binds_and_tmpfs` exercises the same
# `Binds` shape that this manual procedure does.
mkdir -p /tmp/fd-state /tmp/fd-secrets
# Launch with the kitchen-sink dimensions: non-default user, multi-port
# binding (one with explicit HostIp), bind mounts + tmpfs (HostConfig.Tmpfs
# dict), multiple cap_add / cap_drop, sysctls, restart policy with retry
# count, memory + nano-cpus + pids limit, custom stop signal + timeout,
# multiple ulimits, extra_hosts, and freshdock.* labels alongside user
# labels.
docker run -d --name fd-smoke-weird \
--label freshdock.enable=true \
--label freshdock.mode=watch \
--label freshdock.notify=true \
--label app=weird --label team=platform --label owner=owner@example.invalid \
--user 1000:1000 \
--network fd-front --network-alias weird-front \
-p 127.0.0.1:18443:8443 -p 19090:9090 \
-v /tmp/fd-state:/var/lib/state:rw -v /tmp/fd-secrets:/run/secrets:ro \
--tmpfs /run:rw,size=32m --tmpfs /var/cache:rw,size=64m \
--cap-add NET_BIND_SERVICE --cap-add SYS_TIME \
--cap-drop MKNOD --cap-drop AUDIT_WRITE \
--sysctl net.ipv4.ip_unprivileged_port_start=0 \
--sysctl net.core.somaxconn=4096 \
--restart on-failure:5 \
--memory 128m --memory-reservation 64m --cpus 0.5 --pids-limit 256 \
--stop-signal SIGUSR1 --stop-timeout 45 \
--ulimit nofile=8192:16384 --ulimit nproc=512:1024 \
--add-host db.internal:10.0.0.5 \
-e APP_MODE=production -e 'APP_TOKEN=base64=padded==' -e EMPTY_VAR= \
alpine sleep 600
# Attach the second network with its own alias — mirrors the multi-network
# attachment that `weird_spec_preserves_multi_network_endpoints_with_aliases`
# pins on the fixture side.
docker network connect --alias weird-back fd-back fd-smoke-weird
# Capture every dimension we care about *before* the recreate.
before=$(docker inspect fd-smoke-weird)
# Run the recreate.
./target/release/freshdock recreate fd-smoke-weird
# Spot-check the headline #25 assertion: Config.Image must round-trip
# byte-identical (alpine, NOT library/alpine).
docker inspect fd-smoke-weird --format '{{.Config.Image}}' # → alpine
# Diff the recreate-relevant slices of the inspect. Container ID and
# Image (resolved digest) are expected to differ; everything else under
# Config.* and HostConfig.* should match `before`.
after=$(docker inspect fd-smoke-weird)
diff \
<(echo "$before" | jq '.[0].Config' ) \
<(echo "$after" | jq '.[0].Config' ) # only Config.MacAddress may
# appear in AFTER (daemon
# quirk: API-created containers
# surface the auto-assigned MAC
# on Config.MacAddress, while
# `docker run` originals expose
# it only on NetworkSettings).
# Not a recreate bug.
diff \
<(echo "$before" | jq '.[0].HostConfig') \
<(echo "$after" | jq '.[0].HostConfig') # expected: empty diff
# Bind mounts must round-trip: same source/destination/RW. .Mounts entries
# for binds carry only durable fields (the daemon-assigned propagation is
# also stable across recreate).
diff \
<(echo "$before" | jq '[.[0].Mounts[] | select(.Type == "bind") | {Source, Destination, Mode, RW}] | sort_by(.Destination)') \
<(echo "$after" | jq '[.[0].Mounts[] | select(.Type == "bind") | {Source, Destination, Mode, RW}] | sort_by(.Destination)')
# expected: empty diff
# NetworkSettings.Networks: a strict diff would fail because the new
# container gets a fresh IP / MAC / EndpointID — those are network-driver
# assignments, not part of the spec. Docker also auto-adds the new
# container's own short id as an alias on every attached network, which
# differs per instance. Project to the durable invariants — set of
# attached networks + the *user-assigned* aliases (filtering out the
# auto-added short id) — and diff those.
sid_before=$(echo "$before" | jq -r '.[0].Id[0:12]')
sid_after=$(echo "$after" | jq -r '.[0].Id[0:12]')
diff \
<(echo "$before" | jq --arg sid "$sid_before" '.[0].NetworkSettings.Networks | to_entries | map({net: .key, aliases: ((.value.Aliases // []) | map(select(. != $sid)) | sort)}) | sort_by(.net)') \
<(echo "$after" | jq --arg sid "$sid_after" '.[0].NetworkSettings.Networks | to_entries | map({net: .key, aliases: ((.value.Aliases // []) | map(select(. != $sid)) | sort)}) | sort_by(.net)')
# expected: empty diff
Cleanup:
docker rm -f fd-smoke-weird $(docker ps -a --filter name=fd-smoke-weird-old- -q)
docker network rm fd-front fd-back
rm -rf /tmp/fd-state /tmp/fd-secrets
Current limitations (do not file as bugs)
- Registry auth is out of scope. Pull works for Docker Hub anonymously and for any image already present in the local cache. GHCR / Quay / lscr.io land in Phase 5.
- Health timing is not yet configurable from the CLI/config. Phase 3 uses
built-in defaults (
HealthConfig::default()); sourcing it fromfreshdock.toml/labels is Phase 4/5.
Manual smoke test: image cleanup
Verifies the opt-in image cleanup that runs after a successful, health-passed update (PLAN §5.2 step 8). Cleanup is off by default; it removes the image the replaced container was running, and is best-effort — a shared image still referenced by another container is kept, and a cleanup failure never fails the update.
There are two knobs:
[settings] cleanup = true(or per containerfreshdock.cleanup=true) — remove the replaced image after a healthy update.[settings] prune_dangling = true— additionally run a daemon-wide dangling-image prune after each successful update.
The unit tests in src/docker/recreate.rs
(recreate_with_health_removes_old_image_when_cleanup_enabled,
recreate_with_health_prunes_dangling_when_enabled,
cleanup_failure_does_not_fail_the_update) are the authoritative checks; this
procedure is for human verification against a real daemon.
Prerequisites
- A working Docker daemon on the standard socket.
freshdockbuilt locally:just build.
Steps
freshdock recreate recreates against the current tag, so to see the old
image actually become superseded we re-point a local tag at a different image
between launch and recreate. Here :demo first points at one image, then at
another, so the originally-running image is no longer referenced after the
recreate.
# 1. Create a local moving tag pointing at an older image, and run a container
# on it with cleanup opted in.
docker pull nginx:1.27-alpine
docker tag nginx:1.27-alpine fd-cleanup:demo
old_id=$(docker image inspect fd-cleanup:demo --format '{{.Id}}')
docker run -d --name fd-cleanup \
--label freshdock.enable=true \
--label freshdock.mode=watch \
--label freshdock.cleanup=true \
fd-cleanup:demo
# 2. Re-point the tag at a newer image so the recreate pulls a different id.
docker pull nginx:1.28-alpine
docker tag nginx:1.28-alpine fd-cleanup:demo
# 3. Recreate. After the new container is healthy, the superseded image is
# removed (best-effort).
./target/release/freshdock recreate fd-cleanup
# 4. The old image id must be gone (no container references it any more).
docker image inspect "$old_id" >/dev/null 2>&1 \
&& echo "FAIL: old image still present" \
|| echo "OK: superseded image removed"
Expected observations
- The CLI prints
recreated fd-cleanup: healthy — .... - Step 4 prints
OK: superseded image removed. - With
freshdock.cleanup=false(or the label omitted and[settings] cleanupunset), step 4 instead printsFAIL— i.e. the old image is kept. That is the correct default-off behaviour; re-run the procedure without the cleanup label to confirm. - Shared-image guard. If a second container is still running on
$old_idwhen you recreate, the removal is refused by the daemon (HTTP 409), logged as a warning, and the update still reports success. The image stays until the last referencing container is gone.
Cleanup
docker rm -f fd-cleanup $(docker ps -a --filter name=fd-cleanup-old- -q) 2>/dev/null
docker rmi fd-cleanup:demo nginx:1.27-alpine nginx:1.28-alpine 2>/dev/null || true
Manual smoke test: SMTP notifications
The SMTP backend talks a real mail protocol, so CI can’t exercise it without a
relay. Message construction is covered automatically by the build_message
unit tests in src/notify/smtp.rs; this procedure
verifies the transport (connection, STARTTLS, auth) against a local catcher.
Prerequisites
-
freshdockbuilt locally:just build. -
A local SMTP catcher. mailpit is the simplest — it exposes SMTP on
:1025and a web inbox on:8025:docker run --rm -p 1025:1025 -p 8025:8025 axllent/mailpit
Plain delivery (no TLS, no auth)
-
Write a
freshdock.tomlpointing at the catcher.starttls = falsebecause mailpit’s default listener is plaintext:[notifications.email] type = "smtp" host = "localhost" port = 1025 from = "freshdock@example.com" to = ["admin@example.com"] starttls = false # triggers omitted → subscribes to available, succeeded, and failed -
Trigger a notification. The quickest path is a watch-mode container with a newer image available; or force a failed update (a broken healthcheck) to exercise the
failedtrigger and its rollback detail. Run the daemon:cargo run -- run -
Open the mailpit inbox at http://localhost:8025 and confirm a message arrived with the rendered Subject (
Update available: …/Updated: …/Update failed: …) and the matching body.
STARTTLS + auth
STARTTLS and PLAIN/LOGIN auth must be verified against a server that requires
them (mailpit’s --smtp-auth modes, a real provider, or
smtp4dev with TLS enabled):
-
Point
host/portat the TLS-capable relay, setstarttls = true, and add credentials. The password may be supplied inline or via the environment override (so it stays out of the file):[notifications.email] type = "smtp" host = "smtp.example.com" port = 587 username = "freshdock@example.com" from = "freshdock@example.com" to = ["admin@example.com"] starttls = trueexport FRESHDOCK_NOTIFY_EMAIL_PASSWORD='app-password' cargo run -- run -
Confirm the message is delivered. A STARTTLS handshake failure surfaces as a
smtp send failed: …WARN line; delivery is non-fatal, so the daemon keeps running regardless.
Pass criteria
- A message with the correct subject/body lands in the inbox for each trigger.
- The STARTTLS run authenticates and delivers without falling back to plaintext.
- The bot token / SMTP password never appears in
freshdock’s log output.