diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index da402588680e791c6401eebfa1b297dae55c49c2..910ec239cad1c88c12b9ff07f252544ac3bb863c 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,92 +1,143 @@
+image: studip/studip:tests
+
 variables:
-  MYSQL_RANDOM_ROOT_PASSWORD: 1
+  MYSQL_RANDOM_ROOT_PASSWORD: "true"
   MYSQL_DATABASE: studip_db
   MYSQL_USER: studip_user
   MYSQL_PASSWORD: studip_password
   MYSQL_HOST: mariadb
-  DEMO_DATA: 1
+  DEMO_DATA: "true"
   MAIL_TRANSPORT: debug
+  # Optimize caching
+  FF_USE_FASTZIP: "true"
+  CACHE_COMPRESSION_LEVEL: "fast"
+  # User faster docker driver
+  DOCKER_DRIVER: overlay2
 
 stages:
-  - Checks
-  - Tests
-  - Packaging
-  - Release
-  - Build
-
-image: studip/studip:tests
-services:
-  - mariadb
+  - checks
+  - tests
+  - packaging
+  - release
+  - build
 
-Linting:
-  stage: Checks
-  allow_failure: false
-  before_script:
+.scripts:
+  install-composer:
     - make composer-dev
-  script:
-    - php -d memory_limit=-1 composer/bin/phplint --xml report.xml
-  artifacts:
+  configure-studip:
+    - !reference [.scripts, install-composer]
+    - cp docker/studip/config_local.php config/config_local.inc.php
+    - cp config/config.inc.php.dist config/config.inc.php
+  initialize-studip-database:
+    - !reference [.scripts, configure-studip]
+    - chmod +x .gitlab/scripts/install_db.sh
+    - .gitlab/scripts/install_db.sh
+    - cli/studip migrate
+  remove-absolute-path-in-report:
+    - sed -i "s%$PWD/%%" tests/_output/report.xml
+
+.artifacts:
+  common: &common
     when: always
     expire_in: 1 week
+  tests: &test-artifacts
+    <<: *common
     paths:
       - tests/_output
     reports:
       junit: tests/_output/report.xml
 
-Unit Test:
-  stage: Tests
+.caches:
+  php: &php-cache
+    key: "php-$CI_COMMIT_REF_SLUG"
+    paths:
+      - composer/
+      - .phplint-cache
+  js: &js-cache
+    key: "js-$CI_COMMIT_REF_SLUG"
+    paths:
+      - node_modules/
+      - .eslintcache
+
+lint-php:
+  stage: checks
+  needs: []
+  cache: *php-cache
   allow_failure: false
+  interruptible: true
   before_script:
-    - cp docker/studip/config_local.php config/config_local.inc.php
-    - cp config/config.inc.php.dist config/config.inc.php
-    - make composer-dev
+    - !reference [.scripts, install-composer]
   script:
-    - composer/bin/codecept run unit --xml
-    # Remove absolute path in report
-    - sed -i "s%$PWD/%%" tests/_output/report.xml
+    - php -d memory_limit=-1 composer/bin/phplint --xml report.xml
   artifacts:
-    when: always
-    expire_in: 1 week
+    <<: *common
     paths:
-      - tests/_output
+      - report.xml
     reports:
-      junit: tests/_output/report.xml
+      junit: report.xml
 
-Functional Test:
-  stage: Tests
+lint-js:
+  stage: checks
+  needs: []
+  cache: *js-cache
   allow_failure: false
+  interruptible: true
   before_script:
-    - chmod +x .gitlab/scripts/install_db.sh
-    - .gitlab/scripts/install_db.sh
-    - cp docker/studip/config_local.php config/config_local.inc.php
-    - cp config/config.inc.php.dist config/config.inc.php
-    - make composer-dev
-    - cli/studip migrate
+    - make npm
+  script:
+    - npm run lint -- --cache
+
+test-unit:
+  stage: tests
+  needs: [lint-php]
+  cache:
+    <<: *php-cache
+    policy: pull
+  allow_failure: false
+  interruptible: true
+  before_script:
+    - !reference [.scripts, configure-studip]
+  script:
+    - composer/bin/codecept run unit --xml
+    - !reference [.scripts, remove-absolute-path-in-report]
+  artifacts:
+    <<: *test-artifacts
+
+test-functional:
+  stage: tests
+  needs: [lint-php]
+  cache:
+    <<: *php-cache
+    policy: pull
+  services:
+    - mariadb
+  allow_failure: false
+  interruptible: true
+  before_script:
+    - !reference [.scripts, initialize-studip-database]
   script:
     - composer/bin/codecept run functional --xml
-    - sed -i "s%$PWD/%%" tests/_output/report.xml
+    - !reference [.scripts, remove-absolute-path-in-report]
   artifacts:
-    when: always
-    expire_in: 1 week
-    paths:
-      - tests/_output
-    reports:
-      junit: tests/_output/report.xml
+   <<: *test-artifacts
 
-JSONAPI Test:
-  stage: Tests
+test-jsonapi:
+  stage: tests
+  needs: [lint-php]
+  cache:
+    <<: *php-cache
+    policy: pull
+  services:
+    - mariadb
   allow_failure: false
+  interruptible: true
   before_script:
-    - chmod +x .gitlab/scripts/install_db.sh
-    - .gitlab/scripts/install_db.sh
-    - cp docker/studip/config_local.php config/config_local.inc.php
-    - cp config/config.inc.php.dist config/config.inc.php
-    - make composer-dev
-    - cli/studip migrate
+    - !reference [.scripts, initialize-studip-database]
   script:
     - composer/bin/codecept run jsonapi --xml
-    - sed -i "s%$PWD/%%" tests/_output/report.xml
+    - !reference [.scripts, remove-absolute-path-in-report]
   artifacts:
+    <<: *test-artifacts
     when: always
     expire_in: 1 week
     paths:
@@ -94,9 +145,9 @@ JSONAPI Test:
     reports:
       junit: tests/_output/report.xml
 
-Packaging:
-  stage: Packaging
-  image: studip/studip:tests
+packaging:
+  stage: packaging
+  cache: []
   rules:
     - if: $CI_COMMIT_TAG
   before_script:
@@ -116,15 +167,16 @@ Packaging:
     reports:
       dotenv: .packaging.env
 
-Release:
-  stage: Release
+release:
+  stage: release
   image: studip/release-cli
+  cache: []
   rules:
     - if: $CI_COMMIT_TAG
   script:
     - echo 'Running release job'
   needs:
-    - job: Packaging
+    - job: packaging
       artifacts: true
   release:
     name: "Stud.IP-Release-$CI_COMMIT_TAG"
diff --git a/Makefile b/Makefile
index 09b1f029ef1cb1796e6d3b59afd740d5f7236c3b..f33371fba61e38dd3d83433fb342a93444b1bd50 100644
--- a/Makefile
+++ b/Makefile
@@ -30,7 +30,7 @@ clean-composer:
 npm: node_modules/.package-lock.json
 
 node_modules/.package-lock.json: package.json package-lock.json
-	npm install --no-save
+	npm install --no-save --no-audit --no-fund
 
 clean-npm:
 	rm -rf node_modules