diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 79fcba7f330baca65ac81e62e4024cefde53228f..2ae11a983fe440dbbf3d58e5e7fa9ed59f3d5628 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,5 +1,6 @@
 include:
   - .gitlab/ci_templates/kaniko.yml
+  - .gitlab/ci_templates/ssh_setup.yml
 stages:
   - build
   - setup-cluster
@@ -36,14 +37,7 @@ ci_test_image:
 
 bootstrap:
   stage: setup-cluster
-  before_script:
-    - ansible --version
   script:
-    # Ensure test/ is not world-writable otherwise ansible-playbook refuses to run, see
-    # https://docs.ansible.com/ansible/devel/reference_appendices/config.html#cfg-in-world-writable-dir
-    - chmod 755 ansible/
-    - eval $(ssh-agent -s)
-    - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - > /dev/null
     - echo "hostname $HOSTNAME, subdomain $SUBDOMAIN, domain $DOMAIN, address $ADDRESS"
     - python3 -m openappstack $HOSTNAME create --create-droplet $DOMAIN --hostname $HOSTNAME --ssh-key-id $SSH_KEY_ID --create-domain-records --subdomain $SUBDOMAIN
     - python3 -m openappstack $HOSTNAME install --ansible-param='--skip-tags=helmfile'
@@ -59,17 +53,11 @@ bootstrap:
       - helmfiles/**/*
       - test/**/*
       - openappstack/**/*
+  extends: .ssh_setup
 
 install:
   stage: install-apps
-  variables:
-     ANSIBLE_HOST_KEY_CHECKING: 'False'
   script:
-    # Ensure test/ is not world-writable otherwise ansible-playbook refuses to run, see
-    # https://docs.ansible.com/ansible/devel/reference_appendices/config.html#cfg-in-world-writable-dir
-    - chmod 755 ansible/
-    - eval $(ssh-agent -s)
-    - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - > /dev/null
     - python3 -m openappstack $HOSTNAME install --ansible-param='--tags=helmfile'
     # Show versions of installed apps/binaries
     - ansible master -m shell -a 'oas-version-info.sh 2>&1'
@@ -85,14 +73,11 @@ install:
       - helmfiles/**/*
       - test/**/*
       - openappstack/**/*
+  extends: .ssh_setup
 
 testinfra:
   stage: health-test
   script:
-    - mkdir ~/.ssh
-    - eval $(ssh-agent -s)
-    - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - > /dev/null
-    - echo -e 'Host *\n  stricthostkeychecking no' > ~/.ssh/config
     - cd ansible/
     - pytest -v -m 'testinfra' --connection=ansible --ansible-inventory=../clusters/${HOSTNAME}/inventory.yml --hosts='ansible://*'
   only:
@@ -102,15 +87,12 @@ testinfra:
       - helmfiles/**/*
       - test/**/*
       - openappstack/**/*
+  extends: .ssh_setup
 
 certs:
   stage: health-test
   allow_failure: true
   script:
-    - mkdir ~/.ssh
-    - eval $(ssh-agent -s)
-    - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - > /dev/null
-    - echo -e 'Host *\n  stricthostkeychecking no' > ~/.ssh/config
     - cd ansible/
     - pytest -s -m 'certs' --connection=ansible --ansible-inventory=../clusters/${HOSTNAME}/inventory.yml --hosts='ansible://*'
   only:
@@ -120,6 +102,7 @@ certs:
       - helmfiles/**/*
       - test/**/*
       - openappstack/**/*
+  extends: .ssh_setup
 
 prometheus-alerts:
   stage: health-test
@@ -127,10 +110,6 @@ prometheus-alerts:
     OAS_DOMAIN: 'ci-${CI_PIPELINE_ID}.ci.openappstack.net'
   allow_failure: true
   script:
-    - mkdir ~/.ssh
-    - eval $(ssh-agent -s)
-    - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - > /dev/null
-    - echo -e 'Host *\n  stricthostkeychecking no' > ~/.ssh/config
     - cd test/
     - pytest -s -m 'prometheus' --connection=ansible --ansible-inventory=../clusters/${HOSTNAME}/inventory.yml --hosts='ansible://*'
   only:
@@ -139,6 +118,7 @@ prometheus-alerts:
       - ansible/**/*
       - helmfiles/**/*
       - test/**/*
+  extends: .ssh_setup
 
 behave-nextcloud:
   stage: integration-test
diff --git a/.gitlab/ci_templates/ssh_setup.yml b/.gitlab/ci_templates/ssh_setup.yml
new file mode 100644
index 0000000000000000000000000000000000000000..e0d0f6104a91c44825422ec60ddbcd78929fa775
--- /dev/null
+++ b/.gitlab/ci_templates/ssh_setup.yml
@@ -0,0 +1,6 @@
+.ssh_setup:
+  before_script:
+    - mkdir ~/.ssh
+    - echo -e 'Host *\n  stricthostkeychecking no' > ~/.ssh/config
+    - eval $(ssh-agent -s)
+    - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - > /dev/null
diff --git a/ansible/roles/apps/tasks/cert-manager.yml b/ansible/roles/apps/tasks/cert-manager.yml
index 72e451e495d62324cb5d79c25b776aa4cfcd001d..4f08f585ce39cf25272b313973fc89197dfaf4a9 100644
--- a/ansible/roles/apps/tasks/cert-manager.yml
+++ b/ansible/roles/apps/tasks/cert-manager.yml
@@ -40,19 +40,16 @@
     - name: production
       server: "https://acme-v02.api.letsencrypt.org/directory"
 
-- name: Apply cert-manager helmfile
+- name: Install cert-manager
   tags:
     - helmfile
     - cert-manager
-  shell: |
-    set -e -x -o pipefail
-    /usr/local/bin/helmfile \
-    -b /usr/local/bin/helm \
-    -e oas \
-    -f {{ data_directory }}/source/helmfiles/helmfile.d/05-cert-manager.yaml \
-    apply \
-    --suppress-secrets \
-    | sed 's/\x1B\[[0-9;]*[JKmsu]//g' \
-    >> {{ log_directory }}/helmfile.log
-  args:
-    executable: /bin/bash
+  include_role:
+    name: "helmfile"
+    tasks_from: "apply"
+    apply:
+      tags:
+        - helmfile
+        - cert-manager
+  vars:
+      helmfile: '05-cert-manager'
diff --git a/ansible/roles/apps/tasks/helmfiles.yml b/ansible/roles/apps/tasks/helmfiles.yml
deleted file mode 100644
index ea30fda67819245c4b666202f0b4c3ab1bcee475..0000000000000000000000000000000000000000
--- a/ansible/roles/apps/tasks/helmfiles.yml
+++ /dev/null
@@ -1,125 +0,0 @@
----
-- name: Clone nextcloud repo
-  tags:
-    - git
-    - nextcloud
-  git:
-    repo: 'https://open.greenhost.net/openappstack/nextcloud'
-    dest: '{{ data_directory }}/source/repos/nextcloud'
-    version: '{{ git_nextcloud_version }}'
-
-- name: Remove requirements.lock file
-  tags:
-    - git
-    - nextcloud
-    - helmfile
-  file:
-    path: '{{ data_directory }}/source/repos/nextcloud/nextcloud-onlyoffice/requirements.lock'
-    state: absent
-
-- name: Clone local-storage repo
-  tags:
-    - git
-    - local-storage
-  git:
-    repo: 'https://open.greenhost.net/openappstack/local-storage'
-    dest: '{{ data_directory }}/source/repos/local-storage'
-    version: '{{ git_local_storage_version }}'
-
-- name: Make Prometheus custom resource definitions
-  tags:
-    - helmfile
-    - monitoring
-  # NOTE: Change the commit hash in the URL when upgrading Prometheus
-  command: '/snap/bin/kubectl apply -f https://raw.githubusercontent.com/coreos/prometheus-operator/v0.31.1/example/prometheus-operator-crd/{{ item }}'
-  loop:
-    - alertmanager.crd.yaml
-    - prometheus.crd.yaml
-    - prometheusrule.crd.yaml
-    - servicemonitor.crd.yaml
-    - podmonitor.crd.yaml
-
-- name: Get prometheus PV name
-  tags:
-    - prometheus
-  shell: "kubectl -n oas get pvc prometheus-prometheus-oas-{{ release_name }}-prometheus-promet-prometheus-0 -o=jsonpath='{.spec.volumeName}'"
-  register: prometheus_pv_name
-  failed_when: false
-  changed_when: false
-
-# Needed because previously we ran prometheus as root
-- name: Ensure prometheus volume is accessible by the prometheus pod
-  tags:
-    - prometheus
-  file:
-    dest: "{{ data_directory }}/local-storage/{{ prometheus_pv_name.stdout }}"
-    owner: '1000'
-    group: '2000'
-    recurse: true
-  when: prometheus_pv_name.stdout
-
-- name: Apply storage helmfile
-  tags:
-    - helmfile
-  shell: |
-    set -e -x -o pipefail
-    /usr/local/bin/helmfile -b /usr/local/bin/helm -e oas \
-    -f {{ data_directory }}/source/helmfiles/helmfile.d/00-storage.yaml \
-    apply --suppress-secrets \
-    | sed 's/\x1B\[[0-9;]*[JKmsu]//g' \
-    >> {{ log_directory }}/helmfile.log
-  args:
-    executable: /bin/bash
-  when: '"00-storage" in helmfiles'
-
-- name: Apply nginx helmfile
-  tags:
-    - helmfile
-  shell: |
-    set -e -x -o pipefail
-    /usr/local/bin/helmfile -b /usr/local/bin/helm -e oas \
-    -f {{ data_directory }}/source/helmfiles/helmfile.d/10-nginx.yaml \
-    apply --suppress-secrets \
-    | sed 's/\x1B\[[0-9;]*[JKmsu]//g' \
-    >> {{ log_directory }}/helmfile.log
-  args:
-    executable: /bin/bash
-  when: '"10-nginx" in helmfiles'
-
-# Force needed for upgrading from 5 to 6: https://github.com/helm/charts/tree/master/stable/prometheus-operator#upgrading-from-5xx-to-6xx
-- name: Apply monitoring helmfile with force
-  tags:
-    - helmfile
-  environment:
-    - GRAFANA_ADMIN_PASSWORD: "{{ grafana_admin_password }}"
-  shell: |
-    set -e -x -o pipefail
-    /usr/local/bin/helmfile -b /usr/local/bin/helm -e oas \
-    -f {{ data_directory }}/source/helmfiles/helmfile.d/15-monitoring.yaml \
-    apply --suppress-secrets --args='--force' \
-    | sed 's/\x1B\[[0-9;]*[JKmsu]//g' \
-    >> {{ log_directory }}/helmfile.log
-  args:
-    executable: /bin/bash
-  when: '"15-monitoring" in helmfiles'
-
-- name: Apply nextcloud helmfile
-  tags:
-    - helmfile
-  environment:
-    - NEXTCLOUD_PASSWORD: "{{ nextcloud_password }}"
-    - NEXTCLOUD_MARIADB_PASSWORD: "{{ nextcloud_mariadb_password }}"
-    - NEXTCLOUD_MARIADB_ROOT_PASSWORD: "{{ nextcloud_mariadb_root_password }}"
-    - ONLYOFFICE_JWT_SECRET: "{{ onlyoffice_jwt_secret }}"
-    - ONLYOFFICE_POSTGRESQL_PASSWORD: "{{ onlyoffice_postgresql_password }}"
-    - ONLYOFFICE_RABBITMQ_PASSWORD: "{{ onlyoffice_rabbitmq_password }}"
-  shell: |
-    set -e -x -o pipefail
-    /usr/local/bin/helmfile -b /usr/local/bin/helm -e oas \
-    -f {{ data_directory }}/source/helmfiles/helmfile.d/20-nextcloud.yaml \
-    apply --suppress-secrets \
-    | sed 's/\x1B\[[0-9;]*[JKmsu]//g' \
-    >> {{ log_directory }}/helmfile.log
-  args:
-    executable: /bin/bash
-  when: '"20-nextcloud" in helmfiles'
diff --git a/ansible/roles/apps/tasks/local-storage.yml b/ansible/roles/apps/tasks/local-storage.yml
new file mode 100644
index 0000000000000000000000000000000000000000..7c5c8a86109fb7c8005aa8f81a1f105ffa032a47
--- /dev/null
+++ b/ansible/roles/apps/tasks/local-storage.yml
@@ -0,0 +1,24 @@
+---
+- name: Clone local-storage repo
+  tags:
+    - git
+    - helmfile
+    - local-storage
+  git:
+    repo: 'https://open.greenhost.net/openappstack/local-storage'
+    dest: '{{ data_directory }}/source/repos/local-storage'
+    version: '{{ git_local_storage_version }}'
+
+- name: Install local-storage provisioner
+  tags:
+    - helmfile
+    - local-storage
+  include_role:
+    name: "helmfile"
+    tasks_from: "apply"
+    apply:
+      tags:
+        - helmfile
+        - local-storage
+  vars:
+      helmfile: '00-storage'
diff --git a/ansible/roles/apps/tasks/main.yml b/ansible/roles/apps/tasks/main.yml
index a366c9362eafedbb90090cb7ffea89272789066d..2c1afae58ae53bd7ec8bbee5105d471b00f65380 100644
--- a/ansible/roles/apps/tasks/main.yml
+++ b/ansible/roles/apps/tasks/main.yml
@@ -1,4 +1,29 @@
 ---
-- import_tasks: init.yml
-- import_tasks: cert-manager.yml
-- import_tasks: helmfiles.yml
+- name: Import tasks from init.yml
+  import_tasks: init.yml
+  tags: [ helmfile ]
+
+- name: Install local-storage
+  import_tasks: local-storage.yml
+  tags: [ helmfile ]
+  when: '"00-storage" in helmfiles'
+
+- name: Install cert-manager
+  import_tasks: cert-manager.yml
+  tags: [ helmfile ]
+  when: '"05-cert-manager" in helmfiles'
+
+- name: Install nginx
+  import_tasks: nginx.yml
+  tags: [ helmfile ]
+  when: '"10-nginx" in helmfiles'
+
+- name: Install prometheus
+  import_tasks: prometheus.yml
+  tags: [ helmfile ]
+  when: '"15-monitoring" in helmfiles'
+
+- name: Install nextcloud
+  import_tasks: nextcloud.yml
+  tags: [ helmfile ]
+  when: '"20-nextcloud" in helmfiles'
diff --git a/ansible/roles/apps/tasks/nextcloud.yml b/ansible/roles/apps/tasks/nextcloud.yml
new file mode 100644
index 0000000000000000000000000000000000000000..acb05aedd10b04940321766eafe33fb1238cda9f
--- /dev/null
+++ b/ansible/roles/apps/tasks/nextcloud.yml
@@ -0,0 +1,42 @@
+---
+- name: Clone nextcloud repo
+  tags:
+    - git
+    - helmfile
+    - nextcloud
+  git:
+    repo: 'https://open.greenhost.net/openappstack/nextcloud'
+    dest: '{{ data_directory }}/source/repos/nextcloud'
+    version: '{{ git_nextcloud_version }}'
+
+- name: Remove requirements.lock file
+  tags:
+    - git
+    - nextcloud
+    - helmfile
+  file:
+    path: '{{ data_directory }}/source/repos/nextcloud/nextcloud-onlyoffice/requirements.lock'
+    state: absent
+
+- name: Install nextcloud and onlyoffice
+  tags:
+    - helmfile
+    - nextcloud
+    - onlyoffice
+  include_role:
+    name: "helmfile"
+    tasks_from: "apply"
+    apply:
+      tags:
+        - helmfile
+        - nextcloud
+        - onlyoffice
+      environment:
+        - NEXTCLOUD_PASSWORD: "{{ nextcloud_password }}"
+        - NEXTCLOUD_MARIADB_PASSWORD: "{{ nextcloud_mariadb_password }}"
+        - NEXTCLOUD_MARIADB_ROOT_PASSWORD: "{{ nextcloud_mariadb_root_password }}"
+        - ONLYOFFICE_JWT_SECRET: "{{ onlyoffice_jwt_secret }}"
+        - ONLYOFFICE_POSTGRESQL_PASSWORD: "{{ onlyoffice_postgresql_password }}"
+        - ONLYOFFICE_RABBITMQ_PASSWORD: "{{ onlyoffice_rabbitmq_password }}"
+  vars:
+      helmfile: '20-nextcloud'
diff --git a/ansible/roles/apps/tasks/nginx.yml b/ansible/roles/apps/tasks/nginx.yml
new file mode 100644
index 0000000000000000000000000000000000000000..a3a60176429f6227ee84a7b75d4b81a704b8e298
--- /dev/null
+++ b/ansible/roles/apps/tasks/nginx.yml
@@ -0,0 +1,14 @@
+---
+- name: Install nginx ingress controller
+  tags:
+    - helmfile
+    - nginx
+  include_role:
+    name: "helmfile"
+    tasks_from: "apply"
+    apply:
+      tags:
+        - helmfile
+        - nginx
+  vars:
+      helmfile: '10-nginx'
diff --git a/ansible/roles/apps/tasks/prometheus.yml b/ansible/roles/apps/tasks/prometheus.yml
new file mode 100644
index 0000000000000000000000000000000000000000..db9e49a8dd6611404832ac3de12819a4e9ff2414
--- /dev/null
+++ b/ansible/roles/apps/tasks/prometheus.yml
@@ -0,0 +1,52 @@
+---
+
+- name: Make Prometheus custom resource definitions
+  tags:
+    - helmfile
+    - prometheus
+  # NOTE: Change the commit hash in the URL when upgrading Prometheus
+  command: '/snap/bin/kubectl apply -f https://raw.githubusercontent.com/coreos/prometheus-operator/v0.31.1/example/prometheus-operator-crd/{{ item }}'
+  loop:
+    - alertmanager.crd.yaml
+    - prometheus.crd.yaml
+    - prometheusrule.crd.yaml
+    - servicemonitor.crd.yaml
+    - podmonitor.crd.yaml
+
+- name: Get prometheus PV name
+  tags:
+    - prometheus
+  shell: "kubectl -n oas get pvc prometheus-prometheus-oas-{{ release_name }}-prometheus-promet-prometheus-0 -o=jsonpath='{.spec.volumeName}'"
+  register: prometheus_pv_name
+  failed_when: false
+  changed_when: false
+
+# Needed because previously we ran prometheus as root
+- name: Ensure prometheus volume is accessible by the prometheus pod
+  tags:
+    - prometheus
+  file:
+    dest: "{{ data_directory }}/local-storage/{{ prometheus_pv_name.stdout }}"
+    owner: '1000'
+    group: '2000'
+    recurse: true
+  when: prometheus_pv_name.stdout
+
+- name: Install prometheus and graphana
+  include_role:
+    name: "helmfile"
+    tasks_from: "apply"
+    apply:
+      tags:
+        - monitoring
+        - prometheus
+      environment:
+        - GRAFANA_ADMIN_PASSWORD: "{{ grafana_admin_password }}"
+  tags:
+    - monitoring
+    - prometheus
+  vars:
+      helmfile: '15-monitoring'
+      # Force needed for upgrading from 5 to 6, see
+      # https://github.com/helm/charts/tree/master/stable/prometheus-operator#upgrading-from-5xx-to-6xx
+      helmfile_apply_args: '--args="--force"'
diff --git a/ansible/roles/helmfile/defaults/main.yml b/ansible/roles/helmfile/defaults/main.yml
new file mode 100644
index 0000000000000000000000000000000000000000..5521ff672faea62718869f8fd4e0ad18132bee09
--- /dev/null
+++ b/ansible/roles/helmfile/defaults/main.yml
@@ -0,0 +1,2 @@
+# Add additional helmfile apply args
+helmfile_apply_args: ''
diff --git a/ansible/roles/helmfile/tasks/apply.yml b/ansible/roles/helmfile/tasks/apply.yml
new file mode 100644
index 0000000000000000000000000000000000000000..82957b2a68fa506c5a43d5eca1b65c0f459aeea5
--- /dev/null
+++ b/ansible/roles/helmfile/tasks/apply.yml
@@ -0,0 +1,13 @@
+---
+- name: Apply helmfile
+  tags:
+    - helmfile
+  shell: |
+    set -e -x -o pipefail
+    /usr/local/bin/helmfile -b /usr/local/bin/helm -e oas \
+    -f {{ data_directory }}/source/helmfiles/helmfile.d/{{ helmfile }}.yaml \
+    apply --suppress-secrets {{ helmfile_apply_args }} \
+    | sed 's/\x1B\[[0-9;]*[JKmsu]//g' \
+    >> {{ log_directory }}/helmfile.log
+  args:
+    executable: /bin/bash