Repository: containrrr/watchtower
Pull request #965 (2021-05-21 01:47:08)

Use golang:1.15 in ECR credential helper example
The example Dockerfile provided for ecr-credential-helper no longer builds on `golang:1.16`.

This PR simply pins the version specified in the Dockerfile to `golang:1.15`:

```
$ cat Dockerfile
FROM golang:latest

ENV CGO_ENABLED 0
ENV REPO github.com/awslabs/amazon-ecr-credential-helper/ecr-login/cli/docker-credential-ecr-login

RUN go get -u $REPO

RUN rm /go/bin/docker-credential-ecr-login

RUN go build \
 -o /go/bin/docker-credential-ecr-login \
 /go/src/$REPO

WORKDIR /go/bin/
ubuntu@ip-10-0-3-95:~/credential-helper$ docker build -t aws-ecr-dock-cred-helper .
Sending build context to Docker daemon  2.048kB
Step 1/7 : FROM golang:latest
 ---> 96129f3766cf
Step 2/7 : ENV CGO_ENABLED 0
 ---> Using cache
 ---> 905ba7abe5da
Step 3/7 : ENV REPO github.com/awslabs/amazon-ecr-credential-helper/ecr-login/cli/docker-credential-ecr-login
 ---> Using cache
 ---> 7812b2c262c2
Step 4/7 : RUN go get -u $REPO
 ---> Using cache
 ---> d598e79b0d71
Step 5/7 : RUN rm /go/bin/docker-credential-ecr-login
 ---> Using cache
 ---> 0cbf53304a18
Step 6/7 : RUN go build  -o /go/bin/docker-credential-ecr-login  /go/src/$REPO
 ---> Running in 2dad4197b560
go: go.mod file not found in current directory or any parent directory; see 'go help modules'
The command '/bin/sh -c go build  -o /go/bin/docker-credential-ecr-login  /go/src/$REPO' returned a non-zero
code: 1
```
```
$ cat Dockerfile
FROM golang:1.15

ENV CGO_ENABLED 0
ENV REPO github.com/awslabs/amazon-ecr-credential-helper/ecr-login/cli/docker-credential-ecr-login

RUN go get -u $REPO

RUN rm /go/bin/docker-credential-ecr-login

RUN go build \
 -o /go/bin/docker-credential-ecr-login \
 /go/src/$REPO

WORKDIR /go/bin/
$ docker build -t aws-ecr-dock-cred-helper .
Sending build context to Docker daemon  2.048kB
Step 1/7 : FROM golang:1.15
 ---> e5b4455acc93
Step 2/7 : ENV CGO_ENABLED 0
 ---> Using cache
 ---> 32df66289405
Step 3/7 : ENV REPO github.com/awslabs/amazon-ecr-credential-helper/ecr-login/cli/docker-credential-ecr-login
 ---> Using cache
 ---> 3ff787805cb3
Step 4/7 : RUN go get -u $REPO
 ---> Using cache
 ---> 7117a30722f5
Step 5/7 : RUN rm /go/bin/docker-credential-ecr-login
 ---> Using cache
 ---> 2780ccb85ab8
Step 6/7 : RUN go build  -o /go/bin/docker-credential-ecr-login  /go/src/$REPO
 ---> Using cache
 ---> f848f437fa25
Step 7/7 : WORKDIR /go/bin/
 ---> Using cache
 ---> 58cd6fc9e9f1
Successfully built 58cd6fc9e9f1
Successfully tagged aws-ecr-dock-cred-helper:latest
```

codecov[bot]: # [Codecov](https://codecov.io/gh/containrrr/watchtower/pull/965?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=containrrr) Report
> Merging [#965](https://codecov.io/gh/containrrr/watchtower/pull/965?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=containrrr) (f523dc1) into [main](https://codecov.io/gh/containrrr/watchtower/commit/61b715abec79dc931c6312b8334eb4fdd4a8f7e8?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=containrrr) (61b715a) will **not change** coverage.
> The diff coverage is `n/a`.

[![Impacted file tree graph](https://codecov.io/gh/containrrr/watchtower/pull/965/graphs/tree.svg?width=650&height=150&src=pr&token=8pxWgB380Y&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=containrrr)](https://codecov.io/gh/containrrr/watchtower/pull/965?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=containrrr)

```diff
@@           Coverage Diff           @@
##             main     #965   +/-   ##
=======================================
  Coverage   54.47%   54.47%           
=======================================
  Files          25       25           
  Lines        1452     1452           
=======================================
  Hits          791      791           
  Misses        594      594           
  Partials       67       67           
```



------

[Continue to review full report at Codecov](https://codecov.io/gh/containrrr/watchtower/pull/965?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=containrrr).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=containrrr)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/containrrr/watchtower/pull/965?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=containrrr). Last update [61b715a...f523dc1](https://codecov.io/gh/containrrr/watchtower/pull/965?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=containrrr). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=containrrr).


Patch:
diff --git a/.all-contributorsrc b/.all-contributorsrc
index 56cf25a..afe73da 100644
--- a/.all-contributorsrc
+++ b/.all-contributorsrc
@@ -758,6 +758,24 @@
       "contributions": [
         "code"
       ]
+    },
+    {
+      "login": "zenjabba",
+      "name": "zenjabba",
+      "avatar_url": "https://avatars.githubusercontent.com/u/679864?v=4",
+      "profile": "https://github.com/zenjabba",
+      "contributions": [
+        "doc"
+      ]
+    },
+    {
+      "login": "djquan",
+      "name": "Dan Quan",
+      "avatar_url": "https://avatars.githubusercontent.com/u/3526705?v=4",
+      "profile": "https://quan.io",
+      "contributions": [
+        "doc"
+      ]
     }
   ],
   "contributorsPerLine": 7,
diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml
index 24bfe13..80eb3e3 100644
--- a/.github/workflows/pull-request.yml
+++ b/.github/workflows/pull-request.yml
@@ -35,6 +35,7 @@ jobs:
         platform:
           - macos-latest
           - windows-latest
+          - ubuntu-latest
     runs-on: ${{ matrix.platform }}
     steps:
       - name: Checkout
@@ -52,26 +53,6 @@ jobs:
         uses: codecov/codecov-action@v1
         with:
           token: ${{ secrets.CODECOV_TOKEN }}
-  test-ubuntu:
-    name: Test (Ubuntu)
-    runs-on: ubuntu-latest
-    needs: test
-    steps:
-      - name: Checkout
-        uses: actions/checkout@v2
-        with:
-          fetch-depth: 0
-      - name: Set up Go
-        uses: actions/setup-go@v2
-        with:
-          go-version: 1.15.x
-      - name: Run tests
-        run: |
-          go test -v -coverprofile coverage.out -covermode atomic ./...
-      - name: Publish coverage
-        uses: codecov/codecov-action@v1
-        with:
-          token: ${{ secrets.CODECOV_TOKEN }}
   build:
     name: Build
     runs-on: ubuntu-latest
diff --git a/.github/workflows/release-dev.yaml b/.github/workflows/release-dev.yaml
index 5c71258..4b8f238 100644
--- a/.github/workflows/release-dev.yaml
+++ b/.github/workflows/release-dev.yaml
@@ -16,7 +16,7 @@ jobs:
         with:
           go-version: 1.15
       - name: Build
-        run: go build -v ./...
+        run: ./build.sh
   test:
     runs-on: ubuntu-latest
     steps:
diff --git a/README.md b/README.md
index be92875..a51b266 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,6 @@
   [![Circle CI](https://circleci.com/gh/containrrr/watchtower.svg?style=shield)](https://circleci.com/gh/containrrr/watchtower)
   [![codecov](https://codecov.io/gh/containrrr/watchtower/branch/main/graph/badge.svg)](https://codecov.io/gh/containrrr/watchtower)
   [![GoDoc](https://godoc.org/github.com/containrrr/watchtower?status.svg)](https://godoc.org/github.com/containrrr/watchtower)
-  [![Microbadger](https://images.microbadger.com/badges/image/containrrr/watchtower.svg)](https://microbadger.com/images/containrrr/watchtower)
   [![Go Report Card](https://goreportcard.com/badge/github.com/containrrr/watchtower)](https://goreportcard.com/report/github.com/containrrr/watchtower)
   [![latest version](https://img.shields.io/github/tag/containrrr/watchtower.svg)](https://github.com/containrrr/watchtower/releases)
   [![Apache-2.0 License](https://img.shields.io/github/license/containrrr/watchtower.svg)](https://www.apache.org/licenses/LICENSE-2.0)
@@ -145,6 +144,8 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
     <td align="center"><a href="https://indigo.re/"><img src="https://avatars.githubusercontent.com/u/2804645?v=4?s=100" width="100px;" alt=""/><br /><sub><b>hydrargyrum</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=hydrargyrum" title="Documentation">📖</a></td>
     <td align="center"><a href="https://reinout.vanrees.org"><img src="https://avatars.githubusercontent.com/u/121433?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Reinout van Rees</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=reinout" title="Documentation">📖</a></td>
     <td align="center"><a href="https://github.com/DasSkelett"><img src="https://avatars.githubusercontent.com/u/28812678?v=4?s=100" width="100px;" alt=""/><br /><sub><b>DasSkelett</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=DasSkelett" title="Code">💻</a></td>
+    <td align="center"><a href="https://github.com/zenjabba"><img src="https://avatars.githubusercontent.com/u/679864?v=4?s=100" width="100px;" alt=""/><br /><sub><b>zenjabba</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=zenjabba" title="Documentation">📖</a></td>
+    <td align="center"><a href="https://quan.io"><img src="https://avatars.githubusercontent.com/u/3526705?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Dan Quan</b></sub></a><br /><a href="https://github.com/containrrr/watchtower/commits?author=djquan" title="Documentation">📖</a></td>
   </tr>
 </table>
 
diff --git a/build.sh b/build.sh
old mode 100644
new mode 100755
index 47d2b5c..304786d
--- a/build.sh
+++ b/build.sh
@@ -2,4 +2,4 @@
 
 VERSION=$(git describe --tags)
 echo "Building $VERSION..."
-go build -o watchtower -ldflags "-X github.com/containrrr/watchtower/cmd.version=$VERSION"
+go build -o watchtower -ldflags "-X github.com/containrrr/watchtower/internal/meta.Version=$VERSION"
diff --git a/cmd/root.go b/cmd/root.go
index e4c24b3..fb7c29b 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -1,6 +1,7 @@
 package cmd
 
 import (
+	"github.com/containrrr/watchtower/internal/meta"
 	"math"
 	"os"
 	"os/signal"
@@ -33,13 +34,12 @@ var (
 	noRestart      bool
 	monitorOnly    bool
 	enableLabel    bool
-	notifier       *notifications.Notifier
+	notifier       t.Notifier
 	timeout        time.Duration
 	lifecycleHooks bool
 	rollingRestart bool
 	scope          string
 	// Set on build using ldflags
-	version = "v0.0.0-unknown"
 )
 
 var rootCmd = NewRootCommand()
@@ -268,12 +268,12 @@ func writeStartupMessage(c *cobra.Command, sched time.Time, filtering string) {
 		}
 
 		notifs := "Using no notifications"
-		notifList := notifier.String()
-		if len(notifList) > 0 {
-			notifs = "Using notifications: " + notifList
+		notifierNames := notifier.GetNames()
+		if len(notifierNames) > 0 {
+			notifs = "Using notifications: " + strings.Join(notifierNames, ", ")
 		}
 
-		log.Info("Watchtower ", version, "\n", notifs, "\n", filtering, "\n", schedMessage)
+		log.Info("Watchtower ", meta.Version, "\n", notifs, "\n", filtering, "\n", schedMessage)
 		if log.IsLevelEnabled(log.TraceLevel) {
 			log.Warn("trace level enabled: log will include sensitive information as credentials and tokens")
 		}
@@ -338,11 +338,12 @@ func runUpdatesWithNotifications(filter t.Filter) *metrics.Metric {
 		LifecycleHooks: lifecycleHooks,
 		RollingRestart: rollingRestart,
 	}
-	metricResults, err := actions.Update(client, updateParams)
+	result, err := actions.Update(client, updateParams)
 	if err != nil {
 		log.Error(err)
 	}
-	notifier.SendNotification()
+	notifier.SendNotification(result)
+	metricResults := metrics.NewMetric(result)
 	log.Debugf("Session done: %v scanned, %v updated, %v failed",
 		metricResults.Scanned, metricResults.Updated, metricResults.Failed)
 	return metricResults
diff --git a/dockerfiles/Dockerfile.dev-self-contained b/dockerfiles/Dockerfile.dev-self-contained
index b22ef13..79dbe39 100644
--- a/dockerfiles/Dockerfile.dev-self-contained
+++ b/dockerfiles/Dockerfile.dev-self-contained
@@ -18,7 +18,7 @@ COPY . /watchtower
 RUN \
   cd /watchtower && \
   \
-  GO111MODULE=on CGO_ENABLED=0 GOOS=linux go build -a -ldflags "-extldflags '-static' -X github.com/containrrr/watchtower/cmd.version=$(git describe --tags)" . && \
+  GO111MODULE=on CGO_ENABLED=0 GOOS=linux go build -a -ldflags "-extldflags '-static' -X github.com/containrrr/watchtower/internal/meta.Version=$(git describe --tags)" . && \
   GO111MODULE=on go test ./... -v
 
 
diff --git a/dockerfiles/Dockerfile.self-contained b/dockerfiles/Dockerfile.self-contained
index f24701f..303fc53 100644
--- a/dockerfiles/Dockerfile.self-contained
+++ b/dockerfiles/Dockerfile.self-contained
@@ -18,7 +18,7 @@ RUN git clone --branch "${WATCHTOWER_VERSION}" https://github.com/containrrr/wat
 RUN \
   cd watchtower && \
   \
-  GO111MODULE=on CGO_ENABLED=0 GOOS=linux go build -a -ldflags "-extldflags '-static' -X github.com/containrrr/watchtower/cmd.version=$(git describe --tags)" . && \
+  GO111MODULE=on CGO_ENABLED=0 GOOS=linux go build -a -ldflags "-extldflags '-static' -X github.com/containrrr/watchtower/internal/meta.Version=$(git describe --tags)" . && \
   GO111MODULE=on go test ./... -v
 
 
diff --git a/docs/arguments.md b/docs/arguments.md
index 218680e..f08be7a 100644
--- a/docs/arguments.md
+++ b/docs/arguments.md
@@ -178,9 +178,10 @@ Will only monitor for new images, send notifications and invoke
 the [pre-check/post-check hooks](https://containrrr.dev/watchtower/lifecycle-hooks/), but will __not__ update the
 containers.
 
-!!! note Due to Docker API limitations the latest image will still be pulled from the registry.  
-The HEAD digest checks allows watchtower to skip pulling when there are no changes, but to know _what_ has changed it
-will still do a pull whenever the repository digest doesn't match the local image digest.
+!!! note
+    Due to Docker API limitations the latest image will still be pulled from the registry.
+    The HEAD digest checks allows watchtower to skip pulling when there are no changes, but to know _what_ has changed it
+    will still do a pull whenever the repository digest doesn't match the local image digest.
 
 ```text
             Argument: --monitor-only
@@ -258,7 +259,7 @@ Environment Variable: WATCHTOWER_HTTP_API_TOKEN
 ## HTTP API periodic polls
 Keep running periodic updates if the HTTP API mode is enabled, otherwise the HTTP API would prevent periodic polls.  
 
-```
+```text
             Argument: --http-api-periodic-polls
 Environment Variable: WATCHTOWER_HTTP_API_PERIODIC_POLLS
                 Type: Boolean
@@ -340,4 +341,4 @@ requests and may rate limit pull requests (mainly docker.io).
 Environment Variable: WATCHTOWER_WARN_ON_HEAD_FAILURE
      Possible values: always, auto, never
              Default: auto
-```
\ No newline at end of file
+```
diff --git a/docs/container-selection.md b/docs/container-selection.md
index 799091f..b5ccafb 100644
--- a/docs/container-selection.md
+++ b/docs/container-selection.md
@@ -2,8 +2,8 @@ By default, watchtower will watch all containers. However, sometimes only some c
 
 There are two options:
 
-- **Fully exclude**: You can choose to exclude containers entirely from being watched by watchtower.
-- **Monitor only**: In this mode, watchtower checks for container updates, sends notifications and invokes the [pre-check/post-check hooks](https://containrrr.dev/watchtower/lifecycle-hooks/) on the containers but does **not** perform the update.
+-   **Fully exclude**: You can choose to exclude containers entirely from being watched by watchtower.
+-   **Monitor only**: In this mode, watchtower checks for container updates, sends notifications and invokes the [pre-check/post-check hooks](https://containrrr.dev/watchtower/lifecycle-hooks/) on the containers but does **not** perform the update.
 
 ## Full Exclude 
 
@@ -34,8 +34,8 @@ docker run -d --label=com.centurylinklabs.watchtower.enable=true someimage
 If you wish to create a monitoring scope, you will need to [run multiple instances and set a scope for each of them](https://containrrr.github.io/watchtower/running-multiple-instances).
 
 Watchtower filters running containers by testing them against each configured criteria. A container is monitored if all criteria are met. For example:
-- If a container's name is on the monitoring name list (not empty `--name` argument) but it is not enabled (_centurylinklabs.watchtower.enable=false_), it won't be monitored;
-- If a container's name is not on the monitoring name list (not empty `--name` argument), even if it is enabled (_centurylinklabs.watchtower.enable=true_ and `--label-enable` flag is set), it won't be monitored;
+-   If a container's name is on the monitoring name list (not empty `--name` argument) but it is not enabled (_centurylinklabs.watchtower.enable=false_), it won't be monitored;
+-   If a container's name is not on the monitoring name list (not empty `--name` argument), even if it is enabled (_centurylinklabs.watchtower.enable=true_ and `--label-enable` flag is set), it won't be monitored;
 
 ## Monitor Only
 
@@ -54,5 +54,3 @@ docker run -d --label=com.centurylinklabs.watchtower.monitor-only=true someimage
 ```
 
 When the label is specified on a container, watchtower treats that container exactly as if [`WATCHTOWER_MONITOR_ONLY`](https://containrrr.dev/watchtower/arguments/#without_updating_containers) was set, but the effect is limited to the individual container. 
-
-
diff --git a/docs/http-api-mode.md b/docs/http-api-mode.md
index 7af1421..2cf082a 100644
--- a/docs/http-api-mode.md
+++ b/docs/http-api-mode.md
@@ -1,6 +1,6 @@
 Watchtower provides an HTTP API mode that enables an HTTP endpoint that can be requested to trigger container updating. The current available endpoint list is:
 
-- `/v1/update` - triggers an update for all of the containers monitored by this Watchtower instance.
+-   `/v1/update` - triggers an update for all of the containers monitored by this Watchtower instance.
 
 ---
 
diff --git a/docs/index.md b/docs/index.md
index fd1ca1f..ef234ec 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -17,9 +17,6 @@
   <a href="https://godoc.org/github.com/containrrr/watchtower">
     <img alt="GoDoc" src="https://godoc.org/github.com/containrrr/watchtower?status.svg" />
   </a>
-  <a href="https://microbadger.com/images/containrrr/watchtower">
-    <img alt="Microbadger" src="https://images.microbadger.com/badges/image/containrrr/watchtower.svg" />
-  </a>
   <a href="https://goreportcard.com/report/github.com/containrrr/watchtower">
     <img alt="Go Report Card" src="https://goreportcard.com/badge/github.com/containrrr/watchtower" />
   </a>
@@ -58,7 +55,8 @@ the following command:
     ```yaml
     version: "3"
     services:
-    watchtower:
-    image: containrrr/watchtower volumes:
-    - /var/run/docker.sock:/var/run/docker.sock
-    ```
\ No newline at end of file
+      watchtower:
+        image: containrrr/watchtower
+        volumes:
+          - /var/run/docker.sock:/var/run/docker.sock
+    ```
diff --git a/docs/lifecycle-hooks.md b/docs/lifecycle-hooks.md
index 5c53d15..68dbdae 100644
--- a/docs/lifecycle-hooks.md
+++ b/docs/lifecycle-hooks.md
@@ -4,13 +4,16 @@
     These are shell commands executed with `sh`, and therefore require the container to provide the `sh`
     executable.
 
+> **DO NOTE**: If the container is not running then lifecycle hooks can not run and therefore 
+> the update is executed without running any lifecycle hooks.
+
 It is possible to execute _pre/post\-check_ and _pre/post\-update_ commands
 **inside** every container updated by watchtower.
 
-- The _pre-check_ command is executed for each container prior to every update cycle.
-- The _pre-update_ command is executed before stopping the container when an update is about to start.
-- The _post-update_ command is executed after restarting the updated container
-- The _post-check_ command is executed for each container post every update cycle.
+-   The _pre-check_ command is executed for each container prior to every update cycle.
+-   The _pre-update_ command is executed before stopping the container when an update is about to start.
+-   The _post-update_ command is executed after restarting the updated container
+-   The _post-check_ command is executed for each container post every update cycle.
 
 This feature is disabled by default. To enable it, you need to set the option
 `--enable-lifecycle-hooks` on the command line, or set the environment variable
@@ -63,5 +66,5 @@ If the label value is explicitly set to `0`, the timeout will be disabled.
 ### Execution failure
 
 The failure of a command to execute, identified by an exit code different than
-0, will not prevent watchtower from updating the container. Only an error
+0 or 75 (EX_TEMPFAIL), will not prevent watchtower from updating the container. Only an error
 log statement containing the exit code will be reported.
diff --git a/docs/metrics.md b/docs/metrics.md
index 9741955..2829eac 100644
--- a/docs/metrics.md
+++ b/docs/metrics.md
@@ -22,4 +22,4 @@ as well as creating a port mapping for your container for port `8080`.
 The repository contains a demo with prometheus and grafana, available through `docker-compose.yml`. This demo
 is preconfigured with a dashboard, which will look something like this:
 
-![grafana metrics](assets/grafana-dashboard.png)
\ No newline at end of file
+![grafana metrics](assets/grafana-dashboard.png)
diff --git a/docs/notifications.md b/docs/notifications.md
index b0e2adf..3663aff 100644
--- a/docs/notifications.md
+++ b/docs/notifications.md
@@ -5,11 +5,11 @@ system, [logrus](http://github.com/sirupsen/logrus). The types of notifications
 comma-separated list of values to the `--notifications` option
 (or corresponding environment variable `WATCHTOWER_NOTIFICATIONS`), which has the following valid values:
 
-- `email` to send notifications via e-mail
-- `slack` to send notifications through a Slack webhook
-- `msteams` to send notifications via MSTeams webhook
-- `gotify` to send notifications via Gotify
-- `shoutrrr` to send notifications via [containrrr/shoutrrr](https://github.com/containrrr/shoutrrr)
+-   `email` to send notifications via e-mail
+-   `slack` to send notifications through a Slack webhook
+-   `msteams` to send notifications via MSTeams webhook
+-   `gotify` to send notifications via Gotify
+-   `shoutrrr` to send notifications via [containrrr/shoutrrr](https://github.com/containrrr/shoutrrr)
 
 !!! note "Using multiple notifications with environment variables"
     There is currently a bug in Viper (https://github.com/spf13/viper/issues/380), which prevents comma-separated slices to
@@ -24,8 +24,9 @@ comma-separated list of values to the `--notifications` option
 
 ## Settings
 
-- `--notifications-level` (env. `WATCHTOWER_NOTIFICATIONS_LEVEL`): Controls the log level which is used for the notifications. If omitted, the default log level is `info`. Possible values are: `panic`, `fatal`, `error`, `warn`, `info`, `debug` or `trace`.
-- Watchtower will post a notification every time it is started. This behavior [can be changed](https://containrrr.github.io/watchtower/arguments/#without_sending_a_startup_message) with an argument.
+-   `--notifications-level` (env. `WATCHTOWER_NOTIFICATIONS_LEVEL`): Controls the log level which is used for the notifications. If omitted, the default log level is `info`. Possible values are: `panic`, `fatal`, `error`, `warn`, `info`, `debug` or `trace`.
+-   `--notifications-hostname` (env. `WATCHTOWER_NOTIFICATIONS_HOSTNAME`): Custom hostname specified in subject/title. Useful to override the operating system hostname.
+-   Watchtower will post a notification every time it is started. This behavior [can be changed](https://containrrr.github.io/watchtower/arguments/#without_sending_a_startup_message) with an argument.
 
 ## Available services
 
@@ -33,15 +34,15 @@ comma-separated list of values to the `--notifications` option
 
 To receive notifications by email, the following command-line options, or their corresponding environment variables, can be set:
 
-- `--notification-email-from` (env. `WATCHTOWER_NOTIFICATION_EMAIL_FROM`): The e-mail address from which notifications will be sent.
-- `--notification-email-to` (env. `WATCHTOWER_NOTIFICATION_EMAIL_TO`): The e-mail address to which notifications will be sent.
-- `--notification-email-server` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SERVER`): The SMTP server to send e-mails through.
-- `--notification-email-server-tls-skip-verify` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SERVER_TLS_SKIP_VERIFY`): Do not verify the TLS certificate of the mail server. This should be used only for testing.
-- `--notification-email-server-port` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PORT`): The port used to connect to the SMTP server to send e-mails through. Defaults to `25`.
-- `--notification-email-server-user` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SERVER_USER`): The username to authenticate with the SMTP server with.
-- `--notification-email-server-password` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PASSWORD`): The password to authenticate with the SMTP server with. Can also reference a file, in which case the contents of the file are used.
-- `--notification-email-delay` (env. `WATCHTOWER_NOTIFICATION_EMAIL_DELAY`): Delay before sending notifications expressed in seconds.
-- `--notification-email-subjecttag` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SUBJECTTAG`): Prefix to include in the subject tag. Useful when running multiple watchtowers.
+-   `--notification-email-from` (env. `WATCHTOWER_NOTIFICATION_EMAIL_FROM`): The e-mail address from which notifications will be sent.
+-   `--notification-email-to` (env. `WATCHTOWER_NOTIFICATION_EMAIL_TO`): The e-mail address to which notifications will be sent.
+-   `--notification-email-server` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SERVER`): The SMTP server to send e-mails through.
+-   `--notification-email-server-tls-skip-verify` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SERVER_TLS_SKIP_VERIFY`): Do not verify the TLS certificate of the mail server. This should be used only for testing.
+-   `--notification-email-server-port` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PORT`): The port used to connect to the SMTP server to send e-mails through. Defaults to `25`.
+-   `--notification-email-server-user` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SERVER_USER`): The username to authenticate with the SMTP server with.
+-   `--notification-email-server-password` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PASSWORD`): The password to authenticate with the SMTP server with. Can also reference a file, in which case the contents of the file are used.
+-   `--notification-email-delay` (env. `WATCHTOWER_NOTIFICATION_EMAIL_DELAY`): Delay before sending notifications expressed in seconds.
+-   `--notification-email-subjecttag` (env. `WATCHTOWER_NOTIFICATION_EMAIL_SUBJECTTAG`): Prefix to include in the subject tag. Useful when running multiple watchtowers.
 
 Example:
 
@@ -120,7 +121,7 @@ By default, watchtower will send messages under the name `watchtower`, you can c
 
 Other, optional, variables include:
 
-- `--notification-slack-channel` (env. `WATCHTOWER_NOTIFICATION_SLACK_CHANNEL`): A string which overrides the webhook's default channel. Example: #my-custom-channel.
+-   `--notification-slack-channel` (env. `WATCHTOWER_NOTIFICATION_SLACK_CHANNEL`): A string which overrides the webhook's default channel. Example: #my-custom-channel.
 
 Example:
 
@@ -177,15 +178,15 @@ If you want to disable TLS verification for the Gotify instance, you can use eit
 
 To send notifications via shoutrrr, the following command-line options, or their corresponding environment variables, can be set:
 
-- `--notification-url` (env. `WATCHTOWER_NOTIFICATION_URL`): The shoutrrr service URL to be used.
+-   `--notification-url` (env. `WATCHTOWER_NOTIFICATION_URL`): The shoutrrr service URL to be used.
 
-Go to [containrrr.github.io/shoutrrr/services/overview](https://containrrr.github.io/shoutrrr/services/overview) to
+Go to [containrrr.dev/shoutrrr/v0.4/services/overview](https://containrrr.dev/shoutrrr/v0.4/services/overview) to
 learn more about the different service URLs you can use. You can define multiple services by space separating the
 URLs. (See example below)
 
 You can customize the message posted by setting a template.
 
-- `--notification-template` (env. `WATCHTOWER_NOTIFICATION_TEMPLATE`): The template used for the message.
+-   `--notification-template` (env. `WATCHTOWER_NOTIFICATION_TEMPLATE`): The template used for the message.
 
 The template is a Go [template](https://golang.org/pkg/text/template/) and that format a list
 of [log entries](https://pkg.go.dev/github.com/sirupsen/logrus?tab=doc#Entry).
@@ -197,7 +198,7 @@ outputs timestamp and log level.
     If you want to adjust the date/time format it must show how the
     [reference time](https://golang.org/pkg/time/#pkg-constants) (_Mon Jan 2 15:04:05 MST 2006_) would be displayed in your
     custom format.  
-    i.e. The day of the year has to be 1, the month has to be 2 (february), the hour 3 (or 15 for 24h time) etc.
+    i.e., The day of the year has to be 1, the month has to be 2 (february), the hour 3 (or 15 for 24h time) etc.
 
 Example:
 
diff --git a/docs/private-registries.md b/docs/private-registries.md
index 354f369..8b3f28d 100644
--- a/docs/private-registries.md
+++ b/docs/private-registries.md
@@ -5,8 +5,8 @@ environment, watchtower needs to know the credentials to access the registry.
 The credentials can be provided to watchtower in a configuration file called `config.json`.
 There are two ways to generate this configuration file:
 
-* The configuration file can be created manually.
-* Call `docker login <REGISTRY_NAME>` and share the resulting configuration file.
+*   The configuration file can be created manually.
+*   Call `docker login <REGISTRY_NAME>` and share the resulting configuration file.
 
 ### Create the configuration file manually
 Create a new configuration file with the following syntax and a base64 encoded username and
@@ -87,7 +87,6 @@ services:
       - /var/run/docker.sock:/var/run/docker.sock
 ```
 
-
 ## Credential helpers
 Some private Docker registries (the most prominent probably being AWS ECR) use non-standard ways of authentication.
 To be able to use this together with watchtower, we need to use a credential helper.
@@ -98,14 +97,14 @@ helper in a separate container and mount it using volumes.
 ### Example
 Example implementation for use with [amazon-ecr-credential-helper](https://github.com/awslabs/amazon-ecr-credential-helper):
 
-
 Use the dockerfile below to build the [amazon-ecr-credential-helper](https://github.com/awslabs/amazon-ecr-credential-helper),
 in a volume that may be mounted onto your watchtower container.
 
 1.  Create the Dockerfile (contents below):
     ```Dockerfile
-    FROM golang:latest
+    FROM golang:1.16
     
+    ENV GO111MODULE off
     ENV CGO_ENABLED 0
     ENV REPO github.com/awslabs/amazon-ecr-credential-helper/ecr-login/cli/docker-credential-ecr-login
     
@@ -175,9 +174,12 @@ A few additional notes:
 
 1.  With docker-compose the volume (helper, in this case) MUST be set to `external: true`, otherwise docker-compose 
     will preface it with the directory name.
+
 2.  Note that "credsStore" : "ecr-login" is needed - and in theory if you have that you can remove the 
-    credHelpers section 
+    credHelpers section
+
 3.  I have this running on an EC2 instance that has credentials assigned to it - so no keys are needed; however, 
     you may need to include the `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` environment variables as well.
+
 4.  An alternative to adding the various variables is to create a ~/.aws/config and ~/.aws/credentials files and 
     place the settings there, then mount the ~/.aws directory to / in the container.
diff --git a/docs/remote-hosts.md b/docs/remote-hosts.md
index e08fbd3..22c3f94 100644
--- a/docs/remote-hosts.md
+++ b/docs/remote-hosts.md
@@ -15,4 +15,4 @@ docker run -d \
   containrrr/watchtower
 ```
 
-Note in both of the examples above that it is unnecessary to mount the _/var/run/docker.sock_ into the watchtower container.
\ No newline at end of file
+Note in both of the examples above that it is unnecessary to mount the _/var/run/docker.sock_ into the watchtower container.
diff --git a/docs/running-multiple-instances.md b/docs/running-multiple-instances.md
index 641f4e4..3899095 100644
--- a/docs/running-multiple-instances.md
+++ b/docs/running-multiple-instances.md
@@ -1,8 +1,8 @@
 By default, Watchtower will clean up other instances and won't allow multiple instances running on the same Docker host or swarm. It is possible to override this behavior by defining a [scope](https://containrrr.github.io/watchtower/arguments/#filter_by_scope) to each running instance. 
 
 Notice that:
-- Multiple instances can't run with the same scope;
-- An instance without a scope will clean up other running instances, even if they have a defined scope;
+-   Multiple instances can't run with the same scope;
+-   An instance without a scope will clean up other running instances, even if they have a defined scope;
 
 To define an instance monitoring scope, use the `--scope` argument or the `WATCHTOWER_SCOPE` environment variable on startup and set the _com.centurylinklabs.watchtower.scope_ label with the same value for the containers you want to include in this instance's scope (including the instance itself).
 
@@ -24,4 +24,4 @@ services:
     command: --interval 30 --scope myscope
     labels:
       - "com.centurylinklabs.watchtower.scope=myscope"
-```
\ No newline at end of file
+```
diff --git a/goreleaser.yml b/goreleaser.yml
index 3f5e95d..1904d5e 100644
--- a/goreleaser.yml
+++ b/goreleaser.yml
@@ -10,7 +10,7 @@ build:
     - arm
     - arm64
   ldflags:
-    - -s -w -X github.com/containrrr/watchtower/cmd.version={{.Version}}
+    - -s -w -X github.com/containrrr/watchtower/internal/meta.Version={{.Version}}
 archives:
   - 
     name_template: "{{.ProjectName}}_{{.Os}}_{{.Arch}}"
diff --git a/internal/actions/mocks/client.go b/internal/actions/mocks/client.go
index b17c987..6378f43 100644
--- a/internal/actions/mocks/client.go
+++ b/internal/actions/mocks/client.go
@@ -2,6 +2,7 @@ package mocks
 
 import (
 	"errors"
+	"fmt"
 	"github.com/containrrr/watchtower/pkg/container"
 	"time"
 
@@ -40,12 +41,12 @@ func CreateMockClient(data *TestData, api cli.CommonAPIClient, pullImages bool,
 }
 
 // ListContainers is a mock method returning the provided container testdata
-func (client MockClient) ListContainers(f t.Filter) ([]container.Container, error) {
+func (client MockClient) ListContainers(_ t.Filter) ([]container.Container, error) {
 	return client.TestData.Containers, nil
 }
 
 // StopContainer is a mock method
-func (client MockClient) StopContainer(c container.Container, d time.Duration) error {
+func (client MockClient) StopContainer(c container.Container, _ time.Duration) error {
 	if c.Name() == client.TestData.NameOfContainerToKeep {
 		return errors.New("tried to stop the instance we want to keep")
 	}
@@ -53,37 +54,46 @@ func (client MockClient) StopContainer(c container.Container, d time.Duration) e
 }
 
 // StartContainer is a mock method
-func (client MockClient) StartContainer(c container.Container) (string, error) {
+func (client MockClient) StartContainer(_ container.Container) (t.ContainerID, error) {
 	return "", nil
 }
 
 // RenameContainer is a mock method
-func (client MockClient) RenameContainer(c container.Container, s string) error {
+func (client MockClient) RenameContainer(_ container.Container, _ string) error {
 	return nil
 }
 
 // RemoveImageByID increments the TriedToRemoveImageCount on being called
-func (client MockClient) RemoveImageByID(id string) error {
+func (client MockClient) RemoveImageByID(_ t.ImageID) error {
 	client.TestData.TriedToRemoveImageCount++
 	return nil
 }
 
 // GetContainer is a mock method
-func (client MockClient) GetContainer(containerID string) (container.Container, error) {
-	return container.Container{}, nil
+func (client MockClient) GetContainer(_ t.ContainerID) (container.Container, error) {
+	return client.TestData.Containers[0], nil
 }
 
 // ExecuteCommand is a mock method
-func (client MockClient) ExecuteCommand(containerID string, command string, timeout int) error {
-	return nil
+func (client MockClient) ExecuteCommand(_ t.ContainerID, command string, _ int) (SkipUpdate bool, err error) {
+	switch command {
+	case "/PreUpdateReturn0.sh":
+		return false, nil
+	case "/PreUpdateReturn1.sh":
+		return false, fmt.Errorf("command exited with code 1")
+	case "/PreUpdateReturn75.sh":
+		return true, nil
+	default:
+		return false, nil
+	}
 }
 
 // IsContainerStale is always true for the mock client
-func (client MockClient) IsContainerStale(c container.Container) (bool, error) {
-	return true, nil
+func (client MockClient) IsContainerStale(_ container.Container) (bool, t.ImageID, error) {
+	return true, "", nil
 }
 
 // WarnOnHeadPullFailed is always true for the mock client
-func (client MockClient) WarnOnHeadPullFailed(c container.Container) bool {
+func (client MockClient) WarnOnHeadPullFailed(_ container.Container) bool {
 	return true
 }
diff --git a/internal/actions/mocks/container.go b/internal/actions/mocks/container.go
index 0c0ee94..167d571 100644
--- a/internal/actions/mocks/container.go
+++ b/internal/actions/mocks/container.go
@@ -1,10 +1,14 @@
 package mocks
 
 import (
+	"fmt"
 	"github.com/containrrr/watchtower/pkg/container"
+	wt "github.com/containrrr/watchtower/pkg/types"
 	"github.com/docker/docker/api/types"
-	container2 "github.com/docker/docker/api/types/container"
+	dockerContainer "github.com/docker/docker/api/types/container"
 	"github.com/docker/go-connections/nat"
+	"strconv"
+	"strings"
 	"time"
 )
 
@@ -16,11 +20,11 @@ func CreateMockContainer(id string, name string, image string, created time.Time
 			Image:   image,
 			Name:    name,
 			Created: created.String(),
-			HostConfig: &container2.HostConfig{
+			HostConfig: &dockerContainer.HostConfig{
 				PortBindings: map[nat.Port][]nat.PortBinding{},
 			},
 		},
-		Config: &container2.Config{
+		Config: &dockerContainer.Config{
 			Image:        image,
 			Labels:       make(map[string]string),
 			ExposedPorts: map[nat.Port]struct{}{},
@@ -39,6 +43,11 @@ func CreateMockContainer(id string, name string, image string, created time.Time
 
 // CreateMockContainerWithImageInfo should only be used for testing
 func CreateMockContainerWithImageInfo(id string, name string, image string, created time.Time, imageInfo types.ImageInspect) container.Container {
+	return CreateMockContainerWithImageInfoP(id, name, image, created, &imageInfo)
+}
+
+// CreateMockContainerWithImageInfoP should only be used for testing
+func CreateMockContainerWithImageInfoP(id string, name string, image string, created time.Time, imageInfo *types.ImageInspect) container.Container {
 	content := types.ContainerJSON{
 		ContainerJSONBase: &types.ContainerJSONBase{
 			ID:      id,
@@ -46,14 +55,14 @@ func CreateMockContainerWithImageInfo(id string, name string, image string, crea
 			Name:    name,
 			Created: created.String(),
 		},
-		Config: &container2.Config{
+		Config: &dockerContainer.Config{
 			Image:  image,
 			Labels: make(map[string]string),
 		},
 	}
 	return *container.NewContainer(
 		&content,
-		&imageInfo,
+		imageInfo,
 	)
 }
 
@@ -65,13 +74,20 @@ func CreateMockContainerWithDigest(id string, name string, image string, created
 }
 
 // CreateMockContainerWithConfig creates a container substitute valid for testing
-func CreateMockContainerWithConfig(id string, name string, image string, created time.Time, config *container2.Config) container.Container {
+func CreateMockContainerWithConfig(id string, name string, image string, running bool, restarting bool, created time.Time, config *dockerContainer.Config) container.Container {
 	content := types.ContainerJSON{
 		ContainerJSONBase: &types.ContainerJSONBase{
-			ID:      id,
-			Image:   image,
-			Name:    name,
+			ID:    id,
+			Image: image,
+			Name:  name,
+			State: &types.ContainerState{
+				Running:    running,
+				Restarting: restarting,
+			},
 			Created: created.String(),
+			HostConfig: &dockerContainer.HostConfig{
+				PortBindings: map[nat.Port][]nat.PortBinding{},
+			},
 		},
 		Config: config,
 	}
@@ -82,3 +98,19 @@ func CreateMockContainerWithConfig(id string, name string, image string, created
 		},
 	)
 }
+
+// CreateContainerForProgress creates a container substitute for tracking session/update progress
+func CreateContainerForProgress(index int, idPrefix int, nameFormat string) (container.Container, wt.ImageID) {
+	indexStr := strconv.Itoa(idPrefix + index)
+	mockID := indexStr + strings.Repeat("0", 61-len(indexStr))
+	contID := "c79" + mockID
+	contName := fmt.Sprintf(nameFormat, index+1)
+	oldImgID := "01d" + mockID
+	newImgID := "d0a" + mockID
+	imageName := fmt.Sprintf("mock/%s:latest", contName)
+	config := &dockerContainer.Config{
+		Image: imageName,
+	}
+	c := CreateMockContainerWithConfig(contID, contName, oldImgID, true, false, time.Now(), config)
+	return c, wt.ImageID(newImgID)
+}
diff --git a/internal/actions/mocks/progress.go b/internal/actions/mocks/progress.go
new file mode 100644
index 0000000..6883b48
--- /dev/null
+++ b/internal/actions/mocks/progress.go
@@ -0,0 +1,46 @@
+package mocks
+
+import (
+	"errors"
+	"github.com/containrrr/watchtower/pkg/session"
+	wt "github.com/containrrr/watchtower/pkg/types"
+)
+
+// CreateMockProgressReport creates a mock report from a given set of container states
+// All containers will be given a unique ID and name based on its state and index
+func CreateMockProgressReport(states ...session.State) wt.Report {
+
+	stateNums := make(map[session.State]int)
+	progress := session.Progress{}
+	failed := make(map[wt.ContainerID]error)
+
+	for _, state := range states {
+		index := stateNums[state]
+
+		switch state {
+		case session.SkippedState:
+			c, _ := CreateContainerForProgress(index, 41, "skip%d")
+			progress.AddSkipped(c, errors.New("unpossible"))
+			break
+		case session.FreshState:
+			c, _ := CreateContainerForProgress(index, 31, "frsh%d")
+			progress.AddScanned(c, c.ImageID())
+			break
+		case session.UpdatedState:
+			c, newImage := CreateContainerForProgress(index, 11, "updt%d")
+			progress.AddScanned(c, newImage)
+			progress.MarkForUpdate(c.ID())
+			break
+		case session.FailedState:
+			c, newImage := CreateContainerForProgress(index, 21, "fail%d")
+			progress.AddScanned(c, newImage)
+			failed[c.ID()] = errors.New("accidentally the whole container")
+		}
+
+		stateNums[state] = index + 1
+	}
+	progress.UpdateFailed(failed)
+
+	return progress.Report()
+
+}
diff --git a/internal/actions/update.go b/internal/actions/update.go
index 66a28f1..f7eee8e 100644
--- a/internal/actions/update.go
+++ b/internal/actions/update.go
@@ -1,10 +1,11 @@
 package actions
 
 import (
+	"errors"
 	"github.com/containrrr/watchtower/internal/util"
 	"github.com/containrrr/watchtower/pkg/container"
 	"github.com/containrrr/watchtower/pkg/lifecycle"
-	metrics2 "github.com/containrrr/watchtower/pkg/metrics"
+	"github.com/containrrr/watchtower/pkg/session"
 	"github.com/containrrr/watchtower/pkg/sorter"
 	"github.com/containrrr/watchtower/pkg/types"
 	log "github.com/sirupsen/logrus"
@@ -14,9 +15,9 @@ import (
 // used to start those containers have been updated. If a change is detected in
 // any of the images, the associated containers are stopped and restarted with
 // the new image.
-func Update(client container.Client, params types.UpdateParams) (*metrics2.Metric, error) {
+func Update(client container.Client, params types.UpdateParams) (types.Report, error) {
 	log.Debug("Checking containers for updated images")
-	metric := &metrics2.Metric{}
+	progress := &session.Progress{}
 	staleCount := 0
 
 	if params.LifecycleHooks {
@@ -31,7 +32,7 @@ func Update(client container.Client, params types.UpdateParams) (*metrics2.Metri
 	staleCheckFailed := 0
 
 	for i, targetContainer := range containers {
-		stale, err := client.IsContainerStale(targetContainer)
+		stale, newestImage, err := client.IsContainerStale(targetContainer)
 		shouldUpdate := stale && !params.NoRestart && !params.MonitorOnly && !targetContainer.IsMonitorOnly()
 		if err == nil && shouldUpdate {
 			// Check to make sure we have all the necessary information for recreating the container
@@ -51,7 +52,9 @@ func Update(client container.Client, params types.UpdateParams) (*metrics2.Metri
 			log.Infof("Unable to update container %q: %v. Proceeding to next.", targetContainer.Name(), err)
 			stale = false
 			staleCheckFailed++
-			metric.Failed++
+			progress.AddSkipped(targetContainer, err)
+		} else {
+			progress.AddScanned(targetContainer, newestImage)
 		}
 		containers[i].Stale = stale
 
@@ -61,8 +64,6 @@ func Update(client container.Client, params types.UpdateParams) (*metrics2.Metri
 	}
 
 	containers, err = sorter.SortByDependencies(containers)
-
-	metric.Scanned = len(containers)
 	if err != nil {
 		return nil, err
 	}
@@ -74,38 +75,41 @@ func Update(client container.Client, params types.UpdateParams) (*metrics2.Metri
 		for _, c := range containers {
 			if !c.IsMonitorOnly() {
 				containersToUpdate = append(containersToUpdate, c)
+				progress.MarkForUpdate(c.ID())
 			}
 		}
 	}
 
 	if params.RollingRestart {
-		metric.Failed += performRollingRestart(containersToUpdate, client, params)
+		progress.UpdateFailed(performRollingRestart(containersToUpdate, client, params))
 	} else {
-		metric.Failed += stopContainersInReversedOrder(containersToUpdate, client, params)
-		metric.Failed += restartContainersInSortedOrder(containersToUpdate, client, params)
+		failedStop, stoppedImages := stopContainersInReversedOrder(containersToUpdate, client, params)
+		progress.UpdateFailed(failedStop)
+		failedStart := restartContainersInSortedOrder(containersToUpdate, client, params, stoppedImages)
+		progress.UpdateFailed(failedStart)
 	}
 
-	metric.Updated = staleCount - (metric.Failed - staleCheckFailed)
-
 	if params.LifecycleHooks {
 		lifecycle.ExecutePostChecks(client, params)
 	}
-	return metric, nil
+	return progress.Report(), nil
 }
 
-func performRollingRestart(containers []container.Container, client container.Client, params types.UpdateParams) int {
-	cleanupImageIDs := make(map[string]bool)
-	failed := 0
+func performRollingRestart(containers []container.Container, client container.Client, params types.UpdateParams) map[types.ContainerID]error {
+	cleanupImageIDs := make(map[types.ImageID]bool, len(containers))
+	failed := make(map[types.ContainerID]error, len(containers))
 
 	for i := len(containers) - 1; i >= 0; i-- {
 		if containers[i].ToRestart() {
-			if err := stopStaleContainer(containers[i], client, params); err != nil {
-				failed++
-			}
-			if err := restartStaleContainer(containers[i], client, params); err != nil {
-				failed++
+			err := stopStaleContainer(containers[i], client, params)
+			if err != nil {
+				failed[containers[i].ID()] = err
+			} else {
+				if err := restartStaleContainer(containers[i], client, params); err != nil {
+					failed[containers[i].ID()] = err
+				}
+				cleanupImageIDs[containers[i].ImageID()] = true
 			}
-			cleanupImageIDs[containers[i].ImageID()] = true
 		}
 	}
 
@@ -115,14 +119,18 @@ func performRollingRestart(containers []container.Container, client container.Cl
 	return failed
 }
 
-func stopContainersInReversedOrder(containers []container.Container, client container.Client, params types.UpdateParams) int {
-	failed := 0
+func stopContainersInReversedOrder(containers []container.Container, client container.Client, params types.UpdateParams) (failed map[types.ContainerID]error, stopped map[types.ImageID]bool) {
+	failed = make(map[types.ContainerID]error, len(containers))
+	stopped = make(map[types.ImageID]bool, len(containers))
 	for i := len(containers) - 1; i >= 0; i-- {
 		if err := stopStaleContainer(containers[i], client, params); err != nil {
-			failed++
+			failed[containers[i].ID()] = err
+		} else {
+			stopped[containers[i].ImageID()] = true
 		}
+
 	}
-	return failed
+	return
 }
 
 func stopStaleContainer(container container.Container, client container.Client, params types.UpdateParams) error {
@@ -135,11 +143,16 @@ func stopStaleContainer(container container.Container, client container.Client,
 		return nil
 	}
 	if params.LifecycleHooks {
-		if err := lifecycle.ExecutePreUpdateCommand(client, container); err != nil {
+		skipUpdate, err := lifecycle.ExecutePreUpdateCommand(client, container)
+		if err != nil {
 			log.Error(err)
 			log.Info("Skipping container as the pre-update command failed")
 			return err
 		}
+		if skipUpdate {
+			log.Debug("Skipping container as the pre-update command returned exit code 75 (EX_TEMPFAIL)")
+			return errors.New("skipping container as the pre-update command returned exit code 75 (EX_TEMPFAIL)")
+		}
 	}
 
 	if err := client.StopContainer(container, params.Timeout); err != nil {
@@ -149,29 +162,30 @@ func stopStaleContainer(container container.Container, client container.Client,
 	return nil
 }
 
-func restartContainersInSortedOrder(containers []container.Container, client container.Client, params types.UpdateParams) int {
-	imageIDs := make(map[string]bool)
-
-	failed := 0
+func restartContainersInSortedOrder(containers []container.Container, client container.Client, params types.UpdateParams, stoppedImages map[types.ImageID]bool) map[types.ContainerID]error {
+	cleanupImageIDs := make(map[types.ImageID]bool, len(containers))
+	failed := make(map[types.ContainerID]error, len(containers))
 
 	for _, c := range containers {
 		if !c.ToRestart() {
 			continue
 		}
-		if err := restartStaleContainer(c, client, params); err != nil {
-			failed++
+		if stoppedImages[c.ImageID()] {
+			if err := restartStaleContainer(c, client, params); err != nil {
+				failed[c.ID()] = err
+			}
+			cleanupImageIDs[c.ImageID()] = true
 		}
-		imageIDs[c.ImageID()] = true
 	}
 
 	if params.Cleanup {
-		cleanupImages(client, imageIDs)
+		cleanupImages(client, cleanupImageIDs)
 	}
 
 	return failed
 }
 
-func cleanupImages(client container.Client, imageIDs map[string]bool) {
+func cleanupImages(client container.Client, imageIDs map[types.ImageID]bool) {
 	for imageID := range imageIDs {
 		if err := client.RemoveImageByID(imageID); err != nil {
 			log.Error(err)
diff --git a/internal/flags/flags.go b/internal/flags/flags.go
index 177073d..8dd128e 100644
--- a/internal/flags/flags.go
+++ b/internal/flags/flags.go
@@ -179,12 +179,17 @@ func RegisterNotificationFlags(rootCmd *cobra.Command) {
 		viper.GetStringSlice("WATCHTOWER_NOTIFICATIONS"),
 		" notification types to send (valid: email, slack, msteams, gotify, shoutrrr)")
 
-	flags.StringP(
+	flags.String(
 		"notifications-level",
-		"",
 		viper.GetString("WATCHTOWER_NOTIFICATIONS_LEVEL"),
 		"The log level used for sending notifications. Possible values: panic, fatal, error, warn, info or debug")
 
+	flags.StringP(
+		"notifications-hostname",
+		"",
+		viper.GetString("WATCHTOWER_NOTIFICATIONS_HOSTNAME"),
+		"Custom hostname for notification titles")
+
 	flags.StringP(
 		"notification-email-from",
 		"",
@@ -301,18 +306,20 @@ Should only be used for testing.`)
 		`Controls whether watchtower verifies the Gotify server's certificate chain and host name.
 Should only be used for testing.`)
 
-	flags.StringP(
+	flags.String(
 		"notification-template",
-		"",
 		viper.GetString("WATCHTOWER_NOTIFICATION_TEMPLATE"),
 		"The shoutrrr text/template for the messages")
 
-	flags.StringArrayP(
+	flags.StringArray(
 		"notification-url",
-		"",
 		viper.GetStringSlice("WATCHTOWER_NOTIFICATION_URL"),
 		"The shoutrrr URL to send notifications to")
 
+	flags.Bool("notification-report",
+		viper.GetBool("WATCHTOWER_NOTIFICATION_REPORT"),
+		"Use the session report as the notification template data")
+
 	flags.String(
 		"warn-on-head-failure",
 		viper.GetString("WATCHTOWER_WARN_ON_HEAD_FAILURE"),
diff --git a/internal/meta/meta.go b/internal/meta/meta.go
new file mode 100644
index 0000000..1571291
--- /dev/null
+++ b/internal/meta/meta.go
@@ -0,0 +1,13 @@
+package meta
+
+var (
+	// Version is the compile-time set version of Watchtower
+	Version = "v0.0.0-unknown"
+
+	// UserAgent is the http client identifier derived from Version
+	UserAgent string
+)
+
+func init() {
+	UserAgent = "Watchtower/" + Version
+}
diff --git a/pkg/container/client.go b/pkg/container/client.go
index 93eacb7..2771733 100644
--- a/pkg/container/client.go
+++ b/pkg/container/client.go
@@ -26,13 +26,13 @@ const defaultStopSignal = "SIGTERM"
 // Docker API.
 type Client interface {
 	ListContainers(t.Filter) ([]Container, error)
-	GetContainer(containerID string) (Container, error)
+	GetContainer(containerID t.ContainerID) (Container, error)
 	StopContainer(Container, time.Duration) error
-	StartContainer(Container) (string, error)
+	StartContainer(Container) (t.ContainerID, error)
 	RenameContainer(Container, string) error
-	IsContainerStale(Container) (bool, error)
-	ExecuteCommand(containerID string, command string, timeout int) error
-	RemoveImageByID(string) error
+	IsContainerStale(Container) (stale bool, latestImage t.ImageID, err error)
+	ExecuteCommand(containerID t.ContainerID, command string, timeout int) (SkipUpdate bool, err error)
+	RemoveImageByID(t.ImageID) error
 	WarnOnHeadPullFailed(container Container) bool
 }
 
@@ -108,7 +108,7 @@ func (client dockerClient) ListContainers(fn t.Filter) ([]Container, error) {
 
 	for _, runningContainer := range containers {
 
-		c, err := client.GetContainer(runningContainer.ID)
+		c, err := client.GetContainer(t.ContainerID(runningContainer.ID))
 		if err != nil {
 			return nil, err
 		}
@@ -137,10 +137,10 @@ func (client dockerClient) createListFilter() filters.Args {
 	return filterArgs
 }
 
-func (client dockerClient) GetContainer(containerID string) (Container, error) {
+func (client dockerClient) GetContainer(containerID t.ContainerID) (Container, error) {
 	bg := context.Background()
 
-	containerInfo, err := client.api.ContainerInspect(bg, containerID)
+	containerInfo, err := client.api.ContainerInspect(bg, string(containerID))
 	if err != nil {
 		return Container{}, err
 	}
@@ -161,11 +161,12 @@ func (client dockerClient) StopContainer(c Container, timeout time.Duration) err
 		signal = defaultStopSignal
 	}
 
-	shortID := ShortID(c.ID())
+	idStr := string(c.ID())
+	shortID := c.ID().ShortID()
 
 	if c.IsRunning() {
 		log.Infof("Stopping %s (%s) with %s", c.Name(), shortID, signal)
-		if err := client.api.ContainerKill(bg, c.ID(), signal); err != nil {
+		if err := client.api.ContainerKill(bg, idStr, signal); err != nil {
 			return err
 		}
 	}
@@ -178,7 +179,7 @@ func (client dockerClient) StopContainer(c Container, timeout time.Duration) err
 	} else {
 		log.Debugf("Removing container %s", shortID)
 
-		if err := client.api.ContainerRemove(bg, c.ID(), types.ContainerRemoveOptions{Force: true, RemoveVolumes: client.removeVolumes}); err != nil {
+		if err := client.api.ContainerRemove(bg, idStr, types.ContainerRemoveOptions{Force: true, RemoveVolumes: client.removeVolumes}); err != nil {
 			return err
 		}
 	}
@@ -191,7 +192,7 @@ func (client dockerClient) StopContainer(c Container, timeout time.Duration) err
 	return nil
 }
 
-func (client dockerClient) StartContainer(c Container) (string, error) {
+func (client dockerClient) StartContainer(c Container) (t.ContainerID, error) {
 	bg := context.Background()
 	config := c.runtimeConfig()
 	hostConfig := c.hostConfig()
@@ -234,18 +235,19 @@ func (client dockerClient) StartContainer(c Container) (string, error) {
 
 	}
 
+	createdContainerID := t.ContainerID(createdContainer.ID)
 	if !c.IsRunning() && !client.reviveStopped {
-		return createdContainer.ID, nil
+		return createdContainerID, nil
 	}
 
-	return createdContainer.ID, client.doStartContainer(bg, c, createdContainer)
+	return createdContainerID, client.doStartContainer(bg, c, createdContainer)
 
 }
 
 func (client dockerClient) doStartContainer(bg context.Context, c Container, creation container.ContainerCreateCreatedBody) error {
 	name := c.Name()
 
-	log.Debugf("Starting container %s (%s)", name, ShortID(creation.ID))
+	log.Debugf("Starting container %s (%s)", name, t.ContainerID(creation.ID).ShortID())
 	err := client.api.ContainerStart(bg, creation.ID, types.ContainerStartOptions{})
 	if err != nil {
 		return err
@@ -255,38 +257,39 @@ func (client dockerClient) doStartContainer(bg context.Context, c Container, cre
 
 func (client dockerClient) RenameContainer(c Container, newName string) error {
 	bg := context.Background()
-	log.Debugf("Renaming container %s (%s) to %s", c.Name(), ShortID(c.ID()), newName)
-	return client.api.ContainerRename(bg, c.ID(), newName)
+	log.Debugf("Renaming container %s (%s) to %s", c.Name(), c.ID().ShortID(), newName)
+	return client.api.ContainerRename(bg, string(c.ID()), newName)
 }
 
-func (client dockerClient) IsContainerStale(container Container) (bool, error) {
+func (client dockerClient) IsContainerStale(container Container) (stale bool, latestImage t.ImageID, err error) {
 	ctx := context.Background()
 
 	if !client.pullImages {
 		log.Debugf("Skipping image pull.")
 	} else if err := client.PullImage(ctx, container); err != nil {
-		return false, err
+		return false, container.SafeImageID(), err
 	}
 
 	return client.HasNewImage(ctx, container)
 }
 
-func (client dockerClient) HasNewImage(ctx context.Context, container Container) (bool, error) {
-	oldImageID := container.containerInfo.ContainerJSONBase.Image
+func (client dockerClient) HasNewImage(ctx context.Context, container Container) (hasNew bool, latestImage t.ImageID, err error) {
+	currentImageID := t.ImageID(container.containerInfo.ContainerJSONBase.Image)
 	imageName := container.ImageName()
 
 	newImageInfo, _, err := client.api.ImageInspectWithRaw(ctx, imageName)
 	if err != nil {
-		return false, err
+		return false, currentImageID, err
 	}
 
-	if newImageInfo.ID == oldImageID {
+	newImageID := t.ImageID(newImageInfo.ID)
+	if newImageID == currentImageID {
 		log.Debugf("No new images found for %s", container.Name())
-		return false, nil
+		return false, currentImageID, nil
 	}
 
-	log.Infof("Found new %s image (%s)", imageName, ShortID(newImageInfo.ID))
-	return true, nil
+	log.Infof("Found new %s image (%s)", imageName, newImageID.ShortID())
+	return true, newImageID, nil
 }
 
 // PullImage pulls the latest image for the supplied container, optionally skipping if it's digest can be confirmed
@@ -343,12 +346,12 @@ func (client dockerClient) PullImage(ctx context.Context, container Container) e
 	return nil
 }
 
-func (client dockerClient) RemoveImageByID(id string) error {
-	log.Infof("Removing image %s", ShortID(id))
+func (client dockerClient) RemoveImageByID(id t.ImageID) error {
+	log.Infof("Removing image %s", id.ShortID())
 
 	_, err := client.api.ImageRemove(
 		context.Background(),
-		id,
+		string(id),
 		types.ImageRemoveOptions{
 			Force: true,
 		})
@@ -356,7 +359,7 @@ func (client dockerClient) RemoveImageByID(id string) error {
 	return err
 }
 
-func (client dockerClient) ExecuteCommand(containerID string, command string, timeout int) error {
+func (client dockerClient) ExecuteCommand(containerID t.ContainerID, command string, timeout int) (SkipUpdate bool, err error) {
 	bg := context.Background()
 
 	// Create the exec
@@ -366,9 +369,9 @@ func (client dockerClient) ExecuteCommand(containerID string, command string, ti
 		Cmd:    []string{"sh", "-c", command},
 	}
 
-	exec, err := client.api.ContainerExecCreate(bg, containerID, execConfig)
+	exec, err := client.api.ContainerExecCreate(bg, string(containerID), execConfig)
 	if err != nil {
-		return err
+		return false, err
 	}
 
 	response, attachErr := client.api.ContainerExecAttach(bg, exec.ID, types.ExecStartCheck{
@@ -383,7 +386,7 @@ func (client dockerClient) ExecuteCommand(containerID string, command string, ti
 	execStartCheck := types.ExecStartCheck{Detach: false, Tty: true}
 	err = client.api.ContainerExecStart(bg, exec.ID, execStartCheck)
 	if err != nil {
-		return err
+		return false, err
 	}
 
 	var output string
@@ -400,15 +403,16 @@ func (client dockerClient) ExecuteCommand(containerID string, command string, ti
 
 	// Inspect the exec to get the exit code and print a message if the
 	// exit code is not success.
-	err = client.waitForExecOrTimeout(bg, exec.ID, output, timeout)
+	skipUpdate, err := client.waitForExecOrTimeout(bg, exec.ID, output, timeout)
 	if err != nil {
-		return err
+		return true, err
 	}
 
-	return nil
+	return skipUpdate, nil
 }
 
-func (client dockerClient) waitForExecOrTimeout(bg context.Context, ID string, execOutput string, timeout int) error {
+func (client dockerClient) waitForExecOrTimeout(bg context.Context, ID string, execOutput string, timeout int) (SkipUpdate bool, err error) {
+	const ExTempFail = 75
 	var ctx context.Context
 	var cancel context.CancelFunc
 
@@ -430,7 +434,7 @@ func (client dockerClient) waitForExecOrTimeout(bg context.Context, ID string, e
 		}).Debug("Awaiting timeout or completion")
 
 		if err != nil {
-			return err
+			return false, err
 		}
 		if execInspect.Running == true {
 			time.Sleep(1 * time.Second)
@@ -439,13 +443,17 @@ func (client dockerClient) waitForExecOrTimeout(bg context.Context, ID string, e
 		if len(execOutput) > 0 {
 			log.Infof("Command output:\n%v", execOutput)
 		}
+
+		if execInspect.ExitCode == ExTempFail {
+			return true, nil
+		}
+
 		if execInspect.ExitCode > 0 {
-			log.Errorf("Command exited with code %v.", execInspect.ExitCode)
-			log.Error(execOutput)
+			return false, fmt.Errorf("Command exited with code %v  %s", execInspect.ExitCode, execOutput)
 		}
 		break
 	}
-	return nil
+	return false, nil
 }
 
 func (client dockerClient) waitForStopOrTimeout(c Container, waitTime time.Duration) error {
@@ -457,7 +465,7 @@ func (client dockerClient) waitForStopOrTimeout(c Container, waitTime time.Durat
 		case <-timeout:
 			return nil
 		default:
-			if ci, err := client.api.ContainerInspect(bg, c.ID()); err != nil {
+			if ci, err := client.api.ContainerInspect(bg, string(c.ID())); err != nil {
 				return err
 			} else if !ci.State.Running {
 				return nil
diff --git a/pkg/container/container.go b/pkg/container/container.go
index 92abec2..4ea3e9f 100644
--- a/pkg/container/container.go
+++ b/pkg/container/container.go
@@ -6,6 +6,7 @@ import (
 	"strings"
 
 	"github.com/containrrr/watchtower/internal/util"
+	wt "github.com/containrrr/watchtower/pkg/types"
 
 	"github.com/docker/docker/api/types"
 	dockercontainer "github.com/docker/docker/api/types/container"
@@ -35,8 +36,8 @@ func (c Container) ContainerInfo() *types.ContainerJSON {
 }
 
 // ID returns the Docker container ID.
-func (c Container) ID() string {
-	return c.containerInfo.ID
+func (c Container) ID() wt.ContainerID {
+	return wt.ContainerID(c.containerInfo.ID)
 }
 
 // IsRunning returns a boolean flag indicating whether or not the current
@@ -46,15 +47,31 @@ func (c Container) IsRunning() bool {
 	return c.containerInfo.State.Running
 }
 
+// IsRestarting returns a boolean flag indicating whether or not the current
+// container is restarting. The status is determined by the value of the
+// container's "State.Restarting" property.
+func (c Container) IsRestarting() bool {
+	return c.containerInfo.State.Restarting
+}
+
 // Name returns the Docker container name.
 func (c Container) Name() string {
 	return c.containerInfo.Name
 }
 
 // ImageID returns the ID of the Docker image that was used to start the
-// container.
-func (c Container) ImageID() string {
-	return c.imageInfo.ID
+// container. May cause nil dereference if imageInfo is not set!
+func (c Container) ImageID() wt.ImageID {
+	return wt.ImageID(c.imageInfo.ID)
+}
+
+// SafeImageID returns the ID of the Docker image that was used to start the container if available,
+// otherwise returns an empty string
+func (c Container) SafeImageID() wt.ImageID {
+	if c.imageInfo == nil {
+		return ""
+	}
+	return wt.ImageID(c.imageInfo.ID)
 }
 
 // ImageName returns the name of the Docker image that was used to start the
diff --git a/pkg/container/mocks/ApiServer.go b/pkg/container/mocks/ApiServer.go
index 35b52e2..e192496 100644
--- a/pkg/container/mocks/ApiServer.go
+++ b/pkg/container/mocks/ApiServer.go
@@ -25,13 +25,13 @@ func NewMockAPIServer() *httptest.Server {
 
 				Filters := r.URL.Query().Get("filters")
 				var result map[string]interface{}
-				json.Unmarshal([]byte(Filters), &result)
+				_ = json.Unmarshal([]byte(Filters), &result)
 				status := result["status"].(map[string]interface{})
 
 				response = getMockJSONFromDisk("./mocks/data/containers.json")
 				var x2 []types.Container
 				var containers []types.Container
-				json.Unmarshal([]byte(response), &containers)
+				_ = json.Unmarshal([]byte(response), &containers)
 				for _, v := range containers {
 					for key := range status {
 						if v.State == key {
@@ -56,7 +56,7 @@ func NewMockAPIServer() *httptest.Server {
 			} else if isRequestFor("sha256:4dbc5f9c07028a985e14d1393e849ea07f68804c4293050d5a641b138db72daa", r) {
 				response = getMockJSONFromDisk("./mocks/data/image02.json")
 			}
-			fmt.Fprintln(w, response)
+			_, _ = fmt.Fprintln(w, response)
 		},
 	))
 }
@@ -67,10 +67,9 @@ func isRequestFor(urlPart string, r *http.Request) bool {
 
 func getMockJSONFromDisk(relPath string) string {
 	absPath, _ := filepath.Abs(relPath)
-	logrus.Error(absPath)
 	buf, err := ioutil.ReadFile(absPath)
 	if err != nil {
-		logrus.Error(err)
+		logrus.WithError(err).WithField("file", absPath).Error(err)
 		return ""
 	}
 	return string(buf)
diff --git a/pkg/container/util.go b/pkg/container/util.go
deleted file mode 100644
index 261316f..0000000
--- a/pkg/container/util.go
+++ /dev/null
@@ -1,23 +0,0 @@
-package container
-
-import "strings"
-
-// ShortID returns the 12-character (hex) short version of an image ID hash, removing any "sha256:" prefix if present
-func ShortID(imageID string) (short string) {
-	prefixSep := strings.IndexRune(imageID, ':')
-	offset := 0
-	length := 12
-	if prefixSep >= 0 {
-		if imageID[0:prefixSep] == "sha256" {
-			offset = prefixSep + 1
-		} else {
-			length += prefixSep + 1
-		}
-	}
-
-	if len(imageID) >= offset+length {
-		return imageID[offset : offset+length]
-	}
-
-	return imageID
-}
diff --git a/pkg/lifecycle/lifecycle.go b/pkg/lifecycle/lifecycle.go
index df639d7..f99913b 100644
--- a/pkg/lifecycle/lifecycle.go
+++ b/pkg/lifecycle/lifecycle.go
@@ -12,8 +12,8 @@ func ExecutePreChecks(client container.Client, params types.UpdateParams) {
 	if err != nil {
 		return
 	}
-	for _, container := range containers {
-		ExecutePreCheckCommand(client, container)
+	for _, currentContainer := range containers {
+		ExecutePreCheckCommand(client, currentContainer)
 	}
 }
 
@@ -23,8 +23,8 @@ func ExecutePostChecks(client container.Client, params types.UpdateParams) {
 	if err != nil {
 		return
 	}
-	for _, container := range containers {
-		ExecutePostCheckCommand(client, container)
+	for _, currentContainer := range containers {
+		ExecutePostCheckCommand(client, currentContainer)
 	}
 }
 
@@ -37,7 +37,8 @@ func ExecutePreCheckCommand(client container.Client, container container.Contain
 	}
 
 	log.Debug("Executing pre-check command.")
-	if err := client.ExecuteCommand(container.ID(), command, 1); err != nil {
+	_, err := client.ExecuteCommand(container.ID(), command, 1)
+	if err != nil {
 		log.Error(err)
 	}
 }
@@ -51,18 +52,24 @@ func ExecutePostCheckCommand(client container.Client, container container.Contai
 	}
 
 	log.Debug("Executing post-check command.")
-	if err := client.ExecuteCommand(container.ID(), command, 1); err != nil {
+	_, err := client.ExecuteCommand(container.ID(), command, 1)
+	if err != nil {
 		log.Error(err)
 	}
 }
 
 // ExecutePreUpdateCommand tries to run the pre-update lifecycle hook for a single container.
-func ExecutePreUpdateCommand(client container.Client, container container.Container) error {
+func ExecutePreUpdateCommand(client container.Client, container container.Container) (SkipUpdate bool, err error) {
 	timeout := container.PreUpdateTimeout()
 	command := container.GetLifecyclePreUpdateCommand()
 	if len(command) == 0 {
 		log.Debug("No pre-update command supplied. Skipping")
-		return nil
+		return false, nil
+	}
+
+	if !container.IsRunning() || container.IsRestarting() {
+		log.Debug("Container is not running. Skipping pre-update command.")
+		return false, nil
 	}
 
 	log.Debug("Executing pre-update command.")
@@ -70,7 +77,7 @@ func ExecutePreUpdateCommand(client container.Client, container container.Contai
 }
 
 // ExecutePostUpdateCommand tries to run the post-update lifecycle hook for a single container.
-func ExecutePostUpdateCommand(client container.Client, newContainerID string) {
+func ExecutePostUpdateCommand(client container.Client, newContainerID types.ContainerID) {
 	newContainer, err := client.GetContainer(newContainerID)
 	if err != nil {
 		log.Error(err)
@@ -84,7 +91,9 @@ func ExecutePostUpdateCommand(client container.Client, newContainerID string) {
 	}
 
 	log.Debug("Executing post-update command.")
-	if err := client.ExecuteCommand(newContainerID, command, 1); err != nil {
+	_, err = client.ExecuteCommand(newContainerID, command, 1)
+
+	if err != nil {
 		log.Error(err)
 	}
 }
diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go
index d8761ba..b681733 100644
--- a/pkg/metrics/metrics.go
+++ b/pkg/metrics/metrics.go
@@ -1,6 +1,7 @@
 package metrics
 
 import (
+	"github.com/containrrr/watchtower/pkg/types"
 	"github.com/prometheus/client_golang/prometheus"
 	"github.com/prometheus/client_golang/prometheus/promauto"
 )
@@ -24,6 +25,16 @@ type Metrics struct {
 	skipped prometheus.Counter
 }
 
+// NewMetric returns a Metric with the counts taken from the appropriate types.Report fields
+func NewMetric(report types.Report) *Metric {
+	return &Metric{
+		Scanned: len(report.Scanned()),
+		// Note: This is for backwards compatibility. ideally, stale containers should be counted separately
+		Updated: len(report.Updated()) + len(report.Stale()),
+		Failed:  len(report.Failed()),
+	}
+}
+
 // QueueIsEmpty checks whether any messages are enqueued in the channel
 func (metrics *Metrics) QueueIsEmpty() bool {
 	return len(metrics.channel) == 0
diff --git a/pkg/notifications/email.go b/pkg/notifications/email.go
index 4984139..6a61dd1 100644
--- a/pkg/notifications/email.go
+++ b/pkg/notifications/email.go
@@ -25,11 +25,6 @@ type emailTypeNotifier struct {
 	delay                              time.Duration
 }
 
-// NewEmailNotifier is a factory method creating a new email notifier instance
-func NewEmailNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.ConvertibleNotifier {
-	return newEmailNotifier(c, acceptedLogLevels)
-}
-
 func newEmailNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.ConvertibleNotifier {
 	flags := c.PersistentFlags()
 
@@ -60,14 +55,14 @@ func newEmailNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Convert
 	return n
 }
 
-func (e *emailTypeNotifier) GetURL() (string, error) {
+func (e *emailTypeNotifier) GetURL(c *cobra.Command) (string, error) {
 	conf := &shoutrrrSmtp.Config{
 		FromAddress: e.From,
 		FromName:    "Watchtower",
 		ToAddresses: []string{e.To},
 		Port:        uint16(e.Port),
 		Host:        e.Server,
-		Subject:     e.getSubject(),
+		Subject:     e.getSubject(c),
 		Username:    e.User,
 		Password:    e.Password,
 		UseStartTLS: !e.tlsSkipVerify,
@@ -87,8 +82,8 @@ func (e *emailTypeNotifier) GetURL() (string, error) {
 	return conf.GetURL().String(), nil
 }
 
-func (e *emailTypeNotifier) getSubject() string {
-	subject := GetTitle()
+func (e *emailTypeNotifier) getSubject(c *cobra.Command) string {
+	subject := GetTitle(c)
 
 	if e.SubjectTag != "" {
 		subject = e.SubjectTag + " " + subject
diff --git a/pkg/notifications/gotify.go b/pkg/notifications/gotify.go
index 7a6009b..6f01000 100644
--- a/pkg/notifications/gotify.go
+++ b/pkg/notifications/gotify.go
@@ -22,11 +22,6 @@ type gotifyTypeNotifier struct {
 	logLevels                []log.Level
 }
 
-// NewGotifyNotifier is a factory method creating a new gotify notifier instance
-func NewGotifyNotifier(c *cobra.Command, levels []log.Level) t.ConvertibleNotifier {
-	return newGotifyNotifier(c, levels)
-}
-
 func newGotifyNotifier(c *cobra.Command, levels []log.Level) t.ConvertibleNotifier {
 	flags := c.PersistentFlags()
 
@@ -67,7 +62,7 @@ func getGotifyURL(flags *pflag.FlagSet) string {
 	return gotifyURL
 }
 
-func (n *gotifyTypeNotifier) GetURL() (string, error) {
+func (n *gotifyTypeNotifier) GetURL(c *cobra.Command) (string, error) {
 	apiURL, err := url.Parse(n.gotifyURL)
 	if err != nil {
 		return "", err
@@ -77,7 +72,7 @@ func (n *gotifyTypeNotifier) GetURL() (string, error) {
 		Host:       apiURL.Host,
 		Path:       apiURL.Path,
 		DisableTLS: apiURL.Scheme == "http",
-		Title:      GetTitle(),
+		Title:      GetTitle(c),
 		Token:      n.gotifyAppToken,
 	}
 
diff --git a/pkg/notifications/msteams.go b/pkg/notifications/msteams.go
index 6c47229..b95a99e 100644
--- a/pkg/notifications/msteams.go
+++ b/pkg/notifications/msteams.go
@@ -18,11 +18,6 @@ type msTeamsTypeNotifier struct {
 	data       bool
 }
 
-// NewMsTeamsNotifier is a factory method creating a new teams notifier instance
-func NewMsTeamsNotifier(cmd *cobra.Command, acceptedLogLevels []log.Level) t.ConvertibleNotifier {
-	return newMsTeamsNotifier(cmd, acceptedLogLevels)
-}
-
 func newMsTeamsNotifier(cmd *cobra.Command, acceptedLogLevels []log.Level) t.ConvertibleNotifier {
 
 	flags := cmd.PersistentFlags()
@@ -42,7 +37,7 @@ func newMsTeamsNotifier(cmd *cobra.Command, acceptedLogLevels []log.Level) t.Con
 	return n
 }
 
-func (n *msTeamsTypeNotifier) GetURL() (string, error) {
+func (n *msTeamsTypeNotifier) GetURL(c *cobra.Command) (string, error) {
 	webhookURL, err := url.Parse(n.webHookURL)
 	if err != nil {
 		return "", err
@@ -54,7 +49,7 @@ func (n *msTeamsTypeNotifier) GetURL() (string, error) {
 	}
 
 	config.Color = ColorHex
-	config.Title = GetTitle()
+	config.Title = GetTitle(c)
 
 	return config.GetURL().String(), nil
 }
diff --git a/pkg/notifications/notifier.go b/pkg/notifications/notifier.go
index c4e962f..e1cb5e7 100644
--- a/pkg/notifications/notifier.go
+++ b/pkg/notifications/notifier.go
@@ -6,18 +6,10 @@ import (
 	log "github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
 	"os"
-	"strings"
 )
 
-// Notifier can send log output as notification to admins, with optional batching.
-type Notifier struct {
-	types []ty.Notifier
-}
-
 // NewNotifier creates and returns a new Notifier, using global configuration.
-func NewNotifier(c *cobra.Command) *Notifier {
-	n := &Notifier{}
-
+func NewNotifier(c *cobra.Command) ty.Notifier {
 	f := c.PersistentFlags()
 
 	level, _ := f.GetString("notifications-level")
@@ -32,54 +24,26 @@ func NewNotifier(c *cobra.Command) *Notifier {
 		log.Fatalf("Unsupported notification log level provided: %s", level)
 	}
 
-	// Parse types and create notifiers.
-	types, err := f.GetStringSlice("notifications")
-	if err != nil {
-		log.WithField("could not read notifications argument", log.Fields{"Error": err}).Fatal()
-	}
+	reportTemplate, _ := f.GetBool("notification-report")
+	tplString, _ := f.GetString("notification-template")
+	urls, _ := f.GetStringArray("notification-url")
 
-	n.types = n.getNotificationTypes(c, acceptedLogLevels, types)
+	urls = AppendLegacyUrls(urls, c)
 
-	return n
+	return newShoutrrrNotifier(tplString, acceptedLogLevels, !reportTemplate, urls...)
 }
 
-func (n *Notifier) String() string {
-	if len(n.types) < 1 {
-		return ""
-	}
-
-	sb := strings.Builder{}
-	for _, notif := range n.types {
-		for _, name := range notif.GetNames() {
-			sb.WriteString(name)
-			sb.WriteString(", ")
-		}
-	}
+// AppendLegacyUrls creates shoutrrr equivalent URLs from legacy notification flags
+func AppendLegacyUrls(urls []string, cmd *cobra.Command) []string {
 
-	if sb.Len() < 2 {
-		// No notification services are configured, return early as the separator strip is not applicable
-		return "none"
+	// Parse types and create notifiers.
+	types, err := cmd.Flags().GetStringSlice("notifications")
+	if err != nil {
+		log.WithError(err).Fatal("could not read notifications argument")
 	}
 
-	names := sb.String()
-
-	// remove the last separator
-	names = names[:len(names)-2]
-
-	return names
-}
-
-// getNotificationTypes produces an array of notifiers from a list of types
-func (n *Notifier) getNotificationTypes(cmd *cobra.Command, levels []log.Level, types []string) []ty.Notifier {
-	output := make([]ty.Notifier, 0)
-
 	for _, t := range types {
 
-		if t == shoutrrrType {
-			output = append(output, newShoutrrrNotifier(cmd, levels))
-			continue
-		}
-
 		var legacyNotifier ty.ConvertibleNotifier
 		var err error
 
@@ -89,60 +53,39 @@ func (n *Notifier) getNotificationTypes(cmd *cobra.Command, levels []log.Level,
 		case slackType:
 			legacyNotifier = newSlackNotifier(cmd, []log.Level{})
 		case msTeamsType:
-			legacyNotifier = newMsTeamsNotifier(cmd, levels)
+			legacyNotifier = newMsTeamsNotifier(cmd, []log.Level{})
 		case gotifyType:
 			legacyNotifier = newGotifyNotifier(cmd, []log.Level{})
+		case shoutrrrType:
+			continue
 		default:
 			log.Fatalf("Unknown notification type %q", t)
 			// Not really needed, used for nil checking static analysis
 			continue
 		}
 
-		shoutrrrURL, err := legacyNotifier.GetURL()
+		shoutrrrURL, err := legacyNotifier.GetURL(cmd)
 		if err != nil {
 			log.Fatal("failed to create notification config:", err)
 		}
+		urls = append(urls, shoutrrrURL)
 
 		log.WithField("URL", shoutrrrURL).Trace("created Shoutrrr URL from legacy notifier")
-
-		notifier := newShoutrrrNotifierFromURL(
-			cmd,
-			shoutrrrURL,
-			levels,
-		)
-
-		output = append(output, notifier)
-	}
-
-	return output
-}
-
-// StartNotification starts a log batch. Notifications will be accumulated after this point and only sent when SendNotification() is called.
-func (n *Notifier) StartNotification() {
-	for _, t := range n.types {
-		t.StartNotification()
-	}
-}
-
-// SendNotification sends any notifications accumulated since StartNotification() was called.
-func (n *Notifier) SendNotification() {
-	for _, t := range n.types {
-		t.SendNotification()
-	}
-}
-
-// Close closes all notifiers.
-func (n *Notifier) Close() {
-	for _, t := range n.types {
-		t.Close()
 	}
+	return urls
 }
 
 // GetTitle returns a common notification title with hostname appended
-func GetTitle() (title string) {
+func GetTitle(c *cobra.Command) (title string) {
 	title = "Watchtower updates"
 
-	if hostname, err := os.Hostname(); err == nil {
+	f := c.PersistentFlags()
+
+	hostname, _ := f.GetString("notifications-hostname")
+
+	if hostname != "" {
+		title += " on " + hostname
+	} else if hostname, err := os.Hostname(); err == nil {
 		title += " on " + hostname
 	}
 
diff --git a/pkg/notifications/shoutrrr.go b/pkg/notifications/shoutrrr.go
index 087e4d6..41ef126 100644
--- a/pkg/notifications/shoutrrr.go
+++ b/pkg/notifications/shoutrrr.go
@@ -11,12 +11,26 @@ import (
 	"github.com/containrrr/shoutrrr/pkg/types"
 	t "github.com/containrrr/watchtower/pkg/types"
 	log "github.com/sirupsen/logrus"
-	"github.com/spf13/cobra"
 )
 
 const (
-	shoutrrrDefaultTemplate = "{{range .}}{{.Message}}{{println}}{{end}}"
-	shoutrrrType            = "shoutrrr"
+	shoutrrrDefaultLegacyTemplate = "{{range .}}{{.Message}}{{println}}{{end}}"
+	shoutrrrDefaultTemplate       = `{{- with .Report -}}
+{{len .Scanned}} Scanned, {{len .Updated}} Updated, {{len .Failed}} Failed
+{{range .Updated -}}
+- {{.Name}} ({{.ImageName}}): {{.CurrentImageID.ShortID}} updated to {{.LatestImageID.ShortID}}
+{{end -}}
+{{range .Fresh -}}
+- {{.Name}} ({{.ImageName}}): {{.State}}
+{{end -}}
+{{range .Skipped -}}
+- {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}}
+{{end -}}
+{{range .Failed -}}
+- {{.Name}} ({{.ImageName}}): {{.State}}: {{.Error}}
+{{end -}}
+{{end -}}`
+	shoutrrrType = "shoutrrr"
 )
 
 type router interface {
@@ -25,41 +39,49 @@ type router interface {
 
 // Implements Notifier, logrus.Hook
 type shoutrrrTypeNotifier struct {
-	Urls      []string
-	Router    router
-	entries   []*log.Entry
-	logLevels []log.Level
-	template  *template.Template
-	messages  chan string
-	done      chan bool
+	Urls           []string
+	Router         router
+	entries        []*log.Entry
+	logLevels      []log.Level
+	template       *template.Template
+	messages       chan string
+	done           chan bool
+	legacyTemplate bool
+}
+
+// GetScheme returns the scheme part of a Shoutrrr URL
+func GetScheme(url string) string {
+	schemeEnd := strings.Index(url, ":")
+	if schemeEnd <= 0 {
+		return "invalid"
+	}
+	return url[:schemeEnd]
 }
 
 func (n *shoutrrrTypeNotifier) GetNames() []string {
 	names := make([]string, len(n.Urls))
 	for i, u := range n.Urls {
-		schemeEnd := strings.Index(u, ":")
-		if schemeEnd <= 0 {
-			names[i] = "invalid"
-			continue
-		}
-		names[i] = u[:schemeEnd]
+		names[i] = GetScheme(u)
 	}
 	return names
 }
 
-func newShoutrrrNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Notifier {
-	flags := c.PersistentFlags()
-	urls, _ := flags.GetStringArray("notification-url")
-	tpl := getShoutrrrTemplate(c)
-	return createSender(urls, acceptedLogLevels, tpl)
-}
+func newShoutrrrNotifier(tplString string, acceptedLogLevels []log.Level, legacy bool, urls ...string) t.Notifier {
+
+	notifier := createNotifier(urls, acceptedLogLevels, tplString, legacy)
+	log.AddHook(notifier)
+
+	// Do the sending in a separate goroutine so we don't block the main process.
+	go sendNotifications(notifier)
 
-func newShoutrrrNotifierFromURL(c *cobra.Command, url string, levels []log.Level) t.Notifier {
-	tpl := getShoutrrrTemplate(c)
-	return createSender([]string{url}, levels, tpl)
+	return notifier
 }
 
-func createSender(urls []string, levels []log.Level, template *template.Template) t.Notifier {
+func createNotifier(urls []string, levels []log.Level, tplString string, legacy bool) *shoutrrrTypeNotifier {
+	tpl, err := getShoutrrrTemplate(tplString, legacy)
+	if err != nil {
+		log.Errorf("Could not use configured notification template: %s. Using default template", err)
+	}
 
 	traceWriter := log.StandardLogger().WriterLevel(log.TraceLevel)
 	r, err := shoutrrr.NewSender(stdlog.New(traceWriter, "Shoutrrr: ", 0), urls...)
@@ -67,21 +89,15 @@ func createSender(urls []string, levels []log.Level, template *template.Template
 		log.Fatalf("Failed to initialize Shoutrrr notifications: %s\n", err.Error())
 	}
 
-	n := &shoutrrrTypeNotifier{
-		Urls:      urls,
-		Router:    r,
-		messages:  make(chan string, 1),
-		done:      make(chan bool),
-		logLevels: levels,
-		template:  template,
+	return &shoutrrrTypeNotifier{
+		Urls:           urls,
+		Router:         r,
+		messages:       make(chan string, 1),
+		done:           make(chan bool),
+		logLevels:      levels,
+		template:       tpl,
+		legacyTemplate: legacy,
 	}
-
-	log.AddHook(n)
-
-	// Do the sending in a separate goroutine so we don't block the main process.
-	go sendNotifications(n)
-
-	return n
 }
 
 func sendNotifications(n *shoutrrrTypeNotifier) {
@@ -90,8 +106,9 @@ func sendNotifications(n *shoutrrrTypeNotifier) {
 
 		for i, err := range errs {
 			if err != nil {
+				scheme := GetScheme(n.Urls[i])
 				// Use fmt so it doesn't trigger another notification.
-				fmt.Println("Failed to send notification via shoutrrr (url="+n.Urls[i]+"): ", err)
+				fmt.Printf("Failed to send shoutrrr notification (#%d, %s): %v\n", i, scheme, err)
 			}
 		}
 	}
@@ -99,17 +116,21 @@ func sendNotifications(n *shoutrrrTypeNotifier) {
 	n.done <- true
 }
 
-func (n *shoutrrrTypeNotifier) buildMessage(entries []*log.Entry) string {
+func (n *shoutrrrTypeNotifier) buildMessage(data Data) string {
 	var body bytes.Buffer
-	if err := n.template.Execute(&body, entries); err != nil {
+	var templateData interface{} = data
+	if n.legacyTemplate {
+		templateData = data.Entries
+	}
+	if err := n.template.Execute(&body, templateData); err != nil {
 		fmt.Printf("Failed to execute Shoutrrrr template: %s\n", err.Error())
 	}
 
 	return body.String()
 }
 
-func (n *shoutrrrTypeNotifier) sendEntries(entries []*log.Entry) {
-	msg := n.buildMessage(entries)
+func (n *shoutrrrTypeNotifier) sendEntries(entries []*log.Entry, report t.Report) {
+	msg := n.buildMessage(Data{entries, report})
 	n.messages <- msg
 }
 
@@ -119,12 +140,12 @@ func (n *shoutrrrTypeNotifier) StartNotification() {
 	}
 }
 
-func (n *shoutrrrTypeNotifier) SendNotification() {
-	if n.entries == nil || len(n.entries) <= 0 {
-		return
-	}
+func (n *shoutrrrTypeNotifier) SendNotification(report t.Report) {
+	//if n.entries == nil || len(n.entries) <= 0 {
+	//	return
+	//}
 
-	n.sendEntries(n.entries)
+	n.sendEntries(n.entries, report)
 	n.entries = nil
 }
 
@@ -146,36 +167,23 @@ func (n *shoutrrrTypeNotifier) Fire(entry *log.Entry) error {
 		n.entries = append(n.entries, entry)
 	} else {
 		// Log output generated outside a cycle is sent immediately.
-		n.sendEntries([]*log.Entry{entry})
+		n.sendEntries([]*log.Entry{entry}, nil)
 	}
 	return nil
 }
 
-func getShoutrrrTemplate(c *cobra.Command) *template.Template {
-	var tpl *template.Template
-
-	flags := c.PersistentFlags()
-
-	tplString, err := flags.GetString("notification-template")
-
+func getShoutrrrTemplate(tplString string, legacy bool) (tpl *template.Template, err error) {
 	funcs := template.FuncMap{
 		"ToUpper": strings.ToUpper,
 		"ToLower": strings.ToLower,
 		"Title":   strings.Title,
 	}
+	tplBase := template.New("").Funcs(funcs)
 
 	// If we succeed in getting a non-empty template configuration
 	// try to parse the template string.
-	if tplString != "" && err == nil {
-		tpl, err = template.New("").Funcs(funcs).Parse(tplString)
-	}
-
-	// In case of errors (either from parsing the template string
-	// or from getting the template configuration) log an error
-	// message about this and the fact that we'll use the default
-	// template instead.
-	if err != nil {
-		log.Errorf("Could not use configured notification template: %s. Using default template", err)
+	if tplString != "" {
+		tpl, err = tplBase.Parse(tplString)
 	}
 
 	// If we had an error (either from parsing the template string
@@ -183,8 +191,19 @@ func getShoutrrrTemplate(c *cobra.Command) *template.Template {
 	// template wasn't configured (the empty template string)
 	// fallback to using the default template.
 	if err != nil || tplString == "" {
-		tpl = template.Must(template.New("").Funcs(funcs).Parse(shoutrrrDefaultTemplate))
+		defaultTemplate := shoutrrrDefaultTemplate
+		if legacy {
+			defaultTemplate = shoutrrrDefaultLegacyTemplate
+		}
+
+		tpl = template.Must(tplBase.Parse(defaultTemplate))
 	}
 
-	return tpl
+	return
+}
+
+// Data is the notification template data model
+type Data struct {
+	Entries []*log.Entry
+	Report  t.Report
 }
diff --git a/pkg/notifications/slack.go b/pkg/notifications/slack.go
index b3df119..7f6e0d4 100644
--- a/pkg/notifications/slack.go
+++ b/pkg/notifications/slack.go
@@ -19,11 +19,6 @@ type slackTypeNotifier struct {
 	slackrus.SlackrusHook
 }
 
-// NewSlackNotifier is a factory function used to generate new instance of the slack notifier type
-func NewSlackNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.ConvertibleNotifier {
-	return newSlackNotifier(c, acceptedLogLevels)
-}
-
 func newSlackNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.ConvertibleNotifier {
 	flags := c.PersistentFlags()
 
@@ -46,7 +41,7 @@ func newSlackNotifier(c *cobra.Command, acceptedLogLevels []log.Level) t.Convert
 	return n
 }
 
-func (s *slackTypeNotifier) GetURL() (string, error) {
+func (s *slackTypeNotifier) GetURL(c *cobra.Command) (string, error) {
 	trimmedURL := strings.TrimRight(s.HookURL, "/")
 	trimmedURL = strings.TrimLeft(trimmedURL, "https://")
 	parts := strings.Split(trimmedURL, "/")
@@ -57,7 +52,7 @@ func (s *slackTypeNotifier) GetURL() (string, error) {
 			Channel:    parts[len(parts)-3],
 			Token:      parts[len(parts)-2],
 			Color:      ColorInt,
-			Title:      GetTitle(),
+			Title:      GetTitle(c),
 			SplitLines: true,
 			Username:   s.Username,
 		}
@@ -71,7 +66,7 @@ func (s *slackTypeNotifier) GetURL() (string, error) {
 		BotName: s.Username,
 		Token:   tokens,
 		Color:   ColorHex,
-		Title:   GetTitle(),
+		Title:   GetTitle(c),
 	}
 
 	return conf.GetURL().String(), nil
diff --git a/pkg/notifications/smtp.go b/pkg/notifications/smtp.go
deleted file mode 100644
index 82954bc..0000000
--- a/pkg/notifications/smtp.go
+++ /dev/null
@@ -1,77 +0,0 @@
-// Package notifications ...
-// Copyright 2010 The Go Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license.
-package notifications
-
-import (
-	"crypto/tls"
-	"net"
-	"net/smtp"
-)
-
-// SendMail connects to the server at addr, switches to TLS if
-// possible, authenticates with the optional mechanism a if possible,
-// and then sends an email from address from, to addresses to, with
-// message msg.
-// The addr must include a port, as in "mail.example.com:smtp".
-//
-// The addresses in the to parameter are the SMTP RCPT addresses.
-//
-// The msg parameter should be an RFC 822-style email with headers
-// first, a blank line, and then the message body. The lines of msg
-// should be CRLF terminated. The msg headers should usually include
-// fields such as "From", "To", "Subject", and "Cc".  Sending "Bcc"
-// messages is accomplished by including an email address in the to
-// parameter but not including it in the msg headers.
-//
-// The SendMail function and the net/smtp package are low-level
-// mechanisms and provide no support for DKIM signing, MIME
-// attachments (see the mime/multipart package), or other mail
-// functionality. Higher-level packages exist outside of the standard
-// library.
-func SendMail(addr string, insecureSkipVerify bool, a smtp.Auth, from string, to []string, msg []byte) error {
-	c, err := smtp.Dial(addr)
-	if err != nil {
-		return err
-	}
-	defer c.Close()
-	if err = c.Hello("localHost"); err != nil {
-		return err
-	}
-	if ok, _ := c.Extension("STARTTLS"); ok {
-		serverName, _, _ := net.SplitHostPort(addr)
-		config := &tls.Config{ServerName: serverName, InsecureSkipVerify: insecureSkipVerify}
-		if err = c.StartTLS(config); err != nil {
-			return err
-		}
-	}
-	if a != nil {
-		if ok, _ := c.Extension("AUTH"); ok {
-			if err = c.Auth(a); err != nil {
-				return err
-			}
-		}
-	}
-	if err = c.Mail(from); err != nil {
-		return err
-	}
-	for _, addr := range to {
-		if err = c.Rcpt(addr); err != nil {
-			return err
-		}
-	}
-	w, err := c.Data()
-	if err != nil {
-		return err
-	}
-	_, err = w.Write(msg)
-	if err != nil {
-		return err
-	}
-	err = w.Close()
-	if err != nil {
-		return err
-	}
-	return c.Quit()
-}
diff --git a/pkg/notifications/util.go b/pkg/notifications/util.go
deleted file mode 100644
index 5764341..0000000
--- a/pkg/notifications/util.go
+++ /dev/null
@@ -1,24 +0,0 @@
-package notifications
-
-import "bytes"
-
-// SplitSubN splits a string into a list of string with each having
-// a maximum number of characters n
-func SplitSubN(s string, n int) []string {
-	sub := ""
-	subs := []string{}
-
-	runes := bytes.Runes([]byte(s))
-	l := len(runes)
-	for i, r := range runes {
-		sub = sub + string(r)
-		if (i+1)%n == 0 {
-			subs = append(subs, sub)
-			sub = ""
-		} else if (i + 1) == l {
-			subs = append(subs, sub)
-		}
-	}
-
-	return subs
-}
diff --git a/pkg/registry/digest/digest.go b/pkg/registry/digest/digest.go
index 4634688..26fbd8e 100644
--- a/pkg/registry/digest/digest.go
+++ b/pkg/registry/digest/digest.go
@@ -6,6 +6,7 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"github.com/containrrr/watchtower/internal/meta"
 	"github.com/containrrr/watchtower/pkg/registry/auth"
 	"github.com/containrrr/watchtower/pkg/registry/manifest"
 	"github.com/containrrr/watchtower/pkg/types"
@@ -21,6 +22,10 @@ const ContentDigestHeader = "Docker-Content-Digest"
 
 // CompareDigest ...
 func CompareDigest(container types.Container, registryAuth string) (bool, error) {
+	if !container.HasImageInfo() {
+		return false, errors.New("container image info missing")
+	}
+	
 	var digest string
 
 	registryAuth = TransformAuth(registryAuth)
@@ -86,6 +91,7 @@ func GetDigest(url string, token string) (string, error) {
 	client := &http.Client{Transport: tr}
 
 	req, _ := http.NewRequest("HEAD", url, nil)
+	req.Header.Set("User-Agent", meta.UserAgent)
 
 	if token != "" {
 		logrus.WithField("token", token).Trace("Setting request token")
diff --git a/pkg/session/container_status.go b/pkg/session/container_status.go
new file mode 100644
index 0000000..8313da1
--- /dev/null
+++ b/pkg/session/container_status.go
@@ -0,0 +1,82 @@
+package session
+
+import wt "github.com/containrrr/watchtower/pkg/types"
+
+// State indicates what the current state is of the container
+type State int
+
+// State enum values
+const (
+	// UnknownState is only used to represent an uninitialized State value
+	UnknownState State = iota
+	SkippedState
+	ScannedState
+	UpdatedState
+	FailedState
+	FreshState
+	StaleState
+)
+
+// ContainerStatus contains the container state during a session
+type ContainerStatus struct {
+	containerID   wt.ContainerID
+	oldImage      wt.ImageID
+	newImage      wt.ImageID
+	containerName string
+	imageName     string
+	error
+	state State
+}
+
+// ID returns the container ID
+func (u *ContainerStatus) ID() wt.ContainerID {
+	return u.containerID
+}
+
+// Name returns the container name
+func (u *ContainerStatus) Name() string {
+	return u.containerName
+}
+
+// CurrentImageID returns the image ID that the container used when the session started
+func (u *ContainerStatus) CurrentImageID() wt.ImageID {
+	return u.oldImage
+}
+
+// LatestImageID returns the newest image ID found during the session
+func (u *ContainerStatus) LatestImageID() wt.ImageID {
+	return u.newImage
+}
+
+// ImageName returns the name:tag that the container uses
+func (u *ContainerStatus) ImageName() string {
+	return u.imageName
+}
+
+// Error returns the error (if any) that was encountered for the container during a session
+func (u *ContainerStatus) Error() string {
+	if u.error == nil {
+		return ""
+	}
+	return u.error.Error()
+}
+
+// State returns the current State that the container is in
+func (u *ContainerStatus) State() string {
+	switch u.state {
+	case SkippedState:
+		return "Skipped"
+	case ScannedState:
+		return "Scanned"
+	case UpdatedState:
+		return "Updated"
+	case FailedState:
+		return "Failed"
+	case FreshState:
+		return "Fresh"
+	case StaleState:
+		return "Stale"
+	default:
+		return "Unknown"
+	}
+}
diff --git a/pkg/session/progress.go b/pkg/session/progress.go
new file mode 100644
index 0000000..57069be
--- /dev/null
+++ b/pkg/session/progress.go
@@ -0,0 +1,56 @@
+package session
+
+import (
+	"github.com/containrrr/watchtower/pkg/types"
+)
+
+// Progress contains the current session container status
+type Progress map[types.ContainerID]*ContainerStatus
+
+// UpdateFromContainer sets various status fields from their corresponding container equivalents
+func UpdateFromContainer(cont types.Container, newImage types.ImageID, state State) *ContainerStatus {
+	return &ContainerStatus{
+		containerID:   cont.ID(),
+		containerName: cont.Name(),
+		imageName:     cont.ImageName(),
+		oldImage:      cont.SafeImageID(),
+		newImage:      newImage,
+		state:         state,
+	}
+}
+
+// AddSkipped adds a container to the Progress with the state set as skipped
+func (m Progress) AddSkipped(cont types.Container, err error) {
+	update := UpdateFromContainer(cont, cont.SafeImageID(), SkippedState)
+	update.error = err
+	m.Add(update)
+}
+
+// AddScanned adds a container to the Progress with the state set as scanned
+func (m Progress) AddScanned(cont types.Container, newImage types.ImageID) {
+	m.Add(UpdateFromContainer(cont, newImage, ScannedState))
+}
+
+// UpdateFailed updates the containers passed, setting their state as failed with the supplied error
+func (m Progress) UpdateFailed(failures map[types.ContainerID]error) {
+	for id, err := range failures {
+		update := m[id]
+		update.error = err
+		update.state = FailedState
+	}
+}
+
+// Add a container to the map using container ID as the key
+func (m Progress) Add(update *ContainerStatus) {
+	m[update.containerID] = update
+}
+
+// MarkForUpdate marks the container identified by containerID for update
+func (m Progress) MarkForUpdate(containerID types.ContainerID) {
+	m[containerID].state = UpdatedState
+}
+
+// Report creates a new Report from a Progress instance
+func (m Progress) Report() types.Report {
+	return NewReport(m)
+}
diff --git a/pkg/session/report.go b/pkg/session/report.go
new file mode 100644
index 0000000..646a0c0
--- /dev/null
+++ b/pkg/session/report.go
@@ -0,0 +1,90 @@
+package session
+
+import (
+	"github.com/containrrr/watchtower/pkg/types"
+	"sort"
+)
+
+type report struct {
+	scanned []types.ContainerReport
+	updated []types.ContainerReport
+	failed  []types.ContainerReport
+	skipped []types.ContainerReport
+	stale   []types.ContainerReport
+	fresh   []types.ContainerReport
+}
+
+func (r *report) Scanned() []types.ContainerReport {
+	return r.scanned
+}
+func (r *report) Updated() []types.ContainerReport {
+	return r.updated
+}
+func (r *report) Failed() []types.ContainerReport {
+	return r.failed
+}
+func (r *report) Skipped() []types.ContainerReport {
+	return r.skipped
+}
+func (r *report) Stale() []types.ContainerReport {
+	return r.stale
+}
+func (r *report) Fresh() []types.ContainerReport {
+	return r.fresh
+}
+
+// NewReport creates a types.Report from the supplied Progress
+func NewReport(progress Progress) types.Report {
+	report := &report{
+		scanned: []types.ContainerReport{},
+		updated: []types.ContainerReport{},
+		failed:  []types.ContainerReport{},
+		skipped: []types.ContainerReport{},
+		stale:   []types.ContainerReport{},
+		fresh:   []types.ContainerReport{},
+	}
+
+	for _, update := range progress {
+		if update.state == SkippedState {
+			report.skipped = append(report.skipped, update)
+			continue
+		}
+
+		report.scanned = append(report.scanned, update)
+		if update.newImage == update.oldImage {
+			update.state = FreshState
+			report.fresh = append(report.fresh, update)
+			continue
+		}
+
+		switch update.state {
+		case UpdatedState:
+			report.updated = append(report.updated, update)
+		case FailedState:
+			report.failed = append(report.failed, update)
+		default:
+			update.state = StaleState
+			report.stale = append(report.stale, update)
+		}
+	}
+
+	sort.Sort(sortableContainers(report.scanned))
+	sort.Sort(sortableContainers(report.updated))
+	sort.Sort(sortableContainers(report.failed))
+	sort.Sort(sortableContainers(report.skipped))
+	sort.Sort(sortableContainers(report.stale))
+	sort.Sort(sortableContainers(report.fresh))
+
+	return report
+}
+
+type sortableContainers []types.ContainerReport
+
+// Len implements sort.Interface.Len
+func (s sortableContainers) Len() int { return len(s) }
+
+// Less implements sort.Interface.Less
+func (s sortableContainers) Less(i, j int) bool { return s[i].ID() < s[j].ID() }
+
+// Swap implements sort.Interface.Swap
+func (s sortableContainers) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
diff --git a/pkg/types/container.go b/pkg/types/container.go
index 50baac6..22742e9 100644
--- a/pkg/types/container.go
+++ b/pkg/types/container.go
@@ -1,14 +1,53 @@
 package types
 
-import "github.com/docker/docker/api/types"
+import (
+	"github.com/docker/docker/api/types"
+	"strings"
+)
+
+// ImageID is a hash string representing a container image
+type ImageID string
+
+// ContainerID is a hash string representing a container instance
+type ContainerID string
+
+// ShortID returns the 12-character (hex) short version of an image ID hash, removing any "sha256:" prefix if present
+func (id ImageID) ShortID() (short string) {
+	return shortID(string(id))
+}
+
+// ShortID returns the 12-character (hex) short version of a container ID hash, removing any "sha256:" prefix if present
+func (id ContainerID) ShortID() (short string) {
+	return shortID(string(id))
+}
+
+func shortID(longID string) string {
+	prefixSep := strings.IndexRune(longID, ':')
+	offset := 0
+	length := 12
+	if prefixSep >= 0 {
+		if longID[0:prefixSep] == "sha256" {
+			offset = prefixSep + 1
+		} else {
+			length += prefixSep + 1
+		}
+	}
+
+	if len(longID) >= offset+length {
+		return longID[offset : offset+length]
+	}
+
+	return longID
+}
 
 // Container is a docker container running an image
 type Container interface {
 	ContainerInfo() *types.ContainerJSON
-	ID() string
+	ID() ContainerID
 	IsRunning() bool
 	Name() string
-	ImageID() string
+	ImageID() ImageID
+	SafeImageID() ImageID
 	ImageName() string
 	Enabled() (bool, bool)
 	IsMonitorOnly() bool
diff --git a/pkg/types/convertible_notifier.go b/pkg/types/convertible_notifier.go
index 2614d12..87f8659 100644
--- a/pkg/types/convertible_notifier.go
+++ b/pkg/types/convertible_notifier.go
@@ -1,6 +1,8 @@
 package types
 
+import "github.com/spf13/cobra"
+
 // ConvertibleNotifier is a notifier capable of creating a shoutrrr URL
 type ConvertibleNotifier interface {
-	GetURL() (string, error)
+	GetURL(c *cobra.Command) (string, error)
 }
diff --git a/pkg/types/notifier.go b/pkg/types/notifier.go
index f72f980..ccb2cb6 100644
--- a/pkg/types/notifier.go
+++ b/pkg/types/notifier.go
@@ -3,7 +3,7 @@ package types
 // Notifier is the interface that all notification services have in common
 type Notifier interface {
 	StartNotification()
-	SendNotification()
+	SendNotification(Report)
 	GetNames() []string
 	Close()
 }
diff --git a/pkg/types/report.go b/pkg/types/report.go
new file mode 100644
index 0000000..8013b58
--- /dev/null
+++ b/pkg/types/report.go
@@ -0,0 +1,22 @@
+package types
+
+// Report contains reports for all the containers processed during a session
+type Report interface {
+	Scanned() []ContainerReport
+	Updated() []ContainerReport
+	Failed() []ContainerReport
+	Skipped() []ContainerReport
+	Stale() []ContainerReport
+	Fresh() []ContainerReport
+}
+
+// ContainerReport represents a container that was included in watchtower session
+type ContainerReport interface {
+	ID() ContainerID
+	Name() string
+	CurrentImageID() ImageID
+	LatestImageID() ImageID
+	ImageName() string
+	Error() string
+	State() string
+}


Test patch:
diff --git a/internal/actions/update_test.go b/internal/actions/update_test.go
index f1b8e85..8750253 100644
--- a/internal/actions/update_test.go
+++ b/internal/actions/update_test.go
@@ -5,8 +5,9 @@ import (
 	"github.com/containrrr/watchtower/pkg/container"
 	"github.com/containrrr/watchtower/pkg/container/mocks"
 	"github.com/containrrr/watchtower/pkg/types"
-	container2 "github.com/docker/docker/api/types/container"
+	dockerContainer "github.com/docker/docker/api/types/container"
 	cli "github.com/docker/docker/client"
+	"github.com/docker/go-connections/nat"
 	"time"
 
 	. "github.com/containrrr/watchtower/internal/actions/mocks"
@@ -106,8 +107,10 @@ var _ = Describe("the update action", func() {
 								"test-container-02",
 								"test-container-02",
 								"fake-image2:latest",
+								false,
+								false,
 								time.Now(),
-								&container2.Config{
+								&dockerContainer.Config{
 									Labels: map[string]string{
 										"com.centurylinklabs.watchtower.monitor-only": "true",
 									},
@@ -158,4 +161,187 @@ var _ = Describe("the update action", func() {
 		})
 
 	})
+
+	When("watchtower has been instructed to run lifecycle hooks", func() {
+
+		When("prupddate script returns 1", func() {
+			BeforeEach(func() {
+				client = CreateMockClient(
+					&TestData{
+						//NameOfContainerToKeep: "test-container-02",
+						Containers: []container.Container{
+							CreateMockContainerWithConfig(
+								"test-container-02",
+								"test-container-02",
+								"fake-image2:latest",
+								true,
+								false,
+								time.Now(),
+								&dockerContainer.Config{
+									Labels: map[string]string{
+										"com.centurylinklabs.watchtower.lifecycle.pre-update-timeout": "190",
+										"com.centurylinklabs.watchtower.lifecycle.pre-update":         "/PreUpdateReturn1.sh",
+									},
+									ExposedPorts: map[nat.Port]struct{}{},
+								}),
+						},
+					},
+					dockerClient,
+					false,
+					false,
+				)
+			})
+
+			It("should not update those containers", func() {
+				_, err := actions.Update(client, types.UpdateParams{Cleanup: true, LifecycleHooks: true})
+				Expect(err).NotTo(HaveOccurred())
+				Expect(client.TestData.TriedToRemoveImageCount).To(Equal(0))
+			})
+
+		})
+
+		When("prupddate script returns 75", func() {
+			BeforeEach(func() {
+				client = CreateMockClient(
+					&TestData{
+						//NameOfContainerToKeep: "test-container-02",
+						Containers: []container.Container{
+							CreateMockContainerWithConfig(
+								"test-container-02",
+								"test-container-02",
+								"fake-image2:latest",
+								true,
+								false,
+								time.Now(),
+								&dockerContainer.Config{
+									Labels: map[string]string{
+										"com.centurylinklabs.watchtower.lifecycle.pre-update-timeout": "190",
+										"com.centurylinklabs.watchtower.lifecycle.pre-update":         "/PreUpdateReturn75.sh",
+									},
+									ExposedPorts: map[nat.Port]struct{}{},
+								}),
+						},
+					},
+					dockerClient,
+					false,
+					false,
+				)
+			})
+
+			It("should not update those containers", func() {
+				_, err := actions.Update(client, types.UpdateParams{Cleanup: true, LifecycleHooks: true})
+				Expect(err).NotTo(HaveOccurred())
+				Expect(client.TestData.TriedToRemoveImageCount).To(Equal(0))
+			})
+
+		})
+
+		When("prupddate script returns 0", func() {
+			BeforeEach(func() {
+				client = CreateMockClient(
+					&TestData{
+						//NameOfContainerToKeep: "test-container-02",
+						Containers: []container.Container{
+							CreateMockContainerWithConfig(
+								"test-container-02",
+								"test-container-02",
+								"fake-image2:latest",
+								true,
+								false,
+								time.Now(),
+								&dockerContainer.Config{
+									Labels: map[string]string{
+										"com.centurylinklabs.watchtower.lifecycle.pre-update-timeout": "190",
+										"com.centurylinklabs.watchtower.lifecycle.pre-update":         "/PreUpdateReturn0.sh",
+									},
+									ExposedPorts: map[nat.Port]struct{}{},
+								}),
+						},
+					},
+					dockerClient,
+					false,
+					false,
+				)
+			})
+
+			It("should update those containers", func() {
+				_, err := actions.Update(client, types.UpdateParams{Cleanup: true, LifecycleHooks: true})
+				Expect(err).NotTo(HaveOccurred())
+				Expect(client.TestData.TriedToRemoveImageCount).To(Equal(1))
+			})
+		})
+
+		When("container is not running", func() {
+			BeforeEach(func() {
+				client = CreateMockClient(
+					&TestData{
+						//NameOfContainerToKeep: "test-container-02",
+						Containers: []container.Container{
+							CreateMockContainerWithConfig(
+								"test-container-02",
+								"test-container-02",
+								"fake-image2:latest",
+								false,
+								false,
+								time.Now(),
+								&dockerContainer.Config{
+									Labels: map[string]string{
+										"com.centurylinklabs.watchtower.lifecycle.pre-update-timeout": "190",
+										"com.centurylinklabs.watchtower.lifecycle.pre-update":         "/PreUpdateReturn1.sh",
+									},
+									ExposedPorts: map[nat.Port]struct{}{},
+								}),
+						},
+					},
+					dockerClient,
+					false,
+					false,
+				)
+			})
+
+			It("skip running preupdate", func() {
+				_, err := actions.Update(client, types.UpdateParams{Cleanup: true, LifecycleHooks: true})
+				Expect(err).NotTo(HaveOccurred())
+				Expect(client.TestData.TriedToRemoveImageCount).To(Equal(1))
+			})
+
+		})
+
+		When("container is restarting", func() {
+			BeforeEach(func() {
+				client = CreateMockClient(
+					&TestData{
+						//NameOfContainerToKeep: "test-container-02",
+						Containers: []container.Container{
+							CreateMockContainerWithConfig(
+								"test-container-02",
+								"test-container-02",
+								"fake-image2:latest",
+								false,
+								true,
+								time.Now(),
+								&dockerContainer.Config{
+									Labels: map[string]string{
+										"com.centurylinklabs.watchtower.lifecycle.pre-update-timeout": "190",
+										"com.centurylinklabs.watchtower.lifecycle.pre-update":         "/PreUpdateReturn1.sh",
+									},
+									ExposedPorts: map[nat.Port]struct{}{},
+								}),
+						},
+					},
+					dockerClient,
+					false,
+					false,
+				)
+			})
+
+			It("skip running preupdate", func() {
+				_, err := actions.Update(client, types.UpdateParams{Cleanup: true, LifecycleHooks: true})
+				Expect(err).NotTo(HaveOccurred())
+				Expect(client.TestData.TriedToRemoveImageCount).To(Equal(1))
+			})
+
+		})
+
+	})
 })
diff --git a/pkg/api/metrics/metrics_test.go b/pkg/api/metrics/metrics_test.go
index 44379ee..5120f8d 100644
--- a/pkg/api/metrics/metrics_test.go
+++ b/pkg/api/metrics/metrics_test.go
@@ -2,78 +2,89 @@ package metrics_test
 
 import (
 	"fmt"
-	"github.com/containrrr/watchtower/pkg/metrics"
 	"io/ioutil"
 	"net/http"
+	"net/http/httptest"
+	"strings"
 	"testing"
 
 	"github.com/containrrr/watchtower/pkg/api"
 	metricsAPI "github.com/containrrr/watchtower/pkg/api/metrics"
+	"github.com/containrrr/watchtower/pkg/metrics"
 
 	. "github.com/onsi/ginkgo"
 	. "github.com/onsi/gomega"
 )
 
-const Token = "123123123"
+const (
+	token  = "123123123"
+	getURL = "http://localhost:8080/v1/metrics"
+)
 
-func TestContainer(t *testing.T) {
+func TestMetrics(t *testing.T) {
 	RegisterFailHandler(Fail)
 	RunSpecs(t, "Metrics Suite")
 }
 
-func runTestServer(m *metricsAPI.Handler) {
-	http.Handle(m.Path, m.Handle)
-	go func() {
-		http.ListenAndServe(":8080", nil)
-	}()
-}
+func getWithToken(handler http.Handler) map[string]string {
+	metricMap := map[string]string{}
+	respWriter := httptest.NewRecorder()
 
-func getWithToken(c http.Client, url string) (*http.Response, error) {
-	req, _ := http.NewRequest("GET", url, nil)
-	req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", Token))
-	return c.Do(req)
+	req := httptest.NewRequest("GET", getURL, nil)
+	req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
+
+	handler.ServeHTTP(respWriter, req)
+
+	res := respWriter.Result()
+	body, _ := ioutil.ReadAll(res.Body)
+
+	for _, line := range strings.Split(string(body), "\n") {
+		if len(line) < 1 || line[0] == '#' {
+			continue
+		}
+		parts := strings.Split(line, " ")
+		metricMap[parts[0]] = parts[1]
+	}
+
+	return metricMap
 }
 
-var _ = Describe("the metrics", func() {
-	httpAPI := api.New(Token)
+var _ = Describe("the metrics API", func() {
+	httpAPI := api.New(token)
 	m := metricsAPI.New()
 
-	httpAPI.RegisterHandler(m.Path, m.Handle)
-	httpAPI.Start(false)
+	handleReq := httpAPI.RequireToken(m.Handle)
+	tryGetMetrics := func() map[string]string { return getWithToken(handleReq) }
 
 	It("should serve metrics", func() {
+
+		Expect(tryGetMetrics()).To(HaveKeyWithValue("watchtower_containers_updated", "0"))
+
 		metric := &metrics.Metric{
 			Scanned: 4,
 			Updated: 3,
 			Failed:  1,
 		}
+
 		metrics.RegisterScan(metric)
 		Eventually(metrics.Default().QueueIsEmpty).Should(BeTrue())
 
-		c := http.Client{}
-
-		res, err := getWithToken(c, "http://localhost:8080/v1/metrics")
-		Expect(err).ToNot(HaveOccurred())
-
-		contents, err := ioutil.ReadAll(res.Body)
-		Expect(err).ToNot(HaveOccurred())
-		Expect(string(contents)).To(ContainSubstring("watchtower_containers_updated 3"))
-		Expect(string(contents)).To(ContainSubstring("watchtower_containers_failed 1"))
-		Expect(string(contents)).To(ContainSubstring("watchtower_containers_scanned 4"))
-		Expect(string(contents)).To(ContainSubstring("watchtower_scans_total 1"))
-		Expect(string(contents)).To(ContainSubstring("watchtower_scans_skipped 0"))
+		Eventually(tryGetMetrics).Should(SatisfyAll(
+			HaveKeyWithValue("watchtower_containers_updated", "3"),
+			HaveKeyWithValue("watchtower_containers_failed", "1"),
+			HaveKeyWithValue("watchtower_containers_scanned", "4"),
+			HaveKeyWithValue("watchtower_scans_total", "1"),
+			HaveKeyWithValue("watchtower_scans_skipped", "0"),
+		))
 
 		for i := 0; i < 3; i++ {
 			metrics.RegisterScan(nil)
 		}
 		Eventually(metrics.Default().QueueIsEmpty).Should(BeTrue())
 
-		res, err = getWithToken(c, "http://localhost:8080/v1/metrics")
-		Expect(err).ToNot(HaveOccurred())
-
-		contents, err = ioutil.ReadAll(res.Body)
-		Expect(err).ToNot(HaveOccurred())
-		Expect(string(contents)).To(ContainSubstring("watchtower_scans_total 4"))
-		Expect(string(contents)).To(ContainSubstring("watchtower_scans_skipped 3"))
+		Eventually(tryGetMetrics).Should(SatisfyAll(
+			HaveKeyWithValue("watchtower_scans_total", "4"),
+			HaveKeyWithValue("watchtower_scans_skipped", "3"),
+		))
 	})
 })
diff --git a/pkg/container/container_test.go b/pkg/container/container_test.go
index 8f22044..843169b 100644
--- a/pkg/container/container_test.go
+++ b/pkg/container/container_test.go
@@ -204,8 +204,8 @@ var _ = Describe("the container", func() {
 		It("should return its ID on calls to .ID()", func() {
 			id := c.ID()
 
-			Expect(id).To(Equal("container_id"))
-			Expect(id).NotTo(Equal("wrong-id"))
+			Expect(id).To(BeEquivalentTo("container_id"))
+			Expect(id).NotTo(BeEquivalentTo("wrong-id"))
 		})
 		It("should return true, true if enabled on calls to .Enabled()", func() {
 			enabled, exists := c.Enabled()
diff --git a/pkg/container/util_test.go b/pkg/container/util_test.go
index 8cb0328..00912ba 100644
--- a/pkg/container/util_test.go
+++ b/pkg/container/util_test.go
@@ -1,10 +1,9 @@
 package container_test
 
 import (
+	wt "github.com/containrrr/watchtower/pkg/types"
 	. "github.com/onsi/ginkgo"
 	. "github.com/onsi/gomega"
-
-	. "github.com/containrrr/watchtower/pkg/container"
 )
 
 var _ = Describe("container utils", func() {
@@ -12,13 +11,13 @@ var _ = Describe("container utils", func() {
 		When("given a normal image ID", func() {
 			When("it contains a sha256 prefix", func() {
 				It("should return that ID in short version", func() {
-					actual := ShortID("sha256:0123456789abcd00000000001111111111222222222233333333334444444444")
+					actual := shortID("sha256:0123456789abcd00000000001111111111222222222233333333334444444444")
 					Expect(actual).To(Equal("0123456789ab"))
 				})
 			})
 			When("it doesn't contain a prefix", func() {
 				It("should return that ID in short version", func() {
-					actual := ShortID("0123456789abcd00000000001111111111222222222233333333334444444444")
+					actual := shortID("0123456789abcd00000000001111111111222222222233333333334444444444")
 					Expect(actual).To(Equal("0123456789ab"))
 				})
 			})
@@ -26,21 +25,26 @@ var _ = Describe("container utils", func() {
 		When("given a short image ID", func() {
 			When("it contains no prefix", func() {
 				It("should return the same string", func() {
-					Expect(ShortID("0123456789ab")).To(Equal("0123456789ab"))
+					Expect(shortID("0123456789ab")).To(Equal("0123456789ab"))
 				})
 			})
 			When("it contains a the sha256 prefix", func() {
 				It("should return the ID without the prefix", func() {
-					Expect(ShortID("sha256:0123456789ab")).To(Equal("0123456789ab"))
+					Expect(shortID("sha256:0123456789ab")).To(Equal("0123456789ab"))
 				})
 			})
 		})
 		When("given an ID with an unknown prefix", func() {
 			It("should return a short version of that ID including the prefix", func() {
-				Expect(ShortID("md5:0123456789ab")).To(Equal("md5:0123456789ab"))
-				Expect(ShortID("md5:0123456789abcdefg")).To(Equal("md5:0123456789ab"))
-				Expect(ShortID("md5:01")).To(Equal("md5:01"))
+				Expect(shortID("md5:0123456789ab")).To(Equal("md5:0123456789ab"))
+				Expect(shortID("md5:0123456789abcdefg")).To(Equal("md5:0123456789ab"))
+				Expect(shortID("md5:01")).To(Equal("md5:01"))
 			})
 		})
 	})
 })
+
+func shortID(id string) string {
+	// Proxy to the types implementation, relocated due to package dependency resolution
+	return wt.ImageID(id).ShortID()
+}
diff --git a/pkg/notifications/notifications_suite_test.go b/pkg/notifications/notifications_suite_test.go
new file mode 100644
index 0000000..1b77c2a
--- /dev/null
+++ b/pkg/notifications/notifications_suite_test.go
@@ -0,0 +1,13 @@
+package notifications_test
+
+import (
+	"testing"
+
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+)
+
+func TestNotifications(t *testing.T) {
+	RegisterFailHandler(Fail)
+	RunSpecs(t, "Notifications Suite")
+}
diff --git a/pkg/notifications/notifier_test.go b/pkg/notifications/notifier_test.go
index ba6657a..58c1ebb 100644
--- a/pkg/notifications/notifier_test.go
+++ b/pkg/notifications/notifier_test.go
@@ -4,24 +4,14 @@ import (
 	"fmt"
 	"net/url"
 	"os"
-	"testing"
 
 	"github.com/containrrr/watchtower/cmd"
 	"github.com/containrrr/watchtower/internal/flags"
 	"github.com/containrrr/watchtower/pkg/notifications"
-	"github.com/containrrr/watchtower/pkg/types"
-
 	. "github.com/onsi/ginkgo"
 	. "github.com/onsi/gomega"
-	log "github.com/sirupsen/logrus"
-	"github.com/spf13/cobra"
 )
 
-func TestActions(t *testing.T) {
-	RegisterFailHandler(Fail)
-	RunSpecs(t, "Notifier Suite")
-}
-
 var _ = Describe("notifications", func() {
 	Describe("the notifier", func() {
 		When("only empty notifier types are provided", func() {
@@ -36,17 +26,20 @@ var _ = Describe("notifications", func() {
 			Expect(err).NotTo(HaveOccurred())
 			notif := notifications.NewNotifier(command)
 
-			Expect(notif.String()).To(Equal("none"))
+			Expect(notif.GetNames()).To(BeEmpty())
 		})
 	})
 	Describe("the slack notifier", func() {
-		builderFn := notifications.NewSlackNotifier
+		// builderFn := notifications.NewSlackNotifier
 
 		When("passing a discord url to the slack notifier", func() {
+			command := cmd.NewRootCommand()
+			flags.RegisterNotificationFlags(command)
+
 			channel := "123456789"
 			token := "abvsihdbau"
 			color := notifications.ColorInt
-			title := url.QueryEscape(notifications.GetTitle())
+			title := url.QueryEscape(notifications.GetTitle(command))
 			expected := fmt.Sprintf("discord://%s@%s?color=0x%x&colordebug=0x0&colorerror=0x0&colorinfo=0x0&colorwarn=0x0&splitlines=Yes&title=%s&username=watchtower", token, channel, color, title)
 			buildArgs := func(url string) []string {
 				return []string{
@@ -59,96 +52,104 @@ var _ = Describe("notifications", func() {
 
 			It("should return a discord url when using a hook url with the domain discord.com", func() {
 				hookURL := fmt.Sprintf("https://%s/api/webhooks/%s/%s/slack", "discord.com", channel, token)
-				testURL(builderFn, buildArgs(hookURL), expected)
+				testURL(buildArgs(hookURL), expected)
 			})
 			It("should return a discord url when using a hook url with the domain discordapp.com", func() {
 				hookURL := fmt.Sprintf("https://%s/api/webhooks/%s/%s/slack", "discordapp.com", channel, token)
-				testURL(builderFn, buildArgs(hookURL), expected)
+				testURL(buildArgs(hookURL), expected)
 			})
 		})
 		When("converting a slack service config into a shoutrrr url", func() {
 
 			It("should return the expected URL", func() {
+				command := cmd.NewRootCommand()
+				flags.RegisterNotificationFlags(command)
 
 				username := "containrrrbot"
 				tokenA := "aaa"
 				tokenB := "bbb"
 				tokenC := "ccc"
 				color := url.QueryEscape(notifications.ColorHex)
-				title := url.QueryEscape(notifications.GetTitle())
+				title := url.QueryEscape(notifications.GetTitle(command))
 
 				hookURL := fmt.Sprintf("https://hooks.slack.com/services/%s/%s/%s", tokenA, tokenB, tokenC)
 				expectedOutput := fmt.Sprintf("slack://%s@%s/%s/%s?color=%s&title=%s", username, tokenA, tokenB, tokenC, color, title)
 
 				args := []string{
+					"--notifications",
+					"slack",
 					"--notification-slack-hook-url",
 					hookURL,
 					"--notification-slack-identifier",
 					username,
 				}
 
-				testURL(builderFn, args, expectedOutput)
+				testURL(args, expectedOutput)
 			})
 		})
 	})
 
 	Describe("the gotify notifier", func() {
 		When("converting a gotify service config into a shoutrrr url", func() {
-			builderFn := notifications.NewGotifyNotifier
-
 			It("should return the expected URL", func() {
+				command := cmd.NewRootCommand()
+				flags.RegisterNotificationFlags(command)
+
 				token := "aaa"
 				host := "shoutrrr.local"
-				title := url.QueryEscape(notifications.GetTitle())
+				title := url.QueryEscape(notifications.GetTitle(command))
 
 				expectedOutput := fmt.Sprintf("gotify://%s/%s?title=%s", host, token, title)
 
 				args := []string{
+					"--notifications",
+					"gotify",
 					"--notification-gotify-url",
 					fmt.Sprintf("https://%s", host),
 					"--notification-gotify-token",
 					token,
 				}
 
-				testURL(builderFn, args, expectedOutput)
+				testURL(args, expectedOutput)
 			})
 		})
 	})
 
 	Describe("the teams notifier", func() {
 		When("converting a teams service config into a shoutrrr url", func() {
-			builderFn := notifications.NewMsTeamsNotifier
-
 			It("should return the expected URL", func() {
+				command := cmd.NewRootCommand()
+				flags.RegisterNotificationFlags(command)
 
 				tokenA := "11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc"
 				tokenB := "33333333012222222222333333333344"
 				tokenC := "44444444-4444-4444-8444-cccccccccccc"
 				color := url.QueryEscape(notifications.ColorHex)
-				title := url.QueryEscape(notifications.GetTitle())
+				title := url.QueryEscape(notifications.GetTitle(command))
 
 				hookURL := fmt.Sprintf("https://outlook.office.com/webhook/%s/IncomingWebhook/%s/%s", tokenA, tokenB, tokenC)
 				expectedOutput := fmt.Sprintf("teams://%s/%s/%s?color=%s&title=%s", tokenA, tokenB, tokenC, color, title)
 
 				args := []string{
+					"--notifications",
+					"msteams",
 					"--notification-msteams-hook",
 					hookURL,
 				}
 
-				testURL(builderFn, args, expectedOutput)
+				testURL(args, expectedOutput)
 			})
 		})
 	})
 
 	Describe("the email notifier", func() {
-
-		builderFn := notifications.NewEmailNotifier
-
 		When("converting an email service config into a shoutrrr url", func() {
 			It("should set the from address in the URL", func() {
 				fromAddress := "lala@example.com"
 				expectedOutput := buildExpectedURL("containrrrbot", "secret-password", "mail.containrrr.dev", 25, fromAddress, "mail@example.com", "Plain")
 				args := []string{
+					"--notifications",
+					"email",
 					"--notification-email-from",
 					fromAddress,
 					"--notification-email-to",
@@ -160,7 +161,7 @@ var _ = Describe("notifications", func() {
 					"--notification-email-server",
 					"mail.containrrr.dev",
 				}
-				testURL(builderFn, args, expectedOutput)
+				testURL(args, expectedOutput)
 			})
 
 			It("should return the expected URL", func() {
@@ -170,6 +171,8 @@ var _ = Describe("notifications", func() {
 				expectedOutput := buildExpectedURL("containrrrbot", "secret-password", "mail.containrrr.dev", 25, fromAddress, toAddress, "Plain")
 
 				args := []string{
+					"--notifications",
+					"email",
 					"--notification-email-from",
 					fromAddress,
 					"--notification-email-to",
@@ -182,7 +185,7 @@ var _ = Describe("notifications", func() {
 					"mail.containrrr.dev",
 				}
 
-				testURL(builderFn, args, expectedOutput)
+				testURL(args, expectedOutput)
 			})
 		})
 	})
@@ -204,9 +207,7 @@ func buildExpectedURL(username string, password string, host string, port int, f
 		url.QueryEscape(to))
 }
 
-type builderFn = func(c *cobra.Command, acceptedLogLevels []log.Level) types.ConvertibleNotifier
-
-func testURL(builder builderFn, args []string, expectedURL string) {
+func testURL(args []string, expectedURL string) {
 
 	command := cmd.NewRootCommand()
 	flags.RegisterNotificationFlags(command)
@@ -214,10 +215,9 @@ func testURL(builder builderFn, args []string, expectedURL string) {
 	err := command.ParseFlags(args)
 	Expect(err).NotTo(HaveOccurred())
 
-	notifier := builder(command, []log.Level{})
-	actualURL, err := notifier.GetURL()
+	urls := notifications.AppendLegacyUrls([]string{}, command)
 
 	Expect(err).NotTo(HaveOccurred())
 
-	Expect(actualURL).To(Equal(expectedURL))
+	Expect(urls).To(ContainElement(expectedURL))
 }
diff --git a/pkg/notifications/shoutrrr_test.go b/pkg/notifications/shoutrrr_test.go
index 47334af..e92655c 100644
--- a/pkg/notifications/shoutrrr_test.go
+++ b/pkg/notifications/shoutrrr_test.go
@@ -2,169 +2,226 @@ package notifications
 
 import (
 	"github.com/containrrr/shoutrrr/pkg/types"
-	"testing"
-	"text/template"
-
+	"github.com/containrrr/watchtower/internal/actions/mocks"
 	"github.com/containrrr/watchtower/internal/flags"
-	log "github.com/sirupsen/logrus"
+	s "github.com/containrrr/watchtower/pkg/session"
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+	"github.com/onsi/gomega/gbytes"
+
+	"github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
-	"github.com/stretchr/testify/require"
 )
 
-func TestShoutrrrDefaultTemplate(t *testing.T) {
-	cmd := new(cobra.Command)
-
-	shoutrrr := &shoutrrrTypeNotifier{
-		template: getShoutrrrTemplate(cmd),
-	}
-
-	entries := []*log.Entry{
-		{
-			Message: "foo bar",
-		},
-	}
-
-	s := shoutrrr.buildMessage(entries)
-
-	require.Equal(t, "foo bar\n", s)
-}
-
-func TestShoutrrrTemplate(t *testing.T) {
-	cmd := new(cobra.Command)
-	flags.RegisterNotificationFlags(cmd)
-	err := cmd.ParseFlags([]string{"--notification-template={{range .}}{{.Level}}: {{.Message}}{{println}}{{end}}"})
-
-	require.NoError(t, err)
-
-	shoutrrr := &shoutrrrTypeNotifier{
-		template: getShoutrrrTemplate(cmd),
-	}
-
-	entries := []*log.Entry{
-		{
-			Level:   log.InfoLevel,
-			Message: "foo bar",
-		},
-	}
-
-	s := shoutrrr.buildMessage(entries)
-
-	require.Equal(t, "info: foo bar\n", s)
-}
-
-func TestShoutrrrStringFunctions(t *testing.T) {
-	cmd := new(cobra.Command)
-	flags.RegisterNotificationFlags(cmd)
-	err := cmd.ParseFlags([]string{"--notification-template={{range .}}{{.Level | printf \"%v\" | ToUpper }}: {{.Message | ToLower }} {{.Message | Title }}{{println}}{{end}}"})
-
-	require.NoError(t, err)
-
-	shoutrrr := &shoutrrrTypeNotifier{
-		template: getShoutrrrTemplate(cmd),
-	}
-
-	entries := []*log.Entry{
+var legacyMockData = Data{
+	Entries: []*logrus.Entry{
 		{
-			Level:   log.InfoLevel,
+			Level:   logrus.InfoLevel,
 			Message: "foo Bar",
 		},
-	}
-
-	s := shoutrrr.buildMessage(entries)
-
-	require.Equal(t, "INFO: foo bar Foo Bar\n", s)
+	},
 }
 
-func TestShoutrrrInvalidTemplateUsesTemplate(t *testing.T) {
-	cmd := new(cobra.Command)
-
-	flags.RegisterNotificationFlags(cmd)
-	err := cmd.ParseFlags([]string{"--notification-template={{"})
-
-	require.NoError(t, err)
-
-	shoutrrr := &shoutrrrTypeNotifier{
-		template: getShoutrrrTemplate(cmd),
-	}
-
-	shoutrrrDefault := &shoutrrrTypeNotifier{
-		template: template.Must(template.New("").Parse(shoutrrrDefaultTemplate)),
-	}
-
-	entries := []*log.Entry{
-		{
-			Message: "foo bar",
-		},
+func mockDataFromStates(states ...s.State) Data {
+	return Data{
+		Entries: legacyMockData.Entries,
+		Report:  mocks.CreateMockProgressReport(states...),
 	}
-
-	s := shoutrrr.buildMessage(entries)
-	sd := shoutrrrDefault.buildMessage(entries)
-
-	require.Equal(t, sd, s)
 }
 
+var _ = Describe("Shoutrrr", func() {
+	var logBuffer *gbytes.Buffer
+
+	BeforeEach(func() {
+		logBuffer = gbytes.NewBuffer()
+		logrus.SetOutput(logBuffer)
+		logrus.SetFormatter(&logrus.TextFormatter{
+			DisableColors:    true,
+			DisableTimestamp: true,
+		})
+	})
+
+	When("using legacy templates", func() {
+
+		When("no custom template is provided", func() {
+			It("should format the messages using the default template", func() {
+				cmd := new(cobra.Command)
+				flags.RegisterNotificationFlags(cmd)
+
+				shoutrrr := createNotifier([]string{}, logrus.AllLevels, "", true)
+
+				entries := []*logrus.Entry{
+					{
+						Message: "foo bar",
+					},
+				}
+
+				s := shoutrrr.buildMessage(Data{Entries: entries})
+
+				Expect(s).To(Equal("foo bar\n"))
+			})
+		})
+		When("given a valid custom template", func() {
+			It("should format the messages using the custom template", func() {
+
+				tplString := `{{range .}}{{.Level}}: {{.Message}}{{println}}{{end}}`
+				tpl, err := getShoutrrrTemplate(tplString, true)
+				Expect(err).ToNot(HaveOccurred())
+
+				shoutrrr := &shoutrrrTypeNotifier{
+					template:       tpl,
+					legacyTemplate: true,
+				}
+
+				entries := []*logrus.Entry{
+					{
+						Level:   logrus.InfoLevel,
+						Message: "foo bar",
+					},
+				}
+
+				s := shoutrrr.buildMessage(Data{Entries: entries})
+
+				Expect(s).To(Equal("info: foo bar\n"))
+			})
+		})
+
+		When("given an invalid custom template", func() {
+			It("should format the messages using the default template", func() {
+				invNotif, err := createNotifierWithTemplate(`{{ intentionalSyntaxError`, true)
+				Expect(err).To(HaveOccurred())
+
+				defNotif, err := createNotifierWithTemplate(``, true)
+				Expect(err).ToNot(HaveOccurred())
+
+				Expect(invNotif.buildMessage(legacyMockData)).To(Equal(defNotif.buildMessage(legacyMockData)))
+			})
+		})
+
+		When("given a template that is using ToUpper function", func() {
+			It("should return the text in UPPER CASE", func() {
+				tplString := `{{range .}}{{ .Message | ToUpper }}{{end}}`
+				Expect(getTemplatedResult(tplString, true, legacyMockData)).To(Equal("FOO BAR"))
+			})
+		})
+
+		When("given a template that is using ToLower function", func() {
+			It("should return the text in lower case", func() {
+				tplString := `{{range .}}{{ .Message | ToLower }}{{end}}`
+				Expect(getTemplatedResult(tplString, true, legacyMockData)).To(Equal("foo bar"))
+			})
+		})
+
+		When("given a template that is using Title function", func() {
+			It("should return the text in Title Case", func() {
+				tplString := `{{range .}}{{ .Message | Title }}{{end}}`
+				Expect(getTemplatedResult(tplString, true, legacyMockData)).To(Equal("Foo Bar"))
+			})
+		})
+
+	})
+
+	When("using report templates", func() {
+
+		When("no custom template is provided", func() {
+			It("should format the messages using the default template", func() {
+				expected := `4 Scanned, 2 Updated, 1 Failed
+- updt1 (mock/updt1:latest): 01d110000000 updated to d0a110000000
+- updt2 (mock/updt2:latest): 01d120000000 updated to d0a120000000
+- frsh1 (mock/frsh1:latest): Fresh
+- skip1 (mock/skip1:latest): Skipped: unpossible
+- fail1 (mock/fail1:latest): Failed: accidentally the whole container
+`
+				data := mockDataFromStates(s.UpdatedState, s.FreshState, s.FailedState, s.SkippedState, s.UpdatedState)
+				Expect(getTemplatedResult(``, false, data)).To(Equal(expected))
+			})
+
+			It("should format the messages using the default template", func() {
+				expected := `1 Scanned, 0 Updated, 0 Failed
+- frsh1 (mock/frsh1:latest): Fresh
+`
+				data := mockDataFromStates(s.FreshState)
+				Expect(getTemplatedResult(``, false, data)).To(Equal(expected))
+			})
+		})
+	})
+
+	When("sending notifications", func() {
+
+		It("SlowNotificationNotSent", func() {
+			_, blockingRouter := sendNotificationsWithBlockingRouter(true)
+
+			Eventually(blockingRouter.sent).Should(Not(Receive()))
+
+		})
+
+		It("SlowNotificationSent", func() {
+			shoutrrr, blockingRouter := sendNotificationsWithBlockingRouter(true)
+
+			blockingRouter.unlock <- true
+			shoutrrr.Close()
+
+			Eventually(blockingRouter.sent).Should(Receive(BeTrue()))
+		})
+	})
+})
+
 type blockingRouter struct {
 	unlock chan bool
 	sent   chan bool
 }
 
-func (b blockingRouter) Send(message string, params *types.Params) []error {
+func (b blockingRouter) Send(_ string, _ *types.Params) []error {
 	_ = <-b.unlock
 	b.sent <- true
 	return nil
 }
 
-func TestSlowNotificationNotSent(t *testing.T) {
-	_, blockingRouter := sendNotificationsWithBlockingRouter()
-
-	notifSent := false
-	select {
-	case notifSent = <-blockingRouter.sent:
-	default:
-	}
-
-	require.Equal(t, false, notifSent)
-}
-
-func TestSlowNotificationSent(t *testing.T) {
-	shoutrrr, blockingRouter := sendNotificationsWithBlockingRouter()
-
-	blockingRouter.unlock <- true
-	shoutrrr.Close()
-
-	notifSent := false
-	select {
-	case notifSent = <-blockingRouter.sent:
-	default:
-	}
-	require.Equal(t, true, notifSent)
-}
-
-func sendNotificationsWithBlockingRouter() (*shoutrrrTypeNotifier, *blockingRouter) {
-	cmd := new(cobra.Command)
+func sendNotificationsWithBlockingRouter(legacy bool) (*shoutrrrTypeNotifier, *blockingRouter) {
 
 	router := &blockingRouter{
 		unlock: make(chan bool, 1),
 		sent:   make(chan bool, 1),
 	}
 
+	tpl, err := getShoutrrrTemplate("", legacy)
+	Expect(err).NotTo(HaveOccurred())
+
 	shoutrrr := &shoutrrrTypeNotifier{
-		template: getShoutrrrTemplate(cmd),
-		messages: make(chan string, 1),
-		done:     make(chan bool),
-		Router:   router,
+		template:       tpl,
+		messages:       make(chan string, 1),
+		done:           make(chan bool),
+		Router:         router,
+		legacyTemplate: legacy,
 	}
 
-	entry := &log.Entry{
+	entry := &logrus.Entry{
 		Message: "foo bar",
 	}
 
 	go sendNotifications(shoutrrr)
 
 	shoutrrr.StartNotification()
-	shoutrrr.Fire(entry)
+	_ = shoutrrr.Fire(entry)
 
-	shoutrrr.SendNotification()
+	shoutrrr.SendNotification(nil)
 
 	return shoutrrr, router
 }
+
+func createNotifierWithTemplate(tplString string, legacy bool) (*shoutrrrTypeNotifier, error) {
+	tpl, err := getShoutrrrTemplate(tplString, legacy)
+
+	return &shoutrrrTypeNotifier{
+		template:       tpl,
+		legacyTemplate: legacy,
+	}, err
+}
+
+func getTemplatedResult(tplString string, legacy bool, data Data) (string, error) {
+	notifier, err := createNotifierWithTemplate(tplString, legacy)
+	if err != nil {
+		return "", err
+	}
+	return notifier.buildMessage(data), err
+}
diff --git a/pkg/registry/digest/digest_test.go b/pkg/registry/digest/digest_test.go
index 0de6025..70193b8 100644
--- a/pkg/registry/digest/digest_test.go
+++ b/pkg/registry/digest/digest_test.go
@@ -7,6 +7,8 @@ import (
 	wtTypes "github.com/containrrr/watchtower/pkg/types"
 	. "github.com/onsi/ginkgo"
 	. "github.com/onsi/gomega"
+	"github.com/onsi/gomega/ghttp"
+	"net/http"
 	"os"
 	"testing"
 	"time"
@@ -18,14 +20,16 @@ func TestDigest(t *testing.T) {
 	RunSpecs(GinkgoT(), "Digest Suite")
 }
 
-var DockerHubCredentials = &wtTypes.RegistryCredentials{
-	Username: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_DH_USERNAME"),
-	Password: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_DH_PASSWORD"),
-}
-var GHCRCredentials = &wtTypes.RegistryCredentials{
-	Username: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_GH_USERNAME"),
-	Password: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_GH_PASSWORD"),
-}
+var (
+	DockerHubCredentials = &wtTypes.RegistryCredentials{
+		Username: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_DH_USERNAME"),
+		Password: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_DH_PASSWORD"),
+	}
+	GHCRCredentials = &wtTypes.RegistryCredentials{
+		Username: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_GH_USERNAME"),
+		Password: os.Getenv("CI_INTEGRATION_TEST_REGISTRY_GH_PASSWORD"),
+	}
+)
 
 func SkipIfCredentialsEmpty(credentials *wtTypes.RegistryCredentials, fn func()) func() {
 	if credentials.Username == "" {
@@ -55,6 +59,8 @@ var _ = Describe("Digests", func() {
 		mockCreated,
 		mockDigest)
 
+	mockContainerNoImage := mocks.CreateMockContainerWithImageInfoP(mockId, mockName, mockImage, mockCreated, nil)
+
 	When("a digest comparison is done", func() {
 		It("should return true if digests match",
 			SkipIfCredentialsEmpty(GHCRCredentials, func() {
@@ -71,6 +77,11 @@ var _ = Describe("Digests", func() {
 		It("should return an error if the registry isn't available", func() {
 
 		})
+		It("should return an error when container contains no image info", func() {
+			matches, err := digest.CompareDigest(mockContainerNoImage, `user:pass`)
+			Expect(err).To(HaveOccurred())
+			Expect(matches).To(Equal(false))
+		})
 	})
 	When("using different registries", func() {
 		It("should work with DockerHub",
@@ -84,4 +95,32 @@ var _ = Describe("Digests", func() {
 			}),
 		)
 	})
+	When("sending a HEAD request", func() {
+		var server *ghttp.Server
+		BeforeEach(func() {
+			server = ghttp.NewServer()
+		})
+		AfterEach(func() {
+			server.Close()
+		})
+		It("should use a custom user-agent", func() {
+			server.AppendHandlers(
+				ghttp.CombineHandlers(
+					ghttp.VerifyHeader(http.Header{
+						"User-Agent": []string{"Watchtower/v0.0.0-unknown"},
+					}),
+					ghttp.RespondWith(http.StatusOK, "", http.Header{
+						digest.ContentDigestHeader: []string{
+							mockDigest,
+						},
+					}),
+				),
+			)
+			dig, err := digest.GetDigest(server.URL(), "token")
+			println(dig)
+			Expect(server.ReceivedRequests()).Should(HaveLen(1))
+			Expect(err).NotTo(HaveOccurred())
+			Expect(dig).To(Equal(mockDigest))
+		})
+	})
 })