diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 64c1f601f02768763ea69fa7ebe48f5fff2fc87b..5696eb0652acddfaf329b19104bc75eddad8d96f 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,3 +1,5 @@
+include:
+  - .gitlab/ci_templates/kaniko.yml
 stages:
   - build
   - setup-cluster
@@ -5,8 +7,6 @@ stages:
   - health-test
   - integration-test
   - cleanup
-
-image: "${CI_REGISTRY_IMAGE}/openappstack-ci:${CI_COMMIT_REF_NAME}"
 variables:
   SSH_KEY_ID: "411"
   HOSTNAME: "ci-${CI_PIPELINE_ID}"
@@ -15,6 +15,10 @@ variables:
   DOMAIN: "openappstack.net"
   ADDRESS: "ci-${CI_PIPELINE_ID}.ci.openappstack.net"
   ANSIBLE_HOST_KEY_CHECKING: "False"
+  KANIKO_BUILD_IMAGENAME: "openappstack-ci"
+
+default:
+  image: "${CI_REGISTRY_IMAGE}/${KANIKO_BUILD_IMAGENAME}:${CI_COMMIT_REF_NAME}"
 
 ci_test_image:
   stage: build
@@ -23,13 +27,12 @@ ci_test_image:
     # kaniko debug image (https://github.com/GoogleContainerTools/kaniko#debug-image)
     name: gcr.io/kaniko-project/executor:debug
     entrypoint: [""]
-  script:
-    - echo "{\"auths\":{\"$CI_REGISTRY\":{\"username\":\"$CI_REGISTRY_USER\",\"password\":\"$CI_REGISTRY_PASSWORD\"}}}" > /kaniko/.docker/config.json
-    - /kaniko/executor --context ${CI_PROJECT_DIR} --dockerfile ${CI_PROJECT_DIR}/Dockerfile --destination $CI_REGISTRY_IMAGE/openappstack-ci:${CI_COMMIT_REF_NAME}
   only:
     changes:
+      - .gitlab-ci.yml
       - Dockerfile
       - requirements.txt
+  extends: .kaniko_build
 
 bootstrap:
   stage: setup-cluster
@@ -56,7 +59,6 @@ bootstrap:
       - helmfiles/**/*
       - test/**/*
       - openappstack/**/*
-      - .gitlab-ci.yml
 
 install:
   stage: install-apps
@@ -82,7 +84,6 @@ install:
       - helmfiles/**/*
       - test/**/*
       - openappstack/**/*
-      - .gitlab-ci.yml
 
 testinfra:
   stage: health-test
@@ -100,7 +101,6 @@ testinfra:
       - helmfiles/**/*
       - test/**/*
       - openappstack/**/*
-      - .gitlab-ci.yml
 
 certs:
   stage: health-test
@@ -119,7 +119,6 @@ certs:
       - helmfiles/**/*
       - test/**/*
       - openappstack/**/*
-      - .gitlab-ci.yml
 
 prometheus-alerts:
   stage: health-test
@@ -154,7 +153,6 @@ behave-nextcloud:
       - helmfiles/**/*
       - test/**/*
       - openappstack/**/*
-      - .gitlab-ci.yml
 
 behave-grafana:
   stage: integration-test
@@ -172,7 +170,6 @@ behave-grafana:
       - helmfiles/**/*
       - test/**/*
       - openappstack/**/*
-      - .gitlab-ci.yml
 
 terminate:
   stage: cleanup
@@ -188,7 +185,6 @@ terminate:
       - helmfiles/**/*
       - test/**/*
       - openappstack/**/*
-      - .gitlab-ci.yml
 
 # This trivial job works around a Gitlab bug: if no job runs at all due to
 # `only`, Gitlab gets confused and doesn't allow you to merge the MR:
diff --git a/.gitlab/ci_templates/kaniko.yml b/.gitlab/ci_templates/kaniko.yml
new file mode 100644
index 0000000000000000000000000000000000000000..967b8b3316f47da0832d7f06c6b4d0717cd891e9
--- /dev/null
+++ b/.gitlab/ci_templates/kaniko.yml
@@ -0,0 +1,14 @@
+# Optional environment variables:
+# - KANIKO_BUILD_IMAGENAME: Build/target image image
+# - KANIKO_CONTEXT: The subdir which holds the Dockerfile, leave unset if
+#                   the Dockerfile is located at root level of the project.
+.kaniko_build:
+  stage: build
+  image:
+    # We need a shell to provide the registry credentials, so we need to use the
+    # kaniko debug image (https://github.com/GoogleContainerTools/kaniko#debug-image)
+    name: gcr.io/kaniko-project/executor:debug
+    entrypoint: [""]
+  script:
+    - echo "{\"auths\":{\"$CI_REGISTRY\":{\"username\":\"$CI_REGISTRY_USER\",\"password\":\"$CI_REGISTRY_PASSWORD\"}}}" > /kaniko/.docker/config.json
+    - /kaniko/executor --context ${CI_PROJECT_DIR}/${KANIKO_CONTEXT:-.} --dockerfile ${CI_PROJECT_DIR}/${KANIKO_CONTEXT:-.}/Dockerfile --destination $CI_REGISTRY_IMAGE/${KANIKO_BUILD_IMAGENAME/#//}:${CI_COMMIT_REF_NAME}
diff --git a/openappstack/cluster.py b/openappstack/cluster.py
index 7bf626fc5c7efcc2951ef22d7b5eec0b7e737fc8..d728c54a1a37dc19433a079688ccc929de5a5400 100644
--- a/openappstack/cluster.py
+++ b/openappstack/cluster.py
@@ -1,4 +1,4 @@
-"""Contains code for managing the files related to an OpenAppStack cluster"""
+"""Contains code for managing the files related to an OpenAppStack cluster."""
 
 import configparser
 import logging
@@ -22,6 +22,7 @@ DEFAULT_MEMORY_SIZE_MB = 6144
 """Default "image" (operating system): 19  =  Debian buster-x64 """
 DEFAULT_IMAGE = 19
 
+
 class Cluster:
     """
     Helper class for cluster-related paths, files, etc.
@@ -68,12 +69,14 @@ class Cluster:
                 # Work with the master node from the inventory
                 self.hostname = inventory['all']['children']['master']['hosts']
 
-            log.debug('Read data from inventory.yml:\n\thostname: %s', self.hostname)
+            log.debug(
+                'Read data from inventory.yml:\n\thostname: %s', self.hostname)
         else:
             log.debug('Not loading cluster data from file. Set '
                       'Cluster.data_loaded to False if you want a reload.')
         self.data_loaded = True
 
+
     def create_droplet(self, ssh_key_id=0, hostname=None):
         """
         Uses the Cosmos API to create a droplet with OAS default spec
@@ -217,7 +220,7 @@ Cluster "{name}":
 Configuration:
   - Inventory file: {inventory_file}
   - Settings file: {settings_file}
-  
+
 Kubectl:
 
 To use kubectl with this cluster, copy-paste this in your terminal:
diff --git a/openappstack/cosmos.py b/openappstack/cosmos.py
index fc69c25113f2453a3dbb27589b45f499a8307230..c23b7506dd155e526dd135d613baa75f6aa4b099 100755
--- a/openappstack/cosmos.py
+++ b/openappstack/cosmos.py
@@ -14,6 +14,7 @@ import requests
 from tabulate import tabulate
 from pytz import timezone
 
+
 # Helper functions
 def request_api(resource: str, request_type: str = 'GET',
                 data: str = ''):
@@ -129,11 +130,12 @@ def delete_domain_record(domain: str, id: int):
 
 
 def delete_domain_records_by_name(domain: str, name_regex: str):
-    """Delete all domain records in a given domain matching a regex.
+    r"""Delete all domain records in a given domain matching a regex.
 
     Examples:
       delete_domain_records_by_name('openappstack.net', '^\*.ci-')
       delete_domain_records_by_name('openappstack.net', '^ci-')
+
     """
     all = get_domain_records_by_name(domain, name_regex)
     for record in all:
@@ -227,7 +229,7 @@ def list_domain_records(domain: str):
         record['id'], record['name'], record['type'], record['data']]
         for record in records]
     log.info(tabulate(table_records,
-             headers=['ID', 'Name', 'Type', 'Data']))
+                      headers=['ID', 'Name', 'Type', 'Data']))
 
 
 def list_droplets():
@@ -270,17 +272,21 @@ def terminate_droplet(id: int):
     delete_droplet(id)
 
 
-def terminate_droplets_by_name(name_regex: str, ndays: int = 0, domain: str = 'openappstack.net'):
+def terminate_droplets_by_name(name_regex: str, ndays: int = 0,
+                               domain: str = 'openappstack.net'):
     r"""
-    Terminate droplets matching a regex and for x days older than current day. 
-    Droplets defined on the env variable NO_TERMINATE_DROPLETS will not be delated
+    Terminate droplets matching a regex and for x days older than current day.
+
+    Droplets defined on the env variable NO_TERMINATE_DROPLETS will not be
+    delated
 
     Example how to terminate all CI instances:
         terminate_old_droplets(name_regex='^ci\d+', ndays=5)
       will match i.e 'ci1234' , 'ci1', with a creation time older than 5 days
     """
-
-    threshold_time = (datetime.now(tz=timezone('Europe/Stockholm')) - timedelta(days=ndays)).strftime("%Y-%m-%dT%H:%M:%S+00:00")
+    threshold_time = (datetime.now(tz=timezone('Europe/Stockholm')) -
+                      timedelta(days=ndays)).\
+        strftime("%Y-%m-%dT%H:%M:%S+00:00")
     all = get_droplets()
 
     noterminate_droplets = []
@@ -291,10 +297,12 @@ def terminate_droplets_by_name(name_regex: str, ndays: int = 0, domain: str = 'o
         if droplet['name'] not in noterminate_droplets:
             if re.match(name_regex, droplet['name']):
                 if droplet['created_at'] < threshold_time:
-                    delete_domain_records_by_name(domain, '^\*.'+droplet['name'])
+                    delete_domain_records_by_name(
+                        domain, '^\*.'+droplet['name'])
                     delete_domain_records_by_name(domain, '^'+droplet['name'])
                     terminate_droplet(droplet['id'])
 
+
 def wait_for_ssh(ip: str):
     """Wait for ssh to be reachable on port 22."""
     log.info('Waiting for ssh to become available on ip %s', ip)