diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 875087fb5dd5d408cb441138600b213b90fd8e75..88355947e03e65cb5d97a771e82899eb7502935e 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,9 +1,13 @@
 include:
 - local: "{{cookiecutter.project_slug}}/.gitlab-ci.yml"
 
+trigger_prepare:
+  stage: prepare
+  trigger:
+    strategy: depend
+    include: "{{cookiecutter.project_slug}}/.prepare.gitlab-ci.yml"
+
 default:
   before_script:
     - python --version # For debugging
-    - python -m pip install --upgrade pip
-    - pip install --upgrade cookiecutter tox twine
     - cookiecutter --no-input --overwrite-if-exists -output-dir .
diff --git a/docker/ci-runner/Dockerfile b/docker/ci-runner/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..937fe86ec3a04eb6d9bb1463b366168f23b32aec
--- /dev/null
+++ b/docker/ci-runner/Dockerfile
@@ -0,0 +1,4 @@
+FROM python:3.11
+
+RUN python -m pip install --upgrade pip
+RUN pip install --upgrade cookiecutter tox twine
diff --git a/{{cookiecutter.project_slug}}/.gitlab-ci.yml b/{{cookiecutter.project_slug}}/.gitlab-ci.yml
index b17457db642b7eceb4eed0f04724adb973ba7f4e..af7749c58d7d7abf2ca7391d076bdeb87bee6a8f 100644
--- a/{{cookiecutter.project_slug}}/.gitlab-ci.yml
+++ b/{{cookiecutter.project_slug}}/.gitlab-ci.yml
@@ -1,15 +1,14 @@
 default:
-  image: python:3.10  # use latest for building/linting
+  image: $CI_REGISTRY_IMAGE/ci-build-runner:$CI_COMMIT_REF_SLUG
   before_script:
     - python --version # For debugging
-    - python -m pip install --upgrade pip
-    - pip install --upgrade tox twine
   cache:
     paths:
       - .cache/pip
       # Do not cache .tox, to recreate virtualenvs for every step
 
 stages:
+  - prepare
   - lint
   # check if this needs to be a separate step
   # - build_extensions
@@ -22,6 +21,14 @@ stages:
 variables:
   PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
 
+
+# Prepare image to run ci on
+trigger_prepare:
+  stage: prepare
+  trigger:
+    strategy: depend
+    include: .prepare.gitlab-ci.yml
+
 run_black:
   stage: lint
   script:
@@ -45,9 +52,27 @@ run_pylint:
 #   script:
 #     - echo "build fortran/c/cpp extension source code"
 
+# Basic setup for all Python versions for which we don't have a base image
+.run_unit_test_version_base:
+  before_script:
+    - python --version # For debugging
+    - python -m pip install --upgrade pip
+    - pip install --upgrade tox twine
+
+# Run all unit tests for Python versions except the base image
+run_unit_tests:
+  extends: .run_unit_test_version_base
+  stage: test
+  image: python:3.${PY_VERSION}
+  script:
+    - tox -e py3${PY_VERSION}
+  parallel:
+    matrix: # use the matrix for testing
+      - PY_VERSION: [7, 8, 9, 10]
+
+# Run code coverage on the base image thus also performing unit tests
 run_unit_tests_coverage:
   stage: test
-  image: python:3.7
   script:
    - tox -e coverage
   artifacts:
@@ -58,15 +83,6 @@ run_unit_tests_coverage:
     paths:
       - htmlcov/*
 
-run_unit_tests:
-  stage: test
-  image: python:3.${PY_VERSION}
-  script:
-    - tox -e py3${PY_VERSION}
-  parallel:
-    matrix: # use the matrix for testing
-      - PY_VERSION: [7, 8, 9, 10]
-
 package_files:
   stage: package
   artifacts:
diff --git a/{{cookiecutter.project_slug}}/.prepare.gitlab-ci.yml b/{{cookiecutter.project_slug}}/.prepare.gitlab-ci.yml
new file mode 100644
index 0000000000000000000000000000000000000000..e531b6dfa16369347e85f91c5587fb8ed1607074
--- /dev/null
+++ b/{{cookiecutter.project_slug}}/.prepare.gitlab-ci.yml
@@ -0,0 +1,23 @@
+stages:
+  - build
+
+build_ci_runner_image:
+  stage: build
+  image: docker:stable
+  services:
+    - docker:dind
+  script:
+    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
+    - |
+      if docker pull $CI_REGISTRY_IMAGE/ci-build-runner:$CI_COMMIT_REF_SLUG; then
+        docker build --cache-from $CI_REGISTRY_IMAGE/ci-build-runner:$CI_COMMIT_REF_SLUG --tag $CI_REGISTRY_IMAGE/ci-build-runner:$CI_COMMIT_REF_SLUG docker/ci-runner
+      else
+        docker pull $CI_REGISTRY_IMAGE/ci-build-runner:latest || true
+        docker build --cache-from $CI_REGISTRY_IMAGE/ci-build-runner:latest --tag $CI_REGISTRY_IMAGE/ci-build-runner:$CI_COMMIT_REF_SLUG docker/ci-runner
+      fi
+    - docker push $CI_REGISTRY_IMAGE/ci-build-runner:$CI_COMMIT_REF_SLUG  # push the image
+    - |
+      if [[ "$CI_COMMIT_BRANCH" == "$CI_DEFAULT_BRANCH" ]]; then
+        docker image tag $CI_REGISTRY_IMAGE/ci-build-runner:$CI_COMMIT_REF_SLUG $CI_REGISTRY_IMAGE/ci-build-runner:latest
+        docker push $CI_REGISTRY_IMAGE/ci-build-runner:latest
+      fi
diff --git a/{{cookiecutter.project_slug}}/docker/ci-runner/Dockerfile b/{{cookiecutter.project_slug}}/docker/ci-runner/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..01d5ee1b5eb9da1e6f7f3d89806bbc1d1787c7ed
--- /dev/null
+++ b/{{cookiecutter.project_slug}}/docker/ci-runner/Dockerfile
@@ -0,0 +1,4 @@
+FROM python:3.11
+
+RUN python -m pip install --upgrade pip
+RUN pip install --upgrade tox twine
diff --git a/{{cookiecutter.project_slug}}/setup.cfg b/{{cookiecutter.project_slug}}/setup.cfg
index fcc9167c3b9223f216e893f2acb15b1ecc945bf7..1741808bc860dd1e2294dcd1ec8e0d7e6ed8fd2d 100644
--- a/{{cookiecutter.project_slug}}/setup.cfg
+++ b/{{cookiecutter.project_slug}}/setup.cfg
@@ -19,6 +19,7 @@ classifiers =
     Programming Language :: Python :: 3.8
     Programming Language :: Python :: 3.9
     Programming Language :: Python :: 3.10
+    Programming Language :: Python :: 3.11
     Topic :: Internet :: WWW/HTTP
     Topic :: Internet :: WWW/HTTP :: Dynamic Content
     Topic :: Scientific/Engineering