diff --git a/.gitlab-ci/default.yml b/.gitlab-ci/default.yml
index 9ea9baf253909ebc80ce86139b4a679e6c785352..17f7d7739334ea2416f25ad0017005d8aa947803 100644
--- a/.gitlab-ci/default.yml
+++ b/.gitlab-ci/default.yml
@@ -24,8 +24,13 @@ variables:
 configure:
   stage: configure
   script:
-    - dunecontrol --opts=$DUNE_OPTS_FILE --current all
-    - source bin/testing/ci-setup-python-env.sh
+    - dunecontrol --opts=$DUNE_OPTS_FILE --current configure
+    - dunecontrol --opts=$DUNE_OPTS_FILE --current make -j8
+    # cache state of the Dune virtual env for Python if it exists (Dune 2.9)
+    - |
+      if [ -d "/dune/modules/dune-common/build-cmake/dune-env" ]; then
+        cp -r /dune/modules/dune-common/build-cmake/dune-env build-cmake
+      fi
   artifacts:
     paths:
       - build-cmake
@@ -46,9 +51,13 @@ black (python):
 pylint-flake8 (python):
   stage: linting
   script:
-    - source bin/testing/ci-setup-python-env.sh
     - |
       if [ -d build-cmake/python/dumux ] ; then
+        source bin/testing/ci-setup-python-env.sh
+        # if we are in venv (Dune 2.9) install linters
+        if [ -d "/dune/modules/dune-common/build-cmake/dune-env" ]; then
+          python -m pip install pylint flake8
+        fi
         pylint --rcfile=.pylintrc build-cmake/python/dumux
         pylint --rcfile=.pylintrc bin
         flake8 build-cmake/python/dumux
@@ -99,6 +108,11 @@ select tests:
 compile cpp:
   stage: build
   script:
+    # remove cached Python dune-env if existing (not needed for C++) (Dune 2.9)
+    - |
+      if [ -d "build-cmake/dune-env" ]; then
+        rm -r build-cmake/dune-env
+      fi
     - |
       pushd build-cmake
         make clean && make all
@@ -148,7 +162,12 @@ test python:
     OMPI_ALLOW_RUN_AS_ROOT: 1
     OMPI_ALLOW_RUN_AS_ROOT_CONFIRM: 1
   script:
-    - source bin/testing/ci-setup-python-env.sh
+    # restore Python virtual env from cache (job:configure artifacts) (Dune 2.9)
+    - |
+      if [ -d "build-cmake/dune-env" ]; then
+        rm -r /dune/modules/dune-common/build-cmake/dune-env
+        mv build-cmake/dune-env /dune/modules/dune-common/build-cmake/dune-env
+      fi
     - |
       if ([ ! -s changedfiles.txt ] || grep -q python "changedfiles.txt"); then
         if [ ! -s changedfiles.txt ]; then
@@ -158,7 +177,7 @@ test python:
         fi
         source bin/testing/ci-setup-python-env.sh
         pushd build-cmake
-          ctest --output-on-failure -L python
+          DUNE_LOG_LEVEL=DEBUG ctest --output-on-failure -L python
         popd
       else
         echo "No changes in the Python bindings/Python code detected: skipping tests."
diff --git a/CMakeLists.txt b/CMakeLists.txt
index f0f1f229d82ca291fe331416ebae731d69b294dc..257021890fb34a5d8b0c95189c31f9cc853a853f 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -33,9 +33,12 @@ add_subdirectory(dumux)
 add_subdirectory(test EXCLUDE_FROM_ALL)
 add_subdirectory(examples EXCLUDE_FROM_ALL)
 
-# if Python bindings are enabled, include necessary sub directories.
-if(DUNE_ENABLE_PYTHONBINDINGS)
-  if(${dune-common_VERSION} VERSION_GREATER_EQUAL 2.8)
+# from Dune 2.9 on Python bindings are enabled per default
+if(${dune-common_VERSION} VERSION_GREATER_EQUAL 2.9)
+  add_subdirectory(python)
+else()
+  # with Dune 2.8, only if Python bindings are enabled
+  if(DUNE_ENABLE_PYTHONBINDINGS)
     add_subdirectory(python)
     dune_python_install_package(PATH "python")
   endif()
diff --git a/bin/testing/ci-setup-python-env.sh b/bin/testing/ci-setup-python-env.sh
index dd20b89d61a0862ea106e68c8555d7dc5c3cc3cb..5f9b133bfcf50a4a09cf8011bf3626765e72c8fd 100755
--- a/bin/testing/ci-setup-python-env.sh
+++ b/bin/testing/ci-setup-python-env.sh
@@ -1,8 +1,14 @@
 #!/bin/bash
 
-if [ -L /dune/bin/setup-python ] && [ -e /dune/bin/setup-python ] ; then
-    dunecontrol bexec "echo -n :\$(pwd)/python >> $(pwd)/pythonpath.txt"
-    export PYTHONPATH=$PYTHONPATH$(cat pythonpath.txt)
-    rm pythonpath.txt
-    setup-python --opts=$DUNE_OPTS_FILE install
+if [ -d "/dune/modules/dune-common/build-cmake/dune-env" ]; then
+    # Use internal venv of DUNE
+    echo "Activating the Python virtual environment of dune-common"
+    source /dune/modules/dune-common/build-cmake/dune-env/bin/activate
+else
+    if [ -L /dune/bin/setup-python ] && [ -e /dune/bin/setup-python ] ; then
+        dunecontrol bexec "echo -n :\$(pwd)/python >> $(pwd)/pythonpath.txt"
+        export PYTHONPATH=$PYTHONPATH$(cat pythonpath.txt)
+        rm pythonpath.txt
+        setup-python --opts=$DUNE_OPTS_FILE install
+    fi
 fi
diff --git a/python/CMakeLists.txt b/python/CMakeLists.txt
index 0c4ddccf90e05122277901c3cfbf4c9b2957cb68..f34a5c5d5c9293c90c7e4c288eb61ea25ab70cfd 100644
--- a/python/CMakeLists.txt
+++ b/python/CMakeLists.txt
@@ -1,2 +1,20 @@
 add_subdirectory(dumux)
 configure_file(setup.py.in setup.py)
+
+# link properties.hh needed by the Python bindings
+# to determine the list of properties
+# create copy for Windows and symlink otherwise
+if(${CMAKE_SYSTEM_NAME} STREQUAL "Windows")
+  execute_process(COMMAND ${CMAKE_COMMAND} "-E" "copy" "${CMAKE_SOURCE_DIR}/dumux/common/properties.hh" "${CMAKE_CURRENT_BINARY_DIR}/properties.hh")
+else()
+  execute_process(COMMAND ${CMAKE_COMMAND} "-E" "create_symlink" "${CMAKE_SOURCE_DIR}/dumux/common/properties.hh" "${CMAKE_CURRENT_BINARY_DIR}/properties.hh")
+endif()
+
+if(${dune-common_VERSION} VERSION_GREATER_EQUAL 2.9)
+  dune_python_install_package(
+    PATH "."
+    CMAKE_METADATA_FILE dumux/metadata.cmake
+    DEPENDS _common
+    CMAKE_METADATA_FLAGS DUNE_OPTS_FILE
+  )
+endif()
diff --git a/python/dumux/__init__.py b/python/dumux/__init__.py
index bb4c6ae25606e22f49e4e73de4426d1721d0e5d3..5bcb3ab2d1ec8dd680e6e36a83a2f2575d95899e 100644
--- a/python/dumux/__init__.py
+++ b/python/dumux/__init__.py
@@ -10,4 +10,11 @@ DuMux is
 https://dumux.org/
 """
 
-__import__("pkg_resources").declare_namespace(__name__)
+try:
+    from dune.common import registerExternalModule
+
+    # register dumux to be recognized by dune-py (code generation module)
+    # as a module of the dune univers
+    registerExternalModule("dumux")
+except ImportError:
+    pass
diff --git a/python/dumux/common/CMakeLists.txt b/python/dumux/common/CMakeLists.txt
index ffc35a5823ad73956efb36ba72ae5434047f4913..65f0a6b1f590e515998bbf8fcb038b2c90ea9f88 100644
--- a/python/dumux/common/CMakeLists.txt
+++ b/python/dumux/common/CMakeLists.txt
@@ -4,4 +4,7 @@ add_python_targets(common
 )
 dune_add_pybind11_module(NAME _common)
 set_property(TARGET _common PROPERTY LINK_LIBRARIES dunecommon dunegrid APPEND)
-install(TARGETS _common LIBRARY DESTINATION python/dumux/common)
+
+if(SKBUILD)
+  install(TARGETS _common LIBRARY DESTINATION python/dumux/common)
+endif()
diff --git a/python/dumux/common/properties.py b/python/dumux/common/properties.py
index e72dc23f05a089a4c44c2a4fd62f0ab2ca22f696..0e791e1edb85fa349ecefef5fa5444035b7dce98 100644
--- a/python/dumux/common/properties.py
+++ b/python/dumux/common/properties.py
@@ -7,6 +7,7 @@ import os
 from dataclasses import dataclass
 from typing import List, Union
 from dune.common.hashit import hashIt
+import dumux
 
 
 @dataclass
@@ -146,12 +147,46 @@ def listTypeTags():
     print("\n**********************************")
 
 
-def predefinedProperties():
-    """Create a list of properties defined in properties.hh"""
+def propertiesHeaderPath():
+    """Find the path to the properties.hh C++ header"""
+
+    path, _ = os.path.split(dumux.__file__)
+    metaDataFile = os.path.join(path, "data/metadata.cmake")
+    if os.path.exists(metaDataFile):
+        data = {}
+        with open(metaDataFile, "r") as metaData:
+            for line in metaData:
+                try:
+                    key, value = line.split("=", 1)
+                    data[key] = value.strip()
+                except ValueError:  # no '=' in line
+                    pass
+        return os.path.abspath(
+            os.path.join(
+                data["DEPBUILDDIRS"].split(";")[0],
+                "python",
+                "properties.hh",
+            )
+        )
 
+    # as fall-back try relative path
     propertiesHeader = os.path.abspath(
-        os.path.dirname(__file__) + "/../../../../dumux/common/properties.hh"
+        os.path.join(
+            os.path.dirname(__file__),
+            "../../../../dumux/common/properties.hh",
+        )
     )
+
+    if os.path.exists(propertiesHeader):
+        return propertiesHeader
+
+    raise RuntimeError("Could not find properties.hh header")
+
+
+def predefinedProperties():
+    """Create a list of properties defined in properties.hh"""
+
+    propertiesHeader = propertiesHeaderPath()
     with open(propertiesHeader, encoding="utf-8") as header:
         properties = []
         for line in header:
diff --git a/python/setup.py.in b/python/setup.py.in
index 970d78de29b51be6fba1af93e6b12acec5f4f350..8504ecc10b39cc54175d72527bf4365f218b8647 100644
--- a/python/setup.py.in
+++ b/python/setup.py.in
@@ -1,4 +1,6 @@
-from setuptools import setup, find_namespace_packages
+from setuptools import setup
+
+REQUIRED_PACKAGES = '${RequiredPythonModules}'.replace(';',' ').split(' ')
 
 setup(
     name="${ProjectName}",
@@ -6,8 +8,9 @@ setup(
     version="${ProjectVersionString}",
     author="${ProjectAuthor}",
     author_email="${ProjectMaintainerEmail}",
-    packages=find_namespace_packages(include=["dumux.*"]),
+    packages=["dumux"],
     zip_safe=0,
     package_data={"": ["*.so"]},
-    install_requires="${ProjectPythonRequires}".split(" "),
+    install_requires=REQUIRED_PACKAGES,
+    include_package_data=True,
 )