diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index eddd484878fd549b17f218a10bba4f1b6ae74cc4..cac7c3b1967a3ae2fb7bff785648539a8cfb025b 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -176,7 +176,8 @@ create-vps:
   stage: create-vps
   script:
     - *debug_information
-    # Creates the VPS only if an old VPS for this branch is not re-usable
+    # Creates a VPS based on a custom CI image for which --install-kubernetes
+    # has already run. See CONTRIBUTING.md#ci-pipeline-image for more info
     - sh .gitlab/ci_scripts/create_vps.sh
   artifacts:
     paths:
diff --git a/.gitlab/ci_scripts/create_vps.sh b/.gitlab/ci_scripts/create_vps.sh
index ab6df85f5361906c9a5d713ff9052063ea1e36f8..123755568eb647318f686134c4e94b8b889bbdb0 100644
--- a/.gitlab/ci_scripts/create_vps.sh
+++ b/.gitlab/ci_scripts/create_vps.sh
@@ -7,6 +7,7 @@ set -ve
 echo "Deleting old machine"
 python3 -c "import greenhost_cloud; greenhost_cloud.terminate_droplets_by_name(\"^${HOSTNAME}$\")"
 echo "Creating new machine"
+# Uses a custom disk image. See CONTRIBUTING.md#ci-pipeline-image for more info.
 python3 -m openappstack $HOSTNAME create \
   --acme-staging \
   --local-flux \
@@ -16,4 +17,5 @@ python3 -m openappstack $HOSTNAME create \
   --ssh-key-id $SSH_KEY_ID \
   --create-domain-records \
   --subdomain $SUBDOMAIN \
+  --disk-image-id '-7121' \
   --truncate-subdomain
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 0a8b888b993e43910a9fb9b9b8ff0e35523bd64f..bf7445e13d27d3cc9a4bb246b2a16bd4283fa7a2 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -46,3 +46,46 @@ but also its dependencies.
 
 If the new package you are adding is only used by developers,
 please add it to the `requirements-dev.txt` file.
+
+## CI pipeline image
+
+We use a custom disk image for the VPSs used by the CI pipeline. On this image,
+the `install-kubernetes.yaml` playbook has already been applied, which usually
+saves a few minutes of pipeline running time.
+
+### What to do when I change a part of the `install-kubernetes.yaml` playbook?
+
+Don't worry, the playbook *runs* in the CI (just faster, because usually
+nothing needs to change). So if you make changes, you can test those in the CI
+without problems.
+
+If you want to start with a clean slate, however, you might want to change
+`.gitlab/ci_scripts/create_vps.sh` and temporarily remove the `--disk-image-id`
+argument.
+
+#### Before you merge, make sure your changes are applied to a new custom image:
+
+If you changed the `install-kubernetes.yaml` playbook, for example to upgrade
+the k3s version in use, you'll want to generate a new disk image template and
+use it. This is a manual process for now. Follow these steps:
+
+1. Create a new VPS
+2. Run the following to install *only kubernetes* on the VPS:
+   ```
+   $ python3 -m openappstack <cluster> install --install-kubernetes --no-install-openappstack
+   ```
+3. Log into Cosmos with the OpenAppStack account
+4. Go to VPS Cloud -> VPS and shut down your VPS
+5. Go to VPS Cloud -> Disk Images and click edit for your VPSs disk image
+   1. Change the Disk Label to something like `k3s-template`
+   2. Set VPS to `-- not assigned --`
+   3. Click 'make template'
+   4. Remember the disk image ID that you can see in the current URL as `id=...`
+   5. Click save
+6. Change the `--disk-image-id` argument in `.gitlab/ci_scripts/create_vps.sh`
+   to your current disk-image-id **with a minus in front of it**. This is
+   because custom images are negative integers, whereas Greenhost's disk images
+   are positive integers
+
+You are now ready to merge the changes you made to the `install-kubernetes`
+playbook
diff --git a/openappstack/__main__.py b/openappstack/__main__.py
index d68a9a9bab65858ed6adb6cd3bf3feb809937697..b8d0ed6c19e55e9a05b0af2ff33f289b4c7af802 100755
--- a/openappstack/__main__.py
+++ b/openappstack/__main__.py
@@ -147,6 +147,11 @@ def main():  # pylint: disable=too-many-statements,too-many-branches,too-many-lo
         help=("Use this for development clusters. Uses an in-cluster "
               'auto-update feed'))
 
+    droplet_creation_group.add_argument(
+        '--disk-image-id',
+        help=("Custom disk image ID. Use negative value for a custom template "
+              "ID, use positive values for operating system IDs"))
+
     install_parser = subparsers.add_parser(
         'install',
         help=("Use this to run the ansible playbook that sets up your VPS to run "
@@ -182,6 +187,12 @@ def main():  # pylint: disable=too-many-statements,too-many-branches,too-many-lo
         action='store_true',
         help="Installs k3s on your VPS before installing OpenAppStack")
 
+    install_parser.add_argument(
+        '--no-install-openappstack',
+        action='store_true',
+        help=("Skip openappstack installation. This is useful if you only "
+              "want a kubernetes cluster."))
+
     test_parser = subparsers.add_parser(
         'test',
         help=("Write test configuration and run tests on your cluster"))
@@ -312,6 +323,9 @@ def create(clus, args):  # pylint: disable=too-many-branches
 
     clus.domain = fqdn
 
+    if args.disk_image_id:
+        clus.disk_image_id = args.disk_image_id
+
     # Set acme_staging to False so we use Let's Encrypt's live environment
     if args.acme_staging:
         clus.acme_staging = True
@@ -360,10 +374,11 @@ def install(clus, args):
             os.path.join(ansible.ANSIBLE_PATH, 'install-kubernetes.yml'),
             args.ansible_param)
 
-    ansible.run_ansible(
-        clus,
-        os.path.join(ansible.ANSIBLE_PATH, 'install-openappstack.yml'),
-        args.ansible_param)
+    if not args.no_install_openappstack:
+        ansible.run_ansible(
+            clus,
+            os.path.join(ansible.ANSIBLE_PATH, 'install-openappstack.yml'),
+            args.ansible_param)
 
 
 def test(clus, args):
diff --git a/openappstack/cluster.py b/openappstack/cluster.py
index d106dfff89ccaff31beaee13f1fc112af035b29d..44cc0364b98123d4c68adb729f3ed9eb39633774 100644
--- a/openappstack/cluster.py
+++ b/openappstack/cluster.py
@@ -65,6 +65,8 @@ class Cluster:
         # Load data from inventory.yml and settings.yml
         if load_data:
             self.load_data()
+        # Can be used to use a custom disk image.
+        self.disk_image_id = DEFAULT_IMAGE
 
     def load_data(self):
         """
@@ -119,7 +121,7 @@ class Cluster:
             region=DEFAULT_REGION,
             size=ram,
             disk=DEFAULT_DISK_SIZE_GB,
-            image=DEFAULT_IMAGE)
+            image=self.disk_image_id)
         droplet_id = droplet['droplet']['id']
         log.info('Created droplet id: %s', droplet_id)
         greenhost_cloud.wait_for_state(droplet_id, 'running')