diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 70cfcadb05eed8c528f9e0984bc1eb2f572eb1f7..e8d010bd2e12dff9c16febab4aa9725052bfd2e3 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,23 +1,52 @@
 ---
 include:
-  - project: shore/ci-templates
+  - project: shore/ci-stuff
     file: templates/pre-commit.yml
-  - project: shore/ci-templates
+  - project: shore/ci-stuff
     file: templates/docker.yml
-
-stages:
-  - test
-  - build
-  - deploy
+  - project: shore/ci-stuff
+    file: templates/notify.yml
 
 build:
   extends: .compose-build
   tags: &tags [ns4.shore.co.il]
+  rules:
+    - if: $CI_PIPELINE_SOURCE != "schedule"
 
 pull:
   extends: .compose-pull
   tags: *tags
+  rules:
+    - if: $CI_PIPELINE_SOURCE != "schedule"
 
 run:
+  rules:
+    - if: $CI_PIPELINE_SOURCE != "schedule"
+      when: manual
   extends: .compose-run
   tags: *tags
+
+
+backup:
+  rules:
+    - if: $CI_PIPELINE_SOURCE == "schedule"
+  stage: deploy
+  tags: [host01.shore.co.il]
+  image: docker.io/library/docker:20.10
+  before_script:
+    - >-
+      docker build
+      --tag registry.shore.co.il/registry-backup
+      --pull
+      backup
+  script:
+    - >-
+      docker run
+      --volume /var/backups/registry:/var/backups/registry
+      --user nobody
+      --rm
+      registry.shore.co.il/registry-backup
+      backup registry.shore.co.il /var/backups/registry
+  retry:
+    max: 2
+  timeout: 3h
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 1b8468929bf63482ead46185326d9a0a8ca927c4..5ae94b53dadf1ffd48f779f4e46da01d98c3ed56 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -54,3 +54,53 @@ repos:
     rev: v2.7.0
     hooks:
       - id: hadolint
+
+  - repo: https://github.com/ambv/black
+    rev: 21.9b0
+    hooks:
+      - id: black
+        args:
+          - |
+              --line-length=79
+
+  - repo: https://github.com/PyCQA/prospector
+    rev: 1.5.1
+    hooks:
+      - id: prospector
+        args:
+          - |-
+            --max-line-length=79
+          - |-
+            --with-tool=pyroma
+          - |-
+            --with-tool=bandit
+          - |-
+            --without-tool=pep257
+          - |-
+            --doc-warnings
+          - |-
+            --test-warnings
+          - |-
+            --full-pep8
+          - |-
+            --strictness=high
+          - |-
+            --no-autodetect
+        additional_dependencies:
+          - bandit
+          - pyroma
+
+  - repo: https://gitlab.com/pycqa/flake8.git
+    rev: 3.9.2
+    hooks:
+      - id: flake8
+        args:
+          - |-
+            --doctests
+        additional_dependencies:
+          - flake8-bugbear
+
+  - repo: https://github.com/codespell-project/codespell.git
+    rev: v2.1.0
+    hooks:
+      - id: codespell
diff --git a/backup/.dockerignore b/backup/.dockerignore
new file mode 100644
index 0000000000000000000000000000000000000000..5664021a5516007889500c3fb8d99da128793e9a
--- /dev/null
+++ b/backup/.dockerignore
@@ -0,0 +1,3 @@
+*
+!backup
+!restore
diff --git a/backup/Dockerfile b/backup/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..274cba84e3eb9f1276bc754ff3bb2af9f3f763b3
--- /dev/null
+++ b/backup/Dockerfile
@@ -0,0 +1,11 @@
+FROM docker.io/library/alpine:3.14
+# hadolint ignore=DL3018
+RUN echo 'https://dl-cdn.alpinelinux.org/alpine/edge/testing' >> /etc/apk/repositories && \
+    echo 'https://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories && \
+    apk add --update --no-cache \
+        findutils \
+        skopeo \
+        reg \
+    ;
+COPY --chown=root:root backup /usr/local/bin/backup
+COPY --chown=root:root restore /usr/local/bin/restore
diff --git a/backup/backup b/backup/backup
new file mode 100755
index 0000000000000000000000000000000000000000..fe42de0fadd0b25e7a8952124f3626c0c687c8c5
--- /dev/null
+++ b/backup/backup
@@ -0,0 +1,52 @@
+#!/bin/sh
+set -eu
+
+usage() {
+    echo "$0: REGISTRY_DOMAIN BACKUP_DEST"
+}
+
+if [ "${1:-}" = -h ] || [ "${1:-}" = --help ]
+then
+    usage
+    exit 0
+fi
+
+if [ "$#" -ne 2 ]
+then
+    usage
+    exit 1
+fi
+
+command -v skopeo >/dev/null || { echo 'skopeo is missing.' >&2; exit 2; }
+
+registry="$1"
+dest="$2"
+
+mkdir -p "$dest"
+
+reg ls "$registry" | \
+   sed 's/,//g' | \
+   awk -v "registry=$registry" -v "dest=$dest" '
+BEGIN {
+    exitcode = 0
+}
+NR>2 {
+    system("mkdir -p " dest "/" $1)
+    for (i=2; i<=NF; i++) {
+        image_url = registry "/" $1 ":" $(i)
+        image_file = dest "/" $1 "/" $(i) ".tar"
+        printf "Saving %s to %s.\n", image_url, image_file
+        system("rm " image_file)
+        if (system("skopeo copy docker://" image_url " docker-archive://" image_file) == 0)
+            printf "Backup of %s was successful.\n", image_url
+        else {
+            exitcode = 1
+            printf "Backup of %s failed, continuing with other images.\n", image_url
+        }
+    }
+}
+END {
+    if ( exitcode == 1) print "Backup failed for some images."
+    exit exitcode
+}
+'
diff --git a/backup/restore b/backup/restore
new file mode 100755
index 0000000000000000000000000000000000000000..6dc6cf0daa29bd664fca6b8eccc5b62be856eb61
--- /dev/null
+++ b/backup/restore
@@ -0,0 +1,62 @@
+#!/bin/sh
+set -eu
+
+usage() {
+    echo "$0: BACKUP_SOURCE REGISTRY_DOMAIN"
+}
+
+if [ "${1:-}" = -h ] || [ "${1:-}" = --help ]
+then
+    usage
+    exit 0
+fi
+
+if [ "$#" -ne 2 ]
+then
+    usage
+    exit 1
+fi
+
+command -v skopeo >/dev/null || { echo 'skopeo is missing.' >&2; exit 2; }
+
+src="$1"
+registry="$2"
+
+# There's an assumption here that filenames don't have spaces (or other such
+# characters) as the image format prohibits that and I don't want to deal with
+# such issues right now.
+
+images="$(find "$src" -maxdepth 1 -mindepth 1 -type d -printf '%f\n')"
+if [ -z "$images" ]
+then
+    echo 'No images found,' >&2
+    exit 3
+fi
+
+returncode=0
+for image in $images
+do
+    tags="$(find "$src/$image" -maxdepth 1 -mindepth 1 -type f -name '*.tar' -printf '%f\n' | sed 's/\.tar$//g')"
+    if [ -z "$tags" ]
+    then
+        echo "No tags found for image $image, skipping." >&2
+        continue
+    fi
+    for tag in $tags
+    do
+        echo "Restoring $image:$tag" >&2
+        if skopeo copy "docker-archive://$src/$image/$tag.tar" "docker://$registry/$image:$tag"
+        then
+            echo "Restore finished successfully." >&2
+        else
+            echo "Restore failed, continuing with other image." >&2
+            returncode=1
+        fi
+    done
+done
+
+if [ "$returncode" -gt 0 ]
+then
+    echo 'Restoration failed for some images.'
+fi
+exit "$returncode"