diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 7cb68360f4e14993afeee4b789928f9f336adabd..ec64e887f2fd3dfd161fb43cf93d2fc379c0d261 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,21 +1,22 @@
 ---
 
-# YAML anchors
-# ============
+# Global templates and YAML anchors
+# =================================
 #
+# Used in various stages/job definitions
+
 # We don't use a `before_script` definition here because `extend` doesn't merge
 # `before_script` but rather overwrites it.
 # So we rather use [yaml anchors](https://docs.gitlab.com/ce/ci/yaml/README.html#anchors)
 # here. Unfortunatly, anchors can't get included from files so we need to
 # define them here.
-
 .debug_information: &debug_information
   - |
     echo "Env vars:"
     echo
     echo "HOSTNAME:                  $HOSTNAME"
     echo "IP_ADDRESS:                $IP_ADDRESS"
-    echo "Uptime:                    $(uptime -p)"
+    echo "Uptime:                    $(uptime)"
     echo "CLUSTER_DIR:               $CLUSTER_DIR"
     echo "ANSIBLE_HOST_KEY_CHECKING: $ANSIBLE_HOST_KEY_CHECKING"
     echo "KANIKO_BUILD_IMAGENAME:    $KANIKO_BUILD_IMAGENAME"
@@ -26,55 +27,93 @@
     echo
     echo
 
-.image_build_template: &image_build_template
-  stage: build
-  before_script:
-    - *debug_information
-  extends:
-    - .kaniko_build
-  environment:
-    name: image/$CI_COMMIT_REF_SLUG
-    url: https://open.greenhost.net:4567/openappstack/openappstack/openappstack-ci:${CI_COMMIT_REF_SLUG}
-    on_stop: delete-image
-    auto_stop_in: 3 weeks
 
-# YAML extends
-# ============
+# app rules
 #
-
-# the .app_rules should be used whenever an app-specific job is executed.
-# just add the variable app to the job like this:
-
+# Define the rules when/if app specific jobs are run.
+# Just add the variable APP to the job like this:
 #   variables:
 #     APP: "eventrouter"
-
-# and import the template with
-
-#   extends: .app_rules
-
-# .app_rules will ensure that the job is only executed when files related to the app changed in the repo
-.app_rules:
-  before_script:
-    - *debug_information
+# and import the templates with i.e.
+#   extends: .eventrouter_rules
+# .eventrouter_rules will ensure that the job is only executed:
+# - when files related to the app changed in the repo
+# - A pipeline gets started from the UI and the job name is included in the
+#   CI variable `TRIGGER_JOBS`
+# - A commit is pushed containing the pattern TRIGGER_JOBS=.*<job name>
+#   (i.e. TRIGGER_JOBS=ci-test-image-build,enable-grafana)
+#
+# Gitlab CI allows pushing CI vars via `git push` but a bug prevents this when
+# using merge request pipelines (see https://gitlab.com/gitlab-org/gitlab/-/issues/326098)
+.eventrouter_rules:
   rules:
     - changes:
         - flux/**/$APP*.yaml
         - ansible/roles/apps/templates/settings/$APP.yaml
         - ansible/roles/apps/tasks/$APP.yaml
+    - if: '$TRIGGER_JOBS =~ /enable-eventrouter/'
+    - if: '$CI_COMMIT_MESSAGE =~ /TRIGGER_JOBS=.*enable-eventrouter/'
+    - if: '$CI_COMMIT_BRANCH == "master"'
 
-
-.enable_app_template:
-  extends: .app_rules
-  stage: enable-apps
-  script:
-    - |
-      [ ! -d ./enabled_apps ] && mkdir enabled_apps || /bin/true
-      touch ./enabled_apps/$APP
-  artifacts:
-    paths:
-      - ./clusters
-      - ./enabled_apps/$APP
-
+.grafana_rules:
+  rules:
+    - changes:
+      - flux/**/$APP*.yaml
+      - ansible/roles/apps/templates/settings/$APP.yaml
+      - ansible/roles/apps/tasks/$APP.yaml
+    - if: '$TRIGGER_JOBS =~ /enable-grafana/'
+    - if: '$CI_COMMIT_MESSAGE =~ /TRIGGER_JOBS=.*enable-grafana/'
+    - if: '$CI_COMMIT_BRANCH == "master"'
+
+.nextcloud_rules:
+  rules:
+    - changes:
+      - flux/**/$APP*.yaml
+      - ansible/roles/apps/templates/settings/$APP.yaml
+      - ansible/roles/apps/tasks/$APP.yaml
+    - if: '$TRIGGER_JOBS =~ /enable-nextcloud/'
+    - if: '$CI_COMMIT_MESSAGE =~ /TRIGGER_JOBS=.*enable-nextcloud/'
+    - if: '$CI_COMMIT_BRANCH == "master"'
+
+.prometheus_rules:
+  rules:
+    - changes:
+      - flux/**/$APP*.yaml
+      - ansible/roles/apps/templates/settings/$APP.yaml
+      - ansible/roles/apps/tasks/$APP.yaml
+    - if: '$TRIGGER_JOBS =~ /enable-prometheus/'
+    - if: '$CI_COMMIT_MESSAGE =~ /TRIGGER_JOBS=.*enable-prometheus/'
+    - if: '$CI_COMMIT_BRANCH == "master"'
+
+.rocketchat_rules:
+  rules:
+    - changes:
+      - flux/**/$APP*.yaml
+      - ansible/roles/apps/templates/settings/$APP.yaml
+      - ansible/roles/apps/tasks/$APP.yaml
+    - if: '$TRIGGER_JOBS =~ /enable-rocketchat/'
+    - if: '$CI_COMMIT_MESSAGE =~ /TRIGGER_JOBS=.*enable-rocketchat/'
+    - if: '$CI_COMMIT_BRANCH == "master"'
+
+.single_sign_on_rules:
+  rules:
+    - changes:
+      - flux/**/$APP*.yaml
+      - ansible/roles/apps/templates/settings/$APP.yaml
+      - ansible/roles/apps/tasks/$APP.yaml
+    - if: '$TRIGGER_JOBS =~ /enable-single-sign-on/'
+    - if: '$CI_COMMIT_MESSAGE =~ /TRIGGER_JOBS=.*enable-single-sign-on/'
+    - if: '$CI_COMMIT_BRANCH == "master"'
+
+.wordpress_rules:
+  rules:
+    - changes:
+      - flux/**/$APP*.yaml
+      - ansible/roles/apps/templates/settings/$APP.yaml
+      - ansible/roles/apps/tasks/$APP.yaml
+    - if: '$TRIGGER_JOBS =~ /enable-wordpress/'
+    - if: '$CI_COMMIT_MESSAGE =~ /TRIGGER_JOBS=.*enable-wordpress/'
+    - if: '$CI_COMMIT_BRANCH == "master"'
 
 
 # Global declarations
@@ -82,9 +121,8 @@
 
 # https://docs.gitlab.com/ee/ci/yaml/README.html#workflowrules-templates
 include:
-  - template: 'Workflows/Branch-Pipelines.gitlab-ci.yml'
-  - .gitlab/ci_templates/kaniko.yml
-  - .gitlab/ci_templates/ssh_setup.yml
+  - /.gitlab/ci_templates/kaniko.yml
+  - /.gitlab/ci_templates/ssh_setup.yml
   - template: 'Workflows/MergeRequest-Pipelines.gitlab-ci.yml'
 
 stages:
@@ -121,25 +159,26 @@ default:
 # Write "REBUILD_CONTAINER" in your commit message to force rebuilding the container.
 
 ci-test-image-build:
-  <<: *image_build_template
+  stage: build
+  before_script:
+    - *debug_information
+  environment:
+    name: image/$CI_COMMIT_REF_SLUG
+    url: https://open.greenhost.net:4567/openappstack/openappstack/openappstack-ci:${CI_COMMIT_REF_SLUG}
+    on_stop: delete-image
+    auto_stop_in: 3 weeks
   rules:
     # Automatically rebuild the container image if this file, the Dockerfile,
     # the installed requirements or the kaniko template change
     - changes:
-        - .gitlab-ci.yml
         - Dockerfile
         - requirements.txt
-        - .gitlab/ci_templates/**
-        # These  changes need to be tracked because subsequent jobs will try to
-        # use the image that is tagged by this job.
-        - .gitlab/ci_scripts/*
-        - ansible/**/*
-        - flux/**/*
-        - test/**/*
-        - openappstack/**/*
-    # Also rebuild when the CI message contains REBUILD_CONTAINER
-    - if: '$CI_COMMIT_MESSAGE =~ /REBUILD_CONTAINER/'
-
+    # Also rebuild when the CI variable contain this jobs name
+    # or commit msg contains /TRIGGER_JOBS=.*ci-test-image-build/
+    - if: '$TRIGGER_JOBS =~ /ci-test-image-build/'
+    - if: '$CI_COMMIT_MESSAGE =~ /TRIGGER_JOBS=.*ci-test-image-build/'
+  extends:
+    - .kaniko_build
 
 # Stage: create-vps
 # =================
@@ -182,35 +221,60 @@ create-vps:
 #
 # Checks if application needs to get installed
 
+.enable_app_template:
+  stage: enable-apps
+  before_script:
+    - *debug_information
+  script:
+    - |
+      [ ! -d ./enabled_apps ] && mkdir enabled_apps || /bin/true
+      touch ./enabled_apps/$APP
+  artifacts:
+    paths:
+      - ./clusters
+      - ./enabled_apps/$APP
+
 enable-eventrouter:
   variables:
     APP: "eventrouter"
-  extends: .enable_app_template
+  extends:
+    - .enable_app_template
+    - .eventrouter_rules
 
 enable-grafana:
   variables:
     APP: "grafana"
-  extends: .enable_app_template
+  extends:
+    - .enable_app_template
+    - .grafana_rules
 
 enable-nextcloud:
   variables:
     APP: "nextcloud"
-  extends: .enable_app_template
+  extends:
+    - .enable_app_template
+    - .nextcloud_rules
 
 enable-rocketchat:
   variables:
     APP: "rocketchat"
-  extends: .enable_app_template
+  extends:
+    - .enable_app_template
+    - .rocketchat_rules
 
 enable-single-sign-on:
   variables:
     APP: "single-sign-on"
-  extends: .enable_app_template
+  extends:
+    - .enable_app_template
+    - .single_sign_on_rules
 
 enable-wordpress:
   variables:
     APP: "wordpress"
-  extends: .enable_app_template
+  extends:
+    - .enable_app_template
+    - .wordpress_rules
 
 
 # Stage: setup-cluster
@@ -286,42 +350,56 @@ setup-openappstack:
     when: always
   extends:
     - .ssh_setup
-    - .app_rules
 
 eventrouter-helm-release:
   variables:
     APP: "eventrouter"
-  extends: .helm-release
+  extends:
+    - .helm-release
+    - .eventrouter_rules
 
 grafana-helm-release:
   variables:
     APP: "grafana"
-  extends: .helm-release
+  extends:
+    - .helm-release
+    - .grafana_rules
 
 nextcloud-helm-release:
   variables:
     APP: "nextcloud"
-  extends: .helm-release
+  extends:
+    - .helm-release
+    - .nextcloud_rules
 
 prometheus-helm-release:
   variables:
     APP: "prometheus"
-  extends: .helm-release
+  extends:
+    - .helm-release
+    - .prometheus_rules
 
 rocketchat-helm-release:
   variables:
     APP: "rocketchat"
-  extends: .helm-release
+  extends:
+    - .helm-release
+    - .rocketchat_rules
 
 single-sign-on-helm-release:
   variables:
     APP: "single-sign-on"
-  extends: .helm-release
+  extends:
+    - .helm-release
+    - .single_sign_on_rules
 
 wordpress-helm-release:
   variables:
     APP: "wordpress"
-  extends: .helm-release
+  extends:
+    - .helm-release
+    - .wordpress_rules
+
 
 # Stage: app-ready
 # ================
@@ -343,56 +421,69 @@ wordpress-helm-release:
     when: always
   extends:
     - .ssh_setup
-    - .app_rules
 
 eventrouter-ready:
   variables:
     APP: "eventrouter"
   needs:
     - job: eventrouter-helm-release
-  extends: .apps-ready
+  extends:
+    - .apps-ready
+    - .eventrouter_rules
 
 grafana-ready:
   variables:
     APP: "grafana"
   needs:
     - job: grafana-helm-release
-  extends: .apps-ready
+  extends:
+    - .apps-ready
+    - .grafana_rules
 
 nextcloud-ready:
   variables:
     APP: "nextcloud"
   needs:
     - job: nextcloud-helm-release
-  extends: .apps-ready
+  extends:
+    - .apps-ready
+    - .nextcloud_rules
 
 prometheus-ready:
   variables:
     APP: "prometheus"
   needs:
     - job: prometheus-helm-release
-  extends: .apps-ready
+  extends:
+    - .apps-ready
+    - .prometheus_rules
 
 rocketchat-ready:
   variables:
     APP: "rocketchat"
   needs:
     - job: rocketchat-helm-release
-  extends: .apps-ready
+  extends:
+    - .apps-ready
+    - .rocketchat_rules
 
 single-sign-on-ready:
   variables:
     APP: "single-sign-on"
   needs:
     - job: single-sign-on-helm-release
-  extends: .apps-ready
+  extends:
+    - .apps-ready
+    - .single_sign_on_rules
 
 wordpress-ready:
   variables:
     APP: "wordpress"
   needs:
     - job: wordpress-helm-release
-  extends: .apps-ready
+  extends:
+    - .apps-ready
+    - .wordpress_rules
 
 # Stage: certs
 # ================
@@ -412,49 +503,60 @@ wordpress-ready:
     when: always
   extends:
     - .ssh_setup
-    - .app_rules
 
 grafana-cert:
   variables:
     APP: "grafana"
   needs:
     - job: grafana-ready
-  extends: .apps-cert
+  extends:
+    - .apps-cert
+    - .grafana_rules
 
 nextcloud-cert:
   variables:
     APP: "nextcloud"
   needs:
     - job: nextcloud-ready
-  extends: .apps-cert
+  extends:
+    - .apps-cert
+    - .nextcloud_rules
 
 prometheus-cert:
   variables:
     APP: "prometheus"
   needs:
     - job: prometheus-ready
-  extends: .apps-cert
+  extends:
+    - .apps-cert
+    - .prometheus_rules
 
 rocketchat-cert:
   variables:
     APP: "rocketchat"
   needs:
     - job: rocketchat-ready
-  extends: .apps-cert
+  extends:
+    - .apps-cert
+    - .rocketchat_rules
 
 single-sign-on-cert:
   variables:
     APP: "single-sign-on"
   needs:
     - job: single-sign-on-ready
-  extends: .apps-cert
+  extends:
+    - .apps-cert
+    - .single_sign_on_rules
 
 wordpress-cert:
   variables:
     APP: "wordpress"
   needs:
     - job: wordpress-ready
-  extends: .apps-cert
+  extends:
+    - .apps-cert
+    - .wordpress_rules
 
 
 # Stage: health-test
@@ -481,7 +583,7 @@ testinfra:
 prometheus-alerts:
   stage: health-test
   variables:
-    # Adding the app var hier in combination with .app_rules applies app specific gitlab-ci rules
+    # APP var is used in job specific rules (i.e. .grafana_rules)
     APP: "prometheus"
   allow_failure: true
   script:
@@ -490,7 +592,7 @@ prometheus-alerts:
     - pytest -s -m 'prometheus' --connection=ansible --ansible-inventory=${CLUSTER_DIR}/inventory.yml --hosts='ansible://*'
   extends:
     - .ssh_setup
-    - .app_rules
+    - .prometheus_rules
   needs:
     - job: prometheus-ready
 
@@ -514,35 +616,43 @@ prometheus-alerts:
     when: on_failure
   extends:
     - .ssh_setup
-    - .app_rules
 
 grafana-behave:
   variables:
     APP: "grafana"
   needs:
     - job: grafana-cert
-  extends: .behave
+  extends:
+    - .behave
+    - .grafana_rules
 
 nextcloud-behave:
   variables:
     APP: "nextcloud"
   needs:
     - job: nextcloud-cert
-  extends: .behave
+  extends:
+    - .behave
+    - .nextcloud_rules
 
 rocketchat-behave:
   variables:
     APP: "rocketchat"
   needs:
     - job: rocketchat-cert
-  extends: .behave
+  extends:
+    - .behave
+    - .rocketchat_rules
+
 
 wordpress-behave:
   variables:
     APP: "wordpress"
   needs:
     - job: wordpress-cert
-  extends: .behave
+  extends:
+    - .behave
+    - .wordpress_rules
 
 
   # Etc
@@ -588,5 +698,4 @@ gitlab-merge-workaround:
   stage: build
   image: busybox
   script:
-    - *debug_information
     - echo "Not building anything, no changes."
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index cb3547645076f8af153568445c83a6755dcd27d0..096db8e0cb29b80115ab08b3b35f270ebf775ddf 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -9,6 +9,7 @@ repos:
       - id: detect-private-key
       - id: end-of-file-fixer
       - id: trailing-whitespace
+      - id: debug-statements
   - repo: https://github.com/jumanjihouse/pre-commit-hooks
     rev: 2.1.5
     hooks:
@@ -18,3 +19,13 @@ repos:
     rev: v1.23.0
     hooks:
       - id: hadolint
+  - repo: https://github.com/jazzband/pip-tools
+    # Makes sure the requirements.txt file is up to date
+    rev: 5.2.1
+    hooks:
+      - id: pip-compile
+  - repo: https://github.com/timothycrosley/isort
+    # Sorts import statements for you
+    rev: 5.0.9
+    hooks:
+      - id: isort
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000000000000000000000000000000000000..0a8b888b993e43910a9fb9b9b8ff0e35523bd64f
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,48 @@
+# How to contribute
+
+## Preparing the development environment
+
+Make sure you have development dependencies installed in your development environment.
+
+```
+pip install -r requirements-dev.txt
+```
+
+## pre-commit hooks
+
+We use [pre-commit](https://pre-commit.com/) to maintain and install pre-commit
+hooks that should be executed before each commit.
+
+
+Please install these required tools on your system:
+
+* [hadolint](https://github.com/hadolint/hadolint) for linting the `Dockerfile`
+* [shellcheck](https://www.shellcheck.net/) and
+* [shfmt](https://github.com/mvdan/sh) to lint and validate shell scripts
+
+Then install pre-commit hooks:
+
+```
+pre-commit install
+```
+
+Running `git commmit` for the first time after installing the hook usually takes a
+little longer because `pre-commit` pulls it's hooks from upstream repositories.
+You can find all hooks in `.pre-commit-config.yaml`.
+
+In case you need to skip the execution of the pre-commit hooks (please don't!),
+use `git commit --no-verify`.
+
+
+## Adding dependencies
+
+Make sure you update our `requirements.txt` file before you push your changes.
+Whenever you need a new python package, add it to requirements.in and run
+
+`pip-compile`
+
+to generate an new `requirements.txt` which does not only pin the new package
+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.
diff --git a/flux/oas-apps/nextcloud_hr.yaml b/flux/oas-apps/nextcloud_hr.yaml
index 39945192f39af8984668ef44003f750163a8b5ea..0f6c5c50e24c8236d06d4f26197103f83affb100 100644
--- a/flux/oas-apps/nextcloud_hr.yaml
+++ b/flux/oas-apps/nextcloud_hr.yaml
@@ -1,3 +1,4 @@
+---
 apiVersion: helm.fluxcd.io/v1
 kind: HelmRelease
 metadata:
@@ -11,7 +12,7 @@ spec:
   releaseName: nc
   chart:
     git: https://open.greenhost.net/openappstack/nextcloud
-    ref: 0.2.5
+    ref: 0.2.6
     path: .
   valuesFrom:
     - secretKeyRef:
diff --git a/openappstack/cluster.py b/openappstack/cluster.py
index 79a7bb51538afa7946005675b655d81281231ec1..d106dfff89ccaff31beaee13f1fc112af035b29d 100644
--- a/openappstack/cluster.py
+++ b/openappstack/cluster.py
@@ -170,11 +170,22 @@ class Cluster:
         settings['ip_address'] = self.ip_address
         settings['domain'] = self.domain
         settings['admin_email'] = 'admin@{0}'.format(self.domain)
-        settings['acme_staging'] = self.acme_staging
         settings['flux']['local_flux'] = self.local_flux
         settings['cluster_dir'] = self.cluster_dir
         settings['prometheus_enable_ingress'] = self.prometheus_enable_ingress
 
+        # Configure apps to handle invalid certs i.e. from
+        # Letsencrypt staging API
+        settings['acme_staging'] = self.acme_staging
+        nextcloud_extra_values = """
+          onlyoffice:
+            unauthorizedStorage: true
+            httpsHstsEnabled: false
+        """
+        if self.acme_staging:
+            settings['nextcloud_extra_values'] = \
+                yaml.load(nextcloud_extra_values)
+
         file_contents = yaml.safe_dump(settings, default_flow_style=False)
         log.debug(file_contents)
 
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 0575c31c09525e2224ba89dc7c8a6120ea7adf8e..918a023277633eb412d3c1e1f333191bf2f9a051 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -1,3 +1,4 @@
 # Add development requirements here
 # Please pin releases manually if necessary
 pip-tools
+pre-commit
diff --git a/test/behave/features/nextcloud.feature b/test/behave/features/nextcloud.feature
index e9a9d9c8f2f3b6ddd8ce00f201adebea13a6b7fe..e1d77c0f259a18a87d7552143c8475caf582a03d 100644
--- a/test/behave/features/nextcloud.feature
+++ b/test/behave/features/nextcloud.feature
@@ -39,5 +39,5 @@ Scenario: Create a new document in OnlyOffice
   And I click on the element "input.icon-confirm"
   And I focus the last opened tab
   Then I expect a new tab has been opened
-  And I expect that element "div.type-error" does not exist
+  And I expect that element "div.toast-error" does not exist
   And I wait on element "iframe[name='frameEditor']" for 20000ms to be visible
diff --git a/test/pytest/test_certs.py b/test/pytest/test_certs.py
index 460692589bde2fed6271b978fa5aa8c96a702244..c3fde585dcdd61139ed96fb8a9508c6727751bdd 100755
--- a/test/pytest/test_certs.py
+++ b/test/pytest/test_certs.py
@@ -99,6 +99,7 @@ def test_cert_validation(host, app): # pylint: disable=too-many-statements
         # prometheus is only exposed if opted-in in the settings
         'prometheus': 'prometheus',
         'rocketchat': 'chat',
+        'single-sign-on': 'sso',
         'wordpress': 'www'
     }