Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

freshdock documentation

Everything beyond the project README. Start with the quickstart, then dive into the reference pages.

Getting started

Reference

Project & process

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

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 run flags are all environment variables — a container deployment needs no config file. The only thing that still wants a freshdock.toml is declaring a notification target (its secret can stay in the environment). See the configuration reference.

Label translation

Watchtower labelfreshdock labelNotes
com.centurylinklabs.watchtower.enable=truefreshdock.enable=trueOpt in.
com.centurylinklabs.watchtower.enable=false (with global watch)omit the labels, or freshdock.mode=offfreshdock ignores unlabelled containers, so there’s usually nothing to disable.
com.centurylinklabs.watchtower.monitor-only=truefreshdock.mode=watchDetect + 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 / envfreshdock equivalentNotes
--interval / WATCHTOWER_POLL_INTERVALfreshdock run --interval <seconds> or FRESHDOCK_INTERVALCadence for live/watch containers.
--schedule / WATCHTOWER_SCHEDULE (global cron)per-container freshdock.mode + freshdock.schedulefreshdock schedules each container on its own mode.
--monitor-only / WATCHTOWER_MONITOR_ONLYfreshdock.mode=watchPer 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_MODESets 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 containerOff 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.tomlThe 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 configper-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_HOSTDOCKER_HOSTSame — 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 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.

Scheduling & update modes

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

Configuration: labels

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 run flags, and notification secrets. This is the primary way to configure a deployment: nothing to mount.
  • An optional freshdock.toml file — 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 the run flags all have environment variables — a container deployment never has to mount a freshdock.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.

Contents


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

LabelValuesDefaultMeaning
freshdock.enabletrue / falsefalseMaster switch. Without true, the container is invisible to freshdock and every other label is ignored.
freshdock.modelive / nightly / weekly / monthly / watch / offwatch (or [settings] default_mode)How and when this container updates. See scheduling.
freshdock.schedule5-field cronthe mode’s defaultOverride the cron for a calendar mode. Ignored for live / watch / off. See cron syntax.
freshdock.notifytrue / falsefalseEmit notifications for this container’s update events. Requires a configured [notifications.*] target. See notifications.
freshdock.cleanuptrue / 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.schedule only refines the calendar modes (nightly / weekly / monthly). live and watch are polled on the daemon’s run --interval instead 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 as pinned (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 -_.

VariableSets / overridesNotes
FRESHDOCK_CONFIGconfig file pathThe --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>] tokenA token (with or without a username) is enough to create a registry entry from the environment alone — no file needed.
FRESHDOCK_NOTIFY_<NAME>_BOT_TOKENa Telegram target’s bot_tokenOverrides a secret on a target declared in the file — env can’t create the target itself.
FRESHDOCK_NOTIFY_<NAME>_PASSWORDan SMTP target’s passwordSame: overrides a secret on a file-declared target.
FRESHDOCK_DEFAULT_MODE[settings] default_modeOne of live/nightly/weekly/monthly/watch/off. An invalid value warns and the file value (else watch) applies.
FRESHDOCK_CLEANUP[settings] cleanuptrue/false/1/0, case-insensitive. An invalid value warns and the file value applies.
FRESHDOCK_PRUNE_DANGLING[settings] prune_danglingSame boolean forms as FRESHDOCK_CLEANUP.
FRESHDOCK_INTERVAL, FRESHDOCK_TICK, FRESHDOCK_STOP_TIMEOUTthe run flags of the same nameThe flag wins over the env var. An invalid value is a startup error (it is the flag). See the CLI reference.
NO_COLOR--no-colorAny non-empty value disables colored output.
RUST_LOGlog verbositye.g. info, freshdock=debug, trace. Default info.
DOCKER_HOSTDocker daemon endpointHonoured 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:

  1. Declaring a notification target (a [notifications.<name>] block). Env vars can supply that target’s secret, but the target itself must be declared here.
  2. 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:

  1. --config <path> flag
  2. $FRESHDOCK_CONFIG
  3. ./freshdock.toml in 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).
KeyEnv varTypeDefaultNotes
default_modeFRESHDOCK_DEFAULT_MODEstring (a mode name)unset → watchApplied to enabled containers without a freshdock.mode label. A freshdock.mode label always overrides it.
cleanupFRESHDOCK_CLEANUPboolfalseDefault for freshdock.cleanup. Best-effort; a shared image in use elsewhere is kept, and a cleanup failure never fails the update.
prune_danglingFRESHDOCK_PRUNE_DANGLINGboolfalseDaemon-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
KeyTypeRequiredNotes
usernamestringdepends on registryDocker Hub needs the real account name; GHCR and most others accept any non-empty value with a PAT.
tokenstring (secret)yesPassword 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:

typeKeysNotes
webhookurl (secret)Generic JSON POST.
discordwebhook_url (secret)Posts a coloured embed.
telegrambot_token (secret), chat_idPlain-text message via the Bot API.
smtphost, 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.

OptionDefaultMeaning
--no-colorcolour on a TTYDisable ANSI colour. Use for log files / non-interactive output. Setting NO_COLOR to any non-empty value does the same.
--config <PATH>see belowPath 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 valueMeaning
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 unavailableThe 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.

ArgumentMeaning
<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.

OptionEnv varDefaultMeaning
--interval <SECS>FRESHDOCK_INTERVAL300Poll cadence for live and watch containers.
--tick <SECS>FRESHDOCK_TICK60Scheduler loop granularity. Calendar (cron) modes are evaluated once per tick, so this bounds how late a fire can be.
--stop-timeout <SECS>FRESHDOCK_STOP_TIMEOUT30Max 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

ModeWhen it actsWhat it does
liveevery --interval seconds (default 300)Pull and recreate on every new digest.
nightlycron 0 4 * * * (04:00 daily)Recreate if a newer image exists.
weeklycron 0 4 * * 0 (04:00 Sunday)Recreate if a newer image exists.
monthlycron 0 4 1 * * (04:00 on the 1st)Recreate if a newer image exists.
watchevery --interval secondsReport only — emit an available notification, never pull or restart.
offneverIgnored 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.

FieldRange
minute0–59
hour0–23
day-of-month1–31
month1–12
day-of-week0–6 (Sunday = 0; names not supported)

Each field accepts:

  • * — any value
  • N — an exact value
  • A-B — an inclusive range
  • */n or A-B/n or N/n — a step
  • N,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

  1. The image digest is probed against its registry.
  2. For watch: if a newer digest appeared, an available notification is dispatched (once per distinct digest).
  3. 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)

TriggerWhenApplies to modes
availableA newer image exists but was not applied.watch
succeededA recreate passed its health gate.live / nightly / weekly / monthly
failedA 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’s bot_token
  • FRESHDOCK_NOTIFY_<NAME>_PASSWORD — an SMTP target’s password

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:

  1. inspect the running container (capture its full config).
  2. pull the new image.
  3. stop the old container.
  4. rename it to an archive name <name>-old-<timestamp> (kept as the rollback source).
  5. create the new container from the same config + new image.
  6. start it.
  7. health-gate the new container (below).
  8. 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:

VerdictMeaningOutcome
HealthyA declared healthcheck reported healthy, or (no healthcheck declared) the container stayed up for the grace period.Remove the archive; success.
TimeoutA healthcheck was declared but never went healthy within the timeout.Roll back; failure.
CrashedThe 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):

SettingValueMeaning
health timeout120 sMax wait for a declared healthcheck to report healthy.
grace period10 sHow long a container with no healthcheck must stay running to count as healthy.
poll interval1 sHow often the new container’s state is inspected.

A container without a HEALTHCHECK can 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:

  1. Stop the new (failed) container (best-effort — it may already be dead).
  2. Force-remove the new container.
  3. Rename the archive <name>-old-<timestamp> back to the original name.
  4. 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 (or FRESHDOCK_CLEANUP=true)
  • plus a daemon-wide dangling-image prune: [settings] prune_dangling = true (or FRESHDOCK_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

Registryusernametoken
Docker Hubthe real account name (required)password or access token
GHCR (ghcr.io)any non-empty valuea PAT with read:packages
Quay.iooptionalrobot-account token / password
lscr.iooptionalas the registry requires
Other OCI + beareras the registry requiresas 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:

AliasRegistry
dockerhub, docker, docker.io, registry-1.docker.io, index.docker.iodocker.io
ghcrghcr.io
quayquay.io
lscrlscr.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.

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

Socket: read-only vs writable

WorkloadSocket 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 docker group.
  • 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 with stat -c '%g' /var/run/docker.sock).

Note: access to the Docker socket is effectively root on the host — grant it deliberately.

Compatibility

PlatformStatus
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 / SwarmOut 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)

ToolWhat it doesNotes
What’s Up Docker (WUD)Check + optional update + web UI + many notificationsClosest “smart drop-in” replacement; heavy.
DiunNotifications onlyDeliberately read-only.
TugtainerWeb UI, multi-host agents, dependency-aware updates, manual approvalModern, growing user base.
Dockwatch (Notifiarr)Dashboard, fits *arr stacksNiche audience.
dockcheckCLI shell scriptMinimal.
nicholas-fedor/watchtowerActive fork of the originalStop-gap, not a rewrite.

Rust-based tools

ToolWhat it doesGap
Cup (sergi0g/cup)Very fast checker (5.4 MB binary, 58 images in ~3.7s on a Pi 5), CLI + webDeliberately 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

  1. Be a true drop-in replacement for Watchtower’s “set and forget” use case in homelabs.
  2. Support modern Docker (API ≥ 1.44) and Podman without hacks.
  3. Multiple update strategies on a per-container basis (live, scheduled, watch-only).
  4. Healthy-by-default: never leave the user with a broken container if a rollback is possible.
  5. Single static binary, small footprint, fast cold start.
  6. 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:

  1. Modern Docker API. Tested against Docker 24.x through 29+, auto-negotiated.
  2. Health-gated updates. A container is only considered “successfully updated” when the new instance reaches its healthcheck healthy state (or stays running for a configurable grace period if no healthcheck exists). Failed updates trigger automatic rollback to the previous image.
  3. 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.
  4. Dependency-aware ordering. Containers with depends_on are stopped/started in the correct order (inspired by Tugtainer).
  5. Smaller and faster than Go alternatives while retaining the full update cycle (target: ≤ 10 MB binary, ≤ 30 MB resident memory at idle).
  6. OCI-correct. Works with Podman’s API socket without modification.
  7. 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)

ModeBehaviour
livePoll registry frequently (default 5 min); pull and recreate immediately on new digest.
nightlyCheck at a fixed daily window (default 04:00 local time).
weeklyCheck once per week (configurable day + time).
monthlyCheck on the Nth day of the month (configurable).
watchDetect updates and notify only — never pull or restart.
offIgnore 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:

  1. Resolve current image reference (name + tag, or digest).
  2. Query registry for the digest of that tag.
  3. If digest unchanged → skip.
  4. If digest changed:
    1. Pull new image.
    2. Inspect old container; capture full config (env, mounts, networks, restart policy, healthcheck, labels, command, etc.).
    3. Stop old container gracefully (respect stop signal + timeout).
    4. Rename old container <name>-old-<timestamp> (kept for rollback).
    5. Create new container with captured config + new image.
    6. Start new container.
    7. Wait for healthcheck to become healthy (or grace period if no check).
    8. On success: remove -old- container, optionally prune old image (configurable, off by default). Implemented: [settings] cleanup / per-container freshdock.cleanup removes the replaced image; [settings] prune_dangling adds a daemon-wide dangling prune.
    9. 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 with 0 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:

  1. Container labels (preferred — Watchtower-compatible style).
  2. 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

PlatformHow it worksNotes
Plain Docker (24.x, 25.x, 27.x, 28.x, 29+)Talks to /var/run/docker.sockPrimary target.
Docker Desktop (Linux/macOS/Windows)Same socketTested manually.
Portainer (CE + BE)Talks to the same Docker socket Portainer usesDocument 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 discoveryRootless and rootful.
Dockge / Komodo / other compose-based UIsUpdates individual containers via the daemon socketCompose stack files are not edited; users see the new image once they re-run their compose.

6. Technical Architecture

6.1 Stack

ConcernChoice
LanguageRust (stable, edition 2024).
Async runtimeTokio.
Docker clientbollard (mature, supports API 1.52, also handles Podman).
HTTP (registry)reqwest with rustls.
Serializationserde + serde_json + toml.
CLIclap v4 with derive.
Loggingtracing + tracing-subscriber.
Errorsthiserror for libraries, anyhow for the binary entry point.
Schedulingtokio-cron-scheduler or hand-rolled with tokio::time (decide during prototyping).
Configfigment or plain serde over TOML.
Teststokio::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:

  1. Use docker inspect-equivalent (bollard::Docker::inspect_container) to get the full ContainerInspectResponse.
  2. Map that structure into a fresh CreateContainerOptions + Config for the new container.
  3. Re-attach all networks the old container was on (with the same aliases and IP if static).
  4. Re-attach all mounts (binds, volumes, tmpfs).
  5. Preserve restart policy, log driver, capabilities, security opts, sysctls, ulimits, devices, GPU options.
  6. 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 freshdock everywhere 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 minimal Cargo.toml and a stub main.rs; (b) create the GitHub repo under your account or a freshdock org — this also reserves the GHCR namespace (ghcr.io/<owner>/freshdock) for free, since GHCR uses the GitHub namespace automatically; (c) optional — register freshdock.dev or .io if 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 → start cycle 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

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

  1. AGPL-3.0 (like Cup) vs MIT/Apache-2.0 dual licence?Apache-2.0 (Cargo.toml / LICENSE).
  2. CLI subcommand vs daemon-only?CLI subcommands shipped: check, recreate, run.
  3. tokio-cron-scheduler or hand-roll?Hand-rolled (src/cron.rs + src/scheduler.rs); chrono is used only for DST-correct local-time calendar math, not scheduling.
  4. fd.* alias for freshdock.*?Not adopted. Only the full 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 -- --ignored

A 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.
  • freshdock built locally: just build.
  • A checkout where freshdock recreate is wired in (i.e. anything from main post-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 -a shows a single fd-smoke container, running with a fresh id — the archived fd-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 the healthy path.)
  • The new container has the same port mapping (0.0.0.0:8081->80/tcp), the same freshdock.enable=true / freshdock.mode=watch labels, and the same nginx image.
  • Config.Image round-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 to library/nginx:alpine is 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 from freshdock.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 container freshdock.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.
  • freshdock built 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] cleanup unset), step 4 instead prints FAIL — 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_id when 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

  • freshdock built locally: just build.

  • A local SMTP catcher. mailpit is the simplest — it exposes SMTP on :1025 and a web inbox on :8025:

    docker run --rm -p 1025:1025 -p 8025:8025 axllent/mailpit
    

Plain delivery (no TLS, no auth)

  1. Write a freshdock.toml pointing at the catcher. starttls = false because 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
    
  2. 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 failed trigger and its rollback detail. Run the daemon:

    cargo run -- run
    
  3. 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):

  1. Point host/port at the TLS-capable relay, set starttls = 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 = true
    
    export FRESHDOCK_NOTIFY_EMAIL_PASSWORD='app-password'
    cargo run -- run
    
  2. 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.